commit melhorias sgf
This commit is contained in:
@@ -1,5 +1,140 @@
|
||||
# SGF Tempo — instruções locais
|
||||
|
||||
- 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.
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# 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.
|
||||
|
||||
@@ -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,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,79 @@
|
||||
<?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){
|
||||
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){
|
||||
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){$extra=$this->withHost($host,$hostActor,function()use($p,$hostActor){$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']);}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(ModuleRecords::query('si_invoices')->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',ModuleRecords::query('si_invoices')->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\Shared\Context\ModuleRecords::query('si_invoices')->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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ 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');
|
||||
|
||||
@@ -16,7 +16,7 @@ final class CostLedgerSource
|
||||
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(DB::getSchemaBuilder()->hasTable($t),503,'Origem de custo não composta neste ensaio.');return $s;
|
||||
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
|
||||
@@ -30,39 +30,39 @@ final class CostLedgerSource
|
||||
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(DB::getSchemaBuilder()->hasTable('gl_industrial_sources'),503);if(DB::table('ic_inputs as i')->join('gl_industrial_sources as m','m.lot_id','=','i.lot_id')->where('i.tenant_id',$m['tenant_id'])->where('i.company_id',$m['company_id'])->whereIn('i.source_ref',array_values($origins))->exists())throw new \DomainException('Origem já composta em lote industrial; não reconhecer novamente.');}
|
||||
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=DB::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));
|
||||
$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(DB::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=DB::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++;}}
|
||||
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(DB::getSchemaBuilder()->hasTable('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=DB::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.');
|
||||
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||!DB::getSchemaBuilder()->hasTable('gl_cost_correction_capability'))return false;
|
||||
$r=DB::table('gl_correction_results')->where($s)->where('original_journal_id',$original->id)->first();if(!$r)return false;$j=DB::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'];
|
||||
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=DB::table('gl_cost_sources')->where($s);$p=$q->orderBy('id')->paginate(25,['*'],'page',$page);$chosen=null;$source=null;$pending=null;
|
||||
if($order!==null){$m=DB::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?DB::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';
|
||||
$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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,14 +7,17 @@ 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);}
|
||||
@@ -30,16 +33,19 @@ final class GeneralLedgerController extends Controller
|
||||
}
|
||||
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());}
|
||||
@@ -47,17 +53,20 @@ final class GeneralLedgerController extends Controller
|
||||
}
|
||||
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');
|
||||
|
||||
@@ -3,5 +3,6 @@ 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){$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');}
|
||||
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');}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ 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(DB::getSchemaBuilder()->hasTable($t),503,'Origem industrial indisponível.');return $s;
|
||||
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{
|
||||
@@ -19,24 +19,24 @@ final class IndustrialLedgerSource
|
||||
$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(DB::getSchemaBuilder()->hasTable('gl_cost_sources')&&DB::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.');
|
||||
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=DB::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 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(DB::getSchemaBuilder()->hasTable('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=DB::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']]);
|
||||
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||!DB::getSchemaBuilder()->hasTable('gl_industrial_correction_capability'))return false;$r=DB::table('gl_correction_results')->where($s)->where('original_journal_id',$j->id)->first();if(!$r)return false;$replacement=DB::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'];
|
||||
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(DB::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=DB::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)];
|
||||
$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=DB::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=DB::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=DB::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'];
|
||||
$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=DB::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=DB::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]];
|
||||
$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']);});
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ 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');
|
||||
|
||||
@@ -11,6 +11,8 @@ final class SalesLedgerController extends Controller
|
||||
catch(\DomainException $e){abort(403,$e->getMessage());}
|
||||
catch(\InvalidArgumentException $e){abort(422,$e->getMessage());}
|
||||
}
|
||||
public function page(Request $r,GeneralLedger $ledger){return response()->view('accounting.sales-ledger',['data'=>$this->data($r,$ledger)])->header('Cache-Control','no-store');}
|
||||
public function index(Request $r,GeneralLedger $ledger){return response()->json($this->data($r,$ledger))->header('Cache-Control','no-store');}
|
||||
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');}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ 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(DB::getSchemaBuilder()->hasTable($table),503,'Composição comercial contabilística não instalada.');$ctx=app(CompanyContext::class);
|
||||
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;
|
||||
}
|
||||
@@ -37,27 +37,38 @@ final class SalesLedgerSource
|
||||
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',default=>throw new \DomainException('Efeito comercial não suportado.')};
|
||||
$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(!DB::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(!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||!DB::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.');
|
||||
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(!DB::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('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'],true)){if(!DB::table('gl_journals')->where($scope)->where('snapshot->source->effect_type',$e->kind==='allocate'?'receivable.allocated':'receivable.reversed')->where('snapshot->source->source_id',(int)$e->id)->exists())$pending++;}
|
||||
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)];
|
||||
|
||||
@@ -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,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,12 @@
|
||||
<?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');}
|
||||
}
|
||||
@@ -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,112 @@
|
||||
<?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'];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];
|
||||
}
|
||||
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";}
|
||||
public function export(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['bytes'=>$d['artifact_csv'],'sha256'=>$d['artifact_sha256']];}
|
||||
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');$items=[];foreach($page->items()as$r){$a=$this->row($r);$a['effective']=$this->row($effective->get($r->record_key));$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.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,'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,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];});
|
||||
}
|
||||
}
|
||||
@@ -46,8 +46,22 @@ final class IdentityWorkflow
|
||||
'token' => $selected['token'], 'grants' => $grants,
|
||||
'modules' => app(\App\Shared\Context\ModuleNavigation::class)->available($this->context),
|
||||
'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,
|
||||
'stock'=>['period_report'=>app()->environment('budget-testing')&&config('receipt_qa.stock_read_enabled')===true&&config('receipt_qa.stock_period_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,
|
||||
'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,
|
||||
@@ -57,7 +71,7 @@ final class IdentityWorkflow
|
||||
'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' => ['stock_valuation_read' => app()->environment('budget-testing')
|
||||
'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;
|
||||
|
||||
@@ -17,9 +17,10 @@ final class CompositionStockRead
|
||||
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','search'=>'nullable|string|max:100',
|
||||
$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);
|
||||
@@ -28,7 +29,7 @@ final class CompositionStockRead
|
||||
if($filters['view']==='period'){
|
||||
abort_unless(config('receipt_qa.stock_period_enabled')===true,404);
|
||||
$filters+=['from'=>$d['from'],'to'=>$d['to']];
|
||||
}else abort_if(array_key_exists('from',$d)||array_key_exists('to',$d),422);
|
||||
}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)
|
||||
@@ -72,9 +73,9 @@ final class CompositionStockRead
|
||||
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']==='period'
|
||||
$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);
|
||||
:(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');
|
||||
|
||||
@@ -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,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,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());}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,16 @@ final class ReceiptRuntime
|
||||
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);
|
||||
@@ -41,7 +51,23 @@ final class ReceiptRuntime
|
||||
$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;abort_unless($binding,403,'Origem de recepção não autorizada neste contexto.');
|
||||
$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';
|
||||
@@ -69,11 +95,15 @@ final class ReceiptRuntime
|
||||
$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();
|
||||
@@ -81,7 +111,8 @@ final class ReceiptRuntime
|
||||
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){
|
||||
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'];}
|
||||
|
||||
@@ -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,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,32 @@
|
||||
<?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-v2',
|
||||
'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'=>'pending','available'=>null,'pending'=>'Motor operacional de rendimentos/retenções, remunerações, apuramento e modelos ligados às operações do SGF.','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'=>'pending','available'=>null,'pending'=>'Cadastro/processamento de remunerações, contribuições e modelos no SGF.','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'=>'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 com origens distintas.','pending'=>'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,114 @@
|
||||
<?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']];
|
||||
}
|
||||
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];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
namespace App\Modules\Services\Http;
|
||||
use App\Modules\Services\CommercialQuotes;use Illuminate\Http\Request;
|
||||
final class CommercialQuotesController
|
||||
{
|
||||
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'=>'Orçamento ocupado. Recupere a mesma decisão.'],503)->header('Retry-After','5');}}
|
||||
public function index(Request $r,CommercialQuotes $s){return $this->response(fn()=>$s->listing($r->user()->id,$r->query()));}
|
||||
public function options(Request $r,CommercialQuotes $s){return $this->response(fn()=>$s->options($r->user()->id,$r->query()));}
|
||||
public function history(Request $r,CommercialQuotes $s,string $key){$d=$r->validate(['page'=>'sometimes|integer|min:1|max:100000']);abort_if(array_diff(array_keys($r->query()),['page']),422);return $this->response(fn()=>$s->history($r->user()->id,$key,(int)($d['page']??1)));}
|
||||
public function preview(Request $r,CommercialQuotes $s){return $this->response(fn()=>$s->preview($r->user()->id,$r->all(),strtolower((string)$r->header('Idempotency-Key'))));}
|
||||
public function confirm(Request $r,CommercialQuotes $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']));}
|
||||
public function print(Request $r,CommercialQuotes $s,string $key){$d=$r->validate(['version'=>'required|integer|min:1']);abort_if(array_diff(array_keys($r->query()),['version']),422);return response()->view('commercial.quote-print',['quote'=>$s->document($r->user()->id,$key,(int)$d['version'])])->header('Cache-Control','no-store, private');}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
namespace App\Modules\Services\Http;
|
||||
use App\Modules\Services\FiscalAssessment;use Illuminate\Http\Request;
|
||||
final class FiscalAssessmentController
|
||||
{
|
||||
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'=>'Livro fiscal ocupado. Recupere a mesma confirmação; se a versão mudou, confira novamente.'],503)->header('Retry-After','5');}}
|
||||
public function screen(Request $r,FiscalAssessment $f){abort_if($r->query()!==[],422);$f->scope($r->user()->id);return response()->view('fiscal.workspace',['csrf'=>csrf_token(),'context'=>$r->session()->get('company_context.token')])->header('Cache-Control','no-store');}
|
||||
public function index(Request $r,FiscalAssessment $f){return $this->response(fn()=>$f->listing($r->user()->id,$r->query()));}
|
||||
public function preview(Request $r,FiscalAssessment $f){return $this->response(fn()=>$f->preview($r->user()->id,$r->all(),strtolower((string)$r->header('Idempotency-Key'))));}
|
||||
public function confirm(Request $r,FiscalAssessment $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,FiscalAssessment $f,string $key){abort_if($r->query()!==[],422);return $this->response(fn()=>$f->receipt($r->user()->id,strtolower($key)));}
|
||||
public function export(Request $r,FiscalAssessment $f,int $id){abort_if($r->query()!==[],422);$r=$f->export($r->user()->id,$id);return response($r['bytes'])->header('Content-Type','text/csv; charset=UTF-8')->header('Content-Disposition','attachment; filename="mapa-interno-IVA-QA-v'.$r['version'].'.csv"')->header('X-Content-SHA256',$r['sha256'])->header('Cache-Control','no-store, private');}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
namespace App\Modules\Services\Http;
|
||||
use App\Modules\Services\FiscalObligations;use Illuminate\Http\Request;
|
||||
final class FiscalObligationsController
|
||||
{
|
||||
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'=>'Controlo fiscal ocupado. Recupere a mesma confirmação.'],503)->header('Retry-After','5')->header('Cache-Control','no-store');}}
|
||||
public function index(Request $r,FiscalObligations $f){return$this->response(fn()=>$f->listing($r->user()->id,$r->query()));}
|
||||
public function preview(Request $r,FiscalObligations $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,FiscalObligations $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,FiscalObligations $f,string $key){abort_if($r->query()!==[],422);return$this->response(fn()=>$f->receipt($r->user()->id,strtolower($key)));}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
namespace App\Modules\Services\Http;
|
||||
use App\Modules\Services\FiscalRules;use Illuminate\Http\Request;
|
||||
final class FiscalRulesController
|
||||
{
|
||||
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'=>'Catálogo fiscal ocupado. Recupere o mesmo pedido; uma versão alterada exige nova conferência.'],503)->header('Retry-After','5');}}
|
||||
public function index(Request $r,FiscalRules $f){return $this->response(fn()=>$f->listing($r->user()->id,$r->query()));}
|
||||
public function detail(Request $r,FiscalRules $f,int $id){$d=$r->validate(['type'=>'sometimes|in:rule,calculation']);abort_if(array_diff(array_keys($r->query()),['type']),422);return $this->response(fn()=>$f->detail($r->user()->id,$id,$d['type']??'rule'));}
|
||||
public function preview(Request $r,FiscalRules $f){return $this->response(fn()=>$f->preview($r->user()->id,$r->all(),strtolower((string)$r->header('Idempotency-Key'))));}
|
||||
public function confirm(Request $r,FiscalRules $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,FiscalRules $f,string $key){abort_if($r->query()!==[],422);return $this->response(fn()=>$f->receipt($r->user()->id,strtolower($key)));}
|
||||
public function screen(Request $r,FiscalRules $f){abort_if($r->query()!==[],422);$f->scope($r->user()->id);return response()->view('fiscal.rules',['csrf'=>csrf_token(),'context'=>$r->session()->get('company_context.token')])->header('Cache-Control','no-store');}
|
||||
public function documentReport(Request $r,FiscalRules $f,int $id){abort_if($r->query()!==[],422);return $this->response(function()use($r,$f,$id){$d=$f->documentReport($r->user()->id,$id);return response($d['bytes'])->header('Content-Type','text/csv; charset=UTF-8')->header('Content-Disposition','attachment; filename="mapa-linhas-QA-'.$id.'.csv"')->header('X-Content-SHA256',$d['sha256'])->header('Cache-Control','no-store, private');});}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
namespace App\Modules\Services\Http;
|
||||
use App\Modules\Services\ReceivablePositionRead;
|
||||
use Illuminate\Http\Request;
|
||||
final class ReceivablePositionReadController
|
||||
{
|
||||
public function index(Request $request,ReceivablePositionRead $reader){return response()->json($reader->listing($request->user()->id,$request->query()))->header('Cache-Control','private, no-store');}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
namespace App\Modules\Services\Http;
|
||||
use Illuminate\Http\Request;use App\Modules\Services\ServiceDocuments;
|
||||
final class ServiceDocumentsController
|
||||
{
|
||||
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'=>'Documento ocupado. Recupere a mesma confirmação.'],503)->header('Retry-After','5');}}
|
||||
public function index(Request $r,ServiceDocuments $s){return $this->response(fn()=>$s->listing($r->user()->id,$r->query()));}
|
||||
public function register(Request $r,\App\Modules\Services\BillingRegister $s){return $this->response(fn()=>$s->listing($r->user()->id,$r->query()));}
|
||||
public function screen(Request $r,ServiceDocuments $s){$d=$r->validate(['kind'=>'sometimes|in:receipt,credit_note,invoice_receipt']);abort_if(array_diff(array_keys($r->query()),['kind']),422);$s->listing($r->user()->id,['per_page'=>25]);return response()->view('commercial.documents',['csrf'=>csrf_token(),'context'=>$r->session()->get('company_context.token'),'kind'=>$d['kind']??'receipt'])->header('Cache-Control','no-store');}
|
||||
public function options(Request $r,ServiceDocuments $s){return $this->response(fn()=>$s->options($r->user()->id,$r->query()));}
|
||||
public function preview(Request $r,ServiceDocuments $s){return $this->response(fn()=>$s->preview($r->user()->id,$r->all(),strtolower((string)$r->header('Idempotency-Key'))));}
|
||||
public function confirm(Request $r,ServiceDocuments $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']));}
|
||||
public function pdf(Request $r,ServiceDocuments $s,int $id){abort_if($r->query()!==[],422);$row=$s->pdf($r->user()->id,$id);return response(base64_decode($row->pdf_base64,true))->header('Content-Type','application/pdf')->header('Content-Disposition','inline; filename="TEST-SD-'.$row->id.'.pdf"')->header('Cache-Control','no-store, private');}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?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};
|
||||
use Brick\Math\BigDecimal;
|
||||
|
||||
/** Current recognized positions. Reference date classifies due dates, never rewinds effects. */
|
||||
final class ReceivablePositionRead
|
||||
{
|
||||
public function __construct(private CompanyContext $context,private Permissions $permissions){}
|
||||
public function listing(int $actor,array $input):array
|
||||
{
|
||||
CompositionRuntime::assert();ServiceReceivables::guard();$s=$this->context->identifiers()+['unit_id'=>$this->context->unitId()];
|
||||
abort_unless($s['unit_id']&&$actor===$this->context->userId()&&ModuleRecords::active()==='finance',403);$this->context->selectFor($actor,$s['tenant_id'],$s['company_id'],$s['unit_id']);$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'service_receivables.view',$s['unit_id']);
|
||||
$rules=['reference_on'=>'required|date_format:Y-m-d','page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100','search'=>'sometimes|nullable|string|max:100','state'=>'sometimes|in:proposed,refused,open,partial,settled','due_state'=>'sometimes|in:not_open,unknown,overdue,not_due,settled'];abort_if(array_diff(array_keys($input),array_keys($rules)),422);$d=Validator::make($input,$rules)->validate();
|
||||
return DB::transaction(function()use($s,$d){
|
||||
if(DB::transactionLevel()===1)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
$scope=fn($alias)=>array_combine(array_map(fn($k)=>$alias.'.'.$k,array_keys($s)),array_values($s));
|
||||
$effects=DB::table('sr_effects as e')->join('sr_records as scoped','scoped.id','=','e.obligation_id')->where($scope('scoped'))->where('scoped.kind','obligation')->groupBy('e.obligation_id')->selectRaw("e.obligation_id,count(*) revision,bool_or(e.kind='open') opened,bool_or(e.kind='refused' AND e.record_id=e.obligation_id) refused,coalesce(sum(CASE WHEN e.kind='reduction' THEN e.amount WHEN e.kind='reverse_reduction' THEN -e.amount ELSE 0 END),0) reduced,coalesce(sum(CASE WHEN e.kind='allocate' THEN e.amount WHEN e.kind='reverse_allocation' THEN -e.amount ELSE 0 END),0) allocated");
|
||||
$base=DB::table('sr_records as o')->leftJoinSub($effects,'f','f.obligation_id','=','o.id')->join('si_invoices as invoice',function($j){$j->on('invoice.id','=','o.invoice_id');foreach(['tenant_id','company_id','unit_id']as$key)$j->on('invoice.'.$key,'=','o.'.$key);})->where($scope('o'))->where('o.kind','obligation')->select(['o.id','o.invoice_id','o.client_id','o.currency','o.created_at'])->selectRaw("invoice.series_code||'/'||invoice.number::text reference,invoice.snapshot->'document'->'client'->>'name' client,o.amount::text original,coalesce(f.revision,0) revision,coalesce(f.opened,false) opened,coalesce(f.refused,false) refused,coalesce(f.reduced,0)::text reduced,coalesce(f.allocated,0)::text allocated,(o.amount-coalesce(f.reduced,0)-coalesce(f.allocated,0))::text balance,CASE WHEN EXISTS(SELECT 1 FROM sr_effects due WHERE due.obligation_id=o.id AND due.kind='due') THEN (SELECT due.data->>'due_date' FROM sr_effects due WHERE due.obligation_id=o.id AND due.kind='due' ORDER BY due.id DESC LIMIT 1) ELSE o.data->>'due_date' END due_date");
|
||||
$positions=DB::query()->fromSub($base,'p')->select('p.*')->selectRaw("CASE WHEN NOT opened THEN CASE WHEN refused THEN 'refused' ELSE 'proposed' END WHEN balance::numeric=0 THEN 'settled' WHEN allocated::numeric>0 THEN 'partial' ELSE 'open' END state,CASE WHEN NOT opened THEN 'not_open' WHEN balance::numeric=0 THEN 'settled' WHEN due_date IS NULL THEN 'unknown' WHEN ?::date>due_date::date THEN 'overdue' ELSE 'not_due' END due_state,CASE WHEN opened AND balance::numeric>0 AND due_date IS NOT NULL THEN greatest(0,?::date-due_date::date) ELSE NULL END overdue_days",[$d['reference_on'],$d['reference_on']]);
|
||||
$q=DB::query()->fromSub($positions,'positions');foreach(['state','due_state']as$key)if(isset($d[$key]))$q->where($key,$d[$key]);if(($d['search']??'')!==''){$needle='%'.str_replace(['!','%','_'],['!!','!%','!_'],$d['search']).'%';$q->where(fn($x)=>$x->whereRaw("reference ILIKE ? ESCAPE '!'",[$needle])->orWhereRaw("client ILIKE ? ESCAPE '!'",[$needle]));}
|
||||
$totals=(clone$q)->selectRaw("count(*) FILTER(WHERE opened) recognized_positions,count(*) FILTER(WHERE NOT opened) unrecognized_proposals,count(*) FILTER(WHERE due_state='unknown') unknown_due_positions,coalesce(sum(balance::numeric) FILTER(WHERE opened),0)::text recognized_balance,count(*) FILTER(WHERE opened AND balance::numeric<0) invalid_negative_positions")->first();abort_if((int)$totals->invalid_negative_positions>0,409,'Há saldo incompatível com o livro; exige conferência da origem.');$totals->recognized_balance=(string)BigDecimal::of($totals->recognized_balance)->toScale(2);unset($totals->invalid_negative_positions);
|
||||
$p=$q->orderByDesc('id')->paginate((int)($d['per_page']??25),['*'],'page',(int)($d['page']??1));foreach($p->items()as$row){foreach(['original','reduced','allocated','balance']as$key)$row->$key=(string)BigDecimal::of($row->$key)->toScale(2);if(!$row->opened)$row->balance=null;$row->revision=(int)$row->revision;}
|
||||
$records=DB::table('sr_records')->where($s)->selectRaw('count(*) records,coalesce(max(id),0) last_record_id')->first();$marker=DB::table('sr_effects as e')->join('sr_records as scoped','scoped.id','=','e.obligation_id')->where($scope('scoped'))->where('scoped.kind','obligation')->selectRaw('count(*) effects,coalesce(max(e.id),0) last_effect_id')->first();
|
||||
return ['positions'=>$p,'totals'=>$totals,'basis'=>['scope'=>$s,'records'=>(int)$records->records,'last_record_id'=>(int)$records->last_record_id,'effects'=>(int)$marker->effects,'last_effect_id'=>(int)$marker->last_effect_id],'reference_on'=>$d['reference_on'],'observed_at'=>now()->utc()->toIso8601String(),'currency'=>'MZN','read_only'=>true,'balance_basis'=>'current_confirmed_effects_not_historical_cut','notice'=>'Saldos actuais. A data de referência classifica apenas o vencimento; não reconstitui saldos históricos. Propostas não reconhecidas não entram no saldo total.'];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,10 @@ final class ServiceCalculation
|
||||
|
||||
public function __construct(private ServiceWorkflow $workflow, private DocumentPreparation $preparation, private CompanyContext $context) {}
|
||||
|
||||
public static function isolated(): bool { return app()->environment(['testing','browser-testing']); }
|
||||
public static function isolated(): bool {
|
||||
if(app()->environment('budget-testing')&&config('sgf_single_installation.enabled')===true){\App\Shared\Installation\CompositionRuntime::assert();return true;}
|
||||
return app()->environment(['testing','browser-testing']);
|
||||
}
|
||||
|
||||
public static function evaluateReal(array $data, array $prepared, array $profile): array
|
||||
{
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
<?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};
|
||||
|
||||
/** Reviewed, versioned synthetic documents. Original invoice and receivable writers own effects. */
|
||||
final class ServiceDocuments
|
||||
{
|
||||
public function __construct(private CompanyContext $context,private Permissions $permissions){}
|
||||
private function json(array $v):string{return \Tempo\Finance\Payables\Payables::canonical($v);}
|
||||
private function hash(mixed $v):string{return hash('sha256',$this->json($v));}
|
||||
private function scope(int $actor,string $right='view'):array
|
||||
{
|
||||
abort_unless(config('service_documents.enabled')===true,404);CompositionRuntime::assert();ServiceInvoicing::guard();
|
||||
$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',$right] as $r)$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'service_documents.'.$r,$s['unit_id']);return $s;
|
||||
}
|
||||
private function inputs():array
|
||||
{
|
||||
$p=CompositionRuntime::physical();$file='C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/private/receipt-composition-'.$p['composition'].'/single-document-inputs.json';$pin=config('service_documents.inputs_sha256');
|
||||
abort_unless(is_string($pin)&&is_file($file)&&!is_link($file)&&hash_equals($pin,hash_file('sha256',$file)),503);return json_decode(file_get_contents($file),true,32,JSON_THROW_ON_ERROR);
|
||||
}
|
||||
private function current(array $s,string $key):?object{return DB::table('sd_versions')->where($s)->where('document_key',$key)->orderByDesc('version')->first();}
|
||||
private function invoice(array $s,int $id):object
|
||||
{
|
||||
$allowed=$this->inputs();$i=ModuleRecords::query('si_invoices')->where($s)->where('id',$id)->first();abort_unless($i&&in_array($i->draft_id,array_column($allowed,'draft_id'),true),403,'Documento fora das novas origens QA autorizadas.');return $i;
|
||||
}
|
||||
private function right(array $d):string{return match($d['action']){'review','cancel_review'=>'review','issue'=>'issue',default=>'prepare'};}
|
||||
private function basis(int $actor,array $s,array $d):array
|
||||
{
|
||||
$old=$this->current($s,$d['document_key']);abort_unless(($old?->version??0)===$d['version'],409,'Versão alterada.');
|
||||
if($old&&$d['action']!=='prepare'){$data=json_decode($old->data,true);$kind=$old->kind;}else{$data=$d['data'];$kind=$d['kind'];}
|
||||
if(str_starts_with($d['action'],'cancel_')){
|
||||
abort_unless($old&&$old->state==='issued'&&$old->kind===$d['kind']&&$d['data']===[],409);$out=DB::table('sd_outputs')->where($s)->where('document_key',$old->document_key)->firstOrFail();$this->invoice($s,$out->invoice_id);$effect=DB::table('sr_effects')->find($out->effect_id);$last=DB::table('sd_cancellations')->where($s)->where('output_id',$out->id)->orderByDesc('version')->first();$period=DocumentPeriod::open($s);return ['old'=>(array)$old,'data'=>$data,'kind'=>$kind,'origin'=>['output'=>(array)$out,'effect'=>(array)$effect,'last'=>$last?(array)$last:null,'revision'=>DB::table('sr_effects')->where('obligation_id',$effect->obligation_id)->count(),'position'=>app(ServiceReceivables::class)->position($effect->obligation_id,$actor),'period'=>$period]];
|
||||
}
|
||||
abort_unless(!$old||$kind===$d['kind'],409);$origin=[];
|
||||
if($kind==='invoice_receipt'){
|
||||
abort_unless(config('service_documents.invoice_receipt_enabled')===true,403,'Facturação-recibo pendente dos acessos de consulta documental apresentados.');
|
||||
$allowed=$this->inputs()['invoice_receipt'];abort_unless(($data['draft_id']??null)===$allowed['draft_id']&&($data['series_id']??null)===$allowed['series_id']&&($data['rule_id']??null)===$allowed['rule_id'],403);
|
||||
// Readiness uses the proposed document authority; final issue retains the old SI right.
|
||||
$review=app(ServiceInvoicing::class)->reviewDocumentIntent($data['draft_id'],$data['series_id'],$data['rule_id'],$actor);
|
||||
abort_unless($review['ready'],422,'Origem documental ainda não pronta.');$origin=['invoice_review_hash'=>$review['review_hash'],'invoice_review'=>$review['payload']];
|
||||
}else{
|
||||
$invoice=$this->invoice($s,(int)($data['invoice_id']??0));$origin=['invoice_id'=>$invoice->id,'invoice_sha256'=>hash('sha256',$invoice->snapshot)];
|
||||
$ob=DB::table('sr_records')->where($s)->where(['invoice_id'=>$invoice->id,'kind'=>'obligation'])->first();abort_unless($ob,409,'Reconhecimento explícito da obrigação necessário.');
|
||||
$origin['obligation_id']=$ob->id;$origin['revision']=DB::table('sr_effects')->where('obligation_id',$ob->id)->count();
|
||||
if($kind==='receipt'){
|
||||
$effect=DB::table('sr_effects')->where(['obligation_id'=>$ob->id,'id'=>$data['allocation_id']??0,'kind'=>'allocate'])->first();abort_unless($effect,403,'Escolha a afectação efectiva da nova origem.');
|
||||
abort_if(DB::table('sr_effects')->where('target_effect_id',$effect->id)->exists(),409,'Afectação revertida; o recibo não pode ser emitido.');$origin['allocation']=(array)$effect;
|
||||
abort_if(DB::table('sd_outputs')->where($s)->where(['kind'=>'receipt','effect_id'=>$effect->id])->where('document_key','<>',$d['document_key'])->exists(),409,'A afectação já tem recibo. Consulte o documento original.');
|
||||
}
|
||||
}
|
||||
return ['old'=>$old?(array)$old:null,'data'=>$data,'kind'=>$kind,'origin'=>$origin];
|
||||
}
|
||||
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'])->where('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(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'user_id'=>$actor])->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
|
||||
{
|
||||
$rules=['action'=>'required|in:prepare,review,issue,cancel_prepare,cancel_review','kind'=>'required|in:receipt,credit_note,invoice_receipt','document_key'=>'required|uuid','version'=>'required|integer|min:0','reason'=>'required|string|min:10|max:4000','data'=>'present|array'];abort_if(array_diff(array_keys($input),array_keys($rules)),422);$d=Validator::make($input,$rules)->validate();abort_unless(preg_match('/^[a-f0-9-]{36}$/D',$key),422);$s=$this->scope($actor,$this->right($d));
|
||||
return DB::transaction(function()use($actor,$s,$d,$key){$this->lock($actor,$s);$this->scope($actor,$this->right($d));$b=$this->basis($actor,$s,$d);$level=DB::transactionLevel();DB::beginTransaction();try{$out=$this->execute($actor,$s,$d,$b,$key);}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.');}$d=$c['input'];$s=$this->scope($actor,$this->right($d));
|
||||
return DB::transaction(function()use($actor,$s,$c,$d){$this->lock($actor,$s);$this->scope($actor,$this->right($d));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('sd_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];}
|
||||
$b=$this->basis($actor,$s,$d);abort_unless($this->hash($b)===$c['basis'],409,'A origem ou dependências mudaram. Confira novamente.');$out=$this->execute($actor,$s,$d,$b,$c['key']);abort_unless($this->hash($out['impact'])===$this->hash($c['impact']),409,'Impacto alterado.');
|
||||
DB::table('sd_commands')->insert($s+['actor_id'=>$actor,'command_key'=>$c['key'],'request_hash'=>$this->hash($d),'result'=>$this->json($out),'created_at'=>now()]);Audit::record('service_document.'.$d['action'],$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$out['id'],'reason'=>$d['reason'],'before'=>$b['old'],'after'=>$out]);return $out+['replayed'=>false];});
|
||||
}
|
||||
private function execute(int $actor,array $s,array $d,array $b,string $key):array
|
||||
{
|
||||
$old=$b['old'];$kind=$b['kind'];$data=$b['data'];$action=$d['action'];$prepared=$old['prepared_by']??$actor;$effect=null;$output=null;
|
||||
if(str_starts_with($action,'cancel_'))return $this->cancel($actor,$s,$d,$b,$key);
|
||||
if($action==='prepare'){
|
||||
abort_if($old&&$kind==='credit_note',409,'A preparação financeira é imutável; conserve-a e prepare outro processo após a recusa.');
|
||||
abort_unless(!$old||in_array($old['state'],['draft','refused'],true),409);abort_unless(!$old||$prepared===$actor,403);
|
||||
$rules=match($kind){'receipt'=>['invoice_id'=>'required|integer','allocation_id'=>'required|integer'], 'credit_note'=>['invoice_id'=>'required|integer','amount'=>'required|string','explanation'=>'required|string|min:10|max:1000'], 'invoice_receipt'=>['draft_id'=>'required|integer','series_id'=>'required|integer','rule_id'=>'required|integer','payment_date'=>'required|date_format:Y-m-d|before_or_equal:today','evidence'=>'required|string|min:10|max:1000']};
|
||||
abort_if(array_diff(array_keys($d['data']),array_keys($rules)),422);$data=Validator::make($d['data'],$rules)->validate();
|
||||
if($kind==='credit_note'){
|
||||
$data['amount']=ServiceReceivables::money($data['amount']);abort_unless($data['amount']==='100.00',422,'Este recorte QA limita o crédito a100MZN.');
|
||||
$record=app(ServiceReceivables::class)->propose($actor,['kind'=>'correction','invoice_id'=>$data['invoice_id'],'obligation_id'=>$b['origin']['obligation_id'],'currency'=>'MZN','amount'=>$data['amount'],'source'=>'SIM-DOCUMENTOS','external_id'=>'SD-'.$d['document_key'],'revision'=>$b['origin']['revision'],'action'=>'reduction','document_ref'=>'TEST-REDUCTION-'.$d['document_key'],'line'=>'1','reason'=>$data['explanation']],$key);
|
||||
$data['correction_id']=$record['id'];
|
||||
}
|
||||
if($kind==='invoice_receipt')foreach(['prepare','allocate'] as $r)$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'service_receivables.'.$r,$s['unit_id']);
|
||||
$state='draft';
|
||||
}elseif($action==='review'){
|
||||
abort_unless($old&&$old['state']==='draft'&&$prepared!==$actor,403);$review=Validator::make($d['data'],['decision'=>'required|in:approved,refused'])->validate();abort_if(array_diff(array_keys($d['data']),['decision']),422);$state=$review['decision']==='approved'?'approved':'refused';
|
||||
}else{
|
||||
abort_unless($old&&$old['state']==='approved'&&$d['data']===[],409);$state='issued';
|
||||
if($kind==='credit_note'){
|
||||
$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'service_documents.credit_apply',$s['unit_id']);
|
||||
$decided=app(ServiceReceivables::class)->decide($data['correction_id'],$actor,['version'=>1,'revision'=>$b['origin']['revision'],'decision'=>'approved','reason'=>$d['reason']],$key);$effect=$decided['id'];$invoice=$data['invoice_id'];$amount=$data['amount'];
|
||||
}elseif($kind==='receipt'){$effect=$data['allocation_id'];$invoice=$data['invoice_id'];$amount=$b['origin']['allocation']['amount'];}
|
||||
else{
|
||||
$review=app(ServiceInvoicing::class)->review($data['draft_id'],$data['series_id'],$data['rule_id'],$actor);$issued=app(ServiceInvoicing::class)->issue($data['draft_id'],$actor,['series_id'=>$data['series_id'],'rule_id'=>$data['rule_id'],'review_hash'=>$review['review_hash']],$key);$invoice=$issued['id'];$amount='1000.00';
|
||||
$aggregate=app(ServiceReceivables::class)->completeDocument($old['id'],$invoice,$actor,$key);$effect=$aggregate['allocation_id'];
|
||||
}
|
||||
}
|
||||
$id=DB::table('sd_versions')->insertGetId($s+['document_key'=>$d['document_key'],'version'=>$d['version']+1,'previous_id'=>$old['id']??null,'kind'=>$kind,'action'=>$action,'state'=>$state,'prepared_by'=>$prepared,'actor_id'=>$actor,'data'=>$this->json($data),'dependencies'=>$this->json($b['origin']),'reason'=>$d['reason'],'created_at'=>now()]);
|
||||
if($state==='issued'){
|
||||
$number=(int)DB::table('sd_outputs')->where($s)->where('kind',$kind)->max('number')+1;$inv=$this->invoice($s,$invoice);$origin=json_decode($inv->snapshot,true);$label=match($kind){'receipt'=>'Recibo','credit_note'=>'Nota de credito',default=>'Factura-recibo'};
|
||||
$lines=[['title',$label.' de TESTE'],['text','TEST-SD-'.strtoupper($kind).'/'.$number],['text',ServiceInvoicing::NOTICE],['text','Origem: '.$inv->series_code.'/'.$inv->number],['text','Cliente: '.($origin['document']['client']['name']??'Cliente QA')],['text','Montante: '.$amount.' MZN'],['text','Autor da preparacao: '.$prepared],['text','Autor da emissao: '.$actor],['text','Fundamento: '.$d['reason']],['text','Sem dinheiro real. Sem efeito automatico em stock.']];
|
||||
if($kind==='credit_note')$lines[]=['text','Reducao nominal de teste; sem reparticao fiscal certificada.'];if($kind==='invoice_receipt')$lines[]=['text','Base862,07 + imposto documental137,93 =1000,00 MZN.'];
|
||||
$snapshot=['document_lines'=>$lines,'kind'=>$kind,'invoice_id'=>$invoice,'effect_id'=>$effect,'amount'=>$amount,'simulation_only'=>true,'notice'=>ServiceInvoicing::NOTICE,'source_sha256'=>hash('sha256',$inv->snapshot),'prepared_by'=>$prepared,'issued_by'=>$actor,'issued_at'=>now()->toIso8601String()];$pdf=app(InvoicePdf::class)->render($snapshot);
|
||||
$output=DB::table('sd_outputs')->insertGetId($s+['document_key'=>$d['document_key'],'version_id'=>$id,'kind'=>$kind,'number'=>$number,'invoice_id'=>$invoice,'effect_id'=>$effect,'snapshot'=>$this->json($snapshot),'pdf_base64'=>base64_encode($pdf),'pdf_sha256'=>hash('sha256',$pdf),'actor_id'=>$actor,'created_at'=>now()]);
|
||||
}
|
||||
$impact=['kind'=>$kind,'action'=>$action,'state'=>$state,'amount'=>$kind==='credit_note'?($data['amount']??null):($kind==='receipt'?($b['origin']['allocation']['amount']??null):'1000.00'),'physical_change'=>false,'financial_reduction'=>$kind==='credit_note'&&$action==='issue','atomic_invoice_receipt'=>$kind==='invoice_receipt'&&$action==='issue'];
|
||||
return ['id'=>$id,'document_key'=>$d['document_key'],'version'=>$d['version']+1,'state'=>$state,'output_id'=>$output,'effect_id'=>$effect,'impact'=>$impact];
|
||||
}
|
||||
public function listing(int $actor,array $input):array
|
||||
{
|
||||
$s=$this->scope($actor);$d=Validator::make($input,['kind'=>'sometimes|in:receipt,credit_note,invoice_receipt','page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100','document_key'=>'sometimes|uuid'])->validate();abort_if(array_diff(array_keys($input),array_keys($d)),422);
|
||||
$q=DB::table('sd_versions as v')->where(['v.tenant_id'=>$s['tenant_id'],'v.company_id'=>$s['company_id'],'v.unit_id'=>$s['unit_id']]);if(isset($d['document_key']))$q->where('v.document_key',$d['document_key']);else $q->whereNotExists(fn($x)=>$x->selectRaw('1')->from('sd_versions as n')->whereColumn('n.document_key','v.document_key')->whereColumn('n.tenant_id','v.tenant_id')->whereColumn('n.company_id','v.company_id')->whereColumn('n.unit_id','v.unit_id')->whereColumn('n.version','>','v.version'));
|
||||
if(isset($d['kind']))$q->where('v.kind',$d['kind']);$p=$q->orderByDesc('v.id')->paginate((int)($d['per_page']??25),['v.*'],'page',(int)($d['page']??1));$outputs=DB::table('sd_outputs')->where($s)->whereIn('document_key',$p->pluck('document_key')->all())->get(['id','document_key','number','pdf_sha256'])->keyBy('document_key');foreach($p->items() as $row){$row->data=json_decode($row->data,true);$row->output=$outputs->get($row->document_key);}if(DB::getSchemaBuilder()->hasTable('sd_cancellations')){$events=DB::table('sd_cancellations')->where($s)->whereIn('output_id',$outputs->pluck('id')->all())->orderBy('version')->get()->groupBy('output_id');foreach($p->items() as $row)if($row->output)$row->output->cancellations=($events->get($row->output->id)??collect())->all();}$caps=[];foreach(['prepare','review','issue','credit_apply'] as $right)$caps[$right]=$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'service_documents.'.$right,$s['unit_id']);if(($d['kind']??null)==='invoice_receipt')foreach(['prepare','allocate'] as $right)$caps['prepare']=$caps['prepare']&&$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'service_receivables.'.$right,$s['unit_id']);if(($d['kind']??null)==='credit_note')$caps['prepare']=$caps['prepare']&&$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'service_receivables.correction_request',$s['unit_id']);$caps['cancel_prepare']=$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'service_documents.prepare',$s['unit_id'])&&$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'service_receivables.correction_request',$s['unit_id']);$caps['cancel_review']=$caps['review']&&$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'service_receivables.correction_approve',$s['unit_id']);return ['documents'=>$p,'notice'=>ServiceInvoicing::NOTICE,'inputs'=>$this->inputs(),'capabilities'=>$caps,'actor_id'=>$actor];
|
||||
}
|
||||
public function pdf(int $actor,int $id):object{$s=$this->scope($actor);$row=DB::table('sd_outputs')->where($s)->where('id',$id)->first();abort_unless($row,403);$this->invoice($s,$row->invoice_id);abort_unless(hash_equals($row->pdf_sha256,hash('sha256',base64_decode($row->pdf_base64,true))),409,'Integridade documental incompatível.');return $row;}
|
||||
private function cancel(int $actor,array $s,array $d,array $b,string $key):array
|
||||
{
|
||||
$o=$b['origin'];$last=$o['last'];abort_if(($last['state']??null)==='cancelled',409,'Documento já anulado tecnicamente.');$target=$o['effect'];abort_if(DB::table('sr_effects')->where('target_effect_id',$target['id'])->exists(),409,'O efeito já foi revertido; exige reconciliação própria.');$sr=app(ServiceReceivables::class);
|
||||
if($d['action']==='cancel_prepare'){
|
||||
$record=$sr->propose($actor,['kind'=>'correction','invoice_id'=>$o['output']['invoice_id'],'obligation_id'=>$target['obligation_id'],'currency'=>'MZN','source'=>'SIM-SD-ANULACAO','external_id'=>'SD-CANCEL-'.$key,'revision'=>$o['revision'],'action'=>$b['kind']==='credit_note'?'reverse_reduction':'reverse_allocation','target_effect_id'=>$target['id'],'reason'=>$d['reason']],$key);$correction=$record['id'];$effect=null;$state='prepared';
|
||||
}else{
|
||||
abort_unless($last&&$last['state']==='prepared'&&$last['actor_id']!==$actor,403,'Revisão distinta da preparação da anulação necessária.');$correction=$last['correction_id'];$r=DB::table('sr_records')->find($correction);abort_unless((json_decode($r->data,true)['revision']??null)===$o['revision'],409,'O recebível mudou. O preparador deve renovar a conferência da anulação.');
|
||||
if($b['kind']==='credit_note')$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'service_documents.credit_apply',$s['unit_id']);$result=$sr->decide($correction,$actor,['version'=>1,'revision'=>$o['revision'],'decision'=>'approved','reason'=>$d['reason']],$key);$effect=$result['id'];$state='cancelled';
|
||||
}
|
||||
$id=DB::table('sd_cancellations')->insertGetId($s+['cancellation_key'=>$d['document_key'],'version'=>($last['version']??0)+1,'output_id'=>$o['output']['id'],'state'=>$state,'actor_id'=>$actor,'correction_id'=>$correction,'effect_id'=>$effect,'snapshot'=>$this->json(['target_effect_id'=>$target['id'],'output_sha256'=>$o['output']['pdf_sha256'],'revision'=>$o['revision'],'period'=>$o['period'],'invoice_preserved'=>true,'money_refunded'=>false]),'reason'=>$d['reason'],'created_at'=>now()]);
|
||||
return ['id'=>$id,'document_key'=>$d['document_key'],'version'=>$d['version'],'cancellation_version'=>($last['version']??0)+1,'state'=>$state,'effect_id'=>$effect,'impact'=>['kind'=>$b['kind'],'action'=>$d['action'],'state'=>$state,'amount'=>$target['amount'],'balance_increase_on_confirmation'=>$target['amount'],'balance_before'=>$o['position']['balance'],'balance_after_confirmation'=>(string)\Brick\Math\BigDecimal::of($o['position']['balance'])->plus($target['amount'])->toScale(2),'invoice_preserved'=>true,'physical_change'=>false,'money_refunded'=>false,'financial_reduction'=>false,'atomic_invoice_receipt'=>false]];
|
||||
}
|
||||
public function options(int $actor,array $input):array
|
||||
{
|
||||
$s=$this->scope($actor);$d=Validator::make($input,['view'=>'required|in:invoices,allocations','invoice_id'=>'sometimes|integer|min:1','page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100'])->validate();abort_if(array_diff(array_keys($input),array_keys($d)),422);
|
||||
$invoices=ModuleRecords::query('si_invoices')->where($s)->whereIn('draft_id',array_column($this->inputs(),'draft_id'));
|
||||
if($d['view']==='invoices')$q=$invoices->select('id','series_code','number','draft_id');else{
|
||||
$invoice=$this->invoice($s,$d['invoice_id']??0);$q=DB::table('sr_effects as e')->join('sr_records as r','r.id','=','e.record_id')->where(['r.tenant_id'=>$s['tenant_id'],'r.company_id'=>$s['company_id'],'r.unit_id'=>$s['unit_id'],'r.invoice_id'=>$invoice->id,'e.kind'=>'allocate'])->whereNotExists(fn($v)=>$v->selectRaw('1')->from('sr_effects as rev')->whereColumn('rev.target_effect_id','e.id'))->select('e.id','e.amount','r.invoice_id','e.created_at');
|
||||
}return ['records'=>$q->orderBy('id')->paginate((int)($d['per_page']??25),['*'],'page',(int)($d['page']??1))];
|
||||
}
|
||||
public function sourceInvoices(int $actor){$s=$this->scope($actor);return ModuleRecords::query('si_invoices')->where($s)->whereIn('draft_id',array_column($this->inputs(),'draft_id'));}
|
||||
}
|
||||
@@ -55,9 +55,9 @@ final class ServiceFiscal
|
||||
public function listing(int $actor): array
|
||||
{
|
||||
$s=$this->scope($actor);
|
||||
if (!\Illuminate\Support\Facades\Schema::hasTable('sf_proposals')) return ['migration_pending'=>true,'proposals'=>[]];
|
||||
if (!\Illuminate\Support\Facades\Schema::hasTable('sf_proposals')) return ['migration_pending'=>true,'proposals'=>[],'coverage'=>FiscalCoverage::describe()];
|
||||
$rows=DB::table('sf_proposals')->where($s)->orderByDesc('id')->get()->map(function($r) { return (array)$r+['definition'=>$this->data($r),'decision'=>$this->decision($r->id),'active'=>$r->kind==='profile' && $this->activated($r)]; })->all();
|
||||
return ['proposals'=>$rows,'migration_pending'=>false,'notice'=>'RASCUNHO — SEM VALIDADE FISCAL; aprovação não activa o perfil.'];
|
||||
return ['coverage'=>FiscalCoverage::describe(),'proposals'=>$rows,'migration_pending'=>false,'notice'=>'RASCUNHO — SEM VALIDADE FISCAL; aprovação não activa o perfil.'];
|
||||
}
|
||||
public function propose(int $actor,array $input): array
|
||||
{
|
||||
|
||||
@@ -107,6 +107,12 @@ final class ServiceInvoicing
|
||||
{
|
||||
return $this->command($actor,'service_invoices.issue',fn($s)=>$this->reviewInside($draft,$series,$rule,$actor,$s));
|
||||
}
|
||||
/** Readiness for a staged document; issuance still requires the original issue right. */
|
||||
public function reviewDocumentIntent(int $draft,int $series,int $rule,int $actor):array
|
||||
{
|
||||
abort_unless(config('service_documents.enabled')===true,404);
|
||||
return $this->command($actor,'service_documents.view',fn($s)=>$this->reviewInside($draft,$series,$rule,$actor,$s));
|
||||
}
|
||||
private function reviewInside(int $draft,int $series,int $rule,int $actor,array $s): array
|
||||
{
|
||||
$view=$this->preview->captureDraftForIssuance($draft,$actor); $date=now(self::ZONE)->format('Y-m-d'); $blocks=[];
|
||||
|
||||
@@ -85,7 +85,7 @@ final class ServiceReceivables
|
||||
public function propose(int $actor,array $input,string $key): array
|
||||
{
|
||||
self::guard();
|
||||
$d=$this->validate($input,['kind'=>'required|in:obligation,receipt,observation,correction','invoice_id'=>'required|integer|min:1','obligation_id'=>'nullable|integer|min:1','receipt_id'=>'nullable|integer|min:1','currency'=>'required|in:MZN','amount'=>'nullable|string','source'=>'required|string|min:3|max:80','external_id'=>'required|string|min:3|max:100','date'=>'nullable|date_format:Y-m-d','evidence'=>'nullable|string|min:3|max:1000','due_date'=>'nullable|date_format:Y-m-d','reason'=>'nullable|string|min:3|max:1000','action'=>'nullable|in:reverse_allocation,reverse_reconcile,reverse_receipt,reduction,due','target_effect_id'=>'nullable|integer|min:1','document_ref'=>'nullable|string|max:100','line'=>'nullable|in:1','revision'=>'required|integer|min:0']);
|
||||
$d=$this->validate($input,['kind'=>'required|in:obligation,receipt,observation,correction','invoice_id'=>'required|integer|min:1','obligation_id'=>'nullable|integer|min:1','receipt_id'=>'nullable|integer|min:1','currency'=>'required|in:MZN','amount'=>'nullable|string','source'=>'required|string|min:3|max:80','external_id'=>'required|string|min:3|max:100','date'=>'nullable|date_format:Y-m-d','evidence'=>'nullable|string|min:3|max:1000','due_date'=>'nullable|date_format:Y-m-d','reason'=>'nullable|string|min:3|max:1000','action'=>'nullable|in:reverse_allocation,reverse_reconcile,reverse_receipt,reduction,reverse_reduction,due','target_effect_id'=>'nullable|integer|min:1','document_ref'=>'nullable|string|max:100','line'=>'nullable|in:1','revision'=>'required|integer|min:0']);
|
||||
$kind=$d['kind'];
|
||||
$right=$kind==='correction'?'correction_request':'prepare';
|
||||
if(isset($d['amount'])) $d['amount']=self::money($d['amount'],$kind!=='observation');
|
||||
@@ -121,10 +121,10 @@ final class ServiceReceivables
|
||||
abort_unless(isset($d['amount'],$d['document_ref'],$d['line']) && str_starts_with($d['document_ref'],'TEST-REDUCTION-'),422,'Documento redutor sintético e linha necessários.');
|
||||
$amount=$d['amount'];
|
||||
}
|
||||
if(in_array($d['action'],['reverse_allocation','reverse_reconcile'])) {
|
||||
if(in_array($d['action'],['reverse_allocation','reverse_reconcile','reverse_reduction'])) {
|
||||
$effect=DB::table('sr_effects')->where('obligation_id',$ob->id)->where('id',$d['target_effect_id']??0)->first();
|
||||
abort_unless($effect && $effect->kind===($d['action']==='reverse_allocation'?'allocate':'reconcile'),422,'Alvo de reversão incompatível.');
|
||||
$receipt=$this->row($effect->receipt_id,$s,'receipt'); $amount=$effect->amount;
|
||||
abort_unless($effect && $effect->kind===(match($d['action']){'reverse_allocation'=>'allocate','reverse_reduction'=>'reduction',default=>'reconcile'}),422,'Alvo de reversão incompatível.');
|
||||
if($d['action']==='reverse_reduction'){abort_unless(config('service_documents.enabled')===true&&DB::table('sd_outputs')->where($s)->where(['kind'=>'credit_note','effect_id'=>$effect->id])->exists(),403);DocumentPeriod::open($s);}else $receipt=$this->row($effect->receipt_id,$s,'receipt'); $amount=$effect->amount;
|
||||
}
|
||||
if($d['action']==='reverse_receipt') { abort_unless($receipt,422); $amount=$receipt->amount; }
|
||||
}
|
||||
@@ -153,7 +153,7 @@ final class ServiceReceivables
|
||||
if($r->kind==='correction') {
|
||||
abort_unless(($rd['revision']??-1)===$d['revision'],409,'Proposta ligada a revisão anterior; prepare nova proposta.');
|
||||
$kind=$rd['action']; $amount=$r->amount; $receipt=$r->receipt_id; $target=$rd['target_effect_id']??null;
|
||||
if($kind==='reduction' || $kind==='due') $receipt=null;
|
||||
if($kind==='reduction' || $kind==='due' || $kind==='reverse_reduction') $receipt=null;if($kind==='reverse_reduction'){DocumentPeriod::open($s);$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'service_documents.credit_apply',$s['unit_id']);}
|
||||
}
|
||||
} elseif($r->kind==='observation') abort(422,'A conferência regista correspondência ou divergência; não é aprovação de recibo.');
|
||||
return $this->effect($id,$ob,$receipt,$kind,$amount,$target,$d['revision'],$actor,$d+['due_date'=>$rd['due_date']??null]);
|
||||
@@ -188,6 +188,36 @@ final class ServiceReceivables
|
||||
return $this->effect($id,$r->obligation_id,$id,'allocate',$d['amount'],null,$d['revision'],$actor,[]);
|
||||
});
|
||||
}
|
||||
/**
|
||||
* One sealed QA invoice-receipt, using the original record/effect writers.
|
||||
* prepared_by is the actual prior declarant; actor_id on effects is the real executor.
|
||||
* No guard/context impersonation and no general delegation of prepare/allocate rights.
|
||||
*/
|
||||
public function completeDocument(int $versionId,int $invoiceId,int $actor,string $key):array
|
||||
{
|
||||
self::guard();abort_unless(config('service_documents.enabled')===true&&config('service_documents.invoice_receipt_enabled')===true&&DB::transactionLevel()>0,403);
|
||||
$s=$this->scope($actor,'confirm');foreach(['view','issue'] as $r)$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'service_documents.'.$r,$s['unit_id']);
|
||||
$v=DB::table('sd_versions')->where($s)->where(['id'=>$versionId,'kind'=>'invoice_receipt','state'=>'approved'])->first();abort_unless($v&&$v->prepared_by!==$actor,403,'Intenção financeira distinta e revista necessária.');
|
||||
$latest=DB::table('sd_versions')->where($s)->where('document_key',$v->document_key)->max('version');abort_unless($latest===$v->version,409);
|
||||
$intent=json_decode($v->data,true,32,JSON_THROW_ON_ERROR);$preparer=(int)$v->prepared_by;
|
||||
abort_unless(DB::table('users')->where('id',$preparer)->lockForUpdate()->exists()&&DB::table('company_memberships')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'user_id'=>$preparer])->lockForUpdate()->exists(),403);
|
||||
DB::table('permission_grants')->where($s)->where('user_id',$preparer)->orderBy('id')->lockForUpdate()->get();
|
||||
foreach(['service_documents.prepare','service_receivables.view','service_receivables.prepare','service_receivables.allocate'] as $right)$this->permissions->require($preparer,$s['tenant_id'],$s['company_id'],$right,$s['unit_id']);
|
||||
$inv=\App\Shared\Context\ModuleRecords::query('si_invoices')->where($s)->where(['id'=>$invoiceId,'draft_id'=>$intent['draft_id'],'series_id'=>$intent['series_id'],'rule_id'=>$intent['rule_id'],'actor_id'=>$actor])->first();abort_unless($inv,403,'A emissão deve pertencer à origem selada e ao executor actual.');
|
||||
$snap=json_decode($inv->snapshot,true,64,JSON_THROW_ON_ERROR);abort_unless(($snap['simulation_only']??null)===true&&($snap['notice']??null)===self::NOTICE&&($snap['calculation']['total_document_proposed']??null)==='1000.00'&&($snap['calculation']['currency']??null)==='MZN',422);
|
||||
abort_if(DB::table('sr_records')->where('invoice_id',$invoiceId)->exists()||DB::table('sd_outputs')->where('invoice_id',$invoiceId)->exists(),409,'Origem já utilizada.');
|
||||
$history=DB::table('a_history')->where($s)->where(['area'=>'billing_drafts','record_id'=>$inv->draft_id,'version'=>$snap['document']['draft_version']])->value('snapshot');abort_unless($history,409);$client=(int)json_decode($history,true)['client_id'];
|
||||
$source='SIM-FACTURA-RECIBO';$external='SD-'.$v->document_key;$reason='Conclusão atómica da intenção financeira QA revista: '.$v->document_key;
|
||||
$ob=$this->record($s,$preparer,'obligation',$inv,$client,null,null,'1000.00',$source,$external.'-OB',['due_date'=>null,'document_intent_id'=>$v->id]);$obId=$ob['id'];
|
||||
$this->effect($obId,$obId,null,'open','1000.00',null,0,$actor,['reason'=>$reason,'document_intent_id'=>$v->id]);
|
||||
$rc=$this->record($s,$preparer,'receipt',$inv,$client,$obId,null,'1000.00',$source,$external.'-RC',['date'=>$intent['payment_date'],'evidence'=>$intent['evidence'],'document_intent_id'=>$v->id]);
|
||||
$this->effect($rc['id'],$obId,null,'receipt_confirm','1000.00',null,1,$actor,['reason'=>$reason,'document_intent_id'=>$v->id]);
|
||||
$allocation=$this->effect($rc['id'],$obId,$rc['id'],'allocate','1000.00',null,2,$actor,['reason'=>$reason,'document_intent_id'=>$v->id,'aggregate_authority'=>'service_documents.issue']);
|
||||
$result=['id'=>$obId,'obligation_id'=>$obId,'receipt_id'=>$rc['id'],'allocation_id'=>$allocation['id'],'event'=>'invoice_receipt.simulation','balance'=>'0.00'];
|
||||
$command=DB::table('sr_commands')->insertGetId($s+['actor_id'=>$actor,'action'=>'document_issue','command_key'=>$key,'request_hash'=>hash('sha256',$this->json([$v->id,$invoiceId,$intent])),'result'=>$this->json($result),'created_at'=>now()]);
|
||||
$event=DB::table('sr_events')->insertGetId(['command_id'=>$command,'type'=>'invoice_receipt.simulation','schema_version'=>1,'payload'=>$this->json($s+['result'=>$result,'prepared_by'=>$preparer,'executed_by'=>$actor,'document_intent_id'=>$v->id]),'created_at'=>now()]);DB::table('sr_outbox')->insert(['event_id'=>$event,'state'=>'captured_simulation_only']);
|
||||
Audit::record('service_receivables.document_issue',$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['command_id'=>$command,'prepared_by'=>$preparer,'document_intent_id'=>$v->id,'simulation_only'=>true]);return $result;
|
||||
}
|
||||
private function state(int $ob,?string $cut=null): array
|
||||
{
|
||||
$r=DB::table('sr_records')->find($ob); $rd=$this->data($r);
|
||||
@@ -204,7 +234,7 @@ final class ServiceReceivables
|
||||
$x=BigDecimal::of($e->amount)->multipliedBy($e->kind==='allocate'?1:-1); $allocated=$allocated->plus($x);
|
||||
$rc=&$receipts[$e->receipt_id]; $rc['allocated']=(string)BigDecimal::of($rc['allocated'])->plus($x)->toScale(2); $rc['available']=(string)BigDecimal::of($rc['available'])->minus($x)->toScale(2); unset($rc);
|
||||
}
|
||||
if($e->kind==='reduction') $reduced=$reduced->plus($e->amount);
|
||||
if($e->kind==='reduction') $reduced=$reduced->plus($e->amount);if($e->kind==='reverse_reduction') $reduced=$reduced->minus($e->amount);
|
||||
if($e->kind==='reconcile') $receipts[$e->receipt_id]['reconciled']=true;
|
||||
if($e->kind==='reverse_reconcile') $receipts[$e->receipt_id]['reconciled']=false;
|
||||
if($e->kind==='reverse_receipt') { $receipts[$e->receipt_id]['state']='reversed'; $receipts[$e->receipt_id]['available']='0.00'; }
|
||||
|
||||
@@ -89,6 +89,7 @@ final class ServiceWorkflow
|
||||
$old = $id ? $this->query($area, $scope)->where('id', $id)->first() : null;
|
||||
if ($id) {
|
||||
abort_unless($old, 403, 'Registo fora do âmbito autorizado.');
|
||||
if(in_array($area,['service_drafts','billing_drafts'],true)&&\Illuminate\Support\Facades\Schema::hasTable('cq_conversions')){$column=$area==='service_drafts'?'service_draft_id':'billing_draft_id';abort_if(DB::table('cq_conversions')->where($column,$id)->exists(),409,'Rascunho de orçamento convertido: preserve a proposta aceite; alterações exigem correcção própria.');}
|
||||
if (in_array($area, ['service_drafts','billing_drafts'], true) && \Illuminate\Support\Facades\Schema::hasTable('wb_generations')) {
|
||||
$column = $area === 'service_drafts' ? 'service_draft_id' : 'billing_draft_id';
|
||||
abort_if(\App\Modules\Workshop\WorkshopCommercialState::linked($area, $id),409,'Rascunho ligado a oficina: correcção comercial própria necessária; a proposta revista não pode ser alterada por edição directa.');
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
namespace App\Modules\Workshop;
|
||||
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use App\Shared\Installation\CompositionRuntime;
|
||||
use App\Modules\Purchasing\ReceiptRuntime;
|
||||
use Illuminate\Support\Facades\{DB,Auth,Schema};
|
||||
|
||||
/** Keep workshop observations in the host and physical commands in their original book. */
|
||||
final class ComposedWorkshopStockBook
|
||||
{
|
||||
private static ?object $native=null;
|
||||
private static ?int $nativeActor=null;
|
||||
public static function active():bool{return self::$native!==null;}
|
||||
public static function connection(){return self::$native??DB::connection();}
|
||||
public static function table(string $name){return self::connection()->table($name);}
|
||||
public static function hasTable(string $name):bool{return self::connection()->getSchemaBuilder()->hasTable($name);}
|
||||
public static function actor(int $hostActor):int{return self::$nativeActor??$hostActor;}
|
||||
public function run(\Closure $work):mixed
|
||||
{
|
||||
CompositionRuntime::assert();abort_unless(config('workshop_stock.composed_enabled')===true,404);
|
||||
$ctx=app(CompanyContext::class);$scope=$ctx->identifiers()+['unit_id'=>$ctx->unitId()];$host=DB::connection();$user=\App\Models\User::findOrFail($ctx->userId());$physical=CompositionRuntime::physical($host);
|
||||
abort_unless(request()->session()->get('company_context.module_id')==='workshop',409,'Seleccione Oficinas.');
|
||||
$file='C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/private/receipt-composition-'.$physical['composition'].'/workshop-stock-binding.json';$pin=config('workshop_stock.composed_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,'Ligação física não conferida.');
|
||||
$p=json_decode(file_get_contents($file),true,64,JSON_THROW_ON_ERROR);abort_unless(($p['scope']??null)===$scope&&($p['composition']??null)===$physical['composition']&&($p['source']['schema']??null)==='public',403);
|
||||
foreach(['resources','eligible_order_ids','eligible_deliveries'] as $key)abort_unless(is_array($p['source'][$key]??null)&&count($p['source'][$key])<=1000,503);
|
||||
return $host->transaction(function()use($scope,$host,$user,$p,$work){
|
||||
$host->statement("SET LOCAL lock_timeout='5s'");$host->table('companies')->where(['tenant_id'=>$scope['tenant_id'],'id'=>$scope['company_id']])->lockForUpdate()->firstOrFail();
|
||||
return app(ReceiptRuntime::class)->run('stock.view',function($actor,$binding)use($scope,$host,$user,$p,$work){
|
||||
$native=DB::connection();$marker=$native->table('gl_sgf_composition')->where('tenant_id',$scope['tenant_id'])->first();abort_unless($marker&&$marker->host_database===$host->getDatabaseName()&&(int)$marker->company_id===$scope['company_id']&&(int)$marker->unit_id===$scope['unit_id'],403);
|
||||
foreach($p['source']['resources'] as $resource)abort_if(array_diff($resource['warehouse_ids'],$binding['warehouse_ids']??[]),403);
|
||||
// Physical authors are native warehouse identities, never coincident host IDs.
|
||||
if(request()->isMethod('POST')&&str_ends_with(request()->path(),'/confirm'))abort_unless($actor->hasFunctions(['warehouse']),403);
|
||||
$default=DB::getDefaultConnection();$guard=Auth::getDefaultDriver();$environment=app()->environment();$old=config('workshop_stock.synthetic_sources');
|
||||
self::$native=$native;self::$nativeActor=(int)$actor->id;DB::setDefaultConnection($host->getName());Schema::clearResolvedInstance('db.schema');Auth::shouldUse('web');Auth::guard('web')->setUser($user);app()->instance('env','budget-testing');config(['workshop_stock.synthetic_sources.'.implode(':',$scope)=>$p['source']]);
|
||||
try{return $work();}finally{self::$native=null;self::$nativeActor=null;config(['workshop_stock.synthetic_sources'=>$old]);DB::setDefaultConnection($default);Schema::clearResolvedInstance('db.schema');Auth::shouldUse($guard);Auth::guard($guard)->setUser($actor);app()->instance('env',$environment);}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
namespace App\Modules\Workshop\Http;
|
||||
use App\Modules\Workshop\WorkshopEquipment;use Illuminate\Http\Request;
|
||||
final class WorkshopEquipmentController
|
||||
{
|
||||
public function index(Request $r,WorkshopEquipment $w){return response()->json($w->listing($r->user()->id,$r->query()))->header('Cache-Control','no-store, private');}
|
||||
public function preview(Request $r,WorkshopEquipment $w){return response()->json($w->preview($r->user()->id,$r->all(),strtolower((string)$r->header('Idempotency-Key'))))->header('Cache-Control','no-store');}
|
||||
public function confirm(Request $r,WorkshopEquipment $w){$d=$r->validate(['preview'=>'required|string|max:65536']);abort_if(array_diff(array_keys($r->all()),['preview']),422);try{return response()->json($w->confirm($r->user()->id,$d['preview']))->header('Cache-Control','no-store');}catch(\Illuminate\Database\QueryException $e){if(!in_array($e->errorInfo[0]??null,['55P03','57014'],true))throw $e;return response()->json(['message'=>'Registo ocupado. Recupere a mesma confirmação.'],503)->header('Retry-After','5');}}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
namespace App\Modules\Workshop\Http;
|
||||
use App\Modules\Workshop\WorkshopOrderRead;
|
||||
use Illuminate\Http\Request;
|
||||
final class WorkshopOrderReadController
|
||||
{
|
||||
public function index(Request $request,WorkshopOrderRead $reader){return response()->json($reader->listing($request->user()->id,$request->query()))->header('Cache-Control','private, no-store');}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
namespace App\Modules\Workshop\Http;
|
||||
use App\Modules\Workshop\WorkshopSchedule;
|
||||
use Illuminate\Http\Request;
|
||||
final class WorkshopScheduleController
|
||||
{
|
||||
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'=>'Agenda ocupada. Recupere a mesma confirmação.'],503)->header('Retry-After','5');}}
|
||||
public function index(Request $r,WorkshopSchedule $w){return $this->response(fn()=>$w->listing($r->user()->id,$r->query()));}
|
||||
public function options(Request $r,WorkshopSchedule $w){return $this->response(fn()=>$w->options($r->user()->id,$r->query()));}
|
||||
public function preview(Request $r,WorkshopSchedule $w){return $this->response(fn()=>$w->preview($r->user()->id,$r->all(),strtolower((string)$r->header('Idempotency-Key'))));}
|
||||
public function confirm(Request $r,WorkshopSchedule $w){$d=$r->validate(['preview'=>'required|string|max:65536']);abort_if(array_diff(array_keys($r->all()),['preview']),422);return $this->response(fn()=>$w->confirm($r->user()->id,$d['preview']));}
|
||||
public function history(Request $r,WorkshopSchedule $w,string $key){$d=$r->validate(['page'=>'sometimes|integer|min:1|max:100000']);abort_if(array_diff(array_keys($r->query()),['page']),422);return $this->response(fn()=>$w->history($r->user()->id,$key,(int)($d['page']??1)));}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ final class WorkshopStockController
|
||||
{
|
||||
private function response(callable $read)
|
||||
{
|
||||
try{return response()->json($read())->header('Cache-Control','no-store');}
|
||||
try{return config('workshop_stock.composed_enabled')===true?app(\App\Modules\Workshop\ComposedWorkshopStockBook::class)->run(fn()=>response()->json($read())->header('Cache-Control','no-store')):response()->json($read())->header('Cache-Control','no-store');}
|
||||
catch(\Illuminate\Database\QueryException $e){
|
||||
$code=$e->errorInfo[0]??null;
|
||||
if($code==='55P03')return response()->json(['code'=>'workshop_stock_busy','message'=>'Consumo ocupado por outra operação. Aguarde e repita manualmente; o estado será revalidado.'],423)->header('Cache-Control','no-store');
|
||||
@@ -15,7 +15,7 @@ final class WorkshopStockController
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
public function index(Request $r,WorkshopStock $stock){$d=$r->validate(['page'=>'sometimes|integer|min:1|max:100000','proposal_page'=>'sometimes|integer|min:1|max:100000','status'=>'sometimes|in:all,pending,confirmed,withdrawn,rejected']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);return $this->response(fn()=>\Illuminate\Support\Facades\DB::transaction(function()use($stock,$d){\Illuminate\Support\Facades\DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');return $stock->listing((int)($d['page']??1),(int)($d['proposal_page']??1),$d['status']??'all');}));}
|
||||
public function index(Request $r,WorkshopStock $stock){$d=$r->validate(['page'=>'sometimes|integer|min:1|max:100000','proposal_page'=>'sometimes|integer|min:1|max:100000','status'=>'sometimes|in:all,pending,confirmed,withdrawn,rejected']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);return $this->response(fn()=>\Illuminate\Support\Facades\DB::transaction(function()use($stock,$d){if(!\App\Modules\Workshop\ComposedWorkshopStockBook::active())\Illuminate\Support\Facades\DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');return $stock->listing((int)($d['page']??1),(int)($d['proposal_page']??1),$d['status']??'all');}));}
|
||||
public function withdraw(Request $r,int $id,WorkshopStock $stock){return $this->close($r,$id,$stock,'withdrawn');}
|
||||
public function reject(Request $r,int $id,WorkshopStock $stock){return $this->close($r,$id,$stock,'rejected');}
|
||||
private function close(Request $r,int $id,WorkshopStock $stock,string $state){$d=$r->validate(['state_version'=>'required|integer|min:1|max:100000','source_hash'=>'required|string|regex:/^[a-f0-9]{64}$/D','reason'=>'required|string|min:10|max:1000']);abort_if(array_diff(array_keys($r->all()),array_keys($d)),422);return $this->response(fn()=>$stock->close($id,$state,(int)$d['state_version'],$d['source_hash'],$d['reason']));}
|
||||
@@ -26,3 +26,4 @@ final class WorkshopStockController
|
||||
public function prepareReturn(Request $r,WorkshopStock $stock){return $this->delivery($r,$stock,false,true);}
|
||||
private function delivery(Request $r,WorkshopStock $stock,bool $partial,bool $returning=false){$d=$r->validate(['entry_id'=>'required|integer|min:1','version'=>'required|integer|min:1','warehouse_id'=>'required|integer|min:1','movement_id'=>'required|integer|min:1','reason'=>'required|string|min:10|max:1000','evidence'=>'required|string|min:3|max:500']);abort_if(array_diff(array_keys($r->all()),array_keys($d)),422);$key=(string)$r->header('Idempotency-Key');abort_unless(\Illuminate\Support\Str::isUuid($key),422,'Chave de repetição obrigatória.');return $this->response(fn()=>$stock->prepareDelivery($d,$key,$partial,$returning));}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,12 @@ final class WorkshopBilling {
|
||||
foreach(['clients.view','services.view'] as $right)$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],$right);return $s;
|
||||
}
|
||||
private function order(int $id,array $s): object{return DB::table('wo_orders')->where($s)->where('id',$id)->first()??abort(403,'Ordem fora do contexto.');}
|
||||
private function commercial(\Closure $work):mixed{
|
||||
if(config('sgf_single_installation.enabled')!==true)return $work();
|
||||
\App\Shared\Installation\CompositionRuntime::assert();$modules=app(\App\Shared\Context\ModuleNavigation::class)->available($this->context);abort_unless(in_array('commercial',array_column($modules,'id'),true),403,'Comercial não autorizado neste contexto.');
|
||||
$request=request();$old=$request->attributes->get('sgf_module');$request->attributes->set('sgf_module','commercial');try{return $work();}finally{if($old===null)$request->attributes->remove('sgf_module');else $request->attributes->set('sgf_module',$old);}
|
||||
}
|
||||
private function catalogue(string $table){return $this->commercial(fn()=>\App\Shared\Context\ModuleRecords::query($table));}
|
||||
private function source(int $id,int $version,int $order): object {
|
||||
$e=DB::table('wo_entries')->where(['id'=>$id,'version'=>$version,'order_id'=>$order])->first()??abort(409,'Origem/versão incompatível.');$e->data=json_decode($e->data,true);
|
||||
abort_unless(!$e->outside_authority&&!DB::table('wo_entries')->where('previous_id',$id)->exists()&&DB::table('wo_decisions')->where(['entry_id'=>$id,'entry_version'=>$version,'decision'=>'verified'])->exists(),409,'Origem não verificada ou desactualizada.');
|
||||
@@ -35,13 +41,13 @@ final class WorkshopBilling {
|
||||
}
|
||||
private function compose(int $order,array $input,array $s,bool $allowClaim=false): array {
|
||||
$o=$this->order($order,$s);abort_unless($input['client_id']===$o->client_id&&$input['currency']==='MZN',409,'Cliente ou moeda incompatível.');
|
||||
$client=\App\Shared\Context\ModuleRecords::query('a_clients')->where(['id'=>$o->client_id,'tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->first()??abort(409);
|
||||
$client=$this->catalogue('a_clients')->where(['id'=>$o->client_id,'tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->first()??abort(409);
|
||||
abort_unless(count($input['lines'])>0,422);$seen=[];$lines=[];
|
||||
foreach($input['lines'] as $line){
|
||||
$d=$this->exact($line,['entry_id'=>'required|integer|min:1','entry_version'=>'required|integer|min:1','service_id'=>'required|integer|min:1','service_version'=>'required|integer|min:1','price'=>'required|string','price_source'=>'required|string|min:3|max:120']);
|
||||
$e=$this->source($d['entry_id'],$d['entry_version'],$order);abort_if(isset($seen[$e->origin]),409,'Origem repetida.');$seen[$e->origin]=true;
|
||||
abort_if(!$allowClaim&&DB::table('wb_claims')->where(['order_id'=>$order,'origin'=>$e->origin])->exists(),409,'Origem já utilizada comercialmente.');
|
||||
$service=\App\Shared\Context\ModuleRecords::query('a_services')->where(['id'=>$d['service_id'],'version'=>$d['service_version'],'tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->first()??abort(409,'Serviço/versão incompatível.');
|
||||
$service=$this->catalogue('a_services')->where(['id'=>$d['service_id'],'version'=>$d['service_version'],'tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->first()??abort(409,'Serviço/versão incompatível.');
|
||||
$price=DraftValues::decimal($d['price'],'price');abort_if($price===null,422,'Preço desconhecido não permite gerar.');
|
||||
$quantity=$e->kind==='time'?Exact::text(Exact::hours($e->minutes)):$e->quantity;$quantity=DraftValues::decimal($quantity,'quantity',true);
|
||||
$lines[]=['entry_id'=>$e->id,'entry_version'=>$e->version,'origin'=>$e->origin,'task_id'=>$e->task_id,'source_kind'=>$e->kind,'source_quantity'=>$e->kind==='time'?(string)$e->minutes:$e->quantity,'unit'=>$e->kind==='time'?'H':$e->data['unit'],'service_id'=>$service->id,'service_version'=>$service->version,'quantity'=>$quantity,'unit_price'=>$price,'price_source'=>$d['price_source'],'description'=>"Oficina #{$order} · origem #{$e->id} v{$e->version} · ".($e->kind==='time'?'Trabalho efectivo':$e->data['resource'])];
|
||||
@@ -57,7 +63,7 @@ final class WorkshopBilling {
|
||||
}
|
||||
public function prepare(int $order,int $actor,array $in,string $key): array {
|
||||
self::guard();$d=$this->exact($in,['version'=>'required|integer|min:1','previous_id'=>'present|nullable|integer|min:1','client_id'=>'required|integer|min:1','currency'=>'required|in:MZN','lines'=>'required|array|min:1|max:50','evidence'=>'required|string|min:3|max:1000']);
|
||||
return $this->command($order,$actor,'prepare',$d,$key,function($s)use($order,$actor,$d){abort_if(DB::table('wb_generations')->where('order_id',$order)->exists(),409,'Ordem já convertida; exige correcção própria.');$last=DB::table('wb_proposals')->where('order_id',$order)->orderByDesc('version')->first();abort_unless($d['version']===($last?->version??0)+1&&$d['previous_id']===$last?->id,409,'Rever versão anterior.');$snapshot=$this->compose($order,$d,$s);$id=DB::table('wb_proposals')->insertGetId(['order_id'=>$order,'version'=>$d['version'],'previous_id'=>$last?->id,'snapshot'=>$this->json($snapshot),'actor_id'=>$actor,'created_at'=>now()]);return ['id'=>$id,'version'=>$d['version']];});
|
||||
return $this->command($order,$actor,'prepare',$d,$key,function($s)use($order,$actor,$d){abort_if(\Illuminate\Support\Facades\Schema::hasTable('cq_conversions')&&DB::table('cq_conversions')->where('source_order_id',$order)->exists(),409,'Ordem convertida por orçamento; não duplicar a facturação.');abort_if(DB::table('wb_generations')->where('order_id',$order)->exists(),409,'Ordem já convertida; exige correcção própria.');$last=DB::table('wb_proposals')->where('order_id',$order)->orderByDesc('version')->first();abort_unless($d['version']===($last?->version??0)+1&&$d['previous_id']===$last?->id,409,'Rever versão anterior.');$snapshot=$this->compose($order,$d,$s);$id=DB::table('wb_proposals')->insertGetId(['order_id'=>$order,'version'=>$d['version'],'previous_id'=>$last?->id,'snapshot'=>$this->json($snapshot),'actor_id'=>$actor,'created_at'=>now()]);return ['id'=>$id,'version'=>$d['version']];});
|
||||
}
|
||||
private function proposal(int $order,array $d): object {$p=DB::table('wb_proposals')->where(['id'=>$d['id'],'order_id'=>$order,'version'=>$d['version']])->first()??abort(409,'Proposta/versão incompatível.');abort_if(DB::table('wb_proposals')->where('previous_id',$p->id)->exists(),409,'Proposta desactualizada.');return $p;}
|
||||
private function recheck(object $p,array $s,bool $allowClaim=false): array {
|
||||
@@ -69,11 +75,10 @@ final class WorkshopBilling {
|
||||
}
|
||||
public function generate(int $order,int $actor,array $in,string $key): array {
|
||||
self::guard();$d=$this->exact($in,['id'=>'required|integer|min:1','version'=>'required|integer|min:1']);
|
||||
return $this->command($order,$actor,'generate',$d,$key,function($s)use($order,$actor,$d,$key){$p=$this->proposal($order,$d);abort_unless(DB::table('wb_decisions')->where(['proposal_id'=>$p->id,'version'=>$p->version,'decision'=>'approved'])->exists(),409,'Revisão exacta necessária.');abort_if(DB::table('wb_generations')->where('order_id',$order)->exists(),409,'Ordem já convertida.');$snap=$this->recheck($p,$s);
|
||||
return $this->command($order,$actor,'generate',$d,$key,function($s)use($order,$actor,$d,$key){$p=$this->proposal($order,$d);abort_unless(DB::table('wb_decisions')->where(['proposal_id'=>$p->id,'version'=>$p->version,'decision'=>'approved'])->exists(),409,'Revisão exacta necessária.');abort_if(\Illuminate\Support\Facades\Schema::hasTable('cq_conversions')&&DB::table('cq_conversions')->where('source_order_id',$order)->exists(),409,'Ordem convertida por orçamento; não duplicar a facturação.');abort_if(DB::table('wb_generations')->where('order_id',$order)->exists(),409,'Ordem já convertida.');$snap=$this->recheck($p,$s);
|
||||
$this->workflow->scope('service_drafts',$actor,'create');$this->workflow->scope('billing_drafts',$actor,'create');
|
||||
$lines=array_map(fn($l)=>array_intersect_key($l,array_flip(['service_id','quantity','unit_price','description'])),$snap['lines']);$name='Oficina #'.$order.' · proposta #'.$p->id.' v'.$p->version;
|
||||
$service=$this->workflow->save('service_drafts',null,$actor,['client_id'=>$snap['client_id'],'name'=>$name,'currency'=>'MZN','lines'=>$lines,'description'=>'Origem rastreável de oficina; sem validade fiscal.'],substr(hash('sha256','wb-service-'.$key),0,40));
|
||||
$billing=$this->workflow->save('billing_drafts',null,$actor,['source_id'=>$service['id'],'name'=>$name],substr(hash('sha256','wb-billing-'.$key),0,40));
|
||||
[$service,$billing]=$this->commercial(function()use($actor,$snap,$name,$lines,$key){$service=$this->workflow->save('service_drafts',null,$actor,['client_id'=>$snap['client_id'],'name'=>$name,'currency'=>'MZN','lines'=>$lines,'description'=>'Origem rastreável de oficina; sem validade fiscal.'],substr(hash('sha256','wb-service-'.$key),0,40));$billing=$this->workflow->save('billing_drafts',null,$actor,['source_id'=>$service['id'],'name'=>$name],substr(hash('sha256','wb-billing-'.$key),0,40));return [$service,$billing];});
|
||||
$id=DB::table('wb_generations')->insertGetId(['order_id'=>$order,'proposal_id'=>$p->id,'service_draft_id'=>$service['id'],'billing_draft_id'=>$billing['id'],'actor_id'=>$actor,'created_at'=>now()]);foreach($snap['lines'] as $l)DB::table('wb_claims')->insert(['generation_id'=>$id,'order_id'=>$order,'origin'=>$l['origin'],'entry_id'=>$l['entry_id'],'entry_version'=>$l['entry_version']]);return ['id'=>$id,'version'=>$p->version,'service_draft_id'=>$service['id'],'billing_draft_id'=>$billing['id'],'base_exact'=>$snap['base_exact']];});
|
||||
}
|
||||
private function generationState(int $order): ?object {
|
||||
@@ -86,6 +91,7 @@ final class WorkshopBilling {
|
||||
$s=$this->scope($actor,'view');if(!ServiceCalculation::isolated())return ['blocked'=>true,'orders'=>[]];self::guard();
|
||||
return DB::transaction(function()use($s){DB::table('companies')->where('id',$s['company_id'])->lockForUpdate()->firstOrFail();$orders=[];foreach(DB::table('wo_orders')->where($s)->get() as $o){$entries=[];foreach(DB::table('wo_entries')->where('order_id',$o->id)->whereIn('kind',['time','material'])->get() as $e){try{$entries[]=$this->source($e->id,$e->version,$o->id);}catch(\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface){}}
|
||||
$proposals=DB::table('wb_proposals')->where('order_id',$o->id)->orderBy('version')->get()->all();foreach($proposals as $p){$p->snapshot=json_decode($p->snapshot,true);$p->decision=DB::table('wb_decisions')->where('proposal_id',$p->id)->first();$p->current=!DB::table('wb_proposals')->where('previous_id',$p->id)->exists();if($p->current){try{$this->recheck($p,$s,true);}catch(\Throwable){$p->current=false;}}}
|
||||
$orders[]=(array)$o+['entries'=>$entries,'proposals'=>$proposals,'generation'=>$this->generationState($o->id)];}return ['blocked'=>false,'orders'=>$orders,'services'=>\App\Shared\Context\ModuleRecords::query('a_services')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->get()->all()];});
|
||||
$orders[]=(array)$o+['entries'=>$entries,'proposals'=>$proposals,'generation'=>$this->generationState($o->id)];}return ['blocked'=>false,'orders'=>$orders,'services'=>$this->catalogue('a_services')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->get()->all()];});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
namespace App\Modules\Workshop;
|
||||
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};
|
||||
final class WorkshopEquipment
|
||||
{
|
||||
public function __construct(private CompanyContext $context,private Permissions $permissions){}
|
||||
private function scope(int $actor,bool $write=false):array{abort_unless(config('workshop_catalogue.enabled')===true,404);CompositionRuntime::assert();$s=$this->context->identifiers()+['unit_id'=>$this->context->unitId()];abort_unless($s['unit_id']&&$actor===$this->context->userId()&&ModuleRecords::active()==='workshop',403);$this->context->selectFor($actor,$s['tenant_id'],$s['company_id'],$s['unit_id']);foreach(['view',...($write?['open']:[])] as $right)$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'workshop.'.$right,$s['unit_id']);return $s;}
|
||||
private function manager(int $actor,array $s):bool{foreach(['open','assign','authorize','verify'] as $right)if($this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'workshop.'.$right,$s['unit_id']))return true;return false;}
|
||||
private function query(int $actor,array $s){$q=DB::table('wo_equipment as e')->where(['e.tenant_id'=>$s['tenant_id'],'e.company_id'=>$s['company_id']])->whereIn('e.client_id',ModuleRecords::query('a_clients')->select('id'));if(!$this->manager($actor,$s))$q->whereIn('e.id',DB::table('wo_orders as o')->where(['o.tenant_id'=>$s['tenant_id'],'o.company_id'=>$s['company_id'],'o.unit_id'=>$s['unit_id']])->whereRaw("(SELECT a.technician_id FROM wo_entries a WHERE a.order_id=o.id AND a.kind='assignment' ORDER BY a.id DESC LIMIT 1)=?",[$actor])->select('o.equipment_id'));return $q;}
|
||||
private function hash(mixed $v):string{return hash('sha256',json_encode($v,JSON_THROW_ON_ERROR));}
|
||||
public function listing(int $actor,array $input):array{$s=$this->scope($actor);$rules=['view'=>'sometimes|in:equipment,clients','search'=>'sometimes|nullable|string|max:100','client_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($input),array_keys($rules)),422);$d=Validator::make($input,$rules)->validate();$clients=($d['view']??'equipment')==='clients';if($clients){$this->scope($actor,true);$q=ModuleRecords::query('a_clients')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']]);$fields=['id','name','version'];}else{$q=$this->query($actor,$s)->join('a_clients as c','c.id','=','e.client_id');if(isset($d['client_id']))$q->where('e.client_id',$d['client_id']);$fields=['e.*','c.name as client_name'];}if(($d['search']??'')!==''){$needle='%'.str_replace(['!','%','_'],['!!','!%','!_'],$d['search']).'%';if($clients)$q->whereRaw("name ILIKE ? ESCAPE '!'",[$needle]);else$q->where(fn($x)=>$x->whereRaw("e.identifier ILIKE ? ESCAPE '!'",[$needle])->orWhereRaw("e.description ILIKE ? ESCAPE '!'",[$needle])->orWhereRaw("e.serial ILIKE ? ESCAPE '!'",[$needle]));}$p=$q->orderBy($clients?'id':'e.id','desc')->paginate((int)($d['per_page']??25),$fields,'page',(int)($d['page']??1));return ['records'=>$p,'can_create'=>$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'workshop.open',$s['unit_id']),'immutable_identification'=>true];}
|
||||
public function visible(int $actor,int $id):array
|
||||
{
|
||||
$s=$this->scope($actor);$row=$this->query($actor,$s)->where('e.id',$id)->join('a_clients as client',fn($j)=>$j->on('client.id','=','e.client_id')->on('client.tenant_id','=','e.tenant_id')->on('client.company_id','=','e.company_id'))->first(['e.id','e.client_id','e.identifier','e.description','e.serial','client.name as client_name']);abort_unless($row,403,'Equipamento fora do catálogo autorizado.');return(array)$row;
|
||||
}
|
||||
private function data(array $input):array{foreach(['identifier','description','serial','reason'] as $key)if(isset($input[$key])&&is_string($input[$key]))$input[$key]=trim($input[$key]);$rules=['client_id'=>'required|integer|min:1','identifier'=>'required|string|min:2|max:100','description'=>'required|string|min:3|max:300','serial'=>'nullable|string|max:100','reason'=>'required|string|min:10|max:2000'];abort_if(array_diff(array_keys($input),array_keys($rules)),422);$d=Validator::make($input,$rules)->validate();return ['client_id'=>(int)$d['client_id'],'identifier'=>trim($d['identifier']),'description'=>trim($d['description']),'serial'=>isset($d['serial'])&&trim($d['serial'])!==''?trim($d['serial']):null,'reason'=>trim($d['reason'])];}
|
||||
private function client(array $s,array $d):object{return ModuleRecords::query('a_clients')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'id'=>$d['client_id']])->first()??abort(403,'Cliente sem associação explícita ao módulo e à empresa.');}
|
||||
public function preview(int $actor,array $input,string $key):array{$s=$this->scope($actor,true);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);$d=$this->data($input);$c=$this->client($s,$d);abort_if(DB::table('wo_equipment')->where(['company_id'=>$s['company_id'],'identifier'=>$d['identifier']])->exists(),409,'Identificação existente. Reutilize o equipamento.');$scope=['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'unit_id'=>$s['unit_id']];$cut=['actor'=>$actor,'scope'=>$scope,'key'=>$key,'data'=>$d,'client_version'=>(int)$c->version,'generation'=>$this->hash(request()->session()->get('company_context',[])),'observed'=>time()];return ['preview'=>Crypt::encryptString(json_encode($cut,JSON_THROW_ON_ERROR)),'impact'=>['client'=>$c->name,'identifier'=>$d['identifier'],'description'=>$d['description'],'serial'=>$d['serial'],'creates_order'=>false,'financial_effect'=>false]];}
|
||||
public function confirm(int $actor,string $token):array{try{$cut=json_decode(Crypt::decryptString($token),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}$s=$this->scope($actor,true);return DB::transaction(function()use($actor,$cut,$s){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,true);DB::table('permission_grants')->where($s+['user_id'=>$actor])->lockForUpdate()->get();abort_unless(($cut['actor']??null)===$actor&&($cut['scope']??null)===$s&&($cut['generation']??null)===$this->hash(request()->session()->get('company_context',[]))&&($cut['observed']??0)>=time()-600&&$cut['observed']<=time(),409,'Contexto/prévia alterados.');$d=$this->data($cut['data']);$c=$this->client($s,$d);$old=DB::table('wo_equipment_commands')->where($s)->where(['actor_id'=>$actor,'command_key'=>$cut['key']])->first();if($old){abort_unless($old->request_hash===$this->hash($d),409);return ['id'=>$old->equipment_id,'replayed'=>true];}abort_unless((int)$c->version===$cut['client_version'],409,'Cliente alterado. Confira novamente.');abort_if(DB::table('wo_equipment')->where(['company_id'=>$s['company_id'],'identifier'=>$d['identifier']])->exists(),409,'Identificação já registada.');$id=DB::table('wo_equipment')->insertGetId(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'client_id'=>$d['client_id'],'identifier'=>$d['identifier'],'description'=>$d['description'],'serial'=>$d['serial'],'actor_id'=>$actor,'created_at'=>now()]);DB::table('wo_equipment_commands')->insert($s+['actor_id'=>$actor,'command_key'=>$cut['key'],'request_hash'=>$this->hash($d),'equipment_id'=>$id,'reason'=>$d['reason'],'created_at'=>now()]);Audit::record('workshop.equipment.registered',$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$id,'reason'=>$d['reason']]);return ['id'=>$id,'replayed'=>false];});}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
namespace App\Modules\Workshop;
|
||||
|
||||
use App\Modules\Identity\Services\Permissions;
|
||||
use App\Shared\Context\{CompanyContext,ModuleRecords};
|
||||
use App\Shared\Installation\CompositionRuntime;
|
||||
use Illuminate\Support\Facades\{DB,Validator};
|
||||
|
||||
/** Paged observation of original orders. Never prepares, locks or confirms work. */
|
||||
final class WorkshopOrderRead
|
||||
{
|
||||
public function __construct(private CompanyContext $context,private Permissions $permissions){}
|
||||
public function listing(int $actor,array $input):array
|
||||
{
|
||||
CompositionRuntime::assert();WorkshopWorkflow::guard();
|
||||
$s=$this->context->identifiers()+['unit_id'=>$this->context->unitId()];
|
||||
abort_unless($s['unit_id']&&$actor===$this->context->userId()&&ModuleRecords::active()==='workshop',403);
|
||||
$this->context->selectFor($actor,$s['tenant_id'],$s['company_id'],$s['unit_id']);
|
||||
$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'workshop.view',$s['unit_id']);
|
||||
$rules=['equipment_id'=>'sometimes|integer|min:1','page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100','search'=>'sometimes|nullable|string|max:100','state'=>'sometimes|in:open,in_review,exception_review,records_verified','from'=>'required_with:to|date_format:Y-m-d|after_or_equal:2000-01-01|before_or_equal:2100-12-31','to'=>'required_with:from|date_format:Y-m-d|after_or_equal:from|before_or_equal:2100-12-31'];
|
||||
abort_if(array_diff(array_keys($input),array_keys($rules)),422);$d=Validator::make($input,$rules)->validate();
|
||||
$manager=false;foreach(['open','assign','authorize','verify']as$right)$manager=$manager||$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'workshop.'.$right,$s['unit_id']);
|
||||
return DB::transaction(function()use($s,$actor,$manager,$d){
|
||||
if(DB::transactionLevel()===1)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
$equipment=isset($d['equipment_id'])?app(WorkshopEquipment::class)->visible($actor,(int)$d['equipment_id']):null;
|
||||
$current="NOT EXISTS(SELECT 1 FROM wo_entries newer WHERE newer.previous_id=e.id)";
|
||||
$points="SELECT 1 FROM wo_entries e LEFT JOIN wo_decisions decision ON decision.entry_id=e.id WHERE e.order_id=o.id AND e.kind IN ('task','time','material') AND $current";
|
||||
$state="CASE WHEN NOT EXISTS($points) THEN 'open' WHEN EXISTS($points AND e.outside_authority=true) THEN 'exception_review' WHEN EXISTS($points AND (decision.id IS NULL OR decision.decision<>'verified')) THEN 'in_review' ELSE 'records_verified' END";
|
||||
$assignment="(SELECT a.technician_id FROM wo_entries a WHERE a.order_id=o.id AND a.kind='assignment' ORDER BY a.id DESC LIMIT 1)";
|
||||
$q=DB::table('wo_orders as o')->where(array_combine(array_map(fn($k)=>'o.'.$k,array_keys($s)),array_values($s)))
|
||||
->leftJoin('wo_equipment as equipment',fn($j)=>$j->on('equipment.id','=','o.equipment_id')->on('equipment.company_id','=','o.company_id')->on('equipment.tenant_id','=','o.tenant_id'))
|
||||
->leftJoin('a_clients as client',fn($j)=>$j->on('client.id','=','o.client_id')->on('client.company_id','=','o.company_id')->on('client.tenant_id','=','o.tenant_id'))
|
||||
->leftJoin('users as technician',fn($j)=>$j->whereRaw('technician.id='.$assignment));
|
||||
$period=null;if(isset($d['from'])){$start=\Carbon\CarbonImmutable::createFromFormat('!Y-m-d',$d['from'],WorkshopWorkflow::ZONE);$end=\Carbon\CarbonImmutable::createFromFormat('!Y-m-d',$d['to'],WorkshopWorkflow::ZONE)->addDay();$q->where('o.created_at','>=',$start->utc()->format('Y-m-d H:i:sP'))->where('o.created_at','<',$end->utc()->format('Y-m-d H:i:sP'));$period=['from'=>$d['from'],'to'=>$d['to'],'zone'=>WorkshopWorkflow::ZONE,'basis'=>'order_created_at_current_records','start_utc'=>$start->utc()->toIso8601String(),'end_utc_exclusive'=>$end->utc()->toIso8601String()];}
|
||||
if($equipment)$q->where('o.equipment_id',$equipment['id']);
|
||||
if(!$manager)$q->whereRaw($assignment.'=?',[$actor]);
|
||||
if(isset($d['state']))$q->whereRaw("($state)=?",[$d['state']]);
|
||||
if(($d['search']??'')!==''){$needle='%'.str_replace(['!','%','_'],['!!','!%','!_'],$d['search']).'%';$q->where(function($x)use($needle){foreach(['o.reference','equipment.identifier','equipment.description','equipment.serial','client.name','technician.name']as$i=>$column)$x->{$i?'orWhereRaw':'whereRaw'}("$column ILIKE ? ESCAPE '!'",[$needle]);});}
|
||||
$p=$q->orderByDesc('o.id')->paginate((int)($d['per_page']??25),['o.id','o.reference','o.created_at','o.client_id','o.equipment_id','client.name as client','equipment.identifier as equipment','equipment.description','equipment.serial','technician.name as technician',DB::raw("$assignment as technician_id"),DB::raw("$state as state")],'page',(int)($d['page']??1));
|
||||
$stats=DB::table('wo_entries as e')->leftJoin('wo_decisions as decision','decision.entry_id','=','e.id')->whereIn('e.order_id',$p->pluck('id')->all())->whereRaw($current)->groupBy('e.order_id')->selectRaw("e.order_id,count(*) FILTER(WHERE e.kind IN ('task','time','material')) points,count(*) FILTER(WHERE e.kind IN ('task','time','material') AND (decision.id IS NULL OR decision.decision<>'verified')) points_not_verified,coalesce(sum(e.minutes) FILTER(WHERE e.kind='time'),0) minutes_reported,coalesce(sum(e.minutes) FILTER(WHERE e.kind='time' AND decision.decision='verified'),0) minutes_verified")->get()->keyBy('order_id');
|
||||
foreach($p->items()as$row){$statsRow=$stats->get($row->id);foreach(['points','points_not_verified','minutes_reported','minutes_verified']as$key)$row->$key=(int)($statsRow?->$key??0);}
|
||||
return ['equipment'=>$equipment,'orders'=>$p,'observed_at'=>now()->utc()->toIso8601String(),'states_are'=>'current_resource_records_not_delivery_or_payment','read_only'=>true,'opening_period'=>$period,'notice'=>'Estado dos apontamentos actuais; não equivale a conclusão, entrega, facturação ou pagamento.'];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
namespace App\Modules\Workshop;
|
||||
|
||||
use App\Modules\Identity\Services\{Permissions,Audit};
|
||||
use App\Shared\Context\{CompanyContext,ModuleRecords};
|
||||
use App\Shared\Installation\CompositionRuntime;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Facades\{DB,Crypt,Validator};
|
||||
|
||||
/** An appointment reserves time only. It never authorises work, consumes stock or sends messages. */
|
||||
final class WorkshopSchedule
|
||||
{
|
||||
public function __construct(private CompanyContext $context,private Permissions $permissions){}
|
||||
private function scope(int $actor,bool $write=false):array
|
||||
{
|
||||
abort_unless(config('workshop_schedule.enabled')===true,404);CompositionRuntime::assert();
|
||||
$s=$this->context->identifiers()+['unit_id'=>$this->context->unitId()];abort_unless($actor===$this->context->userId()&&$s['unit_id']!==null&&ModuleRecords::active()==='workshop',403);
|
||||
$this->context->selectFor($actor,$s['tenant_id'],$s['company_id'],$s['unit_id']);foreach(['company.view'=>null,'unit.view'=>$s['unit_id'],'workshop.view'=>$s['unit_id']]+($write?['workshop.assign'=>$s['unit_id']]:[]) as $right=>$unit)$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],$right,$unit);return $s;
|
||||
}
|
||||
private function manager(int $actor,array $s):bool{foreach(['open','assign','authorize','verify'] as $right)if($this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'workshop.'.$right,$s['unit_id']))return true;return false;}
|
||||
private function orders(int $actor,array $s)
|
||||
{
|
||||
$q=DB::table('wo_orders as o')->where(['o.tenant_id'=>$s['tenant_id'],'o.company_id'=>$s['company_id'],'o.unit_id'=>$s['unit_id']])->whereIn('o.client_id',ModuleRecords::query('a_clients')->select('id'));
|
||||
if(!$this->manager($actor,$s))$q->whereRaw("(SELECT e.technician_id FROM wo_entries e WHERE e.order_id=o.id AND e.kind='assignment' ORDER BY e.id DESC LIMIT 1)=?",[$actor]);return $q;
|
||||
}
|
||||
private function current(array $s){return DB::table('wo_schedule_events as b')->where(['b.tenant_id'=>$s['tenant_id'],'b.company_id'=>$s['company_id'],'b.unit_id'=>$s['unit_id']])->whereNotExists(fn($q)=>$q->selectRaw('1')->from('wo_schedule_events as n')->whereColumn('n.previous_id','b.id'));}
|
||||
private function json(mixed $v):string{if(is_array($v)&&!array_is_list($v)){ksort($v);foreach($v as &$x)if(is_array($x))$x=json_decode($this->json($x),true);}return json_encode($v,JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR);}
|
||||
private function hash(mixed $v):string{return hash('sha256',$this->json($v));}
|
||||
private function time(string $value):CarbonImmutable
|
||||
{
|
||||
abort_unless(preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?(?:Z|[+-]\d{2}:\d{2})$/D',$value),422,'Indique data/hora com zona explícita.');try{$d=new \DateTimeImmutable($value);$errors=\DateTimeImmutable::getLastErrors();abort_if($errors&&($errors['warning_count']||$errors['error_count']),422);return CarbonImmutable::instance($d)->utc();}catch(\Exception){abort(422,'Data/hora inválida.');}
|
||||
}
|
||||
private function normalize(array $input):array
|
||||
{
|
||||
if(isset($input['reason'])&&is_string($input['reason']))$input['reason']=trim($input['reason']);$rules=['action'=>'required|in:book,reschedule,cancel','booking_key'=>'required|uuid','version'=>'required|integer|min:0','order_id'=>'required|integer|min:1','technician_id'=>'required|integer|min:1','starts_at'=>'required|string|max:35','ends_at'=>'required|string|max:35','reason'=>'required|string|min:10|max:2000'];abort_if(array_diff(array_keys($input),array_keys($rules)),422);$d=Validator::make($input,$rules)->validate();foreach(['version','order_id','technician_id'] as $key)$d[$key]=(int)$d[$key];$d['booking_key']=strtolower($d['booking_key']);$start=$this->time($d['starts_at']);$end=$this->time($d['ends_at']);abort_unless($end>$start&&$end<=$start->addDays(7),422,'Fim deve ser posterior e o intervalo não pode exceder sete dias.');$d['starts_at']=$start->toIso8601String();$d['ends_at']=$end->toIso8601String();return $d;
|
||||
}
|
||||
private function basis(int $actor,array $s,array $d):array
|
||||
{
|
||||
$order=$this->orders($actor,$s)->where('o.id',$d['order_id'])->first();abort_unless($order,403,'Ordem fora do âmbito autorizado.');
|
||||
abort_unless(ModuleRecords::query('a_clients')->where(['id'=>$order->client_id,'tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->exists(),403,'Cliente sem associação explícita a Oficinas.');
|
||||
$assignment=DB::table('wo_entries')->where(['order_id'=>$order->id,'kind'=>'assignment'])->orderByDesc('id')->first();
|
||||
if($d['action']!=='cancel'){abort_unless($assignment&&(int)$assignment->technician_id===$d['technician_id'],409,'Atribua primeiro o técnico na ordem; agendar não altera essa decisão.');$this->permissions->require($d['technician_id'],$s['tenant_id'],$s['company_id'],'workshop.record',$s['unit_id']);}
|
||||
$old=$this->current($s)->where('booking_key',$d['booking_key'])->first();
|
||||
abort_unless(($old?(int)$old->version:0)===$d['version'],409,'Agendamento alterado. Confira novamente.');
|
||||
abort_unless($d['action']==='book'?!$old:($old&&$old->state==='scheduled'&&$old->order_id===$order->id),409,'Agendamento incompatível ou cancelado.');
|
||||
if($d['action']==='cancel')abort_unless($d['starts_at']===CarbonImmutable::parse($old->starts_at)->utc()->toIso8601String()&&$d['ends_at']===CarbonImmutable::parse($old->ends_at)->utc()->toIso8601String()&&$d['technician_id']===$old->technician_id,409,'Cancelar conserva o intervalo e técnico originais.');
|
||||
$conflicts=$d['action']==='cancel'?[]:$this->current($s)->where('booking_key','<>',$d['booking_key'])->where('state','scheduled')->where(fn($q)=>$q->where('technician_id',$d['technician_id'])->orWhere('equipment_id',$order->equipment_id))->where('starts_at','<',$d['ends_at'])->where('ends_at','>',$d['starts_at'])->orderBy('id')->limit(10)->get(['id','booking_key','technician_id','equipment_id','starts_at','ends_at'])->all();
|
||||
$revision=(int)DB::selectOne('SELECT sgf_wo_revision(?) v',[$order->id])->v;
|
||||
return ['order'=>(array)$order,'assignment'=>$assignment?->id,'order_revision'=>$revision,'previous'=>$old?(array)$old:null,'conflicts'=>$conflicts];
|
||||
}
|
||||
private function locked(int $actor,\Closure $fn):mixed
|
||||
{
|
||||
$s=$this->scope($actor,true);return DB::transaction(function()use($actor,$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,true);DB::table('permission_grants')->where($s+['user_id'=>$actor])->lockForUpdate()->get();return $fn($s);});
|
||||
}
|
||||
private function impact(array $d,array $basis):array{return ['action'=>$d['action'],'order_reference'=>$basis['order']['reference'],'technician_id'=>$d['technician_id'],'equipment_id'=>$basis['order']['equipment_id'],'starts_at'=>$d['starts_at'],'ends_at'=>$d['ends_at'],'state'=>$d['action']==='cancel'?'cancelled':'scheduled','conflicts'=>$basis['conflicts'],'stock_effect'=>false,'financial_effect'=>false,'external_messages'=>false];}
|
||||
public function preview(int $actor,array $input,string $key):array
|
||||
{
|
||||
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,'Use uma chave UUID.');$d=$this->normalize($input);
|
||||
return $this->locked($actor,function($s)use($actor,$d,$key){abort_if(DB::table('wo_schedule_events')->where($s)->where(['actor_id'=>$actor,'command_key'=>$key])->exists(),409,'Consulte o recibo da chave já usada.');$basis=$this->basis($actor,$s,$d);abort_if($basis['conflicts']!==[],409,'Técnico ou equipamento ocupado neste intervalo.');$impact=$this->impact($d,$basis);$cut=['actor'=>$actor,'scope'=>$s,'input'=>$d,'key'=>$key,'revision'=>$this->hash($basis),'generation'=>$this->hash(request()->session()->get('company_context',[])),'observed'=>time(),'impact'=>$impact];return ['preview'=>Crypt::encryptString($this->json($cut)),'revision'=>$cut['revision'],'impact'=>$impact];});
|
||||
}
|
||||
public function confirm(int $actor,string $preview):array
|
||||
{
|
||||
try{$cut=json_decode(Crypt::decryptString($preview),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}
|
||||
return $this->locked($actor,function($s)use($actor,$cut){abort_unless(($cut['actor']??null)===$actor&&$this->hash($cut['scope']??null)===$this->hash($s)&&($cut['generation']??null)===$this->hash(request()->session()->get('company_context',[]))&&($cut['observed']??0)>=time()-600&&$cut['observed']<=time(),409,'Contexto/prévia alterados ou expirados.');$d=$this->normalize($cut['input']);$hash=$this->hash($d);$old=DB::table('wo_schedule_events')->where($s)->where(['actor_id'=>$actor,'command_key'=>$cut['key']])->first();
|
||||
if($old){abort_unless(hash_equals($old->request_hash,$hash)&&$this->orders($actor,$s)->where('o.id',$old->order_id)->exists(),409);return $this->result($old)+['replayed'=>true];}
|
||||
$basis=$this->basis($actor,$s,$d);abort_unless(hash_equals($this->hash($basis),$cut['revision'])&&$basis['conflicts']===[],409,'Disponibilidade, ordem ou versão mudou. Faça nova conferência.');abort_unless($this->hash($this->impact($d,$basis))===$this->hash($cut['impact']),409);
|
||||
$id=DB::table('wo_schedule_events')->insertGetId($s+['booking_key'=>$d['booking_key'],'version'=>$d['version']+1,'previous_id'=>$basis['previous']['id']??null,'order_id'=>$d['order_id'],'equipment_id'=>$basis['order']['equipment_id'],'technician_id'=>$d['technician_id'],'starts_at'=>$d['starts_at'],'ends_at'=>$d['ends_at'],'state'=>$d['action']==='cancel'?'cancelled':'scheduled','reason'=>$d['reason'],'actor_id'=>$actor,'command_key'=>$cut['key'],'request_hash'=>$hash,'source_revision'=>$cut['revision'],'snapshot'=>$this->json(['before'=>$basis,'after'=>$cut['impact']]),'created_at'=>now()]);Audit::record('workshop.schedule.'.$d['action'],$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['booking_key'=>$d['booking_key'],'record_id'=>$id,'version'=>$d['version']+1,'reason'=>$d['reason']]);return $this->result(DB::table('wo_schedule_events')->where('id',$id)->first())+['replayed'=>false];
|
||||
});
|
||||
}
|
||||
private function result(object $r):array{return ['id'=>$r->id,'booking_key'=>$r->booking_key,'version'=>$r->version,'state'=>$r->state,'order_id'=>$r->order_id,'starts_at'=>$r->starts_at,'ends_at'=>$r->ends_at];}
|
||||
public function listing(int $actor,array $input):array
|
||||
{
|
||||
$s=$this->scope($actor);$rules=['from'=>'required|date_format:Y-m-d','to'=>'required|date_format:Y-m-d|after_or_equal:from','page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100','state'=>'sometimes|in:scheduled,cancelled','technician_id'=>'sometimes|integer|min:1','search'=>'sometimes|nullable|string|max:100','order_id'=>'sometimes|integer|min:1'];abort_if(array_diff(array_keys($input),array_keys($rules)),422);$d=Validator::make($input,$rules)->validate();$from=CarbonImmutable::parse($d['from'],WorkshopWorkflow::ZONE)->startOfDay();$to=CarbonImmutable::parse($d['to'],WorkshopWorkflow::ZONE)->addDay()->startOfDay();abort_if($from->diffInDays($to)>93,422,'Escolha no máximo93dias.');
|
||||
$query=$this->current($s)->whereIn('b.order_id',$this->orders($actor,$s)->select('o.id'))->join('wo_orders as o','o.id','=','b.order_id')->join('users as u','u.id','=','b.technician_id')->join('wo_equipment as e','e.id','=','b.equipment_id')->where('b.starts_at','<',$to->utc()->toIso8601String())->where('b.ends_at','>',$from->utc()->toIso8601String());foreach(['state','technician_id','order_id'] as $key)if(isset($d[$key]))$query->where('b.'.$key,$d[$key]);if(($d['search']??'')!==''){$needle='%'.str_replace(['!','%','_'],['!!','!%','!_'],$d['search']).'%';$query->where(fn($q)=>$q->whereRaw("o.reference ILIKE ? ESCAPE '!'",[$needle])->orWhereRaw("e.identifier ILIKE ? ESCAPE '!'",[$needle]));}
|
||||
$page=$query->orderBy('b.starts_at')->orderBy('b.id')->paginate((int)($d['per_page']??25),['b.*','o.reference','e.identifier as equipment','u.name as technician'],'page',(int)($d['page']??1));
|
||||
$now=CarbonImmutable::now('UTC');foreach($page->items() as $row)$row->reminder=$row->state!=='scheduled'?'cancelled':(CarbonImmutable::parse($row->ends_at)<$now?'elapsed':(CarbonImmutable::parse($row->starts_at)<=$now?'in_progress':(CarbonImmutable::parse($row->starts_at)<=$now->addDay()?'within24h':'planned')));
|
||||
return ['bookings'=>$page,'can_manage'=>$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'workshop.assign',$s['unit_id']),'zone'=>WorkshopWorkflow::ZONE,'reminders'=>'local_only_no_messages','notice'=>'Agendar não autoriza trabalho nem altera a ordem.'];
|
||||
}
|
||||
public function options(int $actor,array $input):array
|
||||
{
|
||||
$s=$this->scope($actor);$d=Validator::make($input,['search'=>'sometimes|nullable|string|max:100','page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100'])->validate();abort_if(array_diff(array_keys($input),array_keys($d)),422);$q=$this->orders($actor,$s)->join('wo_equipment as e','e.id','=','o.equipment_id');if(($d['search']??'')!=='')$q->whereRaw("o.reference ILIKE ? ESCAPE '!'",['%'.str_replace(['!','%','_'],['!!','!%','!_'],$d['search']).'%']);
|
||||
$p=$q->orderByDesc('o.id')->paginate((int)($d['per_page']??25),['o.id','o.reference','e.identifier as equipment'],'page',(int)($d['page']??1));$ids=$p->pluck('id')->all();$assigned=DB::table('wo_entries as a')->join('users as u','u.id','=','a.technician_id')->whereIn('a.order_id',$ids)->where('a.kind','assignment')->whereNotExists(fn($x)=>$x->selectRaw('1')->from('wo_entries as n')->whereColumn('n.order_id','a.order_id')->where('n.kind','assignment')->whereColumn('n.id','>','a.id'))->get(['a.order_id','a.technician_id','u.name'])->keyBy('order_id');foreach($p->items() as $row)$row->assignment=$assigned->get($row->id);return ['orders'=>$p];
|
||||
}
|
||||
public function history(int $actor,string $key,int $page=1):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)&&$page>0&&$page<=100000,422);$q=DB::table('wo_schedule_events')->where($s)->where('booking_key',$key)->whereIn('order_id',$this->orders($actor,$s)->select('o.id'));abort_unless((clone$q)->exists(),403);return ['events'=>$q->orderByDesc('version')->paginate(25,['*'],'page',$page)];
|
||||
}
|
||||
}
|
||||
@@ -23,29 +23,29 @@ final class WorkshopStock
|
||||
private function binding(array $s): array
|
||||
{
|
||||
$b=config('workshop_stock.synthetic_sources.'.implode(':',$s));
|
||||
abort_unless($b&&preg_match('/^sgf_stock_read_[a-f0-9]{12}$/D',$b['schema']??'')&&!isset($b['connection'])
|
||||
abort_unless($b&&(preg_match('/^sgf_stock_read_[a-f0-9]{12}$/D',$b['schema']??'')||(ComposedWorkshopStockBook::active()&&($b['schema']??null)==='public'))&&!isset($b['connection'])
|
||||
&&($b['lots']??true)===false&&($b['reservations']??true)===false&&($b['spare_parts']??true)===false,503,'Fonte local de consumo ainda não ligada ou fora do recorte sem lotes/reservas/peças.');return $b;
|
||||
}
|
||||
private function lock(array $s): void {DB::table('companies')->where('tenant_id',$s['tenant_id'])->where('id',$s['company_id'])->lockForUpdate()->firstOrFail();}
|
||||
private function allocationTable(array $b): ?string
|
||||
{
|
||||
$table=$b['schema'].'.ws_allocations';
|
||||
if(!array_key_exists($table,$this->allocationTables))$this->allocationTables[$table]=DB::getSchemaBuilder()->hasTable($table);
|
||||
if(!array_key_exists($table,$this->allocationTables))$this->allocationTables[$table]=ComposedWorkshopStockBook::hasTable($table);
|
||||
return $this->allocationTables[$table]?$table:null;
|
||||
}
|
||||
private function returnTable(array $b): ?string
|
||||
{
|
||||
$table=$b['schema'].'.ws_returns';if(!array_key_exists($table,$this->allocationTables))$this->allocationTables[$table]=DB::getSchemaBuilder()->hasTable($table);return $this->allocationTables[$table]?$table:null;
|
||||
$table=$b['schema'].'.ws_returns';if(!array_key_exists($table,$this->allocationTables))$this->allocationTables[$table]=ComposedWorkshopStockBook::hasTable($table);return $this->allocationTables[$table]?$table:null;
|
||||
}
|
||||
private function closureTable(array $b): ?string
|
||||
{
|
||||
$table=$b['schema'].'.ws_closures';
|
||||
if(!array_key_exists($table,$this->allocationTables))$this->allocationTables[$table]=DB::getSchemaBuilder()->hasTable($table);
|
||||
if(!array_key_exists($table,$this->allocationTables))$this->allocationTables[$table]=ComposedWorkshopStockBook::hasTable($table);
|
||||
return $this->allocationTables[$table]?$table:null;
|
||||
}
|
||||
private function closure(array $s,array $b,int $id): ?object
|
||||
{
|
||||
return ($table=$this->closureTable($b))?DB::table($table)->where($s)->where('proposal_id',$id)->first():null;
|
||||
return ($table=$this->closureTable($b))?ComposedWorkshopStockBook::table($table)->where($s)->where('proposal_id',$id)->first():null;
|
||||
}
|
||||
private function proposalResult(array $s,array $b,object $p): array
|
||||
{
|
||||
@@ -61,7 +61,7 @@ final class WorkshopStock
|
||||
$right=$action==='withdrawn'?'prepare':'confirm';$s=$this->scope($right);$b=$this->binding($s);$actor=$this->context->userId();
|
||||
return DB::transaction(function()use($id,$action,$version,$sourceHash,$reason,$right,$s,$b,$actor){
|
||||
$this->lock($s);$this->scope($right);$table=$this->closureTable($b);abort_unless($table,503,'Encerramento de propostas ainda não instalado nesta fonte.');
|
||||
$p=DB::table($b['schema'].'.ws_proposals')->where($s)->where('id',$id)->lockForUpdate()->first();abort_unless($p,403);
|
||||
$p=ComposedWorkshopStockBook::table($b['schema'].'.ws_proposals')->where($s)->where('id',$id)->lockForUpdate()->first();abort_unless($p,403);
|
||||
abort_unless($action==='withdrawn'?(int)$p->preparer_id===$actor:(int)$p->preparer_id!==$actor,403,'Retirada exige o preparador; recusa exige outra identidade autorizada.');
|
||||
abort_unless($version===1&&hash_equals($p->source_hash,$sourceHash),409,'A proposta apresentada mudou. Consulte novamente o histórico.');
|
||||
if($old=$this->closure($s,$b,$id)){
|
||||
@@ -70,18 +70,18 @@ final class WorkshopStock
|
||||
}
|
||||
abort_if($this->proposalClaim($b,$id),409,'Tratamento já confirmado; exige correcção física própria.');
|
||||
// Closing an obsolete preview deliberately does not execute or revalidate its physical effect.
|
||||
$decision=DB::table($table)->insertGetId($s+['proposal_id'=>$id,'actor_id'=>$actor,'source_hash'=>$sourceHash,'reason'=>$reason,'before_state'=>'pending','state'=>$action,'state_version'=>2,'created_at'=>now()]);
|
||||
$decision=ComposedWorkshopStockBook::table($table)->insertGetId($s+['proposal_id'=>$id,'actor_id'=>$actor,'source_hash'=>$sourceHash,'reason'=>$reason,'before_state'=>'pending','state'=>$action,'state_version'=>2,'created_at'=>now()]);
|
||||
Audit::record('workshop_stock.'.$action,$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['area'=>'workshop_stock_proposal','record_id'=>$id,'decision_id'=>$decision,'version'=>2,'review_hash'=>$sourceHash,'reason'=>$reason]);
|
||||
return ['id'=>$id,'state'=>$action,'state_version'=>2,'decision_id'=>$decision,'replayed'=>false,'physical_effect_milli'=>'0'];
|
||||
});
|
||||
}
|
||||
private function originClaimed(array $b,string $origin): bool
|
||||
{
|
||||
return DB::table($b['schema'].'.ws_claims')->where('origin_key',$origin)->exists()||(($t=$this->allocationTable($b))&&DB::table($t)->where('origin_key',$origin)->exists())||(($t=$this->returnTable($b))&&DB::table($t)->where('origin_key',$origin)->exists());
|
||||
return ComposedWorkshopStockBook::table($b['schema'].'.ws_claims')->where('origin_key',$origin)->exists()||(($t=$this->allocationTable($b))&&ComposedWorkshopStockBook::table($t)->where('origin_key',$origin)->exists())||(($t=$this->returnTable($b))&&ComposedWorkshopStockBook::table($t)->where('origin_key',$origin)->exists());
|
||||
}
|
||||
private function proposalClaim(array $b,int $id): ?object
|
||||
{
|
||||
return DB::table($b['schema'].'.ws_claims')->where('proposal_id',$id)->first()??(($t=$this->allocationTable($b))?DB::table($t)->where('proposal_id',$id)->first():null)??(($t=$this->returnTable($b))?DB::table($t)->where('proposal_id',$id)->first():null);
|
||||
return ComposedWorkshopStockBook::table($b['schema'].'.ws_claims')->where('proposal_id',$id)->first()??(($t=$this->allocationTable($b))?ComposedWorkshopStockBook::table($t)->where('proposal_id',$id)->first():null)??(($t=$this->returnTable($b))?ComposedWorkshopStockBook::table($t)->where('proposal_id',$id)->first():null);
|
||||
}
|
||||
private function source(array $s,array $b,int $id,int $version,int $warehouse,bool $lock=false,bool $existingDelivery=false,string $event='consumed'): array
|
||||
{
|
||||
@@ -92,20 +92,20 @@ final class WorkshopStock
|
||||
abort_unless((int)$e->version===$version&&!$e->outside_authority&&!DB::table('wo_entries')->where('previous_id',$id)->exists(),409,'Origem antiga ou fora da autorização. Faça nova conferência.');
|
||||
$decision=DB::table('wo_decisions')->where(['entry_id'=>$id,'entry_version'=>$version,'decision'=>'verified'])->first();abort_unless($decision&&(int)$decision->actor_id!==(int)$e->actor_id,409,'Consumo sem verificação técnica independente.');
|
||||
$map=$b['resources'][$d['resource'].'|'.$d['unit']]??null;abort_unless($map&&in_array($warehouse,$map['warehouse_ids'],true),403,'Sem correspondência explícita de recurso/unidade/armazém.');
|
||||
$item=DB::table($b['schema'].'.stock_items')->where(['tenant_id'=>$b['source_tenant_id'],'id'=>$map['stock_item_id']])->first();
|
||||
$item=ComposedWorkshopStockBook::table($b['schema'].'.stock_items')->where(['tenant_id'=>$b['source_tenant_id'],'id'=>$map['stock_item_id']])->first();
|
||||
abort_unless($item&&$item->unit===$map['stock_unit']&&strtoupper(trim($item->unit))===$d['unit'],409,'Unidade de stock incompatível; sem conversão automática.');
|
||||
abort_unless(DB::table($b['schema'].'.warehouses')->where(['tenant_id'=>$b['source_tenant_id'],'id'=>$warehouse])->exists(),403);
|
||||
abort_unless(ComposedWorkshopStockBook::table($b['schema'].'.warehouses')->where(['tenant_id'=>$b['source_tenant_id'],'id'=>$warehouse])->exists(),403);
|
||||
try{$quantity=BigDecimal::of((string)$e->quantity)->multipliedBy(1000)->toScale(0)->toInt();}catch(\Brick\Math\Exception\MathException){abort(422,'Quantidade não representável exactamente em milésimos.');}abort_unless($quantity>0,422);
|
||||
$origin=hash('sha256',json_encode([$s,$e->order_id,$e->origin],JSON_THROW_ON_ERROR));
|
||||
// Do not deduct consumption again after an unlinked physical delivery/issue.
|
||||
$unknownQuery=DB::table($b['schema'].'.stock_movements AS m')->where(['m.tenant_id'=>$b['source_tenant_id'],'m.stock_item_id'=>$item->id,'m.warehouse_id'=>$warehouse])->where('m.quantity_milli','<',0)
|
||||
$unknownQuery=ComposedWorkshopStockBook::table($b['schema'].'.stock_movements AS m')->where(['m.tenant_id'=>$b['source_tenant_id'],'m.stock_item_id'=>$item->id,'m.warehouse_id'=>$warehouse])->where('m.quantity_milli','<',0)
|
||||
->whereNotExists(fn($x)=>$x->selectRaw('1')->from($b['schema'].'.ws_claims AS c')->whereColumn('c.movement_id','m.id'));
|
||||
$accounted='0';if($t=$this->allocationTable($b))$accounted.=' + (SELECT coalesce(sum(a.quantity_milli),0) FROM '.$t.' AS a WHERE a.movement_id=m.id)';
|
||||
if($t=$this->returnTable($b))$accounted.=' + (SELECT coalesce(sum(r.quantity_milli),0) FROM '.$t.' AS r WHERE r.parent_movement_id=m.id)';
|
||||
$unknownQuery->whereRaw('('.$accounted.') < -m.quantity_milli');
|
||||
$unknown=$unknownQuery->exists();
|
||||
abort_if($unknown&&!$existingDelivery,409,'Existe saída física sem correspondência reconciliada. Não se volta a abater a entrega como consumo.');
|
||||
$book=DB::table($b['schema'].'.stock_movements')->where(['tenant_id'=>$b['source_tenant_id'],'stock_item_id'=>$item->id,'warehouse_id'=>$warehouse])->selectRaw('coalesce(sum(quantity_milli),0)::text AS quantity,coalesce(max(id),0)::text AS revision')->first();
|
||||
$book=ComposedWorkshopStockBook::table($b['schema'].'.stock_movements')->where(['tenant_id'=>$b['source_tenant_id'],'stock_item_id'=>$item->id,'warehouse_id'=>$warehouse])->selectRaw('coalesce(sum(quantity_milli),0)::text AS quantity,coalesce(max(id),0)::text AS revision')->first();
|
||||
abort_unless($existingDelivery||BigDecimal::of($book->quantity)->isGreaterThanOrEqualTo($quantity),409,'Stock físico insuficiente.');
|
||||
return ['entry'=>(array)$e,'decision'=>(array)$decision,'binding'=>$b,'origin_key'=>$origin,'stock_item_id'=>(int)$item->id,'warehouse_id'=>$warehouse,'quantity_milli'=>$quantity,'unit'=>$item->unit,'book'=>(array)$book];
|
||||
}
|
||||
@@ -118,37 +118,37 @@ final class WorkshopStock
|
||||
private function delivery(array $v,array $b,int $movementId,bool $lock=false,bool $partial=false,bool $returning=false): array
|
||||
{
|
||||
abort_unless(($b['eligible_deliveries'][$movementId]['order_id']??null)===(int)$v['entry']['order_id'],403,'Entrega sem elegibilidade explícita para esta ordem.');
|
||||
$q=DB::table($b['schema'].'.stock_movements')->where(['tenant_id'=>$b['source_tenant_id'],'id'=>$movementId,'stock_item_id'=>$v['stock_item_id'],'warehouse_id'=>$v['warehouse_id']]);if($lock)$q->lockForUpdate();
|
||||
$q=ComposedWorkshopStockBook::table($b['schema'].'.stock_movements')->where(['tenant_id'=>$b['source_tenant_id'],'id'=>$movementId,'stock_item_id'=>$v['stock_item_id'],'warehouse_id'=>$v['warehouse_id']]);if($lock)$q->lockForUpdate();
|
||||
$m=$q->first();abort_unless($m,403);
|
||||
$q=DB::table($b['schema'].'.stock_operations')->where(['tenant_id'=>$b['source_tenant_id'],'id'=>$m->stock_operation_id,'stock_item_id'=>$v['stock_item_id'],'warehouse_id'=>$v['warehouse_id']]);if($lock)$q->lockForUpdate();
|
||||
$q=ComposedWorkshopStockBook::table($b['schema'].'.stock_operations')->where(['tenant_id'=>$b['source_tenant_id'],'id'=>$m->stock_operation_id,'stock_item_id'=>$v['stock_item_id'],'warehouse_id'=>$v['warehouse_id']]);if($lock)$q->lockForUpdate();
|
||||
$o=$q->first();abort_unless($o&&$o->kind==='issue'&&$m->movement_type==='issue'&&$m->equipment_id===null&&$m->spare_part_id===null,409,'Movimento incompatível com entrega simples.');
|
||||
$snapshot=json_decode($m->snapshot,true,512,JSON_THROW_ON_ERROR);
|
||||
abort_unless(($snapshot['inventory_operation_hash']??null)===$this->hash((array)$o),409,'Entrega sem fotografia íntegra do escritor comum; exige conferência histórica própria.');
|
||||
abort_if(DB::table($b['schema'].'.stock_movements')->where(['tenant_id'=>$b['source_tenant_id'],'stock_item_id'=>$v['stock_item_id'],'warehouse_id'=>$v['warehouse_id']])->whereNotIn('movement_type',['receipt_in','issue','workshop_return_in'])->exists(),409,'Livro com estornos, transferências ou ajustes fora deste ensaio simples.');
|
||||
abort_if(ComposedWorkshopStockBook::table($b['schema'].'.stock_movements')->where(['tenant_id'=>$b['source_tenant_id'],'stock_item_id'=>$v['stock_item_id'],'warehouse_id'=>$v['warehouse_id']])->whereNotIn('movement_type',['receipt_in','issue','workshop_return_in'])->exists(),409,'Livro com estornos, transferências ou ajustes fora deste ensaio simples.');
|
||||
$returns=$this->returnTable($b);
|
||||
$q=DB::table($b['schema'].'.stock_movements AS m')->where(['m.tenant_id'=>$b['source_tenant_id'],'m.stock_item_id'=>$v['stock_item_id'],'m.warehouse_id'=>$v['warehouse_id'],'m.movement_type'=>'workshop_return_in']);
|
||||
$q=ComposedWorkshopStockBook::table($b['schema'].'.stock_movements AS m')->where(['m.tenant_id'=>$b['source_tenant_id'],'m.stock_item_id'=>$v['stock_item_id'],'m.warehouse_id'=>$v['warehouse_id'],'m.movement_type'=>'workshop_return_in']);
|
||||
if($returns)$q->whereNotExists(fn($x)=>$x->selectRaw('1')->from($returns.' AS r')->whereColumn('r.movement_id','m.id'));
|
||||
abort_if($q->exists(),409,'Entrada de devolução sem decisão rastreável.');
|
||||
$total=BigDecimal::of((string)$m->quantity_milli)->negated();
|
||||
abort_unless($total->isPositive()&&$total->isEqualTo((string)$o->quantity_milli),409,'Quantidade da entrega incompatível com a operação.');
|
||||
$allocations=$this->allocationTable($b);$used=$allocations?(string)DB::table($allocations)->where('movement_id',$movementId)->sum('quantity_milli'):'0';
|
||||
$returned=$returns?(string)DB::table($returns)->where('parent_movement_id',$movementId)->sum('quantity_milli'):'0';$remaining=$total->minus($used)->minus($returned);
|
||||
$allocations=$this->allocationTable($b);$used=$allocations?(string)ComposedWorkshopStockBook::table($allocations)->where('movement_id',$movementId)->sum('quantity_milli'):'0';
|
||||
$returned=$returns?(string)ComposedWorkshopStockBook::table($returns)->where('parent_movement_id',$movementId)->sum('quantity_milli'):'0';$remaining=$total->minus($used)->minus($returned);
|
||||
if($partial||$returning){abort_unless($returning?(($b['stock_returns']??false)&&$returns):(($b['partial_delivery_links']??false)&&$allocations),503,'Tratamento de repartição/devolução não configurado neste ensaio.');abort_unless($remaining->isGreaterThanOrEqualTo($v['quantity_milli']),409,'Quantidade não associada insuficiente. Faça nova conferência.');}
|
||||
else abort_unless($total->isEqualTo($v['quantity_milli'])&&BigDecimal::of($used)->isZero()&&BigDecimal::of($returned)->isZero(),409,'Entrega e consumo devem ter a mesma quantidade integral; use conferência de repartição quando aplicável.');
|
||||
abort_unless(\Carbon\CarbonImmutable::parse($m->moved_at)->lessThanOrEqualTo(\Carbon\CarbonImmutable::parse($v['entry']['starts_at'])),409,'A entrega deve preceder o consumo observado.');
|
||||
abort_if(DB::table($b['schema'].'.ws_claims')->where('movement_id',$movementId)->exists(),409,'Entrega já ligada a outra decisão.');
|
||||
abort_if(ComposedWorkshopStockBook::table($b['schema'].'.ws_claims')->where('movement_id',$movementId)->exists(),409,'Entrega já ligada a outra decisão.');
|
||||
$v['delivery']=['movement'=>(array)$m,'operation'=>(array)$o,'partial'=>$partial,'returning'=>$returning,'allocated_milli'=>$used,'returned_milli'=>$returned,'remaining_milli'=>(string)$remaining];return $v;
|
||||
}
|
||||
public function prepareDelivery(array $d,string $key,bool $partial=false,bool $returning=false): array
|
||||
{
|
||||
$s=$this->scope('prepare');$b=$this->binding($s);$actor=$this->context->userId();$request=['mode'=>$returning?'return_existing_delivery':($partial?'allocate_existing_delivery':'link_existing_delivery')]+$d;
|
||||
return DB::transaction(function()use($s,$b,$actor,$d,$key,$request,$partial,$returning){$this->lock($s);$this->scope('prepare');
|
||||
$old=DB::table($b['schema'].'.ws_proposals')->where($s)->where(['preparer_id'=>$actor,'command_key'=>$key])->first();
|
||||
$old=ComposedWorkshopStockBook::table($b['schema'].'.ws_proposals')->where($s)->where(['preparer_id'=>$actor,'command_key'=>$key])->first();
|
||||
if($old){abort_unless(hash_equals($old->request_hash,$this->hash($request)),409,'Chave repetida com conteúdo diferente.');return $this->proposalResult($s,$b,$old);}
|
||||
$v=$this->source($s,$b,$d['entry_id'],$d['version'],$d['warehouse_id'],true,true,$returning?'returned':'consumed');
|
||||
abort_if($this->originClaimed($b,$v['origin_key']),409,'Origem já ligada; exige correcção própria.');
|
||||
$v=$this->delivery($v,$b,$d['movement_id'],true,$partial,$returning);
|
||||
$id=DB::table($b['schema'].'.ws_proposals')->insertGetId($s+['entry_id'=>$d['entry_id'],'entry_version'=>$d['version'],'preparer_id'=>$actor,'reason'=>$d['reason'],'evidence'=>$d['evidence'],'command_key'=>$key,'request_hash'=>$this->hash($request),'source_hash'=>$this->hash($v),'snapshot'=>json_encode($v,JSON_THROW_ON_ERROR),'created_at'=>now()]);
|
||||
$id=ComposedWorkshopStockBook::table($b['schema'].'.ws_proposals')->insertGetId($s+['entry_id'=>$d['entry_id'],'entry_version'=>$d['version'],'preparer_id'=>$actor,'reason'=>$d['reason'],'evidence'=>$d['evidence'],'command_key'=>$key,'request_hash'=>$this->hash($request),'source_hash'=>$this->hash($v),'snapshot'=>json_encode($v,JSON_THROW_ON_ERROR),'created_at'=>now()]);
|
||||
Audit::record($returning?'workshop_stock.return_prepared':'workshop_stock.delivery_prepared',$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['proposal_id'=>$id,'entry_id'=>$d['entry_id'],'version'=>$d['version'],'movement_id'=>$d['movement_id'],'physical_effect'=>$returning?(string)$v['quantity_milli']:'0']);
|
||||
return ['id'=>$id,'impact'=>$this->impact($v),'reason'=>$d['reason'],'evidence'=>$d['evidence'],'source_hash'=>$this->hash($v),'state'=>'pending','state_version'=>1,'replayed'=>false];});
|
||||
}
|
||||
@@ -160,7 +160,7 @@ final class WorkshopStock
|
||||
->where(['e.kind'=>'material','e.outside_authority'=>false,'d.decision'=>'verified'])->whereRaw("e.data::jsonb->>'owner'='company'")->whereRaw(($b['stock_returns']??false)?"e.data::jsonb->>'event' IN ('consumed','returned')":"e.data::jsonb->>'event'='consumed'")
|
||||
->whereNotExists(fn($x)=>$x->selectRaw('1')->from('wo_entries AS n')->whereColumn('n.previous_id','e.id'))
|
||||
->select(['e.id','e.order_id','e.origin','e.version','e.quantity','e.data'])->orderBy('e.id')->paginate(25,['*'],'page',$page);
|
||||
$proposalQuery=DB::table($b['schema'].'.ws_proposals AS p')->where($s);
|
||||
$proposalQuery=ComposedWorkshopStockBook::table($b['schema'].'.ws_proposals AS p')->where($s);
|
||||
$closureTable=$this->closureTable($b);
|
||||
if(in_array($status,['withdrawn','rejected'],true)){
|
||||
if($closureTable)$proposalQuery->whereExists(fn($x)=>$x->selectRaw('1')->from($closureTable.' AS z')->whereColumn('z.proposal_id','p.id')->where('z.state',$status));
|
||||
@@ -172,14 +172,29 @@ final class WorkshopStock
|
||||
});
|
||||
if($status==='pending'&&$closureTable)$proposalQuery->whereNotExists(fn($x)=>$x->selectRaw('1')->from($closureTable.' AS z')->whereColumn('z.proposal_id','p.id'));
|
||||
$proposalPager=$proposalQuery->orderByDesc('p.id')->paginate(25,['p.id','p.entry_id','p.entry_version','p.preparer_id','p.reason','p.evidence','p.snapshot','p.source_hash','p.created_at'],'proposal_page',$proposalPage);$proposals=$proposalPager->getCollection();
|
||||
$keys=array_map(fn($e)=>$this->hash([$s,$e->order_id,$e->origin]),$q->items());$linked=DB::table($b['schema'].'.ws_claims')->whereIn('origin_key',$keys)->get(['origin_key','movement_id'])->keyBy('origin_key');
|
||||
if($t=$this->allocationTable($b))$linked=$linked->merge(DB::table($t)->whereIn('origin_key',$keys)->get(['origin_key','movement_id'])->keyBy('origin_key'));
|
||||
if($t=$this->returnTable($b))$linked=$linked->merge(DB::table($t)->whereIn('origin_key',$keys)->get(['origin_key','movement_id'])->keyBy('origin_key'));
|
||||
foreach($q->items() as $e)$e->movement_id=$linked->get($this->hash([$s,$e->order_id,$e->origin]))?->movement_id;
|
||||
$claims=DB::table($b['schema'].'.ws_claims')->whereIn('proposal_id',$proposals->pluck('id'))->get(['proposal_id','movement_id'])->keyBy('proposal_id');
|
||||
if($t=$this->allocationTable($b))foreach(DB::table($t)->whereIn('proposal_id',$proposals->pluck('id'))->get(['proposal_id','movement_id']) as $a)$claims->put($a->proposal_id,$a);
|
||||
if($t=$this->returnTable($b))foreach(DB::table($t)->whereIn('proposal_id',$proposals->pluck('id'))->get(['proposal_id','movement_id']) as $a)$claims->put($a->proposal_id,$a);
|
||||
$closed=$closureTable?DB::table($closureTable)->where($s)->whereIn('proposal_id',$proposals->pluck('id'))->get()->keyBy('proposal_id'):collect();
|
||||
$keys=array_map(fn($e)=>$this->hash([$s,$e->order_id,$e->origin]),$q->items());$linked=ComposedWorkshopStockBook::table($b['schema'].'.ws_claims')->whereIn('origin_key',$keys)->get(['origin_key','movement_id'])->keyBy('origin_key');
|
||||
if($t=$this->allocationTable($b))$linked=$linked->merge(ComposedWorkshopStockBook::table($t)->whereIn('origin_key',$keys)->get(['origin_key','movement_id'])->keyBy('origin_key'));
|
||||
if($t=$this->returnTable($b))$linked=$linked->merge(ComposedWorkshopStockBook::table($t)->whereIn('origin_key',$keys)->get(['origin_key','movement_id'])->keyBy('origin_key'));
|
||||
$physicalWarehouses=collect();$physicalDeliveries=collect();
|
||||
if(ComposedWorkshopStockBook::active()){
|
||||
$warehouses=[];$articles=[];foreach($q->items() as $entry){$data=json_decode($entry->data,true);$map=$b['resources'][$data['resource'].'|'.$data['unit']]??null;if($map){$warehouses=[...$warehouses,...$map['warehouse_ids']];$articles[]=$map['stock_item_id'];}}
|
||||
$orders=array_column($q->items(),'order_id');$deliveries=[];foreach($b['eligible_deliveries'] as $id=>$known)if(in_array($known['order_id'],$orders,true))$deliveries[]=(int)$id;
|
||||
$physicalWarehouses=ComposedWorkshopStockBook::table($b['schema'].'.warehouses')->where('tenant_id',$b['source_tenant_id'])->whereIn('id',array_unique($warehouses))->orderBy('id')->get(['id','code','name']);
|
||||
$physicalDeliveries=ComposedWorkshopStockBook::table($b['schema'].'.stock_movements as m')->join($b['schema'].'.stock_operations as o','o.id','=','m.stock_operation_id')->where(['m.tenant_id'=>$b['source_tenant_id'],'m.movement_type'=>'issue'])->whereIn('m.stock_item_id',array_unique($articles))->whereIn('m.id',$deliveries)->whereIn('m.warehouse_id',array_unique($warehouses))->orderBy('m.id')->get(['m.id','m.stock_item_id','o.reference','m.warehouse_id','m.quantity_milli']);
|
||||
}
|
||||
foreach($q->items() as $e){
|
||||
$e->movement_id=$linked->get($this->hash([$s,$e->order_id,$e->origin]))?->movement_id;
|
||||
if(ComposedWorkshopStockBook::active()){
|
||||
$data=json_decode($e->data,true);$map=$b['resources'][$data['resource'].'|'.$data['unit']]??null;
|
||||
$e->warehouses=$physicalWarehouses->whereIn('id',$map['warehouse_ids']??[])->values()->all();
|
||||
$deliveries=[];foreach(($b['eligible_deliveries']??[]) as $id=>$known)if($known['order_id']===$e->order_id)$deliveries[]=(int)$id;
|
||||
$e->deliveries=$physicalDeliveries->where('stock_item_id',$map['stock_item_id']??0)->whereIn('id',$deliveries)->whereIn('warehouse_id',$map['warehouse_ids']??[])->values()->all();
|
||||
}
|
||||
}
|
||||
$claims=ComposedWorkshopStockBook::table($b['schema'].'.ws_claims')->whereIn('proposal_id',$proposals->pluck('id'))->get(['proposal_id','movement_id'])->keyBy('proposal_id');
|
||||
if($t=$this->allocationTable($b))foreach(ComposedWorkshopStockBook::table($t)->whereIn('proposal_id',$proposals->pluck('id'))->get(['proposal_id','movement_id']) as $a)$claims->put($a->proposal_id,$a);
|
||||
if($t=$this->returnTable($b))foreach(ComposedWorkshopStockBook::table($t)->whereIn('proposal_id',$proposals->pluck('id'))->get(['proposal_id','movement_id']) as $a)$claims->put($a->proposal_id,$a);
|
||||
$closed=$closureTable?ComposedWorkshopStockBook::table($closureTable)->where($s)->whereIn('proposal_id',$proposals->pluck('id'))->get()->keyBy('proposal_id'):collect();
|
||||
foreach($proposals as $p){$p->impact=$this->impact(json_decode($p->snapshot,true,512,JSON_THROW_ON_ERROR));unset($p->snapshot);$p->movement_id=$claims->get($p->id)?->movement_id;$p->closure=$closed->get($p->id);$p->state=$p->closure?->state??($p->movement_id?'confirmed':'pending');$p->state_version=$p->state==='pending'?1:2;
|
||||
$p->can_withdraw=$closureTable&&$p->state==='pending'&&(int)$p->preparer_id===$this->context->userId();$p->can_review=$p->state==='pending'&&(int)$p->preparer_id!==$this->context->userId();}
|
||||
return ['sources'=>$q->items(),'pagination'=>['page'=>$q->currentPage(),'last_page'=>$q->lastPage(),'total'=>$q->total()],
|
||||
@@ -189,11 +204,11 @@ final class WorkshopStock
|
||||
{
|
||||
$s=$this->scope('prepare');$b=$this->binding($s);$actor=$this->context->userId();
|
||||
return DB::transaction(function()use($s,$b,$actor,$d,$key){$this->lock($s);$this->scope('prepare');
|
||||
$old=DB::table($b['schema'].'.ws_proposals')->where($s)->where(['preparer_id'=>$actor,'command_key'=>$key])->first();
|
||||
$old=ComposedWorkshopStockBook::table($b['schema'].'.ws_proposals')->where($s)->where(['preparer_id'=>$actor,'command_key'=>$key])->first();
|
||||
if($old){abort_unless(hash_equals($old->request_hash,$this->hash($d)),409,'Chave repetida com conteúdo diferente.');return $this->proposalResult($s,$b,$old);}
|
||||
$v=$this->source($s,$b,$d['entry_id'],$d['version'],$d['warehouse_id'],true);
|
||||
abort_if($this->originClaimed($b,$v['origin_key']),409,'Origem já consumida; correcções exigem compensação própria.');
|
||||
$id=DB::table($b['schema'].'.ws_proposals')->insertGetId($s+['entry_id'=>$d['entry_id'],'entry_version'=>$d['version'],'preparer_id'=>$actor,'reason'=>$d['reason'],'evidence'=>$d['evidence'],
|
||||
$id=ComposedWorkshopStockBook::table($b['schema'].'.ws_proposals')->insertGetId($s+['entry_id'=>$d['entry_id'],'entry_version'=>$d['version'],'preparer_id'=>$actor,'reason'=>$d['reason'],'evidence'=>$d['evidence'],
|
||||
'command_key'=>$key,'request_hash'=>$this->hash($d),'source_hash'=>$this->hash($v),'snapshot'=>json_encode($v,JSON_THROW_ON_ERROR),'created_at'=>now()]);
|
||||
Audit::record('workshop_stock.prepared',$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['proposal_id'=>$id,'entry_id'=>$d['entry_id'],'version'=>$d['version']]);
|
||||
return ['id'=>$id,'impact'=>$this->impact($v),'reason'=>$d['reason'],'evidence'=>$d['evidence'],'source_hash'=>$this->hash($v),'state'=>'pending','state_version'=>1,'replayed'=>false];});
|
||||
@@ -202,7 +217,7 @@ final class WorkshopStock
|
||||
{
|
||||
$s=$this->scope('confirm');$b=$this->binding($s);$actor=$this->context->userId();
|
||||
return DB::transaction(function()use($s,$b,$actor,$id,$reason,$receivedInWarehouse){$this->lock($s);$this->scope('confirm');
|
||||
$p=DB::table($b['schema'].'.ws_proposals')->where($s)->where('id',$id)->lockForUpdate()->first();abort_unless($p,403);abort_if((int)$p->preparer_id===$actor,403,'Confirmação deve ser independente da preparação.');
|
||||
$p=ComposedWorkshopStockBook::table($b['schema'].'.ws_proposals')->where($s)->where('id',$id)->lockForUpdate()->first();abort_unless($p,403);abort_if((int)$p->preparer_id===$actor,403,'Confirmação deve ser independente da preparação.');
|
||||
abort_if($this->closure($s,$b,$id),409,'Proposta retirada ou recusada. Faça nova conferência; este original permanece encerrado.');
|
||||
$old=$this->proposalClaim($b,$id);if($old)return ['ok'=>true,'replayed'=>true,'movement_id'=>(int)$old->movement_id,'claim_id'=>(int)$old->id];
|
||||
$frozen=json_decode($p->snapshot,true,512,JSON_THROW_ON_ERROR);$link=isset($frozen['delivery']);$returning=$link&&($frozen['delivery']['returning']??false);$v=$this->source($s,$b,(int)$p->entry_id,(int)$p->entry_version,$frozen['warehouse_id'],true,$link,$returning?'returned':'consumed');
|
||||
@@ -213,17 +228,19 @@ final class WorkshopStock
|
||||
abort_unless(hash_equals($p->source_hash,$this->hash($v)),409,'Origem, mapa ou livro mudou. Faça nova conferência.');
|
||||
if($link&&!$returning){$operation=(int)$v['delivery']['operation']['id'];$out=['movement_id'=>(int)$v['delivery']['movement']['id']];}
|
||||
else {
|
||||
$operation=DB::table($b['schema'].'.stock_operations')->insertGetId(['tenant_id'=>$b['source_tenant_id'],'created_by'=>$actor,'responsible_id'=>$actor,'warehouse_id'=>$v['warehouse_id'],'stock_item_id'=>$v['stock_item_id'],
|
||||
$operation=ComposedWorkshopStockBook::table($b['schema'].'.stock_operations')->insertGetId(['tenant_id'=>$b['source_tenant_id'],'created_by'=>ComposedWorkshopStockBook::actor($actor),'responsible_id'=>ComposedWorkshopStockBook::actor($actor),'warehouse_id'=>$v['warehouse_id'],'stock_item_id'=>$v['stock_item_id'],
|
||||
'kind'=>$returning?'workshop_return':'issue','reference'=>'SGF-WO-'.$v['origin_key'],'idempotency_key'=>(string)Str::uuid(),'payload_hash'=>$p->source_hash,'destination'=>'workshop-origin:'.$v['origin_key'],'reason'=>$reason,
|
||||
'quantity_milli'=>$v['quantity_milli'],'theoretical_milli'=>$v['book']['quantity'],'ledger_version'=>$v['book']['revision'],'recorded_at'=>now(),
|
||||
'snapshot'=>json_encode(($returning?['delivery_movement_id'=>(int)$v['delivery']['movement']['id'],'delivery_movement_hash'=>$this->hash($v['delivery']['movement']),'received_in_warehouse'=>$receivedInWarehouse]:[])+['workshop_origin'=>$v['entry'],'verification'=>$v['decision'],'proposal_id'=>$id,'preparer_id'=>$p->preparer_id,'scope'=>$s,'evidence'=>$p->evidence],JSON_THROW_ON_ERROR)]);
|
||||
$writer=$returning?\Tempo\Inventory\DeliveryReturnWriter::class:\Tempo\Inventory\IssueMovementWriter::class;
|
||||
$w=new $writer(DB::connection(),$b['schema'],$b['source_tenant_id'],$actor,function(){$this->scope('confirm');});$out=$w->write($operation,$w->preview($operation));
|
||||
$w=new $writer(ComposedWorkshopStockBook::connection(),$b['schema'],$b['source_tenant_id'],ComposedWorkshopStockBook::actor($actor),function(){$this->scope('confirm');});$out=$w->write($operation,$w->preview($operation));
|
||||
}
|
||||
$after=$this->impact($v)['physical_after'];
|
||||
$claim=DB::table($returning?$this->returnTable($b):($partial?$this->allocationTable($b):$b['schema'].'.ws_claims'))->insertGetId($s+($returning?['quantity_milli'=>$v['quantity_milli'],'parent_movement_id'=>(int)$v['delivery']['movement']['id']]:($partial?['quantity_milli'=>$v['quantity_milli']]:[]))+['origin_key'=>$v['origin_key'],'proposal_id'=>$id,'entry_id'=>$p->entry_id,'entry_version'=>$p->entry_version,'operation_id'=>$operation,'movement_id'=>$out['movement_id'],
|
||||
$claim=ComposedWorkshopStockBook::table($returning?$this->returnTable($b):($partial?$this->allocationTable($b):$b['schema'].'.ws_claims'))->insertGetId($s+($returning?['quantity_milli'=>$v['quantity_milli'],'parent_movement_id'=>(int)$v['delivery']['movement']['id']]:($partial?['quantity_milli'=>$v['quantity_milli']]:[]))+['origin_key'=>$v['origin_key'],'proposal_id'=>$id,'entry_id'=>$p->entry_id,'entry_version'=>$p->entry_version,'operation_id'=>$operation,'movement_id'=>$out['movement_id'],
|
||||
'preparer_id'=>$p->preparer_id,'confirmer_id'=>$actor,'reason'=>$reason,'before_milli'=>$v['book']['quantity'],'after_milli'=>$after,'source_hash'=>$p->source_hash,'created_at'=>now()]);
|
||||
Audit::record($returning?'workshop_stock.return_confirmed':($partial?'workshop_stock.delivery_allocated':($link?'workshop_stock.delivery_linked':'workshop_stock.confirmed')),$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['claim_id'=>$claim,'proposal_id'=>$id,'entry_id'=>$p->entry_id,'version'=>$p->entry_version,'movement_id'=>$out['movement_id'],'linked_quantity'=>$link?(string)$v['quantity_milli']:null,'remaining_after'=>($partial||$returning)?$this->impact($v)['unallocated_after']:null,'before'=>$v['book']['quantity'],'after'=>$after,'reason'=>$reason]);
|
||||
return ['ok'=>true,'replayed'=>false,'movement_id'=>$out['movement_id'],'claim_id'=>$claim];});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ class AppServiceProvider extends ServiceProvider
|
||||
|
||||
Event::listen(CommandStarting::class, function (CommandStarting $event): void {
|
||||
if (in_array($event->command, ['migrate:fresh', 'migrate:refresh', 'migrate:reset', 'migrate:rollback', 'db:wipe'], true)) {
|
||||
if($this->app->environment('budget-testing'))throw new \RuntimeException('SGF_COMPOSITION_DESTRUCTIVE_COMMAND_REFUSED');
|
||||
$connection = $event->input->hasOption('database') ? $event->input->getOption('database') : null;
|
||||
TestingDatabaseGuard::assertSafe($connection);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Sgc;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreInvoiceRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true; // Em produção, verificar token/API key
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'metadata.event_type' => 'required|in:INVOICE_CREATED',
|
||||
'metadata.timestamp' => 'required|date',
|
||||
'sgf_doc_number' => 'required|string|max:40',
|
||||
'sgf_invoice_id' => 'required|integer',
|
||||
'oc_reference' => 'required|string|max:50',
|
||||
'supplier.sgf_supplier_id' => 'required|string|max:40',
|
||||
'supplier.nuit' => 'required|string|max:20',
|
||||
'supplier.name' => 'required|string|max:200',
|
||||
'amounts.subtotal' => 'required|numeric|min:0',
|
||||
'amounts.iva_amount' => 'required|numeric|min:0',
|
||||
'amounts.total' => 'required|numeric|min:0',
|
||||
'amounts.currency' => 'required|string|size:3',
|
||||
'amounts.exchange_rate' => 'required|numeric|min:0',
|
||||
'dates.invoice_date' => 'required|date',
|
||||
'dates.due_date' => 'required|date|after_or_equal:dates.invoice_date',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'sgf_doc_number.required' => 'Número do documento SGF é obrigatório',
|
||||
'oc_reference.required' => 'Referência da OC é obrigatória',
|
||||
'amounts.subtotal.min' => 'Valor subtotal deve ser positivo',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Sgc;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StorePaymentRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'metadata.event_type' => 'required|in:PAYMENT_EXECUTED',
|
||||
'metadata.timestamp' => 'required|date',
|
||||
'sgf_doc_number' => 'required|string|max:40',
|
||||
'payment_id' => 'required|string|max:50',
|
||||
'payment_date' => 'required|date',
|
||||
'payment_method' => 'required|string|max:50',
|
||||
'amount_paid' => 'required|numeric|min:0',
|
||||
'currency' => 'required|string|size:3',
|
||||
'bank_reference' => 'required|string|max:100',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Sgc;
|
||||
|
||||
use App\Models\Sgc\OrdemCompra;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class BudgetCommitmentService
|
||||
{
|
||||
/**
|
||||
* Verifica disponibilidade orçamental no SGF
|
||||
*/
|
||||
public function verificarSaldo(string $centroCustoId, string $rubrica, float $valor): array
|
||||
{
|
||||
// Em produção, isto chamaria uma API do SGF
|
||||
// Por agora, simulamos a resposta
|
||||
return [
|
||||
'disponivel' => true,
|
||||
'saldo_atual' => 500000.00,
|
||||
'valor_solicitado' => $valor,
|
||||
'saldo_apos_compromisso' => 500000.00 - $valor,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cabimenta (compromete) orçamento no SGF
|
||||
*/
|
||||
public function cabimentar(OrdemCompra $oc): array
|
||||
{
|
||||
$verificacao = $this->verificarSaldo(
|
||||
$oc->centroCusto->sgf_centro_custo_id,
|
||||
$oc->categoria,
|
||||
$oc->valor_comprometido
|
||||
);
|
||||
|
||||
if (!$verificacao['disponivel']) {
|
||||
throw new \Exception('Saldo orçamental insuficiente');
|
||||
}
|
||||
|
||||
// Em produção, isto chamaria uma API do SGF para criar o compromisso
|
||||
// Simulamos o sucesso
|
||||
$compromissoId = 'COMP-' . strtoupper(substr(md5($oc->oc_id), 0, 8));
|
||||
|
||||
DB::transaction(function () use ($oc, $compromissoId) {
|
||||
$oc->update([
|
||||
'cabimentado' => true,
|
||||
'sgf_compromisso_id' => $compromissoId,
|
||||
]);
|
||||
});
|
||||
|
||||
Log::info('Cabimentação realizada', [
|
||||
'oc_id' => $oc->oc_id,
|
||||
'compromisso_id' => $compromissoId,
|
||||
'valor' => $oc->valor_comprometido,
|
||||
]);
|
||||
|
||||
return [
|
||||
'sucesso' => true,
|
||||
'compromisso_id' => $compromissoId,
|
||||
'saldo_apos_compromisso' => $verificacao['saldo_apos_compromisso'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancela cabimentação (quando OC é cancelada)
|
||||
*/
|
||||
public function cancelarCabimentacao(OrdemCompra $oc): bool
|
||||
{
|
||||
if (!$oc->cabimentado) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Em produção, chamaria API do SGF para cancelar o compromisso
|
||||
DB::transaction(function () use ($oc) {
|
||||
$oc->update([
|
||||
'cabimentado' => false,
|
||||
'sgf_compromisso_id' => null,
|
||||
]);
|
||||
});
|
||||
|
||||
Log::info('Cabimentação cancelada', [
|
||||
'oc_id' => $oc->oc_id,
|
||||
'compromisso_id' => $oc->sgf_compromisso_id,
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Sgc;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use Generator;
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class CalendarDimension
|
||||
{
|
||||
/** Weekdays are a calendar approximation, not an official holiday calendar. */
|
||||
public static function days(int $startYear, int $years): Generator
|
||||
{
|
||||
if ($startYear < 1 || $years < 1 || $years > 100 || $startYear + $years > 10000) throw new InvalidArgumentException('Invalid calendar interval.');
|
||||
$months = [1=>'Janeiro','Fevereiro','Março','Abril','Maio','Junho','Julho','Agosto','Setembro','Outubro','Novembro','Dezembro'];
|
||||
$weekdays = [1=>'Segunda','Terça','Quarta','Quinta','Sexta','Sábado','Domingo'];
|
||||
$date = new DateTimeImmutable(sprintf('%04d-01-01', $startYear), new DateTimeZone('Africa/Maputo'));
|
||||
while ((int)$date->format('Y') < $startYear + $years) {
|
||||
$month = (int)$date->format('n'); $weekday = (int)$date->format('N');
|
||||
yield ['data_id'=>(int)$date->format('Ymd'), 'data_completa'=>$date->format('Y-m-d'),
|
||||
'dia_semana'=>$weekday, 'nome_dia_semana'=>$weekdays[$weekday], 'dia_mes'=>(int)$date->format('j'),
|
||||
'mes'=>$month, 'nome_mes'=>$months[$month], 'trimestre'=>intdiv($month-1,3)+1,
|
||||
'ano'=>(int)$date->format('Y'), 'ano_mes'=>$date->format('Y-m'),
|
||||
'eh_dia_util'=>$weekday<=5, 'eh_feriado'=>false, 'semestre'=>$month<=6?1:2];
|
||||
$date = $date->modify('+1 day');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Sgc;
|
||||
|
||||
use App\Models\Sgc\Fatura;
|
||||
use App\Models\Sgc\Fornecedor;
|
||||
use App\Models\Sgc\OrdemCompra;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class IntegrationService
|
||||
{
|
||||
private ThreeWayMatchService $matchService;
|
||||
private BudgetCommitmentService $budgetService;
|
||||
|
||||
public function __construct(
|
||||
ThreeWayMatchService $matchService,
|
||||
BudgetCommitmentService $budgetService
|
||||
) {
|
||||
$this->matchService = $matchService;
|
||||
$this->budgetService = $budgetService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recebe fatura do SGC e processa no SGF
|
||||
*/
|
||||
public function receberFatura(array $payload, string $idempotencyKey): array
|
||||
{
|
||||
// Verificar idempotência
|
||||
$logExistente = DB::table('sgc_integration_log')
|
||||
->where('idempotency_key', $idempotencyKey)
|
||||
->first();
|
||||
|
||||
if ($logExistente) {
|
||||
return [
|
||||
'status' => 'DUPLICATE',
|
||||
'original_receipt_id' => $logExistente->id,
|
||||
'message' => 'Idempotency key já processada',
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
$resultado = DB::transaction(function () use ($payload) {
|
||||
// Buscar ou criar fornecedor
|
||||
$fornecedor = $this->sincronizarFornecedor($payload['supplier']);
|
||||
|
||||
// Buscar OC
|
||||
$oc = OrdemCompra::where('oc_id', $payload['oc_reference'])->first();
|
||||
if (!$oc) {
|
||||
throw new \Exception('OC não encontrada: ' . $payload['oc_reference']);
|
||||
}
|
||||
|
||||
// Verificar se fornecedor bate
|
||||
if ($oc->fornecedor_id !== $fornecedor->id) {
|
||||
throw new \Exception('Fornecedor não corresponde à OC');
|
||||
}
|
||||
|
||||
// Criar fatura
|
||||
$fatura = Fatura::create([
|
||||
'fatura_id' => $payload['sgf_invoice_id'],
|
||||
'ordem_compra_id' => $oc->id,
|
||||
'fornecedor_id' => $fornecedor->id,
|
||||
'data_fatura' => $payload['dates']['invoice_date'],
|
||||
'data_vencimento' => $payload['dates']['due_date'],
|
||||
'valor_fatura' => $payload['amounts']['subtotal'],
|
||||
'iva_valor' => $payload['amounts']['iva_amount'],
|
||||
'moeda' => $payload['amounts']['currency'],
|
||||
'taxa_cambio' => $payload['amounts']['exchange_rate'],
|
||||
'status_3_way_match' => 'Pendente',
|
||||
'status_pagamento' => 'Aguardando',
|
||||
'sgf_doc_number' => $payload['sgf_doc_number'],
|
||||
]);
|
||||
|
||||
// Executar 3-Way Match
|
||||
$matchResult = $this->matchService->executarMatch($fatura);
|
||||
|
||||
return [
|
||||
'status' => 'ACCEPTED',
|
||||
'fatura_id' => $fatura->id,
|
||||
'sgf_doc_number' => $fatura->sgf_doc_number,
|
||||
'match_result' => $matchResult,
|
||||
];
|
||||
});
|
||||
|
||||
// Log de sucesso
|
||||
DB::table('sgc_integration_log')->insert([
|
||||
'idempotency_key' => $idempotencyKey,
|
||||
'endpoint' => '/api/v1/integration/sgc/invoices',
|
||||
'event_type' => 'INVOICE_CREATED',
|
||||
'payload' => json_encode($payload),
|
||||
'status' => 'ACCEPTED',
|
||||
'response' => json_encode($resultado),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return $resultado;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
// Log de erro
|
||||
DB::table('sgc_integration_log')->insert([
|
||||
'idempotency_key' => $idempotencyKey,
|
||||
'endpoint' => '/api/v1/integration/sgc/invoices',
|
||||
'event_type' => 'INVOICE_CREATED',
|
||||
'payload' => json_encode($payload),
|
||||
'status' => 'REJECTED',
|
||||
'error_code' => 'PROCESSING_ERROR',
|
||||
'error_message' => $e->getMessage(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
Log::error('Erro ao processar fatura', [
|
||||
'idempotency_key' => $idempotencyKey,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return [
|
||||
'status' => 'REJECTED',
|
||||
'error_code' => 'PROCESSING_ERROR',
|
||||
'message' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recebe confirmação de pagamento do SGC
|
||||
*/
|
||||
public function receberPagamento(array $payload, string $idempotencyKey): array
|
||||
{
|
||||
// Verificar idempotência
|
||||
$logExistente = DB::table('sgc_integration_log')
|
||||
->where('idempotency_key', $idempotencyKey)
|
||||
->first();
|
||||
|
||||
if ($logExistente) {
|
||||
return [
|
||||
'status' => 'DUPLICATE',
|
||||
'original_receipt_id' => $logExistente->id,
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
$resultado = DB::transaction(function () use ($payload) {
|
||||
// Buscar fatura
|
||||
$fatura = Fatura::where('sgf_doc_number', $payload['sgf_doc_number'])->first();
|
||||
if (!$fatura) {
|
||||
throw new \Exception('Fatura não encontrada: ' . $payload['sgf_doc_number']);
|
||||
}
|
||||
|
||||
// Atualizar fatura com pagamento
|
||||
$fatura->update([
|
||||
'data_pagamento' => $payload['payment_date'],
|
||||
'valor_pago' => $payload['amount_paid'],
|
||||
'status_pagamento' => 'Pago',
|
||||
'numero_comprovativo' => $payload['bank_reference'],
|
||||
]);
|
||||
|
||||
// Atualizar OC se totalmente paga
|
||||
$oc = $fatura->ordemCompra;
|
||||
$totalPago = $oc->faturas()->where('status_pagamento', 'Pago')->sum('valor_pago');
|
||||
|
||||
if ($totalPago >= $oc->valor_comprometido) {
|
||||
$oc->update(['status' => 'Totalmente Paga']);
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 'ACCEPTED',
|
||||
'fatura_id' => $fatura->id,
|
||||
'payment_id' => $payload['payment_id'],
|
||||
];
|
||||
});
|
||||
|
||||
// Log de sucesso
|
||||
DB::table('sgc_integration_log')->insert([
|
||||
'idempotency_key' => $idempotencyKey,
|
||||
'endpoint' => '/api/v1/integration/sgc/payments',
|
||||
'event_type' => 'PAYMENT_EXECUTED',
|
||||
'payload' => json_encode($payload),
|
||||
'status' => 'ACCEPTED',
|
||||
'response' => json_encode($resultado),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return $resultado;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
DB::table('sgc_integration_log')->insert([
|
||||
'idempotency_key' => $idempotencyKey,
|
||||
'endpoint' => '/api/v1/integration/sgc/payments',
|
||||
'event_type' => 'PAYMENT_EXECUTED',
|
||||
'payload' => json_encode($payload),
|
||||
'status' => 'REJECTED',
|
||||
'error_code' => 'PROCESSING_ERROR',
|
||||
'error_message' => $e->getMessage(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return [
|
||||
'status' => 'REJECTED',
|
||||
'error_code' => 'PROCESSING_ERROR',
|
||||
'message' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sincroniza fornecedor do SGC para o SGF
|
||||
*/
|
||||
private function sincronizarFornecedor(array $supplierData): Fornecedor
|
||||
{
|
||||
return Fornecedor::updateOrCreate(
|
||||
['sgf_fornecedor_id' => $supplierData['sgf_supplier_id']],
|
||||
[
|
||||
'nuit' => $supplierData['nuit'],
|
||||
'nome_fornecedor' => $supplierData['name'],
|
||||
'categoria_fornecedor' => $supplierData['category'] ?? 'Geral',
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Sgc;
|
||||
|
||||
use App\Models\Sgc\Fatura;
|
||||
use App\Models\Sgc\OrdemCompra;
|
||||
use App\Models\Sgc\Recebimento;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ThreeWayMatchService
|
||||
{
|
||||
private const TOLERANCIA_PERCENTUAL = 0.01; // 1%
|
||||
private const TOLERANCIA_VALOR = 50.00; // 50 MT
|
||||
|
||||
/**
|
||||
* Executa o 3-Way Match: OC + Recebimento + Fatura
|
||||
*/
|
||||
public function executarMatch(Fatura $fatura): array
|
||||
{
|
||||
$oc = $fatura->ordemCompra;
|
||||
$recebimentos = $oc->recebimentos;
|
||||
|
||||
// Calcular total recebido
|
||||
$valorTotalRecebido = $recebimentos->sum('valor_recebido');
|
||||
$valorFatura = $fatura->valor_fatura;
|
||||
$valorOC = $oc->valor_comprometido;
|
||||
|
||||
// Verificar divergências
|
||||
$divergenciaOC = abs($valorFatura - $valorOC);
|
||||
$divergenciaRecebimento = abs($valorFatura - $valorTotalRecebido);
|
||||
|
||||
// Aplicar tolerâncias
|
||||
$toleranciaOC = max($valorOC * self::TOLERANCIA_PERCENTUAL, self::TOLERANCIA_VALOR);
|
||||
$toleranciaRecebimento = max($valorTotalRecebido * self::TOLERANCIA_PERCENTUAL, self::TOLERANCIA_VALOR);
|
||||
|
||||
$matchOC = $divergenciaOC <= $toleranciaOC;
|
||||
$matchRecebimento = $divergenciaRecebimento <= $toleranciaRecebimento;
|
||||
|
||||
// Determinar status
|
||||
$status = 'Conforme';
|
||||
$motivo = null;
|
||||
|
||||
if (!$matchOC && !$matchRecebimento) {
|
||||
$status = 'Com Excecao';
|
||||
$motivo = "Divergência com OC ({$divergenciaOC} MT) e Recebimento ({$divergenciaRecebimento} MT)";
|
||||
} elseif (!$matchOC) {
|
||||
$status = 'Com Excecao';
|
||||
$motivo = "Divergência com OC: {$divergenciaOC} MT";
|
||||
} elseif (!$matchRecebimento) {
|
||||
$status = 'Com Excecao';
|
||||
$motivo = "Divergência com Recebimento: {$divergenciaRecebimento} MT";
|
||||
}
|
||||
|
||||
// Atualizar fatura
|
||||
DB::transaction(function () use ($fatura, $status, $motivo) {
|
||||
$fatura->update([
|
||||
'status_3_way_match' => $status,
|
||||
'motivo_excecao' => $motivo,
|
||||
]);
|
||||
});
|
||||
|
||||
Log::info('3-Way Match executado', [
|
||||
'fatura_id' => $fatura->fatura_id,
|
||||
'status' => $status,
|
||||
'valor_fatura' => $valorFatura,
|
||||
'valor_oc' => $valorOC,
|
||||
'valor_recebido' => $valorTotalRecebido,
|
||||
]);
|
||||
|
||||
return [
|
||||
'status' => $status,
|
||||
'motivo' => $motivo,
|
||||
'valor_fatura' => $valorFatura,
|
||||
'valor_oc' => $valorOC,
|
||||
'valor_recebido' => $valorTotalRecebido,
|
||||
'divergencia_oc' => $divergenciaOC,
|
||||
'divergencia_recebimento' => $divergenciaRecebimento,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Processa todas as faturas pendentes de match
|
||||
*/
|
||||
public function processarPendentes(): int
|
||||
{
|
||||
$faturasPendentes = Fatura::where('status_3_way_match', 'Pendente')->get();
|
||||
$processadas = 0;
|
||||
|
||||
foreach ($faturasPendentes as $fatura) {
|
||||
$this->executarMatch($fatura);
|
||||
$processadas++;
|
||||
}
|
||||
|
||||
return $processadas;
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,14 @@ use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class ModuleNavigation
|
||||
{
|
||||
public const LABELS = ['commercial'=>'Comercial', 'purchasing'=>'Compras', 'stock'=>'Stock', 'workshop'=>'Oficinas', 'finance'=>'Financeiro', 'accounting'=>'Contabilidade'];
|
||||
public const LABELS = ['commercial'=>'Comercial', 'purchasing'=>'Compras', 'stock'=>'Stock', 'workshop'=>'Oficinas', 'finance'=>'Financeiro', 'accounting'=>'Contabilidade', 'human_resources'=>'Recursos Humanos'];
|
||||
private const RIGHTS = [
|
||||
'commercial'=>['clients.', 'services.', 'service_drafts.', 'billing_drafts.', 'issuer_documents.', 'client_documents.', 'draft_documents.', 'service_invoices.'],
|
||||
'human_resources'=>['human_resources.remunerations.','human_resources.contracts.','human_resources.attendance.','human_resources.leave.'],
|
||||
'commercial'=>['commercial_quotes.','service_documents.','clients.', 'services.', 'service_drafts.', 'billing_drafts.', 'issuer_documents.', 'client_documents.', 'draft_documents.', 'service_invoices.'],
|
||||
'purchasing'=>['purchasing_budget.', 'purchasing_requisitions.', 'purchasing_receipts.', 'purchasing_orders.', 'purchasing_sourcing.', 'purchasing_suppliers.', 'purchasing_requests.', 'purchasing_invoices.', 'purchasing_obligations.', 'supplier_payables.'],
|
||||
'stock'=>['stock.'], 'workshop'=>['workshop.', 'workshop_billing.', 'workshop_corrections.'],
|
||||
'finance'=>['direct_costs.', 'industrial_costs.', 'industrial_budgets.', 'industrial_budget_comparisons.', 'service_receivables.'],
|
||||
'accounting'=>['catalogue_accounting.', 'service_fiscal.', 'general_ledger.'],
|
||||
'finance'=>['financial_analysis.','bank_reconciliation.','cash.', 'direct_costs.', 'industrial_costs.', 'industrial_budgets.', 'industrial_budget_comparisons.', 'service_receivables.'],
|
||||
'accounting'=>['fiscal_obligations.','fiscal_rules.','fiscal_assessments.','fiscal_forms.','catalogue_accounting.', 'service_fiscal.', 'general_ledger.'],
|
||||
];
|
||||
|
||||
public function available(CompanyContext $context): array
|
||||
@@ -50,6 +51,14 @@ final class ModuleNavigation
|
||||
// Company, unit and access configuration are explicitly general.
|
||||
public static function owner(string $path): ?string
|
||||
{
|
||||
if(preg_match('~^(stock/catalogue|api/stock-catalogue)(/|$)~',$path))return 'stock';
|
||||
if(preg_match('~^(finance/cash|api/cash)(/|$)~',$path))return 'finance';
|
||||
if(preg_match('~^api/(remunerations|hr-personnel)(/|$)~',$path))return 'human_resources';
|
||||
if(preg_match('~^api/fiscal-obligations(/|$)~',$path))return 'accounting';
|
||||
if(preg_match('~^api/finance/payable-positions(/|$)~',$path))return 'finance';
|
||||
if(preg_match('~^api/finance/payable-due(/|$)~',$path))return 'finance';
|
||||
if(preg_match('~^(finance/payables|api/finance/payables)(/|$)~',$path))return 'finance';
|
||||
if (preg_match('~^api/v1/integration/sgc/invoices(?:/orders/[1-9][0-9]*)?$~D', $path)) return 'purchasing';
|
||||
if (preg_match('~^(geography|geography/segments|api/geography/entities|api/geography/segments)/client(/|$)~', $path)) return 'commercial';
|
||||
if (preg_match('~^(geography|geography/segments|api/geography/entities|api/geography/segments)/supplier(/|$)~', $path)) return 'purchasing';
|
||||
if (preg_match('~^(purchasing/dossiers|api/purchasing-dossiers)(/|$)~', $path)) return 'purchasing';
|
||||
@@ -63,10 +72,17 @@ final class ModuleNavigation
|
||||
if (preg_match('~^(purchasing/receipts|api/purchasing-receipts)(/|$)~', $path)) return 'purchasing';
|
||||
if (preg_match('~^api/stock-valuation-read(/|$)~', $path)) return 'accounting';
|
||||
if (preg_match('~^(purchasing/internal-requisitions|api/internal-requisitions)(/|$)~', $path)) return 'purchasing';
|
||||
if (preg_match('~^(commercial/documents|api/service-documents)(/|$)~', $path)) return 'commercial';
|
||||
if(preg_match('~^(finance/indicators|api/financial-indicators)(/|$)~',$path))return 'finance';
|
||||
if(preg_match('~^(finance/bank|api/bank-reconciliation)(/|$)~',$path))return 'finance';
|
||||
if(preg_match('~^(fiscal/rules|api/fiscal-rules)(/|$)~',$path))return 'accounting';
|
||||
if(preg_match('~^(fiscal/iva|api/fiscal-assessments)(/|$)~',$path))return 'accounting';
|
||||
if (preg_match('~^(general-ledger|api/general-ledger)(/|$)~', $path)) return 'accounting';
|
||||
if(preg_match('~^(api/commercial-quotes|commercial-quotes)(/|$)~',$path))return 'commercial';
|
||||
if (preg_match('~^api/stock-read(/|$)~', $path)) return 'stock';
|
||||
if (preg_match('~^(stock/(operations|final|completion)|api/(stock-operations|stock-final|stock-completion))(/|$)~', $path)) return 'stock';
|
||||
if (preg_match('~^(supplier-payables|supplier-payments|api/(supplier-payables|supplier-payments|purchasing-budget))(/|$)~', $path)) return 'purchasing';
|
||||
if (preg_match('~^api/(workshop|workshop-stock|workshop-billing|workshop-corrections)(/|$)~', $path)) return 'workshop';
|
||||
if (preg_match('~^api/(workshop|workshop-schedule|workshop-equipment|workshop-stock|workshop-billing|workshop-corrections)(/|$)~', $path)) return 'workshop';
|
||||
if (preg_match('~^api/(direct-costs|industrial-costs|industrial-budgets|service-receivables)(/|$)~', $path)) return 'finance';
|
||||
if (preg_match('~^api/(catalogue-accounting|service-fiscal)(/|$)~', $path)) return 'accounting';
|
||||
if (preg_match('~^api/(a|b1|service-invoices|service-calculations|document-preview|document-preparation)(/|$)~', $path)) return 'commercial';
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\Installation;
|
||||
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/** The composed presentation remains physically isolated, regardless of environment labels. */
|
||||
final class CompositionRuntime
|
||||
{
|
||||
public static function physical(?ConnectionInterface $db=null):array
|
||||
{
|
||||
$db??=DB::connection();$c=$db->getConfig();$run=(string)getenv('SGF_ISOLATED_REGRESSION_RUN');
|
||||
abort_unless($db->getDriverName()==='pgsql'&&preg_match('/^budget_core_sgf_([a-f0-9]{12})$/D',$db->getDatabaseName(),$m)
|
||||
&&($c['username']??null)==='ir_composition_'.$m[1]&&($c['host']??null)==='127.0.0.1'&&(int)($c['port']??0)===55439
|
||||
&&preg_match('/^[a-f0-9]{12}$/D',$run)&&empty($c['url'])&&empty($c['read'])&&empty($c['write'])&&($c['search_path']??'public')==='public',403);
|
||||
$actual=$db->selectOne("SELECT current_database() db,current_user role,inet_server_port() port,current_setting('cluster_name') cluster,rolsuper OR rolcreatedb OR rolcreaterole OR rolreplication OR rolbypassrls elevated FROM pg_roles WHERE rolname=current_user");
|
||||
abort_unless($actual->db===$db->getDatabaseName()&&$actual->role===$c['username']&&(int)$actual->port===55439&&$actual->cluster==='sgf_modular_regression_'.$run&&!$actual->elevated,403);
|
||||
return ['database'=>$actual->db,'role'=>$actual->role,'cluster'=>$actual->cluster,'composition'=>$m[1]];
|
||||
}
|
||||
|
||||
public static function assert(?string $connection=null):void
|
||||
{
|
||||
abort_unless(app()->environment('budget-testing')&&config('sgf_single_installation.enabled')===true,403);
|
||||
$db=DB::connection($connection);self::physical($db);$pin=config('sgf_single_installation.schema_sha256');
|
||||
abort_unless(is_string($pin)&&preg_match('/^[a-f0-9]{64}$/D',$pin)&&$db->getSchemaBuilder()->hasTable('sgf_composition_schema'),503,'Composição SGF não instalada ou não conferida.');
|
||||
abort_unless(hash_equals($pin,(string)$db->table('sgf_composition_schema')->where('id',1)->value('source_hash')),503,'A composição SGF mudou; exige nova conferência.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\Installation;
|
||||
|
||||
use Illuminate\Support\Facades\{DB,Schema};
|
||||
|
||||
/** Explicit additive composition, separate from global migrate and the SGC connector. */
|
||||
final class CompositionSchema
|
||||
{
|
||||
public const MIGRATIONS=[
|
||||
'2026_10_25_030000_create_services_delivery_a.php',
|
||||
'2026_10_25_040000_create_internal_confirmations.php',
|
||||
'2026_10_26_010000_create_document_preparation.php',
|
||||
'2026_10_26_020000_create_document_preview_models.php',
|
||||
'2026_10_26_030000_create_service_calculations.php',
|
||||
'2026_10_26_040000_create_catalogue_accounting.php',
|
||||
'2026_10_26_050000_create_service_fiscal_lifecycle.php',
|
||||
'2026_10_26_060000_create_test_invoice_issuance.php',
|
||||
'2026_10_27_070000_create_simulated_receivables.php',
|
||||
'2026_10_27_080000_create_workshop_resource_records.php',
|
||||
'2026_10_27_090000_create_workshop_direct_costs.php',
|
||||
'2026_10_27_100000_create_industrial_cost_simulation.php',
|
||||
'2026_10_28_110000_create_industrial_budgets.php',
|
||||
'2026_10_28_120000_create_workshop_billing_drafts.php',
|
||||
'2026_10_28_130000_create_workshop_billing_corrections.php',
|
||||
];
|
||||
|
||||
public static function identity():array
|
||||
{
|
||||
abort_unless(app()->runningInConsole()&&app()->environment('budget-testing'),403);
|
||||
return CompositionRuntime::physical();
|
||||
}
|
||||
|
||||
/** Callback may prove installed structures inside the same transaction.
|
||||
* Default is a full rollback. No role, grant, account, association or document is created.
|
||||
*/
|
||||
public function run(bool $commit=false,?\Closure $verify=null):array
|
||||
{
|
||||
$identity=self::identity();$host=DB::connection();
|
||||
abort_unless($host->transactionLevel()===0,409,'Não compor dentro de uma transacção alheia.');
|
||||
$constraint=$host->selectOne("SELECT pg_get_constraintdef(oid) value FROM pg_constraint WHERE conrelid='permission_grants'::regclass AND conname='permission_grants_scope_check'")->value??null;
|
||||
if(!is_string($constraint)||!preg_match('/^CHECK \((.*)\)$/s',$constraint,$m))throw new \LogicException('COMPOSITION_PERMISSION_CHECK_MISSING');
|
||||
$connection=new CompositionSchemaConnection($host->getPdo(),$host->getDatabaseName(),'',$host->getConfig());
|
||||
$connection->originalPermissionCheck=$m[1];
|
||||
$name='sgf_composition_schema';$original=DB::getDefaultConnection();
|
||||
config(['database.connections.'.$name=>$host->getConfig()]);DB::extend($name,fn()=>$connection);DB::setDefaultConnection($name);Schema::clearResolvedInstance('db.schema');app()->forgetInstance('db.schema');
|
||||
$result=[];$connection->beginTransaction();
|
||||
try{
|
||||
$connection->statement("SET LOCAL lock_timeout='5s'");$connection->statement("SET LOCAL statement_timeout='30s'");
|
||||
$connection->selectOne("SELECT pg_advisory_xact_lock(hashtext('sgf-single-installation-schema'))");
|
||||
if($connection->getSchemaBuilder()->hasTable('sgf_composition_schema')){
|
||||
$stored=$connection->table('sgf_composition_schema')->where('id',1)->first();
|
||||
if(!$stored||$stored->source_hash!==$this->fingerprint())throw new \LogicException('COMPOSITION_SCHEMA_VERSION_CHANGED');
|
||||
$result=['identity'=>$identity,'replayed'=>true,'source_hash'=>$stored->source_hash,'reused_tables'=>[],'migrations'=>[]];
|
||||
}else{
|
||||
$before=array_column($connection->select("SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename"),'tablename');
|
||||
foreach(['b_internal_confirmations','doc_records','wo_orders','sr_records','ca_proposals'] as $table)if(in_array($table,$before,true))throw new \LogicException('COMPOSITION_PARTIAL_SCHEMA_REQUIRES_REVIEW: '.$table);
|
||||
if(!$connection->selectOne("SELECT to_regprocedure('public.sgf_append_only_audit()') value")->value)$connection->unprepared("CREATE FUNCTION sgf_append_only_audit() RETURNS trigger LANGUAGE plpgsql AS \$\$ BEGIN RAISE EXCEPTION 'SGF_APPEND_ONLY_AUDIT'; END; \$\$;");
|
||||
$migrations=[];
|
||||
$auditTables=['a_history','a_events','a_commands','a_outbox','a_pending'];$present=array_intersect($auditTables,$before);
|
||||
if(count($present)!==0&&count($present)!==count($auditTables))throw new \LogicException('COMPOSITION_PARTIAL_A_HISTORY');
|
||||
foreach(self::MIGRATIONS as $index=>$file){
|
||||
if($index===0&&count($present)===count($auditTables)){
|
||||
foreach(['a_clients','a_services','a_service_drafts','a_billing_drafts'] as $table){
|
||||
if(!in_array($table,$before,true))throw new \LogicException('COMPOSITION_PARTIAL_A_RECORDS');
|
||||
$connection->getSchemaBuilder()->create($table,fn()=>null);
|
||||
}
|
||||
$guards=$connection->selectOne("SELECT count(*) n FROM pg_trigger WHERE NOT tgisinternal AND tgenabled='O' AND tgname IN ('a_clients_guard','a_services_guard','a_service_drafts_guard','a_billing_drafts_guard','a_history_scope','a_history_append_only','a_events_append_only','a_commands_append_only','a_outbox_append_only','a_pending_append_only')")->n;
|
||||
if((int)$guards!==10)throw new \LogicException('COMPOSITION_A_GUARDS_INCOMPLETE');
|
||||
continue;
|
||||
}
|
||||
$migration=require database_path('migrations/'.$file);$migration->up();$migrations[]=$file;
|
||||
}
|
||||
$connection->unprepared('CREATE TABLE sgf_composition_schema(id smallint PRIMARY KEY CHECK(id=1),source_hash char(64) NOT NULL,installed_at timestamptz NOT NULL DEFAULT now())');
|
||||
$connection->table('sgf_composition_schema')->insert(['id'=>1,'source_hash'=>$this->fingerprint()]);
|
||||
$connection->unprepared('CREATE TRIGGER composition_schema_append_only BEFORE UPDATE OR DELETE ON sgf_composition_schema FOR EACH ROW EXECUTE FUNCTION sgf_append_only_audit()');
|
||||
// Rebind ONLY the newly created QA guards to this exact database,
|
||||
// role and cluster. The source SQL and all other installations retain
|
||||
// their original guard; no general local/development bypass is added.
|
||||
foreach(['wo','sr','dc','ic'] as $prefix){
|
||||
$connection->unprepared("CREATE OR REPLACE FUNCTION sgf_{$prefix}_environment() RETURNS void LANGUAGE plpgsql AS \$\$ BEGIN IF current_database() IS DISTINCT FROM '".$identity['database']."' OR current_user IS DISTINCT FROM '".$identity['role']."' OR inet_server_port() IS DISTINCT FROM 55439 OR current_setting('cluster_name') IS DISTINCT FROM '".$identity['cluster']."' THEN RAISE EXCEPTION 'SGF_COMPOSITION_OWNED_QA_ONLY'; END IF; END; \$\$;");
|
||||
}
|
||||
$after=array_column($connection->select("SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename"),'tablename');
|
||||
$result=['identity'=>$identity,'replayed'=>false,'source_hash'=>$this->fingerprint(),'reused_tables'=>$connection->getSchemaBuilder()->reused,'new_tables'=>array_values(array_diff($after,$before)),'migrations'=>$migrations];
|
||||
}
|
||||
if($verify)$result['verification']=$verify($connection);
|
||||
if($commit)$connection->commit();else $connection->rollBack();
|
||||
$result['committed']=$commit;return $result;
|
||||
}finally{
|
||||
while($connection->transactionLevel()>0)$connection->rollBack();
|
||||
DB::setDefaultConnection($original);Schema::clearResolvedInstance('db.schema');app()->forgetInstance('db.schema');DB::purge($name);
|
||||
}
|
||||
}
|
||||
|
||||
public function fingerprint():string
|
||||
{
|
||||
$files=[];foreach(self::MIGRATIONS as $file)$files['database/migrations/'.$file]=hash_file('sha256',database_path('migrations/'.$file));
|
||||
foreach(glob(database_path('sql/*.sql')) as $file)$files['database/sql/'.basename($file)]=hash_file('sha256',$file);
|
||||
foreach(['CompositionSchema.php','CompositionSchemaConnection.php','CompositionSchemaBuilder.php','CompositionRuntime.php'] as $file)$files[$file]=hash_file('sha256',__DIR__.'/'.$file);
|
||||
return hash('sha256',json_encode($files,JSON_THROW_ON_ERROR));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\Installation;
|
||||
|
||||
use Illuminate\Database\Schema\PostgresBuilder;
|
||||
|
||||
/** Reuse only the four explicitly verified foundation tables; never skip arbitrary DDL. */
|
||||
final class CompositionSchemaBuilder extends PostgresBuilder
|
||||
{
|
||||
public array $reused=[];
|
||||
|
||||
public function create($table,\Closure $callback)
|
||||
{
|
||||
if(in_array($table,['a_clients','a_services','a_service_drafts','a_billing_drafts'],true)&&$this->hasTable($table)){
|
||||
$expected=['id','tenant_id','company_id','unit_id','name','version','data','created_at','updated_at'];
|
||||
if(in_array($table,['a_service_drafts','a_billing_drafts'],true))$expected=[...$expected,'client_id','state'];
|
||||
if($table==='a_billing_drafts')$expected[]='source_id';
|
||||
$columns=$this->getColumns($table);$names=array_column($columns,'name');sort($expected);sort($names);
|
||||
if($names!==$expected)throw new \LogicException('COMPOSITION_EXISTING_TABLE_INCOMPATIBLE: '.$table);
|
||||
foreach($columns as $column){
|
||||
$type=$column['type_name'];$name=$column['name'];
|
||||
$valid=match($name){'data'=>$type==='jsonb','name','state'=>$type==='varchar','created_at','updated_at'=>$type==='timestamptz','version'=>in_array($type,['int4','integer'],true),default=>in_array($type,['int8','bigint'],true)};
|
||||
if(!$valid)throw new \LogicException('COMPOSITION_EXISTING_COLUMN_INCOMPATIBLE: '.$table.'.'.$name);
|
||||
}
|
||||
$this->reused[]=$table;
|
||||
return;
|
||||
}
|
||||
parent::create($table,$callback);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\Installation;
|
||||
|
||||
use Illuminate\Database\PostgresConnection;
|
||||
|
||||
/** Compatibility at installation time only; runtime business connections are untouched. */
|
||||
final class CompositionSchemaConnection extends PostgresConnection
|
||||
{
|
||||
public string $originalPermissionCheck;
|
||||
public ?CompositionSchemaBuilder $compositionBuilder=null;
|
||||
|
||||
public function getSchemaBuilder()
|
||||
{
|
||||
$this->useDefaultSchemaGrammar();
|
||||
return $this->compositionBuilder??=new CompositionSchemaBuilder($this);
|
||||
}
|
||||
|
||||
public function statement($query,$bindings=[])
|
||||
{
|
||||
// Older migrations replaced the permission whitelist. Preserve its original
|
||||
// branches as well as the migration's additions, without granting any right.
|
||||
if(preg_match('/^ALTER TABLE permission_grants ADD CONSTRAINT permission_grants_scope_check CHECK \((.*)\)$/s',$query,$m)){
|
||||
$query='ALTER TABLE permission_grants ADD CONSTRAINT permission_grants_scope_check CHECK (('.$this->originalPermissionCheck.') OR ('.$m[1].'))';
|
||||
}
|
||||
return parent::statement($query,$bindings);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
namespace App\Shared\Installation;
|
||||
use App\Models\User;
|
||||
use App\Modules\Identity\Services\Audit;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
/** Separate QA registry; never rewrites the accepted ten-profile matrix. */
|
||||
final class HumanResourcesProfiles
|
||||
{
|
||||
public static function policy():array{$p=json_decode(file_get_contents(base_path('docs/integration/sgf-human-resources-access-proposal.json')),true,64,JSON_THROW_ON_ERROR);abort_unless(($p['approved']??false)===true&&($p['access_authorized']??false)===true&&($p['scope']??null)===['tenant_id'=>1,'company_id'=>2,'unit_id'=>2]&&($p['authorization']['decision']??null)==='Autorizar os perfis QA próprios de RH e as concessões apresentadas',403,'Perfis próprios de RH por autorizar.');return$p;}
|
||||
public static function fingerprint(array $p):string{return hash('sha256',json_encode(array_intersect_key($p,array_flip(['version','scope','module','profiles'])),JSON_THROW_ON_ERROR));}
|
||||
public static function registered():array{
|
||||
abort_unless(config('human_resources.enabled')===true,404);CompositionRuntime::assert();$p=self::policy();$pin=self::fingerprint($p);abort_unless(hash_equals($pin,(string)config('human_resources.profiles_sha256')),503,'Matriz de RH não conferida.');$rows=DB::table('hr_qa_profiles')->orderBy('profile_key')->get();$expected=collect($p['profiles'])->keyBy('id');abort_unless($rows->count()===3&&$rows->every(fn($r)=>isset($expected[$r->profile_key])&&hash_equals($pin,$r->source_hash)&&$r->tenant_id===1&&$r->company_id===2&&$r->unit_id===2),503,'Registo de perfis de RH alterado.');return$rows->all();
|
||||
}
|
||||
public static function assertActor(int $actor,array $s,string $action):void{$rows=self::registered();$row=collect($rows)->firstWhere('user_id',$actor);abort_unless($row&&['tenant_id'=>$row->tenant_id,'company_id'=>$row->company_id,'unit_id'=>$row->unit_id]===$s,403,'Perfil próprio de RH obrigatório.');if($action!=='view')abort_unless($row->profile_key===($action==='prepare'?'hr_prepare':'hr_review'),403);}
|
||||
public function install():array{
|
||||
CompositionRuntime::assert();$p=self::policy();$pin=self::fingerprint($p);$expected=[];foreach(['prepare','review','read']as$role){$rights=[['permission'=>'company.view','scope'=>'company'],['permission'=>'unit.view','scope'=>'unit'],['permission'=>'human_resources.remunerations.view','scope'=>'unit']];if($role!=='read')$rights[]=['permission'=>'human_resources.remunerations.'.$role,'scope'=>'unit'];$expected['hr_'.$role]=$rights;}abort_unless(count($p['profiles'])===3&&collect($p['profiles'])->every(fn($r)=>isset($expected[$r['id']])&&$r['rights']===$expected[$r['id']]),403,'Não ampliar a matriz aprovada.');
|
||||
return DB::transaction(function()use($p,$pin){DB::selectOne("SELECT pg_advisory_xact_lock(hashtext('sgf-approved-human-resources-profiles'))");DB::table('companies')->where(['tenant_id'=>1,'id'=>2])->lockForUpdate()->firstOrFail();
|
||||
if(!DB::getSchemaBuilder()->hasTable('hr_qa_profiles')){DB::unprepared("CREATE TABLE hr_qa_profiles(profile_key text PRIMARY KEY CHECK(profile_key IN('hr_prepare','hr_review','hr_read')),user_id bigint UNIQUE NOT NULL REFERENCES users(id),tenant_id bigint NOT NULL,company_id bigint NOT NULL,unit_id bigint NOT NULL,label text NOT NULL,source_hash char(64) NOT NULL,created_at timestamptz NOT NULL DEFAULT now(),FOREIGN KEY(tenant_id,company_id,unit_id) REFERENCES units(tenant_id,company_id,id));CREATE TRIGGER hr_qa_profiles_immutable BEFORE UPDATE OR DELETE ON hr_qa_profiles FOR EACH ROW EXECUTE FUNCTION sgf_append_only_audit();REVOKE ALL ON TABLE hr_qa_profiles FROM PUBLIC;");}
|
||||
$old=DB::table('hr_qa_profiles')->get();if($old->isNotEmpty()){abort_unless($old->count()===3&&$old->every(fn($r)=>hash_equals($pin,$r->source_hash)),409,'Preservar perfis RH diferentes.');return['profiles'=>$old->all(),'replayed'=>true,'source_hash'=>$pin,'new_grants'=>0];}
|
||||
$check=DB::selectOne("SELECT pg_get_constraintdef(oid) d FROM pg_constraint WHERE conrelid='permission_grants'::regclass AND conname='permission_grants_scope_check'")->d;abort_unless(preg_match('/^CHECK \((.*)\)$/s',$check,$m),503);if(!str_contains($check,'human_resources.remunerations.view')){DB::statement('ALTER TABLE permission_grants DROP CONSTRAINT permission_grants_scope_check');DB::statement('ALTER TABLE permission_grants ADD CONSTRAINT permission_grants_scope_check CHECK (('.$m[1].") OR (unit_id IS NOT NULL AND permission IN('human_resources.remunerations.view','human_resources.remunerations.prepare','human_resources.remunerations.review')))");}
|
||||
foreach($p['profiles']as$profile){$email='sgf-'.$profile['id'].'-'.CompositionRuntime::physical()['composition'].'@example.test';abort_if(User::where('email',$email)->exists(),409,'Identidade existente sem registo: conferir, sem adoptar.');$user=User::create(['name'=>$profile['label'],'email'=>$email,'password'=>bin2hex(random_bytes(32))]);DB::table('company_memberships')->insert(['user_id'=>$user->id,'tenant_id'=>1,'company_id'=>2]);foreach($profile['rights']as$r){$g=['user_id'=>$user->id,'tenant_id'=>1,'company_id'=>2,'unit_id'=>$r['scope']==='unit'?2:null,'permission'=>$r['permission']];DB::table('permission_grants')->insert($g);Audit::record('access.granted_on_creation',null,1,2,$g['unit_id'],$user->id,['permission'=>$r['permission'],'reason'=>'Matriz de RH exclusivamente QA autorizada11/11.']);}DB::table('hr_qa_profiles')->insert(['profile_key'=>$profile['id'],'user_id'=>$user->id]+$p['scope']+['label'=>$profile['label'],'source_hash'=>$pin]);Audit::record('access.synthetic_hr_profile_created',null,1,2,2,$user->id,['reason'=>'Perfil autónomo QA de RH expressamente autorizado.']);}
|
||||
return['profiles'=>DB::table('hr_qa_profiles')->orderBy('profile_key')->get()->all(),'replayed'=>false,'source_hash'=>$pin,'new_grants'=>11];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\Installation;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Shared\Context\{CompanyContext,ModuleNavigation};
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/** One explicit chooser over approved local identities, not an authentication bypass outside QA. */
|
||||
final class PresentationEntry
|
||||
{
|
||||
private const TOUR=[
|
||||
'commercial'=>['sgf:read_only','/?start=service_invoice_setup','Comercial','Clientes, orçamentos, documentos e PDF; passagem explícita para recebíveis'],
|
||||
'purchasing'=>['purchasing:actor','/purchasing/workspace','Compras','Fornecedores, requisição, propostas, ordem, recepção, factura e obrigação'],
|
||||
'stock'=>['purchasing:validator','/stock/final','Stock','Artigos, armazéns, existências, trânsito, lotes, contagem e histórico'],
|
||||
'workshop'=>['sgf:workshop_manage','/?start=workshop','Oficinas','Equipamento, cliente, agenda, ordem, autorização e apontamentos verificados'],
|
||||
'finance'=>['sgf:read_only','/?start=service_receivables','Financeiro','Recebíveis, obrigações, Caixa, conferência de extracto e indicadores'],
|
||||
'accounting'=>['sgf:read_only','/general-ledger','Contabilidade','Origens, diário integrado, plano, balancete, razão e mapas fiscais internos'],
|
||||
'human_resources'=>['hr:hr_read','/?start=remunerations','Recursos Humanos','Cadastro fictício, regras artificiais, processamento interno e revisão distinta; acesso próprio'],
|
||||
];
|
||||
private function guard():void
|
||||
{
|
||||
abort_unless(in_array(request()->ip(),['127.0.0.1','::1'],true),404);
|
||||
CompositionRuntime::assert();
|
||||
abort_unless(is_string(config('sgf_single_installation.profiles_sha256'))&&preg_match('/^[a-f0-9]{64}$/D',config('sgf_single_installation.profiles_sha256')),503);
|
||||
}
|
||||
|
||||
private function profiles():array
|
||||
{
|
||||
$pin=config('sgf_single_installation.profiles_sha256');$out=[];$context=app(CompanyContext::class);
|
||||
$rows=DB::table('sgf_presentation_profiles')->orderBy('profile_key')->get();
|
||||
abort_unless($rows->count()===10&&$rows->every(fn($r)=>hash_equals($r->source_hash,$pin)),503,'Perfis QA não conferidos.');
|
||||
try{
|
||||
foreach($rows as $row){
|
||||
$context->selectFor((int)$row->user_id,(int)$row->tenant_id,(int)$row->company_id,(int)$row->unit_id);
|
||||
$out['sgf:'.$row->profile_key]=['user_id'=>(int)$row->user_id,'label'=>$row->label,'scope'=>['tenant_id'=>(int)$row->tenant_id,'company_id'=>(int)$row->company_id,'unit_id'=>(int)$row->unit_id],'modules'=>app(ModuleNavigation::class)->available($context)];
|
||||
}
|
||||
if(config('human_resources.enabled')===true){foreach(HumanResourcesProfiles::registered() as$row){
|
||||
$context->selectFor($row->user_id,$row->tenant_id,$row->company_id,$row->unit_id);
|
||||
$out['hr:'.$row->profile_key]=['user_id'=>$row->user_id,'label'=>$row->label,'scope'=>['tenant_id'=>$row->tenant_id,'company_id'=>$row->company_id,'unit_id'=>$row->unit_id],'modules'=>app(ModuleNavigation::class)->available($context)];
|
||||
}}
|
||||
$scope=config('purchasing_workspace.scope');
|
||||
foreach(config('purchasing_workspace.profiles',[]) as $key=>$profile){
|
||||
$context->selectFor($profile['user_id'],$scope['tenant_id'],$scope['company_id'],$scope['unit_id']);
|
||||
$out['purchasing:'.$key]=['user_id'=>$profile['user_id'],'label'=>$profile['label'].' — Compras','scope'=>$scope,'modules'=>app(ModuleNavigation::class)->available($context)];
|
||||
}
|
||||
}finally{$context->clear();}
|
||||
return $out;
|
||||
}
|
||||
|
||||
public function screen(Request $r)
|
||||
{
|
||||
$this->guard();abort_if($r->query()!==[],422);
|
||||
$profiles=$this->profiles();$tour=[];foreach(self::TOUR as $module=>[$key,$url,$label,$description])if(isset($profiles[$key])&&in_array($module,array_column($profiles[$key]['modules'],'id'),true))$tour[$module]=['profile'=>$key,'function'=>$profiles[$key]['label'],'label'=>$label,'description'=>$description];
|
||||
$primary=['commercial'=>['sgf:commercial_prepare','sgf:commercial_review'],'purchasing'=>array_map(fn($id)=>'purchasing:'.$id,['requester','approver','actor','issuer','validator','reviewer','auditor']),'stock'=>['purchasing:validator','sgf:workshop_verify','sgf:read_only'],'workshop'=>['sgf:workshop_manage','sgf:workshop_record','sgf:workshop_verify','sgf:finance_prepare','sgf:finance_review','purchasing:validator'],'finance'=>['sgf:finance_prepare','sgf:finance_review'],'accounting'=>['sgf:accounting_prepare','sgf:accounting_review','sgf:finance_prepare','sgf:finance_review']];
|
||||
if(config('human_resources.enabled')===true)$primary['human_resources']=['hr:hr_prepare','hr:hr_review','hr:hr_read'];
|
||||
$moduleProfiles=[];foreach($primary as $module=>$keys)foreach($keys as $key)if(isset($profiles[$key])&&in_array($module,array_column($profiles[$key]['modules'],'id'),true))$moduleProfiles[$module][$key]=$profiles[$key];
|
||||
return response()->view('sgf-presentation-entry',['profiles'=>$profiles,'tour'=>$tour,'moduleProfiles'=>$moduleProfiles])->header('Cache-Control','no-store')->header('X-SGF-Installation',CompositionRuntime::physical()['composition']);
|
||||
}
|
||||
|
||||
public function enter(Request $r)
|
||||
{
|
||||
$this->guard();$d=$r->validate(['profile'=>'required|string|max:100','module_id'=>'required|string|max:30','landing'=>'sometimes|in:'.implode(',',array_keys(self::TOUR))]);
|
||||
abort_if(array_diff(array_keys($r->all()),['_token','profile','module_id','landing']),422);
|
||||
$target='/';if(isset($d['landing'])){$route=self::TOUR[$d['landing']];abort_unless($d['profile']===$route[0]&&$d['module_id']===$d['landing'],403);$target=$route[1];}
|
||||
$profile=$this->profiles()[$d['profile']]??null;
|
||||
abort_unless($profile&&in_array($d['module_id'],array_column($profile['modules'],'id'),true),403,'Função ou módulo não autorizado.');
|
||||
$user=User::findOrFail($profile['user_id']);auth()->login($user);$r->session()->regenerate();
|
||||
$r->session()->put('auth_version',$user->auth_version);
|
||||
$r->session()->put('company_context',$profile['scope']+['module_id'=>$d['module_id'],'token'=>bin2hex(random_bytes(24))]);
|
||||
\App\Modules\Identity\Services\Audit::record('context.synthetic_profile_selected',$user->id,$profile['scope']['tenant_id'],$profile['scope']['company_id'],$profile['scope']['unit_id'],details:['module_id'=>$d['module_id'],'reason'=>'Escolha explícita da função QA aprovada, na instalação única.']);
|
||||
return redirect($target);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\Installation;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Identity\Services\Audit;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/** Explicitly approved fixture installation. No runtime grants, role creation or legacy assignment. */
|
||||
final class SyntheticProfiles
|
||||
{
|
||||
public static function policy():array
|
||||
{
|
||||
$p=json_decode(file_get_contents(base_path('docs/integration/sgf-8085-access-proposal.json')),true,64,JSON_THROW_ON_ERROR);
|
||||
abort_unless(($p['approved']??false)===true&&($p['authorization']['decision']??null)==='autorizo'&&($p['scope']??null)===['tenant_id'=>1,'company_id'=>2,'unit_id'=>2],403);
|
||||
return $p;
|
||||
}
|
||||
|
||||
public static function fingerprint(array $p):string
|
||||
{
|
||||
return hash('sha256',json_encode(array_intersect_key($p,array_flip(['version','scope','profiles','existing_stock_profile','new_right_names','financial_source_policy'])),JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
public function install():array
|
||||
{
|
||||
CompositionSchema::identity();$p=self::policy();$pin=self::fingerprint($p);$scope=$p['scope'];
|
||||
$known=[];
|
||||
foreach([base_path('app/Modules/Services/DocumentPreview.php'),base_path('app/Modules/Purchasing/ReceiptRuntime.php'),...glob(database_path('migrations/*.php'))] as $file){preg_match_all("/'([a-z_]+\\.[a-z_.]+)'/",file_get_contents($file),$matches);$known=[...$known,...$matches[1]];}
|
||||
foreach(\App\Modules\Services\ServicePermissions::AREAS as $area)foreach(\App\Modules\Services\ServicePermissions::ACTIONS as $action)$known[]=$area.'.'.$action;
|
||||
foreach(['view','prepare','validate','post','correct_prepare','correct_validate','correct_post','period_prepare','period_validate','period_close'] as $action)$known[]='general_ledger.'.$action;
|
||||
foreach(['view','prepare','confirm'] as $action)$known[]='workshop_stock.'.$action;
|
||||
$known=[...$known,'stock.view','stock.operations.prepare','stock.operations.confirm'];
|
||||
foreach($p['profiles'] as $profile)foreach($profile['rights'] as $right)abort_unless(in_array($right['permission'],$known,true),422,'Direito fora do catálogo existente/aprovado: '.$right['permission']);
|
||||
foreach($p['existing_stock_profile']['rights'] as $right)abort_unless(in_array($right,$known,true),422);
|
||||
return DB::transaction(function()use($p,$pin,$scope){
|
||||
DB::selectOne("SELECT pg_advisory_xact_lock(hashtext('sgf-approved-presentation-profiles'))");
|
||||
DB::table('companies')->where('id',$scope['company_id'])->where('tenant_id',$scope['tenant_id'])->lockForUpdate()->firstOrFail();
|
||||
abort_unless(DB::table('units')->where(['tenant_id'=>$scope['tenant_id'],'company_id'=>$scope['company_id'],'id'=>$scope['unit_id']])->exists(),403);
|
||||
if(!DB::getSchemaBuilder()->hasTable('sgf_presentation_profiles')){
|
||||
DB::unprepared('CREATE TABLE sgf_presentation_profiles(profile_key varchar(80) PRIMARY KEY,user_id bigint NOT NULL UNIQUE REFERENCES users(id),tenant_id bigint NOT NULL,company_id bigint NOT NULL,unit_id bigint NOT NULL,label text NOT NULL,source_hash char(64) NOT NULL,created_at timestamptz NOT NULL DEFAULT now())');
|
||||
DB::statement('REVOKE ALL ON TABLE sgf_presentation_profiles FROM PUBLIC');
|
||||
DB::statement('CREATE TRIGGER sgf_presentation_profiles_append_only BEFORE UPDATE OR DELETE ON sgf_presentation_profiles FOR EACH ROW EXECUTE FUNCTION sgf_append_only_audit()');
|
||||
}
|
||||
$existing=DB::table('sgf_presentation_profiles')->get();
|
||||
if($existing->isNotEmpty()){
|
||||
abort_unless($existing->count()===count($p['profiles'])&&$existing->every(fn($v)=>hash_equals($v->source_hash,$pin)),409,'A matriz de perfis mudou; não ampliar acessos por inferência.');
|
||||
return ['profiles'=>$existing->keyBy('profile_key')->map(fn($r)=>['user_id'=>(int)$r->user_id,'label'=>$r->label])->all(),'replayed'=>true,'source_hash'=>$pin];
|
||||
}
|
||||
$company=[];$unit=[];
|
||||
foreach($p['profiles'] as $profile)foreach($profile['rights'] as $r){
|
||||
abort_unless(preg_match('/^[a-z][a-z_.]+$/D',$r['permission'])&&in_array($r['scope'],['company','unit'],true),422);
|
||||
if($r['scope']==='company')$company[]=$r['permission'];else $unit[]=$r['permission'];
|
||||
}
|
||||
$unit=[...$unit,...$p['existing_stock_profile']['rights']];
|
||||
$old=DB::selectOne("SELECT pg_get_constraintdef(oid) value FROM pg_constraint WHERE conrelid='permission_grants'::regclass AND conname='permission_grants_scope_check'")->value;
|
||||
if(!preg_match('/^CHECK \((.*)\)$/s',$old,$m))throw new \LogicException('Permission schema changed');
|
||||
$literal=fn($values)=>implode(',',array_map(fn($v)=>"'".$v."'",array_unique($values)));
|
||||
DB::statement('ALTER TABLE permission_grants DROP CONSTRAINT permission_grants_scope_check');
|
||||
DB::statement('ALTER TABLE permission_grants ADD CONSTRAINT permission_grants_scope_check CHECK (('.$m[1].') OR (unit_id IS NULL AND permission IN ('.$literal($company).')) OR (unit_id IS NOT NULL AND permission IN ('.$literal($unit).')))');
|
||||
$profiles=[];
|
||||
foreach($p['profiles'] as $profile){
|
||||
abort_if(User::where('email','sgf-'.$profile['id'].'-'.CompositionRuntime::physical()['composition'].'@example.test')->exists(),409,'Conta existente não registada nesta matriz.');
|
||||
$user=User::create(['name'=>$profile['label'],'email'=>'sgf-'.$profile['id'].'-'.CompositionRuntime::physical()['composition'].'@example.test','password'=>bin2hex(random_bytes(32))]);
|
||||
DB::table('company_memberships')->insert(['user_id'=>$user->id,'tenant_id'=>$scope['tenant_id'],'company_id'=>$scope['company_id']]);
|
||||
foreach($profile['rights'] as $right)$this->grant($user->id,$scope,$right['permission'],$right['scope']==='unit');
|
||||
DB::table('sgf_presentation_profiles')->insert(['profile_key'=>$profile['id'],'user_id'=>$user->id]+$scope+['label'=>$profile['label'],'source_hash'=>$pin]);
|
||||
Audit::record('access.synthetic_profile_created',null,$scope['tenant_id'],$scope['company_id'],$scope['unit_id'],$user->id,['operator'=>'Autorização expressa do proprietário em10/11/2026','reason'=>'Perfil exclusivamente sintético da matriz aprovada.']);
|
||||
$profiles[$profile['id']]=['user_id'=>$user->id,'label'=>$profile['label']];
|
||||
}
|
||||
$stock=(int)$p['existing_stock_profile']['user_id'];
|
||||
abort_unless(DB::table('company_memberships')->where(['user_id'=>$stock,'tenant_id'=>$scope['tenant_id'],'company_id'=>$scope['company_id']])->exists(),403);
|
||||
foreach($p['existing_stock_profile']['rights'] as $right)$this->grant($stock,$scope,$right,true);
|
||||
return ['profiles'=>$profiles,'replayed'=>false,'source_hash'=>$pin];
|
||||
});
|
||||
}
|
||||
|
||||
private function grant(int $user,array $scope,string $right,bool $unit):void
|
||||
{
|
||||
$values=['tenant_id'=>$scope['tenant_id'],'company_id'=>$scope['company_id'],'unit_id'=>$unit?$scope['unit_id']:null,'user_id'=>$user,'permission'=>$right];
|
||||
if(DB::table('permission_grants')->where($values)->exists())return;
|
||||
DB::table('permission_grants')->insert($values);
|
||||
Audit::record('access.granted_on_creation',null,$scope['tenant_id'],$scope['company_id'],$values['unit_id'],$user,['permission'=>$right,'operator'=>'Autorização10/11/2026','reason'=>'Apenas perfil QA e âmbito da matriz aprovada; sem acesso real.']);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ final class TestingDatabaseGuard
|
||||
{
|
||||
public static function assertSafe(?string $connection = null): void
|
||||
{
|
||||
if(app()->environment('budget-testing')&&config('sgf_single_installation.enabled')===true){\App\Shared\Installation\CompositionRuntime::assert($connection);return;}
|
||||
$name = $connection ?: config('database.default');
|
||||
$config = config("database.connections.{$name}", []);
|
||||
if(app()->environment(['testing','browser-testing'])&&(config('sales_ledger.synthetic_enabled')===true||config('cost_ledger.synthetic_enabled')===true||config('industrial_ledger.synthetic_enabled')===true)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user