Compare commits

..
Author SHA1 Message Date
nala 3f7082a3e6 docs: record sequential competitiveness installation and preservation 2026-10-29 13:11:31 +02:00
nala 23ecccfaad feat(clusters): prepare evidence-based five forces analysis
tests / ci (push) Canceled after 0s
2026-10-29 13:03:02 +02:00
nala 332c16ebb5 feat: expose guided manual IVA and IRPC form preparation
tests / ci (push) Canceled after 0s
2026-10-28 21:54:25 +02:00
nala 17444aaaa5 docs(investor): record served release and grouped preservation receipts 2026-10-28 21:43:46 +02:00
nala 789fffd098 fix(sector): use effective creation access and preserve corridor presentation contract
tests / ci (push) Canceled after 0s
2026-10-28 21:35:21 +02:00
nala 502e3ec4d5 fix(investor): simplify preparation flow, profile requests and scoped zone search
tests / ci (push) Canceled after 0s
2026-10-28 21:26:59 +02:00
nala 54d1b2a543 fix(catalogue): keep filtered investment results separate from educational guides 2026-10-28 21:04:10 +02:00
nala 6520d303f7 fix(territory): combine search and selection in one dropdown field 2026-10-28 21:00:30 +02:00
nala 47dae1b77e docs(catalogue): register national verification in existing requirements 2026-10-28 20:54:48 +02:00
nala 2c8eb95e10 test(catalogue): verify provincial coverage nationally and across sectors 2026-10-28 20:54:30 +02:00
nala 1b2ede41e2 fix(catalogue): include approved provincial corridor coverage and show territorial detail 2026-10-28 20:49:30 +02:00
nala d7a9284d5f docs(navigation): record installed Git delivery and pending visual conference 2026-10-28 20:24:28 +02:00
nala 6e8de43ae7 fix(navigation): expose work area and preserve authorised menu destinations in QA
tests / ci (push) Canceled after 0s
2026-10-28 20:08:54 +02:00
nala 77d133ef0e docs(proactive): clarify connector outage and remaining visual conference 2026-10-28 19:33:27 +02:00
nala 930013f920 docs(proactive): record installed private supply delivery and scoped verification 2026-10-28 19:26:40 +02:00
nala 6d14769369 feat(proactive): prepare controlled private catalogue delivery and reject repeated comparisons
tests / ci (push) Canceled after 0s
2026-10-28 19:13:18 +02:00
nala a022ed9848 fix(proactive): retain unnamed administrative rows and preserve accepted import snapshot 2026-10-28 17:28:31 +02:00
nala 2f58b7d438 feat(proactive): integrate private supply observations and demonstrate Gurue comparison in QA 2026-10-28 17:19:55 +02:00
nala 150bef4b89 docs(irpc): record verified grouped Git delivery and concrete remaining L8 scope 2026-10-28 14:52:22 +02:00
nala 569a1cd750 docs(irpc): bind economic QA receipt to tested compatible candidate
tests / ci (push) Canceled after 0s
2026-10-28 14:30:52 +02:00
nala aae67c169d feat(irpc): demonstrate ordinary economic credit and compatible carry in QA 2026-10-28 14:29:55 +02:00
nala 6ededb597a docs(irpc): bind carry QA receipt to tested implementation 2026-10-28 14:01:33 +02:00
nala d134a92d16 feat(irpc): demonstrate documented prior credit and PEC balances in QA 2026-10-28 14:01:07 +02:00
nala cf4e0638c1 docs(irpc): record verified international credit Git delivery 2026-10-27 15:23:21 +02:00
nala 838ad941a2 feat(irpc): demonstrate documented international credit in QA
tests / ci (push) Canceled after 0s
2026-10-27 14:57:18 +02:00
nala 814fe0a9b7 docs: record verified PEC delivery and concrete remaining L8 scope 2026-10-27 14:20:56 +02:00
nala e182d1d79d docs: preserve verified sector delivery receipts in PEC candidate
tests / ci (push) Canceled after 0s
2026-10-27 14:03:07 +02:00
nala 9a093046e5 merge: reconcile accepted PEC QA with installed sector navigation 2026-10-27 14:02:47 +02:00
nala 4ee427bad4 docs: record verified sector navigation installation and preservation receipts 2026-10-27 13:57:29 +02:00
nala 669cb8979b fix: open individual sectors from catalogue cards without repeated detail
tests / ci (push) Canceled after 0s
2026-10-27 13:47:50 +02:00
nala 7fdb6b424b feat: add bounded current-year PEC deduction to annual IRPC QA 2026-10-27 13:15:18 +02:00
nala dc14f01e14 docs: prepare real team use evaluation without repeating accepted technical proof 2026-10-27 12:58:29 +02:00
nala 1ee0c6d21d docs: record verified L8 and proactive installations with fresh restore receipts 2026-10-27 12:53:01 +02:00
nala 9fda6d6e12 test: make proactive API snapshot proof portable without local QA outputs
tests / ci (push) Canceled after 0s
2026-10-27 12:45:32 +02:00
nala 56c0c6a7ca docs: record authorised delivery sequence and SSH execution handoff
tests / ci (push) Canceled after 0s
2026-10-27 12:16:23 +02:00
nala 74f7f93a75 merge: reconcile proactive delivery after L8 capacity preparation 2026-10-27 12:14:40 +02:00
nala 0ff402a11d docs: demonstrate team UI autonomy and consolidate proactive delivery 2026-10-27 12:05:51 +02:00
nala 51d536b7ba fix: keep private idea preparation valid in complement lookup 2026-10-27 12:01:24 +02:00
nala 403c444a3e docs: hand off metrology prospecting and start private Gurue pilot 2026-10-27 11:40:23 +02:00
nala a78abf06d0 docs(investment): delimit regional prospects demand costs and partner capacity 2026-10-27 10:58:05 +02:00
nala 52f54655d2 docs(investment): focus real pilot on opportunities generated by demand 2026-10-27 10:39:34 +02:00
nala 4f9450a71d docs(investment): demonstrate source-based Cahora Bassa idea in private QA dossier 2026-10-27 10:22:33 +02:00
nala 1afab4a2a7 docs(investment): record proactive QA proof guide and concrete residuals 2026-10-27 09:43:04 +02:00
nala 3439f35038 feat(investment): prepare evidence-led territorial needs and ideas in existing workflows 2026-10-27 09:35:20 +02:00
nala c85e772d2f docs(irpc): hold L8 delivery pending server capacity as requested
tests / ci (push) Canceled after 0s
2026-10-27 08:08:49 +02:00
nala 1436074819 docs(irpc): record first L8 QA settlement and concrete remaining scope 2026-10-26 17:03:05 +02:00
nala 11a6cee981 feat(irpc): simulate annual settlement with actual withholding and PPC proofs 2026-10-26 16:59:59 +02:00
nala d5f11462e9 docs(irpc): record combinations installation and delimit first L8 settlement 2026-10-26 16:32:26 +02:00
nala f8d57bddf9 docs(irpc): record current main compatibility and preserved fiscal evidence
tests / ci (push) Canceled after 0s
2026-10-26 16:08:45 +02:00
nala ea2bceb5ce merge: preserve current main catalogue and restore accepted fiscal manual history 2026-10-26 16:07:28 +02:00
nala 245549f9e8 feat(irpc): qualify supported benefit combinations and prevent duplicate deductions in QA 2026-10-26 16:04:05 +02:00
paulomassigue a7434cd27c .
tests / ci (push) Canceled after 0s
2026-10-26 16:04:04 +02:00
paulomassigue 71551deb73 restaturacao da cor nornmal de porque incvestir em Mocambique 2026-10-26 16:02:04 +02:00
nala f33d99b4d5 docs(irpc): record grouped CBF16 installation and remaining L7 scope 2026-10-26 15:37:27 +02:00
nala 632dc06930 docs(irpc): prepare grouped cessation and prior quota QA delivery
tests / ci (push) Canceled after 0s
2026-10-26 15:20:58 +02:00
nala ff84ab648f merge: preserve current main presentation alongside grouped L7 QA 2026-10-26 15:17:11 +02:00
nala 992c97ad4e feat(irpc): reconcile CBF16 cessation and documented prior quotas in QA 2026-10-26 15:16:45 +02:00
paulomassigue ae0d74bb3a azul claro como fundo em porque investir em mocambique
tests / ci (push) Canceled after 0s
2026-10-26 15:06:35 +02:00
paulomassigue 894ca4ec69 correcao de conflitos
tests / ci (push) Canceled after 0s
2026-10-26 14:57:27 +02:00
nala ba83afe7d8 feat(irpc): qualify CBF16 monthly quotas for resident start periods in QA 2026-10-26 14:55:35 +02:00
paulomassigue 4da7a16a50 mudanca da cor em por que investir em Mocambique 2026-10-26 14:54:49 +02:00
nala eccd95f9e7 docs(irpc): record monthly CBF16 installation and next short-period scope 2026-10-26 14:43:40 +02:00
nala 0bd5d49550 feat(irpc): reuse L6 monthly quotas for qualified CBF16 in QA
tests / ci (push) Canceled after 0s
2026-10-26 14:24:28 +02:00
nala aed020d19e docs(irpc): reconcile installed L7 server capacity and next monthly asset scope 2026-10-26 14:02:57 +02:00
nala 1649710cf7 docs(irpc): record grouped L7 installation and concrete remaining scope 2026-10-26 13:05:56 +02:00
nala e07f89da3d Merge demonstrated L7 treatments preserving concurrent portal changes and accepted history
tests / ci (push) Canceled after 0s
2026-10-26 12:53:38 +02:00
nala ab2ff2fdbc feat(irpc): qualify L7 expenses and compatible special treatments in QA 2026-10-26 12:14:52 +02:00
paulomassigue a2122dc34c correcao de conflitos
tests / ci (push) Canceled after 0s
2026-10-26 12:02:22 +02:00
nala d37fffb2d2 docs(irpc): accept bounded L6 operations and delimit L7 treatments 2026-10-26 11:46:33 +02:00
nala 45fd591c7f docs(irpc): record 5aaf1a56 delivery and separate remaining code from documentary questions 2026-10-26 11:07:26 +02:00
paulomassigue 17d633c99c Adicao do texto por que investir em Moz e ajuste dos elementos 2026-10-26 11:01:35 +02:00
nala 5aaf1a56f9 feat(irpc): prepare exact values and documented M20 M22 field mappings
tests / ci (push) Canceled after 0s
2026-10-26 10:51:05 +02:00
nala 2116f66b2b docs(irpc): record 56475acb installation and remaining L6 scope 2026-10-26 10:33:32 +02:00
paulomassigue 8f1ab24b22 acrescimo no hero de Porquê investir em Moçambique?
Partir de uma ideia Sectores de investimento
2026-10-26 10:22:17 +02:00
nala 56475acb2f feat(irpc): map short periods and foreign currency plans with explicit simulation evidence
tests / ci (push) Canceled after 0s
2026-10-26 09:55:25 +02:00
nala 0effdc9fcb docs(irpc): record e9fe1022 installation and partial L6 scope 2026-10-26 09:44:28 +02:00
nala e9fe102259 feat(irpc): demonstrate documented depreciation methods and prior quotas
tests / ci (push) Canceled after 0s
2026-10-26 09:24:06 +02:00
nala 44d0858cee docs(irpc): record 043dd35d installation and remaining L6 cases 2026-10-26 08:43:14 +02:00
nala 043dd35d8e feat(irpc): prepare scoped cost limits provisions and timing in QA
tests / ci (push) Canceled after 0s
2026-10-26 08:29:28 +02:00
nala f400e56a5b docs(irpc): record a2de7259 delivery and bounded L6 remainder 2026-10-25 22:39:35 +02:00
nala a2de725901 feat(irpc): prepare compatible L6 schedules and scoped form mappings in QA
tests / ci (push) Canceled after 0s
2026-10-25 22:29:06 +02:00
nala 82d0d05887 Record installed normal colecta and concrete L6 remainder separate from L7 L8 2026-10-25 22:11:55 +02:00
nala 2f684e5984 Calculate scoped normal IRPC colecta and preserve annual settlement boundary
tests / ci (push) Canceled after 0s
2026-10-25 21:58:59 +02:00
nala b8d45796dd Determine scoped IRPC taxable base from documented own loss balances 2026-10-25 21:46:59 +02:00
nala 5e02c23af8 Complete scoped IRPC taxable profit with explicit evidence states
tests / ci (push) Canceled after 0s
2026-10-25 21:33:51 +02:00
nala 664c88c8f7 Start scoped normal IRPC adjustments and record operational L5 installation 2026-10-25 21:22:42 +02:00
nala 7a11c4ef8f Specify AT interest and CER questions and label simulation assumptions
tests / ci (push) Canceled after 0s
2026-10-25 21:04:53 +02:00
nala acf8ec3cb5 Prepare documented interest calculations and separate MRFTA working cases 2026-10-25 20:52:21 +02:00
nala 5fa64bc0f4 Preserve exact original BM source bytes in the QA archive
tests / ci (push) Canceled after 0s
2026-10-25 20:19:53 +02:00
nala 85e3cd5c7b Prepare scoped MRFTA and documented compensatory interest with QA recovery 2026-10-25 20:19:08 +02:00
nala ce746d889d Implement scoped IRPC reliefs and PT-MZ royalties with QA recovery evidence
tests / ci (push) Canceled after 0s
2026-10-25 19:56:32 +02:00
nala 2b33a1c343 Identify IRPC income category and retention nature in Portuguese
tests / ci (push) Canceled after 0s
2026-10-25 19:20:44 +02:00
nala 4d6f828230 Group compatible IRPC withholding rates and reuse delivered FX selection 2026-10-25 19:14:46 +02:00
nala da0d8abb47 Start scoped IRPC L5 withholding workflow with document and payment reuse 2026-10-25 18:45:04 +02:00
nala 5c0cb25cea Preserve documented liberatory settlement when changing ISPC route
tests / ci (push) Canceled after 0s
2026-10-25 18:18:03 +02:00
nala 3dda7130ee Group ISPC occasional operations, excess acquisition treatment and 2026 formal preparation in QA 2026-10-25 18:12:34 +02:00
nala 6af7092e83 Reconcile installed L3 receipt and preserve current balance in compatible ISPC QA
tests / ci (push) Canceled after 0s
2026-10-25 17:15:52 +02:00
nala 49b448eb47 Prepare compatible ISPC quarterly QA on L3 baseline without experimental P001 changes 2026-10-25 17:11:58 +02:00
nala fa32a2b9e1 Align legacy message assertion with agreed P001 residual without fiscal changes
tests / ci (push) Canceled after 0s
2026-10-25 17:09:26 +02:00
nala 0f4df8a4d1 Prepare compatible L3 additional delivery with preserved residuals and accepted proofs
tests / ci (push) Canceled after 0s
2026-10-25 16:38:24 +02:00
nala b189d0fa43 Prepare exact beverage partitions, fiscal gap diagnostics and L3 form review in QA 2026-10-25 15:55:25 +02:00
nala dbeaf886ee Record installed L3 E delta and keep L3 and L1 L2 residuals open 2026-10-25 15:29:38 +02:00
nala 7ad187f9f0 Prepare demonstrated L3 E delivery after verified backup and isolated restore
tests / ci (push) Canceled after 0s
2026-10-25 15:25:44 +02:00
nala 4708662532 feat(iva): prepare beverage restitution QA and retain residual scope 2026-10-25 15:08:10 +02:00
nala 46944402da Record installed grouped L3 C D delta and preserved residuals 2026-10-25 14:53:45 +02:00
nala de36f02880 Prepare grouped L3 C D delivery after verified backup restore
tests / ci (push) Canceled after 0s
2026-10-25 14:50:04 +02:00
nala 9e2d90c880 Prepare L3 D refund lifecycle and grouped C D QA delivery with preserved credits 2026-10-25 14:47:03 +02:00
nala b7a745e64f Prepare L3 C substitutions with frozen predecessor and payment reconciliation in QA 2026-10-25 14:23:56 +02:00
nala 3aa851a467 Record installed L3 A/B delta and preserved residuals 2026-10-25 14:13:30 +02:00
nala 109cb0fe9e Prepare grouped L3 A/B delivery after verified backup restore
tests / ci (push) Canceled after 0s
2026-10-25 14:10:24 +02:00
nala f7b778cdce Implement scoped L3 A B credit carry and VAT closure with frozen L2 sources 2026-10-25 14:01:33 +02:00
nala 508aeb5205 Delimit L3 VAT closure increments using existing L1 L2 and cashflow mechanisms 2026-10-25 13:40:20 +02:00
nala acbd1f3315 Record installed L2 demonstrated scope and retain residual regularization work 2026-10-25 13:35:03 +02:00
nala 7a380b7690 Prepare grouped L2 D E delivery after authenticated backup and isolated restore
tests / ci (push) Canceled after 0s
2026-10-25 13:31:55 +02:00
nala dde20c3348 Implement scoped L2 E annual adjustments and asset control with grouped D E manifest 2026-10-25 13:22:17 +02:00
nala 2c2a2f2010 Implement L2 D allocation and provisional proportions with exact totals and frozen history 2026-10-25 13:00:59 +02:00
nala 855ab066c4 Record installed grouped L2 ABC and preserved L1 closure balance 2026-10-25 12:44:51 +02:00
nala 819ae791e9 Prepare grouped L2 ABC delivery after authenticated backup and isolated restore
tests / ci (push) Canceled after 0s
2026-10-25 12:41:42 +02:00
nala fc7e590d4e Prepare grouped L2 A B C with customs deduction exclusions and exact affected totals 2026-10-25 12:29:10 +02:00
nala 56c948ec45 Implement L2-A acquisition deduction rights with preserved history and affected totals 2026-10-25 12:12:29 +02:00
nala 940d69b82a Record limited L1 operational closure and executable L2 scope 2026-10-25 11:59:50 +02:00
nala 7febb6f7be Record installed P047 and explicitly partial P058 baggage delivery 2026-10-25 11:53:35 +02:00
nala ada9629bb6 Prepare grouped P047 delivery with explicitly partial P058 baggage
tests / ci (push) Canceled after 0s
2026-10-25 11:50:02 +02:00
nala 6e1dc4104c Implement P047 four remission families with scoped customs evidence and QA 2026-10-25 11:43:00 +02:00
nala 4f28fac5d0 Clarify L1 residual closure criteria and add partial returnee baggage QA 2026-10-25 11:24:02 +02:00
nala 9019f766f2 Record installed P104 and explicitly partial offer returnee scopes 2026-10-25 11:08:21 +02:00
nala e38aa74920 Authorize grouped installer after authenticated backup and isolated restore
tests / ci (push) Canceled after 0s
2026-10-25 11:04:59 +02:00
nala fab72d2e0c Prepare joint P104 delivery with explicit partial branch coverage 2026-10-25 11:02:30 +02:00
nala 7b6a95a718 Record P104 operational QA and partial offers returnee coverage 2026-10-25 10:57:42 +02:00
nala 9536eb710b Implement Tempo FX selection and offer request decision workflow in QA 2026-10-25 10:54:51 +02:00
nala 8b3ec2b04d docs(iva): receipt joint P046 P059 delivery with scoped RSA benefits 2026-10-25 10:26:31 +02:00
nala a665ee9c97 deploy(iva): deliver P046 P059 jointly with scoped miner benefits
tests / ci (push) Canceled after 0s
2026-10-25 10:22:39 +02:00
nala 2b130a66ec docs(iva): identify miner sources and record compatible P046 P059 QA 2026-10-25 10:16:33 +02:00
nala 4e550c3892 feat(iva): prepare documented P059 miner import paths in QA 2026-10-25 10:13:51 +02:00
nala 34bbcfa7e4 docs(iva): record P046 QA and concrete remaining source actions 2026-10-25 09:51:14 +02:00
nala 0807201517 feat(iva): prepare P046 objective import correspondence in QA 2026-10-25 09:48:47 +02:00
nala f059622562 docs(iva): receipt joint P129 P133 delivery with fiscal continuity 2026-10-25 09:34:10 +02:00
nala a48e974979 docs(iva): prepare joint travel event delivery preserving fiscal continuity
tests / ci (push) Canceled after 0s
2026-10-25 01:48:26 +02:00
nala 1a5f72209e docs(iva): record joint travel and special event QA with limitations 2026-10-25 01:45:12 +02:00
nala c309215d0f fix(iva): report only applicable special event facts preserving drafts 2026-10-25 01:43:06 +02:00
nala 7986d02405 feat(iva): add travel period margin and special travel suspensive events 2026-10-25 01:42:05 +02:00
nala 91c4063338 docs(iva): receipt joint P002 P121 P130 limited-scope delivery 2026-10-25 01:30:16 +02:00
nala df43aeb1e5 docs(iva): prepare joint foreign and limited digital delivery after restore
tests / ci (push) Canceled after 0s
2026-10-25 01:27:21 +02:00
nala de4b25bbd7 docs(iva): record digital QA and prepare grouped P002 delivery 2026-10-25 01:24:34 +02:00
nala 5f088e3411 feat(iva): classify digital goods and services with separate obligations 2026-10-25 01:23:14 +02:00
nala e4c490b323 docs: record P002 QA and delimit P104 currency finding 2026-10-25 01:15:52 +02:00
nala 86970cd507 Implement P002 reusing territoriality and events and clarify P104 legal cotejo 2026-10-25 01:13:46 +02:00
nala 2de67bebf9 docs(iva): receipt P131 P132 delivery keeping P104 conditional and pending 2026-10-25 01:07:32 +02:00
nala e8b5ae43ad docs(iva): prepare P131 P132 delivery with conditional P104 after isolated restore
tests / ci (push) Canceled after 0s
2026-10-25 01:04:54 +02:00
nala 0052cd42d2 docs(iva): record P131 P132 QA and delimit partial P104 currency coverage 2026-10-25 01:02:16 +02:00
nala 7e6e462e12 Implement P131 P132 event timing and conditional P104 draft in QA 2026-10-25 01:01:15 +02:00
nala 7a3ac43224 docs(iva): receipt P087 P127 delivery and remaining dependency groups 2026-10-25 00:52:54 +02:00
nala 92eaa93570 docs(iva): prepare joint P087 P127 delivery after isolated restore
tests / ci (push) Canceled after 0s
2026-10-25 00:50:30 +02:00
nala d84883b801 docs(iva): record P087 P127 QA and concrete actions for 16 pending IDs 2026-10-25 00:48:08 +02:00
nala 5999e35fcb Implement P087 binding lease and P127 business transfers in QA 2026-10-25 00:46:56 +02:00
nala 4cacef23dd docs(iva): receipt P123 P126 delivery with consignment and contrary proof explicit 2026-10-25 00:43:07 +02:00
nala ce9d649fbf docs(iva): prepare joint P123 P126 delivery after backup and isolated restore
tests / ci (push) Canceled after 0s
2026-10-25 00:40:41 +02:00
nala a5b1ae6657 docs(iva): record P123 P126 grouped QA and correct consignment designation 2026-10-25 00:37:34 +02:00
nala f640e19370 Implement P123 consignment and P126 rebuttable goods presumptions in QA 2026-10-25 00:36:56 +02:00
nala 4ef7be3d6f docs(iva): receipt grouped P092 P093 P125 delivery preserving margin eligibility 2026-10-25 00:33:14 +02:00
nala 019bcbad6c docs(iva): prepare joint P092 P093 P125 delivery after isolated restore
tests / ci (push) Canceled after 0s
2026-10-25 00:30:43 +02:00
nala 0ebb20c86a docs(iva): record grouped QA P092 P093 P125 and preserve 111 deliveries 2026-10-25 00:28:41 +02:00
nala b0818c0163 Implement P092 P093 P125 individual secondhand bases in QA 2026-10-25 00:27:37 +02:00
nala ec786f4ae2 docs(iva): receipt P088-P124 delivery with deduction history preserved 2026-10-25 00:19:44 +02:00
nala 6c12aabb3a docs(iva): prepare joint P088-P124 delivery after backup and isolated restore
tests / ci (push) Canceled after 0s
2026-10-25 00:17:11 +02:00
nala cd259623ae docs(iva): record P088-P124 QA with historical deduction separate from new deduction 2026-10-25 00:15:24 +02:00
nala 9b95e5e7a8 feat(iva): prepare goods allocation classification and current acquisition-cost bases in QA 2026-10-25 00:15:19 +02:00
nala 1b67fa85ff docs(iva): receipt joint P122-P128 delivery preserving P091 and prior closures 2026-10-25 00:10:37 +02:00
nala 63cc336787 docs(iva): prepare joint P122-P128 delivery after backup and isolated restore
tests / ci (push) Canceled after 0s
2026-10-25 00:08:02 +02:00
nala af7d250320 docs(iva): record P122-P128 QA preserving 107 delivered branches 2026-10-25 00:06:28 +02:00
nala 2b592ded66 feat(iva): prepare own-name goods and service chains together in QA 2026-10-25 00:06:24 +02:00
nala 5837f34a2c docs(iva): receipt joint P096-P099 delivery with deduction outside scope 2026-10-25 00:02:07 +02:00
nala 2fdca86367 docs(iva): prepare joint P096-P099 delivery after backup and isolated restore
tests / ci (push) Canceled after 0s
2026-10-24 23:59:51 +02:00
nala 833b4e5cef docs(iva): record four proportional bases in QA preserving 103 deliveries 2026-10-24 23:58:10 +02:00
nala 67a96085c8 feat(iva): prepare four proportional tax bases in one QA set 2026-10-24 23:58:06 +02:00
nala b0e01ef737 docs(iva): receipt joint G9A-G9J delivery preserving 93 closures 2026-10-24 23:53:30 +02:00
nala 2a7254406d docs(iva): prepare one G9A-G9J delivery after backup and isolated restore
tests / ci (push) Canceled after 0s
2026-10-24 23:49:58 +02:00
nala 4601bf64a0 docs(iva): record P111-P120 QA with separate territoriality and obligations 2026-10-24 23:48:12 +02:00
nala 6cffa1188f feat(iva): prepare P111-P120 listed services as one compatible QA set 2026-10-24 23:48:08 +02:00
nala 4853bc7284 docs(iva): receipt one joint G8B-G8F delivery preserving 88 closures 2026-10-24 23:43:03 +02:00
nala 03c26d6613 docs(iva): prepare one G8B-G8F delivery after backup and isolated restore
tests / ci (push) Canceled after 0s
2026-10-24 23:40:32 +02:00
nala 3f4e71c3f3 docs(iva): record five G8 branches in QA for one compatible delivery 2026-10-24 23:38:44 +02:00
nala ec33fb5be2 feat(iva): prepare five G8 service territoriality branches together in QA 2026-10-24 23:38:14 +02:00
nala aac3f46306 docs(iva): receipt G8A installation and adopt grouped future deliveries 2026-10-24 23:33:35 +02:00
nala 8f14f5dde2 docs(iva): prepare G8A delivery after verified backup and isolated restore
tests / ci (push) Canceled after 0s
2026-10-24 23:31:08 +02:00
nala fb7248661d docs(iva): record G8A P105 QA and preserve 87 delivered branches 2026-10-24 23:29:12 +02:00
nala 0682ef74e2 feat(iva): classify goods territoriality for L1 P105 in QA 2026-10-24 23:29:03 +02:00
nala 21ce03f65c docs(iva): record installed G7I P089 and distinct prior deduction scope 2026-10-24 23:23:49 +02:00
nala 6f1cf7b7a0 docs(iva): prepare G7I delivery after verified backup and isolated restore
tests / ci (push) Canceled after 0s
2026-10-24 23:21:33 +02:00
nala a4329bb4bf docs(iva): record G7I P089 QA and preserve 86 deliveries 2026-10-24 23:19:43 +02:00
nala 90b31c677f feat(iva): prepare P089 service normal values with distinct own conditions in QA 2026-10-24 23:19:00 +02:00
nala 33cc971a12 docs(iva): record installed G7H P103 and referenced correction scope 2026-10-24 23:15:02 +02:00
nala cd530b248b docs(iva): prepare G7H delivery after verified backup and isolated restore
tests / ci (push) Canceled after 0s
2026-10-24 23:12:48 +02:00
nala 2087134143 docs(iva): record G7H P103 QA and preserve 85 deliveries 2026-10-24 23:10:58 +02:00
nala 67b8b58149 feat(iva): prepare P103 price comparison and referenced correction in QA 2026-10-24 23:10:11 +02:00
nala adb849d4fc docs(iva): record installed G7G P102 and four exclusion scopes 2026-10-24 23:06:42 +02:00
nala 5d5f3b4148 docs(iva): prepare G7G delivery after verified backup and isolated restore
tests / ci (push) Canceled after 0s
2026-10-24 23:04:14 +02:00
nala 9c2b2c6560 docs(iva): record G7G P102 QA and preserve 84 deliveries 2026-10-24 23:00:49 +02:00
nala acc633bf08 feat(iva): prepare four P102 exclusions with own conditions in QA 2026-10-24 22:59:39 +02:00
nala a89f9eca8c docs(iva): record installed G7F P101 and single operation base 2026-10-24 22:55:06 +02:00
nala 7e5710ebbd docs(iva): prepare G7F delivery after verified backup and isolated restore
tests / ci (push) Canceled after 0s
2026-10-24 22:52:59 +02:00
nala 6f90fcb9d4 docs(iva): record G7F P101 QA and preserve 83 deliveries 2026-10-24 22:50:04 +02:00
nala e3caf55361 feat(iva): prepare P101 consideration composition without doublecounting in QA 2026-10-24 22:49:56 +02:00
nala 0bef805ede docs(iva): record installed G7E P100 and preserved valuation scope 2026-10-24 22:45:45 +02:00
nala 70b4a640ee docs(iva): prepare G7E delivery after verified backup and isolated restore
tests / ci (push) Canceled after 0s
2026-10-24 22:43:24 +02:00
nala 0460c85330 docs(iva): record G7E P100 QA and preserve 82 deliveries 2026-10-24 22:41:41 +02:00
nala 9f053ac9d3 feat(iva): prepare P100 noncash consideration bases in QA 2026-10-24 22:41:33 +02:00
nala 6234d34392 docs(iva): record installed G7D P095 and TC excluded once 2026-10-24 22:38:04 +02:00
nala 92a7418cb6 docs(iva): prepare G7D delivery after verified backup and isolated restore
tests / ci (push) Canceled after 0s
2026-10-24 22:35:57 +02:00
nala 76be72c38c docs(iva): record G7D P095 QA and preserve 81 deliveries 2026-10-24 22:34:04 +02:00
nala 3bcd98e78e feat(iva): prepare P095 regulated fuel base excluding TC in QA 2026-10-24 22:33:57 +02:00
nala 9600880169 docs(iva): record installed G7C P094 and preserved valuation scope 2026-10-24 22:29:08 +02:00
nala df7cb7fa47 docs(iva): prepare G7C delivery after verified backup and isolated restore
tests / ci (push) Canceled after 0s
2026-10-24 22:26:45 +02:00
nala 287f2f8321 docs(iva): record G7C P094 QA and preserve 80 deliveries 2026-10-24 22:23:42 +02:00
nala 4672789f2b feat(iva): prepare P094 act transfer valuation bases in QA 2026-10-24 22:23:10 +02:00
nala 15894f4b04 docs(iva): record installed G7B P091 and preserved commission directions 2026-10-24 22:15:55 +02:00
nala e62b7dfdc2 docs(iva): prepare G7B delivery after verified backup and isolated restore
tests / ci (push) Canceled after 0s
2026-10-24 22:13:42 +02:00
nala 9bd2e062f4 docs(iva): record G7B P091 QA and preserve 79 delivered branches 2026-10-24 22:04:25 +02:00
nala 948621a65c feat(iva): determine goods commission sale and purchase tax bases 2026-10-24 22:03:44 +02:00
nala 50463ca716 docs(iva): record installed G7A P090 with base VAT separation 2026-10-24 21:52:31 +02:00
nala 4193056bdd docs(iva): prepare G7A delivery after verified backup and isolated restore
tests / ci (push) Canceled after 0s
2026-10-24 21:50:21 +02:00
nala 20bfeb4f71 docs(iva): record G7A P090 QA and preserve 78 delivered branches 2026-10-24 21:47:38 +02:00
nala 1dda6ea258 feat(iva): determine public authority compensation tax bases 2026-10-24 21:46:47 +02:00
nala 0a80f1c457 docs(iva): record installed G6H P060 and preserved decision scope 2026-10-24 21:39:03 +02:00
nala eb9294ef42 docs(iva): prepare G6H delivery after verified backup and isolated restore
tests / ci (push) Canceled after 0s
2026-10-24 21:36:55 +02:00
nala 2359a88dba docs(iva): record G6H P060 QA and preserve 77 delivered branches 2026-10-24 21:29:32 +02:00
nala 69b6c49a60 feat(iva): classify UN development imports with proportional relief 2026-10-24 21:29:22 +02:00
nala 163e090ea5 docs(iva): record installed G6G P056 delivery and preserved authorization scope 2026-10-24 21:13:56 +02:00
nala 9b26217dec docs(iva): prepare G6G delivery after verified backup and isolated restore
tests / ci (push) Canceled after 0s
2026-10-24 21:11:50 +02:00
nala ebf5e53c3c docs(iva): record G6G P056 QA and preserve 76 delivered branches 2026-10-24 20:53:12 +02:00
nala 9783c54b2c feat(iva): classify authorized class K investment equipment imports 2026-10-24 20:52:32 +02:00
nala 0f9f7e3409 docs(iva): record installed G6F and partial P084 P085 delivery 2026-10-24 20:47:58 +02:00
nala 78c9f4a356 docs(iva): prepare G6F delivery after verified backup and restore
tests / ci (push) Canceled after 0s
2026-10-24 20:45:46 +02:00
nala 7996c35933 docs(iva): record G6F P050 QA and authorized partial offer coverage 2026-10-24 20:41:20 +02:00
nala 29e708ba2d fix(iva): exclude import-linked services from goods gift references 2026-10-24 20:39:54 +02:00
nala f9fb04f947 feat(iva): classify P050 services included in import taxable base 2026-10-24 20:39:23 +02:00
nala 77d1290258 docs(iva): record distinct offer paths and scoped conditional scenarios QA 2026-10-24 20:33:20 +02:00
nala 946fc08906 fix(iva): preserve exact and literal scope of offer decisions 2026-10-24 20:31:58 +02:00
nala 7ecf9ec79a feat(iva): distinguish offer requests emergency drafts and conditional scenarios 2026-10-24 20:30:54 +02:00
nala 536559d651 docs(iva): receipt for installed G6E P052 and partial draft preparation 2026-10-24 20:26:59 +02:00
nala c1314cdf90 docs(iva): prepare G6E delivery after fresh backup and isolated restore
tests / ci (push) Waiting to run
2026-10-24 20:23:46 +02:00
nala ff8848d32d docs(iva): record G6E QA and Model I1 enabling-diploma scope 2026-10-24 20:19:29 +02:00
nala 1565aa4ffb feat(iva): classify P052 onboard supply imports with statutory exclusions 2026-10-24 20:18:20 +02:00
nala 4e4bf6b859 docs(iva): record P084 P085 draft-only QA and joint source research 2026-10-24 20:11:22 +02:00
nala 5ec2f198a3 feat(iva): prepare distinct P084 P085 offer drafts without applying benefits 2026-10-24 20:10:28 +02:00
nala e32690799f docs(iva): receipt for installed joint P048 P080 delivery 2026-10-24 20:07:57 +02:00
nala 2d889a211b docs(iva): prepare joint P048 P080 Git delivery after verified restore
tests / ci (push) Canceled after 0s
2026-10-24 20:04:30 +02:00
nala 50fdd97916 docs(iva): record P080 QA and individual P084 P085 remaining work 2026-10-24 20:01:41 +02:00
nala 9842dcbb0e feat(iva): classify P080 special-regime imports and delimit P084/P085 2026-10-24 20:00:48 +02:00
nala e384418ecc docs(iva): record G6D P048 three treatments QA and preserved 72 closures 2026-10-24 19:55:26 +02:00
nala 0eb37ef59b feat(iva): classify P048 import customs regimes with full duty exemption 2026-10-24 19:54:06 +02:00
nala 2ed62165b7 docs(delivery): record G6C installed delta and reconciled 72 plus 60 balance 2026-10-24 19:48:13 +02:00
nala d46b794ab2 docs(delivery): prepare G6C with fresh backup and verified isolated restore
tests / ci (push) Canceled after 0s
2026-10-24 19:45:57 +02:00
nala d65d3f5c4e docs(iva): record G6C P053 P054 four treatments QA and preserved 70 closures 2026-10-24 19:43:54 +02:00
nala 8e5565d5e0 feat(iva): classify eligible vessel and aircraft imports in scoped G6C 2026-10-24 19:42:37 +02:00
nala 72e56f7ae5 docs(delivery): record G6B installed delta and reconciled 70 plus 62 balance 2026-10-24 19:37:22 +02:00
nala 4cebd61765 docs(delivery): prepare G6B with fresh backup and verified isolated restore
tests / ci (push) Canceled after 0s
2026-10-24 19:35:02 +02:00
nala 1efb32c244 docs(iva): record G6B P055 P057 QA and preserved 68 delivered branches 2026-10-24 19:32:07 +02:00
nala f75f36f0e3 feat(iva): classify artwork and fire brigade imports in scoped G6B 2026-10-24 19:29:00 +02:00
nala a23f1dcb6e docs(delivery): record G6A installed delta and reconciled 68 plus 64 balance 2026-10-24 19:24:39 +02:00
nala e3601c124d docs(delivery): prepare G6A with fresh backup and verified isolated restore
tests / ci (push) Canceled after 0s
2026-10-24 19:22:06 +02:00
nala 615f78e79f docs(iva): record G6A P049 P051 QA and preserved 66 delivered branches 2026-10-24 19:20:09 +02:00
nala 965da26426 feat(iva): classify reimports and central bank gold imports in scoped G6A 2026-10-24 19:18:46 +02:00
nala e99ea1d013 docs(delivery): record G5B eliminated provision routing and installed delta 2026-10-24 19:10:47 +02:00
nala 6f056359df docs(delivery): prepare G5B with fresh backup and verified isolated restore
tests / ci (push) Canceled after 0s
2026-10-24 19:08:39 +02:00
nala 0cd7e9fb62 docs(iva): record P086 rectification and demonstrated G5B acquisition continuity QA 2026-10-24 19:05:10 +02:00
nala 689a4c93c3 feat(iva): explain eliminated P086 exemption and preserve acquisition continuity 2026-10-24 19:03:00 +02:00
nala d3e9f3732c docs(delivery): record G5A installed delta and reconciled 65 plus 67 balance 2026-10-24 18:49:57 +02:00
nala 69f2e365bf chore(delivery): prepare G5A with fresh authenticated backup and restore
tests / ci (push) Waiting to run
2026-10-24 18:47:03 +02:00
nala 4e97cd05ea docs(iva): map accepted G4E treatments and record demonstrated G5A QA 2026-10-24 18:37:36 +02:00
nala 1f7fc13872 feat(iva): add G5A goods and connected services under special regimes 2026-10-24 18:36:01 +02:00
nala 0eb2c485cc docs(delivery): record G4E installed delta and reconciled 62 plus 70 balance 2026-10-24 18:28:22 +02:00
nala 2e21ca4500 chore(delivery): prepare G4E after fresh authenticated backup and restore
tests / ci (push) Canceled after 0s
2026-10-24 18:26:03 +02:00
nala fae8162c58 docs(iva): close G4E QA from Tempo manual visual confirmation 2026-10-24 18:23:30 +02:00
nala d03101bb69 docs(iva): record G4E QA evidence and pending directed visual check 2026-10-24 18:22:16 +02:00
nala 38faeac685 feat(iva): add G4E export buyer and beneficiary conditions in QA 2026-10-24 18:20:04 +02:00
nala 3be371c823 docs(delivery): record G4D installed delta and beverage follow-up in L2 L3 2026-10-24 18:09:53 +02:00
nala 686328a085 chore(delivery): prepare G4D with verified destination backup and restore
tests / ci (push) Canceled after 0s
2026-10-24 18:07:35 +02:00
nala 40a4ddcbde docs(iva): close G4D QA with Tempo directed visual confirmation 2026-10-24 18:06:14 +02:00
nala e50733ba1d docs(iva): record G4D coverage exclusions and pending visual proof 2026-10-24 18:05:20 +02:00
nala 8df721ac51 feat(iva): classify G4D onboard supplies with explicit beverage exceptions 2026-10-24 18:04:31 +02:00
nala 25cae1d98b docs(delivery): record G4C installed delta and preserved baseline 2026-10-24 17:59:25 +02:00
nala 679203d4b1 chore(delivery): prepare G4C after authenticated backup and isolated restore
tests / ci (push) Canceled after 0s
2026-10-24 17:56:23 +02:00
nala 2fccbc8ee8 docs(iva): close G4C QA with Tempo manual visual confirmation 2026-10-24 17:55:23 +02:00
nala 19b31d51c5 docs(iva): record G4C code/API coverage and pending directed visual proof 2026-10-24 17:54:36 +02:00
nala b1c8e0e343 feat(iva): add G4C linked export services with explicit operation reference 2026-10-24 17:53:35 +02:00
nala 117f2a70a8 docs(iva): record G4B homologation delivery and preserved balance 2026-10-24 17:42:50 +02:00
nala a153d0782f docs(iva): prepare G4B delivery with fresh authenticated backup and restore
tests / ci (push) Canceled after 0s
2026-10-24 17:40:34 +02:00
nala 0b0e002d15 docs(iva): record Tempo G4B visual completion and current saved result 2026-10-24 17:31:20 +02:00
nala b641887274 docs(iva): record G4B code QA and explicit browser access gate 2026-10-24 17:29:11 +02:00
nala f946364baa feat(iva): implement G4B eligible vessel aircraft and direct services 2026-10-24 17:27:32 +02:00
nala 3846be8fc6 docs(iva): record G4A homologation delivery and preserved balance 2026-10-24 17:21:31 +02:00
nala b898955603 docs(iva): prepare G4A delivery with fresh backup and restore
tests / ci (push) Canceled after 0s
2026-10-24 17:19:15 +02:00
nala 7b54efb0fc docs(iva): record Tempo G4A visual completion and preserved historical result 2026-10-24 17:16:23 +02:00
nala 7a58fa2435 docs(iva): record G4A code QA coverage and explicit visual remainder 2026-10-24 17:08:36 +02:00
nala 0f0d2e566f feat(iva): implement G4A direct international operations with scoped evidence 2026-10-24 17:07:26 +02:00
nala 82e010c44f docs(iva): record G3 homologation delivery and preserved balance 2026-10-24 17:03:13 +02:00
nala 6eefe4b332 docs(iva): prepare G3 delivery with fresh backup restore and compatible rollback
tests / ci (push) Canceled after 0s
2026-10-24 17:01:08 +02:00
nala 0dc2d64b20 docs(iva): record Tempo visual completion of G3 QA 2026-10-24 16:58:34 +02:00
nala bda6d1e3a0 docs(iva): record G3 cessation code QA and explicit visual verification remainder 2026-10-24 16:54:23 +02:00
nala ac948bd8b7 feat(iva): implement G3 temporary benefit cessation and explicit treatment continuity 2026-10-24 16:51:52 +02:00
nala 908c8afc54 docs(iva): make G2L manual visual evidence and technical corroboration explicit 2026-10-24 16:44:26 +02:00
nala 643ba6ed72 docs(iva): record G2K G2L homologation delivery and preserved balance 2026-10-24 16:41:57 +02:00
nala ccdecbd8d7 docs(iva): verify G2K G2L joint delivery backup and restore
tests / ci (push) Canceled after 0s
2026-10-24 16:39:06 +02:00
nala da798dd1d8 docs(iva): complete G2L QA with Tempo review and actual saved payload evidence 2026-10-24 16:36:58 +02:00
nala 0304612093 docs(iva): record partial Tempo review and clarify QA save recovery controls 2026-10-24 16:33:05 +02:00
nala d76078a81f docs(iva): accept Tempo manual verification route without claiming G2L closure 2026-10-24 16:24:02 +02:00
nala c4cc7d83e7 docs(iva): record G2L code QA and explicit pending visual access gate 2026-10-24 16:20:31 +02:00
nala 744682835a feat(iva): implement G2L Annex I and explicit continuity pending visual QA 2026-10-24 16:19:52 +02:00
nala 1fe742561f docs(iva): record G2K P008 QA candidate and preserved balance 2026-10-24 16:06:48 +02:00
nala 8882dc54d0 feat(iva): implement G2K pharmaceutical manufacturing inputs in QA 2026-10-24 16:05:55 +02:00
nala eb9e0dbc60 docs(iva): record G2I G2J homologation delivery and calendar resolution 2026-10-24 15:52:45 +02:00
nala e45a1394fa docs(iva): verify G2I G2J joint delivery backup and calendar evidence
tests / ci (push) Canceled after 0s
2026-10-24 15:49:08 +02:00
nala 72f4187d3b feat(iva): add fundraising group and correct production option calendar in QA 2026-10-24 15:47:02 +02:00
nala a5ac8ca1c9 docs(iva): record G2I QA candidate and explicit calendar limit 2026-10-24 15:31:29 +02:00
nala fe69a16eea feat(iva): add own-production exemptions and dated tax options in QA 2026-10-24 15:31:27 +02:00
nala 7696af6814 docs(iva): record G2G G2H homologation delivery and preserved balance 2026-10-24 15:15:49 +02:00
nala d4eae917ee docs(iva): verify G2G G2H joint delivery backup and compatibility
tests / ci (push) Canceled after 0s
2026-10-24 15:13:32 +02:00
nala b297058d11 docs(iva): record G2H QA candidate and one-branch coverage 2026-10-24 15:12:30 +02:00
nala 48146089cd feat(iva): add eighteen enumerated products within P026 in QA 2026-10-24 15:12:14 +02:00
nala 2440cb448a docs(iva): record G2G QA candidate and four-branch balance 2026-10-24 15:06:45 +02:00
nala 9dbfdd67ad feat(iva): add conditional public official and asset-sale operations in QA 2026-10-24 15:06:43 +02:00
nala b1928b61e5 docs(iva): clarify delivered G2F IDs and evidence paths 2026-10-24 15:03:42 +02:00
nala 541ea70484 docs(iva): record G2E G2F homologation delivery and preservation 2026-10-24 15:01:45 +02:00
nala 9a56d98242 docs(iva): verify G2E G2F destination backup and isolated restore
tests / ci (push) Canceled after 0s
2026-10-24 14:59:24 +02:00
nala 38b49bd5fa docs(iva): prepare G2E G2F joint delivery and authenticated preflight 2026-10-24 14:57:53 +02:00
nala 9b0f437073 docs(iva): record G2F candidate and seven QA branches 2026-10-24 14:57:23 +02:00
nala 8171c7992e feat(iva): add games stamped values and passenger transport in QA 2026-10-24 14:57:22 +02:00
nala 30760cab9b docs(iva): record G2E QA candidate and preserved balance 2026-10-24 14:53:24 +02:00
nala 33d18b9efc feat(iva): add conditional agropecuary operations in QA 2026-10-24 14:53:23 +02:00
nala 0aac0d1555 docs(iva): record G2C G2D homologation delivery and preservation 2026-10-24 14:49:27 +02:00
nala 3b788ec009 docs(iva): record G2C G2D backup and activation scope
tests / ci (push) Canceled after 0s
2026-10-24 14:46:36 +02:00
nala db4ff64e87 docs(iva): prepare verified G2C G2D joint Git delivery 2026-10-24 14:46:24 +02:00
nala c28e290b42 docs(iva): record G2D candidate and joint QA balance 2026-10-24 14:42:43 +02:00
nala 8d127799cd feat(iva): add author rights and eligible publications in QA 2026-10-24 14:42:38 +02:00
nala d32ad1e0a6 docs(iva): record G2C QA candidate and preserved delivery balance 2026-10-24 14:39:04 +02:00
nala 122f4f04b9 feat(iva): add conditional cultural and membership operations in QA 2026-10-24 14:38:55 +02:00
nala dacda11c07 Record joint L1 homologation delivery 490527b9 and verified preservation 2026-10-24 14:32:35 +02:00
nala 490527b92c Record joint L1 QA candidate, verified Git bundle and integration preview
tests / ci (push) Canceled after 0s
2026-10-24 14:22:15 +02:00
nala 4a35d63cd3 Complete G2B social QA and prepare joint L1 delivery with compatible rollback 2026-10-24 14:21:21 +02:00
nala 0e644b5524 Record G2A QA candidate and reconciled L1 closure counts 2026-10-24 14:04:45 +02:00
nala 675cf38f67 Implement G2A health exemptions in QA and reconcile L1 branch inventory 2026-10-24 14:04:18 +02:00
nala 89ab288334 Record L1 grouped QA candidate and verified final export evidence 2026-10-24 13:54:42 +02:00
nala 5030116ee5 Group L1 coverage and implement documented service exemptions in QA 2026-10-24 13:54:32 +02:00
nala 8719e916ea docs: record rational VAT QA candidate and open form dependency 2026-10-24 13:20:52 +02:00
nala 689e7cbe8b feat: preserve exact included VAT fractions and enumerate L1 remainder 2026-10-24 13:20:32 +02:00
nala d89912e579 docs: record IVA L1 QA candidate and explicit remaining scope 2026-10-24 13:00:26 +02:00
nala 544068e8cc feat: demonstrate scoped IVA operations L1 in QA 2026-10-24 12:59:37 +02:00
nala e8c39a1b54 docs: receipt for installed Modelo A acquisitions and totals 2026-10-24 12:19:34 +02:00
nala 1655f57e69 docs: register Modelo A acquisitions QA candidate and evidence
tests / ci (push) Canceled after 0s
2026-10-24 12:14:21 +02:00
nala d18ff0f049 feat: implement opportunity-scoped Modelo A acquisitions in QA 2026-10-24 12:13:57 +02:00
nala 355656b5c8 docs: receipt for installed simulator S1-S3 closure in homologation 2026-10-24 11:41:57 +02:00
nala cd10cccbf4 docs: close S3 monthly PDF review and mark simulator candidate ready for integration
tests / ci (push) Canceled after 0s
2026-10-24 11:37:09 +02:00
nala d044ef1efc docs: record S1 and S2 QA results and directed S3 file checks 2026-10-24 11:30:59 +02:00
nala 65b87819a0 fix: preserve cashflow currency and recover admitted monthly IVA executions in QA 2026-10-24 11:29:15 +02:00
nala be62783b98 docs: delimit simulator closure to two code gaps and monthly export proof 2026-10-24 11:16:42 +02:00
nala 98fc16a3fb docs: record installed stabilization reference and finite remaining scope 2026-10-24 11:10:51 +02:00
nala 276d833a4b test: consolidate stabilization reference and preserve accepted contour scope
tests / ci (push) Canceled after 0s
2026-10-24 11:08:35 +02:00
nala 5d45541a11 docs: receipt for installed shared map stabilization and accepted public contours 2026-10-24 10:51:31 +02:00
nala 066707ece6 fix: restore shared map access and publish accepted administrative boundary projection
tests / ci (push) Canceled after 0s
2026-10-24 10:47:13 +02:00
nala c1f4a71963 docs: record installed anchor integration and exact territorial recovery 2026-10-23 19:26:07 +02:00
nala cf2ddf21a1 fix: verify territorial recovery manifest with canonical Git line endings
tests / ci (push) Canceled after 0s
2026-10-23 19:20:25 +02:00
nala 3fd5128c62 fix: connect documentary anchors to investment preparation and recover accepted territories
tests / ci (push) Canceled after 0s
2026-10-23 19:17:15 +02:00
nala cdf8fcfba4 docs: record installed treatment scope and verified native PDF 2026-10-23 18:50:13 +02:00
nala 8036b9b071 docs: verify saved native PDF and release delivery condition
tests / ci (push) Canceled after 0s
2026-10-23 18:44:09 +02:00
nala 6985f82e9c docs: delimit anchor findings and pending native PDF delivery gate 2026-10-23 18:36:08 +02:00
nala 2856240cf4 fix: print selected requirements and translated opportunity stage states 2026-10-23 18:34:39 +02:00
nala 32144aa2f3 docs: record treatment scope QA and anchor search analysis boundary 2026-10-23 18:26:00 +02:00
nala 1afdfee8c0 fix: share selected opportunity across simulator stages and land shortcuts 2026-10-23 18:25:09 +02:00
nala bc84fbdb3e docs: delimit remaining transversal investment scope 2026-10-23 18:06:42 +02:00
nala b6a626c92d Record installed opportunity-consumer delta in homologation 2026-10-23 17:58:15 +02:00
nala 55136b446d Record directed QA proof for all four opportunity consumers
tests / ci (push) Canceled after 0s
2026-10-23 17:51:45 +02:00
nala 4a01e9e2b7 Scope parks and downstream investment requests to the treated opportunity 2026-10-23 17:49:22 +02:00
nala ad4160f040 Record second-opportunity context assessment and bounded consumer gaps 2026-10-23 17:18:55 +02:00
nala af9261809c Record installed complements delta and preserved acceptance 2026-10-23 17:14:04 +02:00
nala d8224ba84b Align public simulator entry check with integrated home layout
tests / ci (push) Canceled after 0s
2026-10-23 17:11:15 +02:00
nala 088f2c2cf4 Merge remote-tracking branch 'origin/main' into codex/investment-complements-20261023
tests / ci (push) Canceled after 0s
2026-10-23 16:35:03 +02:00
nala 603a88e1d3 Link documented investment complements to existing fiches and dossier context 2026-10-23 16:34:57 +02:00
paulomassigue 43cd0be7cb correcao de conflitos
tests / ci (push) Canceled after 0s
2026-10-23 16:03:34 +02:00
paulomassigue 89a9c3d994 mudanca o botao Tempo no hear fica Mais 2026-10-23 15:58:59 +02:00
nala 4098192a05 Record installed continuity corrections and destination delta checks 2026-10-23 15:48:52 +02:00
paulomassigue d242482042 home ajustado 2026-10-23 15:46:53 +02:00
nala 6b544548d3 Fix compressed draft saves provenance and Beira documentary continuity
tests / ci (push) Canceled after 0s
2026-10-23 15:41:37 +02:00
nala c3f7c3ad10 Record homologation product journey findings and publication status 2026-10-23 15:26:38 +02:00
nala 445aa7e69d Record consolidated adaptive portfolio installation receipt 2026-10-23 15:17:59 +02:00
nala 7c1f532134 Preserve inactive document history in adaptive reports
tests / ci (push) Canceled after 0s
2026-10-23 15:14:44 +02:00
nala 115c773162 Record QA continuity recovery after return reload and login
tests / ci (push) Canceled after 0s
2026-10-23 15:03:22 +02:00
paulomassigue ec4b2f2af2 organizacao da tela inicial 2026-10-23 14:51:34 +02:00
nala 3d877ec745 Preserve selected portfolio context through investment preparation 2026-10-23 14:35:50 +02:00
nala 6948ed62fc Consolidate adaptive preparation QA delivery and accepted evidence 2026-10-23 14:19:05 +02:00
nala 7d798579a6 Reconcile portfolio recovery evidence and integrated journey gap 2026-10-23 14:17:42 +02:00
nala 5bda74aedc Adapt land status to saved investment land need 2026-10-23 14:13:58 +02:00
nala f2150b1912 Reuse investment context as editable simulator summaries 2026-10-23 14:03:37 +02:00
nala 7ca94967f3 Adapt preparation presentation to declared applicability 2026-10-23 13:53:02 +02:00
nala e1f010f941 fix: preserve corridor filters and keep investment previews readable
tests / ci (push) Canceled after 0s
2026-10-23 13:30:10 +02:00
nala 0025309b98 feat: adapt investment territories to Tempo corridor configuration
tests / ci (push) Canceled after 0s
2026-10-23 13:24:58 +02:00
nala a0e80db4ec docs: confirm opportunity preparation continuity in homologation
tests / ci (push) Canceled after 0s
2026-10-23 12:31:36 +02:00
nala 7226287526 fix: keep opportunity preparation focused on the pending selection
tests / ci (push) Canceled after 0s
2026-10-23 12:29:26 +02:00
nala f1ac82ae4d docs: record clean investor entry verified in homologation
tests / ci (push) Canceled after 0s
2026-10-23 12:20:28 +02:00
nala 98474777f6 fix: remove internal metadata from investor dossier presentation
tests / ci (push) Canceled after 0s
2026-10-23 12:18:25 +02:00
nala 3848f29bea docs: record corridor catalogue verification in homologation
tests / ci (push) Canceled after 0s
2026-10-23 12:12:05 +02:00
nala 203492997e feat: explore the public opportunity catalogue by corridor
tests / ci (push) Canceled after 0s
2026-10-23 12:08:33 +02:00
nala 165c2f6171 fix: keep internal agribusiness delivery notes out of portal presentation
tests / ci (push) Canceled after 0s
2026-10-23 11:22:39 +02:00
nala 4ad1bf402b docs: record authenticated publication of 34 agribusiness opportunities
tests / ci (push) Canceled after 0s
2026-10-23 11:12:45 +02:00
nala e12ef6ad42 Record installed agribusiness workflow, preserved destination and pending real-account publication
tests / ci (push) Canceled after 0s
2026-10-23 11:00:30 +02:00
nala c641b267ac Publish agribusiness lot through scoped management and extend existing dossier for multiple opportunities
tests / ci (push) Canceled after 0s
2026-10-23 10:49:50 +02:00
paulomassigue e2b0fb4b2d melhorias de home 1 2026-10-23 10:36:34 +02:00
nala 7d08c2c664 Register 34 private agribusiness opportunities with promotional management and investor paths in QA 2026-10-23 10:15:41 +02:00
nala 761035aefb Record homologation agribusiness installation and preserved QA evidence
tests / ci (push) Canceled after 0s
2026-10-23 09:58:13 +02:00
nala 20a4099d10 Present 34 agribusiness opportunities in existing corridor sections
tests / ci (push) Canceled after 0s
2026-10-23 09:56:22 +02:00
nala e03de682ca Preserve completed features and restore approved portfolio visibility
tests / ci (push) Canceled after 0s
2026-10-23 09:19:25 +02:00
nala 37a8eb015a docs: record private legacy registration and authenticated editorial access
tests / ci (push) Canceled after 0s
2026-10-22 23:55:57 +02:00
nala 65eddf6bc7 merge: install reviewed private legacy editorial workflow
tests / ci (push) Canceled after 0s
2026-10-22 23:40:27 +02:00
nala fb4e7d8970 Complete private legacy article editing and previews in QA 2026-10-22 23:36:07 +02:00
nala bc93ccdb9e docs: reconcile all legacy articles and verify private QA batches 2026-10-22 23:10:21 +02:00
nala 7ccf62adec docs: record authenticated tender installation and final links
tests / ci (push) Canceled after 0s
2026-10-22 22:54:59 +02:00
nala f4d0970917 merge: install reviewed tender publishing in homologation
tests / ci (push) Canceled after 0s
2026-10-22 22:49:18 +02:00
nala e46f973159 docs: plan staged tender schema installation and activation 2026-10-22 22:44:42 +02:00
nala 1bcf34c0c0 feat: add QA tender and announcement publishing workflow 2026-10-22 22:41:28 +02:00
nala ef0c2acd36 docs: record authenticated homologation geometry restoration and access proof
tests / ci (push) Canceled after 0s
2026-10-22 22:04:32 +02:00
nala 30aed7cd12 Merge approved administrative access for geometry restoration
tests / ci (push) Canceled after 0s
2026-10-22 21:55:48 +02:00
nala 95d4cf44d7 fix: reuse approved administration for authenticated geometry import 2026-10-22 21:55:36 +02:00
nala 73c046e104 Merge territorial demarcation recovery reviewed for homologation
tests / ci (push) Canceled after 0s
2026-10-22 21:45:58 +02:00
nala 15d82611b6 fix: recover territorial demarcation QA and prepare retained geometry restoration 2026-10-22 21:43:54 +02:00
nala 8286d7504d fix: preserve approved editorial access independently of technical reservation
tests / ci (push) Canceled after 0s
2026-10-22 21:03:11 +02:00
nala aa2b3d05b0 fix: route authorised content staff through management access entry
tests / ci (push) Waiting to run
2026-10-22 21:00:05 +02:00
nala 97d51db60a feat: add unified team content management entry in QA
tests / ci (push) Canceled after 0s
2026-10-22 20:55:08 +02:00
Nelson_Matsinhe a1b553372e ajuste do tamanho da navbar
tests / ci (push) Canceled after 0s
2026-10-22 15:26:56 +02:00
nala f80d30f478 docs: record Tempo approval for catalogue selector integration
tests / ci (push) Canceled after 0s
2026-10-22 14:36:36 +02:00
nala 916819eb35 docs: record keyboard selector proof and mouse observation limit 2026-10-22 14:33:59 +02:00
nala 1d921c9d03 fix: keep public catalogue selectors independent of opportunity results 2026-10-22 14:28:40 +02:00
nala 7b1c35793c Fix Node navigation import and use Tempo commercial footer contact
tests / ci (push) Canceled after 0s
2026-10-22 13:54:20 +02:00
nala 1ec318561c docs: record authorised Inhambane preparation publication
tests / ci (push) Canceled after 0s
2026-10-22 13:47:50 +02:00
nala 9ac623a433 fix: preserve territorial search across investment catalogues 2026-10-22 12:52:52 +02:00
nala 5f4eb5cfaa Record authorized adaptive path deployment and enabled destination state 2026-10-22 10:18:38 +02:00
nala 042ffbde44 Preserve legacy draft shape when no compatible context is inherited
tests / ci (push) Canceled after 0s
2026-10-22 10:14:46 +02:00
nala 064296e1fb Record current v4 report proof and adaptive path integration dependencies
tests / ci (push) Canceled after 0s
2026-10-22 10:06:08 +02:00
nala a1fbda06ad Wire grounded private investment preparation into QA dossier path 2026-10-22 10:00:23 +02:00
nala 52fbb1468a Close context increment review and ground first adaptive investment path 2026-10-22 09:25:14 +02:00
nala a76369eb86 Preserve dossier context and expose contextual applicability in QA 2026-10-22 09:16:15 +02:00
nala 57fc914821 Merge verified IVA monthly projection and Modelo A interface into homologation
tests / ci (push) Canceled after 0s
2026-10-21 14:34:47 +02:00
nala 0b0cf76137 Reconcile IVA projection with Tempo Modelo A in QA 2026-10-21 14:27:24 +02:00
nala 3f5430c3ce Integrate monthly IVA with projected sales in QA 2026-10-21 14:05:46 +02:00
nala 0d7b70459d fix: explain IVA query conditions and distinguish integration failures 2026-10-21 13:35:22 +02:00
paulomassigue f3ec686f8c diminuicao do tamanho do footer
tests / ci (push) Canceled after 0s
2026-10-21 13:16:35 +02:00
nala bd5166bd11 docs: record authorised fiscal rule publication and consumer activation 2026-10-21 12:57:40 +02:00
paulomassigue d3fe8a02ec Merge branch 'main' of https://git.tempo.co.mz/nala/Investidor-V2
tests / ci (push) Canceled after 0s
2026-10-21 12:46:54 +02:00
paulomassigue f5b1d67ee5 remocao de inicio em descoberta 2026-10-21 12:46:26 +02:00
paulomassigue 90179149c7 Merge branch 'main' of https://git.tempo.co.mz/nala/Investidor-V2 2026-10-21 12:42:05 +02:00
paulomassigue 364be835fe correcao de conflitos 2026-10-21 12:41:18 +02:00
nala 4a4d121275 fix: allow authorised fiscal maintenance by the same responsible person
tests / ci (push) Canceled after 0s
2026-10-21 12:39:07 +02:00
paulomassigue 480effa5fc separacao de sobre nos no dropdow Tempo 2026-10-21 12:36:08 +02:00
nala 226fd308dd feat: connect published IVA consumer to normal simulator in QA
tests / ci (push) Canceled after 0s
2026-10-21 11:59:50 +02:00
nala 2ced7b790c docs: delimit published IVA consumer and three rollout operations 2026-10-21 11:39:17 +02:00
nala d186f94206 feat: demonstrar percurso IVA QA e consolidar revisao 2026-10-21 09:40:49 +02:00
nala 83d2f97d82 feat: preparar lote IVA exacto em QA sem activacao 2026-10-21 09:22:11 +02:00
nala 37085b40c1 docs: fundamentar base IVA e delimitar lacuna de arredondamento 2026-10-21 09:03:01 +02:00
nala 325d58b694 docs: preparar ficha fiscal IVA v1 sem activacao 2026-10-21 08:57:56 +02:00
nala 0fafd348e7 docs: delimitar entrega de planeamento e propor primeiro lote fiscal 2026-10-21 08:47:07 +02:00
nala 13f68f1bd8 fix: concluir recuperacao de exportacoes e consolidar candidata QA
tests / ci (push) Canceled after 0s
2026-10-21 08:43:12 +02:00
nala c22003a3da fix: recover simulator cashflow and verify QA continuity and report assumptions 2026-10-20 22:00:48 +02:00
nala a690c665fb fix: validate simulator dates and reconcile QA calculations reports and language 2026-10-20 21:00:52 +02:00
nala 59ab4fe956 fix: validar datas fiscais e iniciar revisao integrada do simulador 2026-10-20 20:03:40 +02:00
nala e69ab1499f docs: comprovar guias na biblioteca QA existente
tests / ci (push) Canceled after 0s
2026-10-20 19:49:55 +02:00
nala 3258a25f76 feat: apresentar guias educativos com imagem resumo e contexto 2026-10-20 19:45:45 +02:00
nala 0e7d51f18f fix: concluir apresentacao publica dos negocios e zonas
tests / ci (push) Canceled after 0s
2026-10-20 19:00:32 +02:00
nala fd341b627e fix: harmonizar pesquisa e navegacao dos catalogos em QA 2026-10-20 18:53:41 +02:00
nala 1350b4df73 autenticacao 1
tests / ci (push) Canceled after 0s
2026-10-20 17:51:31 +02:00
nala 8cf820ef49 Reservar espaco minimo antes de compilar nova release
tests / ci (push) Canceled after 0s
2026-10-20 17:26:01 +02:00
nala 210b7d273b Clarificar comando de consulta para demonstrar entrega automatica 2026-10-20 17:25:29 +02:00
nala be49f4c39c Integrar navbar e sobre dos programadores preservando identidade e acessos 2026-10-20 17:23:45 +02:00
nala 9b3c20fc2a Registar resolucao do diagnostico e alinhar traducao financeira aceite 2026-10-20 17:23:20 +02:00
nala de061b81aa Conferir alteracoes durante CI e sinalizar falha ao systemd 2026-10-20 15:12:54 +02:00
nala 6e439def02 Reconciliar verificacoes com navegacao e relatorios aprovados 2026-10-20 15:12:17 +02:00
nala 74b081e93e Evitar migracao desnecessaria no teste unitario de exemplo 2026-10-20 15:09:51 +02:00
nala 9d148ca241 Preservar diagnostico mais recente dos programadores e isolar fixtures unitarias 2026-10-20 15:07:54 +02:00
nala 5be831ac44 Usar contrato QA existente e documentar operacao e reversao 2026-10-20 15:06:45 +02:00
nala f0e26e0d8a Preparar cadeia Git com verificacao isolada e releases reversiveis 2026-10-20 14:57:08 +02:00
nala 9ab721c97f Reconciliar fontes instaladas 423bd512d8c5 com main 2026-10-20 14:45:19 +02:00
paulomassigue 02d4e6b2fd remocao de publicidade em sobre nos e alinhamento de items no navbar
tests / ci (push) Canceled after 0s
2026-10-20 14:38:37 +02:00
paulomassigue 507387502c Correcao no aviso dos chunks
tests / ci (push) Waiting to run
2026-10-20 12:08:09 +02:00
paulomassigue 97a08df2a0 ajuste no header e organizaco de elementos, e ajuste no footer
tests / ci (push) Canceled after 0s
2026-10-20 11:39:25 +02:00
nala a128c5a1b8 Merge origin/main preservando reconciliacao da matriz
tests / ci (push) Canceled after 0s
2026-10-20 09:14:24 +02:00
nala 6e57d1f601 autenticacao 2026-10-20 09:09:57 +02:00
paulomassigue 9bc450e87c correcao do tamanho da frase:invista em mocambique
tests / ci (push) Canceled after 0s
2026-10-19 15:13:54 +02:00
paulomassigue cb438b2b28 correcao dos links do footer
tests / ci (push) Canceled after 0s
2026-10-19 15:01:58 +02:00
paulomassigue becb211f8c ajuste de texto inicial e centralizacao da informaco
tests / ci (push) Canceled after 0s
2026-10-19 14:45:30 +02:00
paulomassigue 14ad2f60c5 header:navbar e footer
tests / ci (push) Canceled after 0s
2026-10-19 13:07:16 +02:00
nala 3ca67a53c1 chore: exclude local QA outputs and nested dependencies
tests / ci (push) Canceled after 0s
2026-10-18 16:00:55 +02:00
nala cf003c8623 versao nrgocios 4
tests / ci (push) Canceled after 0s
2026-10-18 15:57:06 +02:00
nala 6bc0cc6f52 versao nrgocios 3
tests / ci (push) Canceled after 0s
2026-10-18 11:17:35 +02:00
nala cef6e201c7 versao nrgocios 2
tests / ci (push) Canceled after 0s
2026-10-16 20:11:05 +02:00
nala 2d23157e40 versao nrgocios
tests / ci (push) Canceled after 0s
2026-10-16 13:06:58 +02:00
nala 6a76aac80f versao compilada 1
tests / ci (push) Canceled after 0s
2026-10-16 09:17:28 +02:00
nala c13f250b87 versao compilada
tests / ci (push) Canceled after 0s
2026-10-15 17:37:00 +02:00
nala 9a96ee8dfe oportunidades 10
tests / ci (push) Canceled after 0s
2026-10-15 08:50:21 +02:00
nala 5738418838 oportunidades 9
tests / ci (push) Canceled after 0s
2026-10-14 16:57:56 +02:00
nala ba1c0340ea oportunidades 8
tests / ci (push) Canceled after 0s
2026-10-14 08:25:30 +02:00
nala 287e991486 oportunidades 7
tests / ci (push) Waiting to run
2026-10-13 11:40:29 +02:00
nala 20971d523f oportunidades 6
tests / ci (push) Canceled after 0s
2026-10-10 21:17:46 +02:00
nala 01405cbf8a oportunidades 5
tests / ci (push) Canceled after 0s
2026-10-10 14:15:28 +02:00
nala 6ddafc95ba oportunidades 4
tests / ci (push) Canceled after 0s
2026-10-10 07:03:59 +02:00
nala e662230df7 oportunidades 3
tests / ci (push) Canceled after 0s
2026-10-09 15:00:37 +02:00
nala 9d6176f55b feat: verify active opportunity management and prepare versioned resources 2026-10-09 13:16:41 +02:00
nala 60784a2ed2 oportunidades 2
tests / ci (push) Canceled after 0s
2026-10-09 08:48:42 +02:00
nala 7ecfe3d543 feat: integrate opportunity draft management with audited lifecycle 2026-10-09 08:16:51 +02:00
nala ebfc188240 Confirm active opportunity classifications with read-only audit 2026-10-09 07:38:01 +02:00
nala ae789ce024 vrsao 2
tests / ci (push) Canceled after 0s
2026-10-09 02:57:39 +02:00
nala 344545430b Add audited opportunity origin review and QA separation 2026-10-09 02:46:36 +02:00
nala 6c7a9c9442 Exige motivo na reversão territorial e prepara novo plano 2026-10-09 02:15:36 +02:00
nala 6a71aa47e3 Consolida rastreabilidade territorial e plano autenticado V2 2026-10-09 02:05:44 +02:00
nala 30acdc9934 Liga acervo documental e guias a jornada do investidor 2026-10-08 18:54:24 +02:00
nala 51c7fb92b1 Corrige validacao, recuperacao e acesso ao calculo do rascunho 2026-10-08 18:07:29 +02:00
nala 1106509116 vrsao 2
tests / ci (push) Canceled after 0s
2026-10-08 14:02:01 +02:00
nala bdd766c68a resolvemos o problema dos ZEE e ZFI
tests / ci (push) Canceled after 0s
2026-10-07 19:46:35 +02:00
nala 0a086c3fa5 resolvemos o problema dos ZEE e ZFI
tests / ci (push) Canceled after 0s
2026-10-07 14:00:55 +02:00
nala 59d108a576 resolvemos o problema dos ZEE e ZFI
tests / ci (push) Canceled after 0s
2026-10-07 09:08:43 +02:00
nala e65cb26901 varias alteracoes
tests / ci (push) Canceled after 0s
2026-10-06 14:50:12 +02:00
2517 changed files with 448982 additions and 17499 deletions
+16
View File
@@ -0,0 +1,16 @@
.git
.idea
.agents
.codex
node_modules
vendor
public/build
storage/logs
storage/framework/cache
storage/framework/sessions
storage/framework/views
tests
Doc
docs
*.zip
.phpunit.result.cache
+31 -6
View File
@@ -4,6 +4,11 @@ APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost:8000
# Separate from publication. Bind only an explicitly authorised published rule version.
INVESTMOZ_IVA_CONSUMER_ENABLED=false
INVESTMOZ_IVA_CONSUMER_RULE_ID=
INVESTMOZ_IVA_CONSUMER_RULE_VERSION=0
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
@@ -20,12 +25,14 @@ LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_DATABASE=laravel
# DB_USERNAME=root
# DB_PASSWORD=
# Full application schema requires PostgreSQL with PostGIS; SQLite is unsupported.
DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=investemoz
# Configure a least-privilege application role; provision PostGIS before migrations.
DB_USERNAME=investmoz_app
DB_PASSWORD=
SESSION_DRIVER=database
SESSION_LIFETIME=120
@@ -36,6 +43,16 @@ SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
DB_QUEUE_RETRY_AFTER=210
MALWARE_SCANNER=clamd
CLAMD_HOST=127.0.0.1
CLAMD_PORT=3310
CLAMD_TIMEOUT_SECONDS=120
CLAMD_MAX_SIGNATURE_AGE_HOURS=48
CLAMD_MAX_FILE_BYTES=20971520
CLAMD_MAX_STREAM_BYTES=26214400
CLAMD_MAX_DECOMPRESSED_BYTES=104857600
CLAMD_MAX_ARCHIVE_ENTRIES=1000
CACHE_STORE=database
# CACHE_PREFIX=
@@ -94,3 +111,11 @@ SUITECRM_BASE_URL=
SUITECRM_CLIENT_ID=
SUITECRM_CLIENT_SECRET=
SUITECRM_CACHE_STORE=database
# Tax-rule governance roles are empty by default and require named verified managers with MFA.
INVESTMOZ_TAX_RULE_EDITORS=
INVESTMOZ_TAX_RULE_REVIEWERS=
INVESTMOZ_TAX_RULE_APPROVERS=
INVESTMOZ_TAX_RULE_PUBLISHERS=
INVESTMOZ_ZONE_TERRITORY_REVIEWERS=
+6
View File
@@ -1,5 +1,8 @@
* text=auto eol=lf
# Preserve the exact bytes pinned by EconomicReferenceCatalog::MANIFEST_SHA256.
database/data/economic-references-lot-01.json text eol=crlf
*.blade.php diff=html
*.css diff=css
*.html diff=html
@@ -9,3 +12,6 @@
CHANGELOG.md export-ignore
README.md export-ignore
.github/workflows/browser-tests.yml export-ignore
# Preserve the original archived BM HTML bytes and whitespace.
docs/investmoz/simulations/source-archive/BM-Mercado-Monetario-MAIBOR-20261025.html -text -diff
BIN
View File
Binary file not shown.
+9 -4
View File
@@ -20,7 +20,7 @@ jobs:
POSTGRES_USER: investmoz_test
POSTGRES_PASSWORD: test-only-password
ports:
- 5432:5432
- 55441:5432
options: >-
--health-cmd "pg_isready -U investmoz_test -d investmoz_test"
--health-interval 10s --health-timeout 5s --health-retries 5
@@ -28,7 +28,7 @@ jobs:
APP_ENV: testing
DB_CONNECTION: pgsql
DB_HOST: 127.0.0.1
DB_PORT: 5432
DB_PORT: 55441
DB_DATABASE: investmoz_test
DB_USERNAME: investmoz_test
DB_PASSWORD: test-only-password
@@ -63,6 +63,11 @@ jobs:
npm ci
npm run build
- name: Prevent accidental diagnostic exposure (C132)
run: |
php tools/portal-diagnostics-deployment-guard.php --self-test
php tools/portal-diagnostics-deployment-guard.php
- name: Run CI Checks
run: composer ci:check
@@ -71,5 +76,5 @@ jobs:
INVESTMOZ_SCHEMA_TESTS: '1'
run: php vendor/bin/phpunit --configuration=phpunit.investmoz.xml --do-not-cache-result
- name: Test frontend API contracts
run: node --test tests/frontend/portal-api.test.mjs
- name: Test all frontend contracts and calculators
run: node --test tests/frontend/*.test.mjs
+4
View File
@@ -1,7 +1,11 @@
/.phpunit.cache
/bootstrap/ssr
/node_modules
**/node_modules/
**/vendor/
/outputs/
/public/build
/public/build-qa-opportunities
/public/fonts-manifest.dev.json
/public/hot
/public/storage
+66
View File
@@ -0,0 +1,66 @@
# Instruções permanentes do projeto InvesteMoz
## Percurso adaptativo — decisão expressa da Tempo, 23/10/2026
Carteira/projectos âncora e percurso adaptativo têm diagnósticos distintos, mas fazem parte da mesma experiência e responsabilidade de correcção. Separar o diagnóstico não autoriza adiamento, desligamento do percurso ou perda de contexto entre corredor/localização, oportunidades, associações documentadas a âncoras/clusters, dossier e simulador. A reposição prioritária só se considera demonstrada com causa delimitada e disponibilidade recuperada, reutilizando o inventário e os recibos existentes; melhorias noutro ponto não a comprovam. Consultar os registos de reposição antes de voltar a declarar causa desconhecida ou registos desaparecidos.
Apresentar apenas o necessário ao investimento em preparação, com entrada por ideia, oportunidade, sector ou localização, reutilizando informação válida e autorizada da oportunidade, perfil, organização, dossier e fontes anteriores. As etapas são adaptáveis, não seis formulários obrigatórios. Não sobrescrever edições deliberadas; explicar divergências e impactos antes da confirmação. Conservar escolhas ao avançar, regressar, actualizar e autenticar-se. Seleccionar opções únicas e pedir apenas dados em falta ou desactualizados.
Ocultar campos e acções sem aplicação, conservando explicação breve e histórico; desconhecido não significa não aplicável. Na selecção múltipla, partilhar dados comuns sem perder os requisitos específicos de cada oportunidade. Consulta pública livre; autenticação no momento de manifestar interesse ou iniciar operação privada. Relatórios e exportações devem reflectir o contexto e as etapas efectivamente aplicáveis. O utilizador deve perceber onde está, o que falta e a próxima acção. Aperfeiçoar os componentes existentes por incrementos e registar cobertura na matriz existente; esta instrução não declara concluída a sua implementação transversal.
## Regras permanentes do InvesteMoz — decisão da Tempo, 12/10/2026
1. O portal InvesteMoz é propriedade da Tempo, Lda.
2. As regras de funcionamento são ditadas pela Tempo, Lda.
3. Os papéis dos seus funcionários são determinados e regidos pela Tempo, Lda.
4. As funcionalidades do portal são determinadas pela Tempo, Lda.
5. Este portal é de âmbito nacional e serve tanto a pessoas nacionais como estrangeiras e exigirá vários intervenientes para a sua operação (manutenção, cadastro, viabilização dos negócios, etc.). Se as regras não são ditadas por quem opera e, ao mesmo tempo, é proprietário, a sua viabilidade é nula.
6. Os constrangimentos criados sem a autorização da Tempo e que impedem o desenvolvimento normal devem ser removidos ou alterados.
7. Antes de iniciar a execução de uma nova funcionalidade, verificar quais são os constrangimentos que podem criar atrasos e círculos viciosos de correcção de erros que poderiam ter sido evitados.
8. As instruções anteriores ditadas pela Tempo mantêm-se.
Registar a origem das restrições: decisão expressa da Tempo, requisito anterior aprovado ou escolha de implementação. Uma escolha de implementação não constitui uma nova exigência organizacional. As autorizações e limites específicos de cada tarefa mantêm-se, incluindo a separação entre preparação em QA e alteração no ativo.
### Aplicação na execução — referência permanente confirmada em 12/10/2026
A Tempo, Lda define o produto, as regras operacionais e as funções dos seus funcionários. As instruções anteriores mantêm-se. Esta formulação consolida as oito regras acima e orienta a leitura das especificações e da matriz de auditoria existente.
- Cada restrição deve ter origem identificável: decisão expressa da Tempo, referência e versão de uma instrução anterior aprovada ou escolha técnica. Escolhas técnicas não se tornam regras de negócio sem autorização da Tempo.
- Constrangimentos introduzidos sem autorização devem ser identificados e removidos ou ajustados, preservando dados, relações, histórico e instruções aprovadas.
- Antes de implementar, verificar as dependências técnicas e operacionais que possam causar bloqueios previsíveis. Distinguir dependências reais de opções de implementação e apresentar antecipadamente as decisões concretas necessárias.
- O portal deve contemplar investidores nacionais e estrangeiros e os diferentes intervenientes da operação, incluindo manutenção, cadastro e viabilização de negócios. O âmbito do portal é nacional.
Referenciar estas orientações nos requisitos correspondentes da matriz existente, sem criar outra matriz ou backlog. A referência documental não encerra um requisito funcional nem substitui a demonstração do seu critério de aceitação.
## Aceitação das fontes oficiais — decisão do responsável, 08/10/2026
- Dados e documentos com origem oficial/governamental identificada (incluindo BR, decretos e leis) são aceites pelo responsável como fontes fidedignas e válidas. Não exigir segunda ou terceira validação da autenticidade/proveniência oficial, nem certificação por outra fonte.
- Registar e recuperar origem, exemplar, versão e citação é rastreabilidade; a ausência desses campos num cadastro não revoga a aceitação nem torna o dado inválido. Preservar as validações e decisões já documentadas.
- Esta decisão tem precedência sobre pendências genéricas de revalidação de proveniência oficial em relatórios históricos. Não estender a classificação oficial a documentos de promotores ou de programação sem origem oficial identificada.
- A aceitação da fonte não substitui a identificação do âmbito, período aplicável, alterações/revogações expressas no próprio acervo oficial ou dos parâmetros necessários a uma regra/cálculo. Conferir esses elementos no documento oficial é aplicação da fonte aceite, não uma segunda validação da sua credibilidade. Não ativar cálculos por importação/publicação.
- O responsável define o acervo e a finalidade. Inventariar todos os ficheiros e anexos acessíveis antes de seleccionar documentos para consulta; não omitir por não serem usados por uma funcionalidade.
- Manter três estados independentes: disponibilidade para consulta, situação jurídica/documental e utilização numa regra ou cálculo. Importação/publicação nunca activa um cálculo fiscal.
- Não usar associação a módulo, requisito ou artigo como pré-condição para cadastrar ou consultar um documento autorizado. Relações sem citação verificada ficam pendentes e visivelmente identificadas.
- Respeitar a autorização de divulgação registada e o seu âmbito. Só bloquear por impedimento concreto (por exemplo malware, dados pessoais/confidenciais ou restrição efectiva de divulgação), registado por ficheiro. Não atribuir decisões a gestores inexistentes nem contornar autenticação.
- Importações em lote devem ter pré-visualização, cálculo de hash, detecção de duplicados por bytes e conflitos de identidade/versão, escrita privada, análise antimalware e execução idempotente. Nunca publicar automaticamente um lote.
- Preservar o ficheiro original, a proveniência, o identificador exacto, a edição/data, o hash e o histórico. Não marcar autenticidade, vigência, direitos ou scan como confirmados sem evidência.
- Para fontes fiscais, inventariar também as fontes não analisadas e os diplomas/artigos sem correspondência documental. Parâmetros ausentes ou ambiguidades permanecem indeterminados; não usar zero nem a taxa mais recente como substituto.
- Antes de aplicar mudanças em dados activos, confirmar backup/restauro e a acção autenticada que continua necessária. Testes e migrações devem usar exclusivamente uma base QA isolada com guarda contra a base activa.
## Execução das fontes e decisões — orientação permanente da Tempo, 22/10/2026
A legislação, os formulários e os procedimentos oficiais disponibilizados são fontes válidas para implementar os requisitos. As decisões e procedimentos da Tempo orientam a sua utilização no portal. Traduzir essas fontes em campos, regras, documentos, cálculos e percursos executáveis com rastreabilidade, sem exigir nova validação jurídica genérica ou criar aprovadores, restrições ou condições por interpretação do Codex.
Uma divergência concreta deve identificar os trechos, períodos e comportamento afectados; prosseguir com os restantes requisitos. Uma decisão de implementação em falta deve ser apresentada especificamente à Tempo, sem a substituir por regra inventada. Rever e remover bloqueios anteriores sem fundamento numa fonte aplicável ou decisão expressa da Tempo, preservando dados e histórico. A rastreabilidade explica a execução e não constitui uma autoridade adicional. Mantêm-se os limites autorizados de QA e das alterações no activo.
## Preservação do concluído — pedido expresso da Tempo, 23/10/2026
Antes de avançar com novas rondas de testes/validações, inventariar na matriz e no manual existentes as funcionalidades concluídas e as funcionalidades desaparecidas, e repor as perdas confirmadas. Preservar as aprovações, dados, originais, relações, versões, histórico e acessos já concluídos. Não transformar uma entrega anterior em funcionalidade nova nem exigir novamente a decisão organizacional já registada.
Comparar código, schema, dados, configuração e permissões entre o último estado demonstrado e o destino. Distinguir eliminação, perda de visibilidade, diferença de ambiente, pendência real e estado documental desactualizado; uma página vazia ou a falta de nova prova não apagam a aceitação anterior. Reutilizar as provas aceites e conferir apenas o delta da reposição. Cada entrega seguinte deve explicar qualquer redução do âmbito previamente disponível, em vez de a aceitar como consequência normal da implementação.
Transporte privado não equivale à revogação de uma divulgação anterior aprovada. Reconciliar a decisão anterior de forma delimitada e auditada, sem publicar automaticamente conteúdos novos, reviver exemplares arquivados ou sobrescrever edições posteriores. Mantêm-se backup/restauro, autenticação e os limites concretos de cada tarefa.
Escolha técnica de verificação: comparar dados protegidos por segurança por linha (RLS) com identidades e contexto de acesso equivalentes. Uma leitura da aplicação sem actor pode ocultar registos que o utilizador do backup consegue ler; confirmar essa diferença antes de declarar perda ou propor reposição. Esta orientação não altera permissões do portal nem autoriza contornar RLS numa operação da equipa.
File diff suppressed because one or more lines are too long
Binary file not shown.
+342
View File
File diff suppressed because one or more lines are too long
Binary file not shown.
+13 -1
View File
@@ -1,4 +1,16 @@
# Portal do Investidor — esqueleto Laravel
# Portal do Investidor — InvesteMoz
## Executar esta plataforma no Windows
Consulte [Execução local](deploy/local/README.md). Com Docker Desktop iniciado,
execute `./deploy/local/start.ps1`. Depois da preparação, `composer run dev`
inicia a plataforma em http://127.0.0.1:8000.
O ambiente local usa uma base PostgreSQL/PostGIS de demonstração isolada.
Não execute os exemplos históricos de criação de um projecto novo sobre esta
instalação ou sobre dados existentes.
## Referência histórica do pacote inicial
Estes ficheiros correspondem ao domínio de **sectores**, **oportunidades** e
**manifestações de interesse** do Portal do Investidor. Foram desenhados
+1101 -853
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,120 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Sleep;
use Throwable;
class CheckDevelopmentEnvironment extends Command
{
protected $signature = 'portal:dev-check {--wait=0 : Segundos para aguardar uma falha transitória do PostgreSQL}';
protected $description = 'Verifica o arranque local sem alterar dados ou mostrar credenciais';
public function handle(): int
{
$wait = filter_var($this->option('wait'), FILTER_VALIDATE_INT, ['options' => ['min_range' => 0, 'max_range' => 3600]]);
if ($wait === false) {
$this->error('--wait deve ser um inteiro entre 0 e 3600 segundos.');
return self::INVALID;
}
$checks = [
'PHP >= 8.4.1' => version_compare(PHP_VERSION, '8.4.1', '>='),
'Driver PostgreSQL activo' => extension_loaded('pdo_pgsql'),
'APP_KEY definida' => filled(config('app.key')),
'Base PostgreSQL configurada' => config('database.default') === 'pgsql',
'Cache de vistas gravável' => is_dir(config('view.compiled') ?: '') && is_writable(config('view.compiled') ?: ''),
'Sessões e logs graváveis' => is_writable(storage_path('framework/sessions')) && is_writable(storage_path('logs')),
'Dependências frontend instaladas' => is_file(base_path('node_modules/.bin/vp')) || is_file(base_path('node_modules/.bin/vp.cmd')),
];
if ($checks['Driver PostgreSQL activo'] && $checks['Base PostgreSQL configurada']) {
$checks['Ligação à base de dados'] = $this->checkConnection($wait);
}
if ($checks['Ligação à base de dados'] ?? false) {
try {
$checks['Extensão PostGIS instalada'] = (bool) DB::selectOne("SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'postgis') AS installed")->installed;
$checks['Tabelas principais instaladas'] = collect(['migrations', 'users', 'sectors', 'opportunities', 'sessions', 'cache', 'jobs'])
->every(fn (string $table): bool => Schema::hasTable($table));
} catch (Throwable) {
$checks['Esquema da base de dados verificável'] = false;
$this->warn('A ligação foi estabelecida, mas a verificação do esquema falhou. Verifique as permissões e a preparação QA.');
}
}
foreach ($checks as $label => $passed) {
$this->line(($passed ? '[OK] ' : '[FALHA] ').$label);
}
if (in_array(false, $checks, true)) {
$this->error('Arranque interrompido. Consulte deploy/local/README.md e corrija os itens acima.');
return self::FAILURE;
}
return self::SUCCESS;
}
private function checkConnection(int $wait): bool
{
$deadline = microtime(true) + $wait;
while (true) {
try {
DB::selectOne('SELECT 1');
return true;
} catch (Throwable $error) {
[$reason, $retryable] = $this->connectionFailure($error);
$remaining = $deadline - microtime(true);
if (! $retryable || $remaining <= 0) {
$this->warn($reason);
if ($retryable) {
$this->warn('Inicie Docker Desktop e execute ./deploy/local/start.ps1. Consulte os registos com docker compose --env-file .env -f deploy/local/compose.yaml logs --tail 40 postgres.');
}
return false;
}
$this->line('[AGUARDA] '.$reason.' Nova tentativa; prazo restante: '.(int) ceil($remaining).' s.');
DB::purge();
Sleep::for(min(5, $remaining))->seconds();
}
}
}
/** @return array{string, bool} */
private function connectionFailure(Throwable $error): array
{
// Inspect the chain internally; never print exception messages, DSNs or credentials.
$messages = '';
do {
$messages .= ' '.strtolower($error->getMessage()).' '.strtolower((string) $error->getCode());
$error = $error->getPrevious();
} while ($error !== null);
if (str_contains($messages, '28p01') || str_contains($messages, '28000')
|| str_contains($messages, 'password authentication failed') || str_contains($messages, 'no pg_hba.conf entry')) {
return ['Autenticação PostgreSQL recusada. Verifique as credenciais e as regras de acesso; aguardar não corrige este erro.', false];
}
if (str_contains($messages, '3d000') || preg_match('/database .* does not exist/', $messages)) {
return ['A base PostgreSQL configurada não existe. Verifique a configuração e a preparação QA.', false];
}
if (str_contains($messages, '57p03') || str_contains($messages, 'recovery mode')
|| str_contains($messages, 'not yet accepting connections') || str_contains($messages, 'database system is starting up')) {
return ['PostgreSQL em arranque ou recuperação; aguarde a conclusão sem remover o volume.', true];
}
if (preg_match('/\b08[0-9a-z]{3}\b/', $messages) || str_contains($messages, 'connection refused')
|| str_contains($messages, '57p01') || str_contains($messages, '57p02')) {
return ['PostgreSQL indisponível. Verifique se Docker Desktop e o contentor local estão iniciados.', true];
}
return ['Falha PostgreSQL não transitória. Verifique a configuração e os registos do servidor.', false];
}
}
@@ -0,0 +1,116 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Throwable;
class CheckDocumentLibrarySchema extends Command
{
protected $signature = 'portal:check-document-library-schema';
protected $description = 'Confere o esquema e as migrações da biblioteca documental antes da activação';
public function handle(): int
{
$reference = (string) Str::uuid();
try {
$required = [
'portal_resources' => [
'id', 'document_id', 'title', 'category', 'topic', 'legislation', 'essential', 'published',
'source_reference', 'rights_status', 'description', 'document_type', 'document_number',
'document_date', 'source_url', 'language', 'version_label', 'document_group_id',
'applicable_from', 'applicable_until',
'territorial_scope', 'territory_id', 'source_confirmation', 'legal_status', 'last_reviewed_at',
'document_identity_key', 'duplicate_of_resource_id', 'metadata_version', 'created_by',
'last_reviewed_by', 'independent_review',
],
'documents' => [
'id', 'status', 'data_classification', 'sha256', 'deleted_at', 'file_name', 'media_type',
'storage_key', 'size_bytes', 'version_no', 'issuer', 'issued_on', 'valid_until', 'uploaded_by',
'rights_status', 'original_source', 'malware_scan_status', 'malware_scanned_sha256',
'malware_engine', 'malware_engine_version', 'malware_signatures_at', 'malware_scanned_at',
'malware_failure_reason', 'malware_scan_attempts',
],
'portal_resource_links' => ['id', 'resource_id', 'target_type', 'target_key', 'relationship', 'applicability'],
'portal_resource_revisions' => ['id', 'resource_id', 'revision_no', 'change_type', 'snapshot'],
'document_malware_scans' => ['id', 'document_id', 'file_sha256', 'result', 'engine', 'engine_version', 'started_at'],
];
$missing = [];
foreach ($required as $table => $columns) {
if (! Schema::hasTable($table)) {
$missing[] = "tabela {$table}";
continue;
}
foreach (array_diff($columns, Schema::getColumnListing($table)) as $column) {
$missing[] = "{$table}.{$column}";
}
}
$requiredMigrations = [
'2026_10_05_120000_extend_portal_document_catalogue',
'2026_10_05_130000_add_document_malware_scanning',
];
if (! Schema::hasTable('migrations')) {
$missing[] = 'repositório de migrações';
} else {
$ran = DB::table('migrations')->whereIn('migration', $requiredMigrations)->pluck('migration')->all();
foreach (array_diff($requiredMigrations, $ran) as $migration) {
$missing[] = "migração {$migration}";
}
}
if ($missing !== []) {
$this->error('Esquema da biblioteca incompatível. Não activar o catálogo; faltam elementos de esquema ou migrações.');
foreach ($missing as $item) {
$this->line('- '.$item);
}
return self::FAILURE;
}
foreach ([
['portal_resources', 'rights_status', 'unconfirmed'],
['documents', 'rights_status', 'unconfirmed'],
['documents', 'malware_scan_status', 'pending'],
] as [$table, $column, $expectedDefault]) {
$default = DB::selectOne(
'select column_default from information_schema.columns where table_schema = current_schema() and table_name = ? and column_name = ?',
[$table, $column],
)?->column_default;
if (! is_string($default) || ! str_contains(strtolower($default), $expectedDefault)) {
$this->error("Valor predefinido inseguro ou ausente em {$table}.{$column}; preservar os registos e rever a migração.");
return self::FAILURE;
}
}
$invalidCleanRecords = DB::table('documents')->where('malware_scan_status', 'clean')
->where(function ($query): void {
$query->whereNull('malware_scanned_sha256')->orWhereColumn('malware_scanned_sha256', '!=', 'sha256');
})->count();
if ($invalidCleanRecords > 0) {
$this->error('Existem documentos marcados como limpos sem correspondência do hash analisado.');
return self::FAILURE;
}
$this->info('Esquema da biblioteca compatível; verificações de direitos e análise antimalware disponíveis.');
return self::SUCCESS;
} catch (Throwable $exception) {
Log::error('Document library schema compatibility check failed.', ['reference' => $reference, 'exception' => $exception]);
$this->error('Não foi possível verificar a compatibilidade do esquema. Consulte o registo interno. Referência: '.$reference);
return self::FAILURE;
}
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Console\Commands;
use App\Services\Malware\MalwareScanner;
use Illuminate\Console\Command;
class CheckMalwareScanner extends Command
{
protected $signature = 'malware:health';
protected $description = 'Check ClamAV availability and signature freshness';
public function handle(MalwareScanner $scanner): int
{
$health = $scanner->health();
$this->line('Scanner: '.($health['engine']));
$this->line('Available: '.($health['available'] ? 'yes' : 'no'));
$this->line('Engine version: '.($health['engine_version'] ?? 'unavailable'));
$this->line('Signatures UTC: '.($health['signatures_at']?->format(DATE_ATOM) ?? 'unavailable'));
$this->line('Signature age hours: '.($health['signature_age_hours'] === null ? 'unavailable' : number_format($health['signature_age_hours'], 2)));
if (! $health['available']) {
$this->error('Reason: '.($health['reason_code'] ?? 'scanner_unavailable'));
return self::FAILURE;
}
return self::SUCCESS;
}
}
@@ -0,0 +1,427 @@
<?php
namespace App\Console\Commands;
use App\Jobs\ScanDocumentForMalware;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use RuntimeException;
use Throwable;
class ImportDocumentBatch extends Command
{
protected $signature = 'documents:import-batch {manifest : JSON manifest inside storage/app/private/document-import-staging} {--apply : Register private drafts and queue malware scans} {--actor= : Existing user ID responsible for an applied import} {--pilot-agronegocio : Restrict to the approved two-original pilot} {--sectors-lot20 : Restrict to the named 20-resource sector batch} {--sectors-trio : Restrict to the three named sector articles and reuse the existing image} {--backup-proof= : Fresh verified destination backup receipt for active pilot apply}';
protected $description = 'Preview or idempotently import a document batch into the existing private library';
private const MAX_BYTES = 10485760;
private const PILOT_MANIFEST_SHA256 = '0b720b0a6f7539de1d3eca9e32f89a6b9038848dc0b97ad21ace605154d9ddd8';
private const PILOT_HASHES = [
'legacy-tempo-post-171985-20260420' => '0929184a6d4645b9046512675394ad4f60575273f6dab0955f71cc7474a6a4e2',
'legacy-tempo-agronegocio-image-5e3840705cf98226' => '5e3840705cf98226d1f189cf665aaa98cb05da96c645159a1332a1d429d642eb',
];
private const SECTORS_MANIFEST_SHA256 = 'ade9a00c031534f4341e8f6bd5e425b502ee03efc9fcfaec4f463a10fb28201b';
private const SECTORS_HASHES = [
'legacy-tempo-post-171981-20260430' => '26a70f16cd511c579f08633655ff312db05122f81e44852dd4b163fa6eef85f1',
'legacy-tempo-post-172679-20260430' => '2003755b7dac53b43f4608e707321db6d2f29878926a0e74f4cf27627b70a9cb',
'legacy-tempo-post-172674-20260430' => '3ae780f082a8fa7c767635cce98563f5bc027c9656c9f41f8788843a17fa1f54',
'legacy-tempo-post-171990-20260616' => '8cab862be245382dd4c1fd5fcb423c47aed7be3a22e0a7f8d5521510cc36bc72',
'legacy-tempo-post-172263-20260414' => '538495cd5b31047ac1c5993fdaf298ec1303330368248b251c87d201d31ff211',
'legacy-tempo-post-172256-20260414' => 'dcc29cbd36cf57e0a5c88dd312c84ee03c32a3d18ae9216d3715dc7b5a6960bf',
'legacy-tempo-post-172261-20260414' => '88177fccd78d8516dba670c6f71272af062cc981d52d97adefe5bf77c613b16a',
'legacy-tempo-post-172258-20260417' => '48314a576dea2f2dadf47d8f1eadd7ab58030606b02eebe17aa9bd6badffb7e9',
'legacy-tempo-post-172253-20260414' => 'e90e39e49ba65bbbcbbc303c6eca456e445238daf9acc935ab03690819281678',
'legacy-tempo-post-172240-20260414' => '8c5e91c937032680883793a12844f1128f7ac99e97c63b68c23d1396e009ba9d',
'legacy-tempo-post-172237-20260417' => 'a62a43e31da32795a7286f4c152a93d5dc829233e72023b00ea61fd8e2fcb58c',
'legacy-tempo-post-172233-20260417' => '345dfd72d8ebf459eae8eb025d699afddbae751df55ffa8d053568cd08096c97',
'legacy-tempo-post-172230-20260414' => '4f4570f7bbd5599cb925ed996c1734a23582362fe485736931a53b9266565d4e',
'legacy-tempo-post-172223-20260414' => '514a9d88c097d234038fa6341f5a5b01e8938cb6a9535a807145c84c37010a4d',
'legacy-tempo-post-172219-20260414' => '0d468ae0de9a76d2b140bc8cb5c555f29626fb6422287c917762bec3e615a70c',
'legacy-tempo-post-171992-20260430' => '05eb492dd6cfca6a7f305b633658a16723c315168f4b2c75103f22d1fac84032',
'legacy-tempo-image-0a2c50d91dad3a60' => '0a2c50d91dad3a6007544634540e0f61b11d3506682a5a19066bae03cdcba490',
'legacy-tempo-image-28f41f12e10ab345' => '28f41f12e10ab34545d8e432f6e8c74f86960e17e0c4a9956f514bcde4881eaa',
'legacy-tempo-image-fe9a2e382e385b35' => 'fe9a2e382e385b35f6e61ae320e199a979678677fd327fb7d4b07d8ce0111c11',
'legacy-tempo-image-8e58544e49e719f9' => '8e58544e49e719f9c865c91e07ef1030155a9e3d59260e6dc93e9c9e25674e39',
];
private const TRIO_MANIFEST_SHA256 = '9210ad7e1bea557349c440fd8295b321a10c2ddd34cc170f3dad18b5517751f8';
private const TRIO_REUSED_IMAGE_SHA256 = '28f41f12e10ab34545d8e432f6e8c74f86960e17e0c4a9956f514bcde4881eaa';
private const TRIO_HASHES = [
'legacy-tempo-post-173209-20260623' => '926f5e4096737c936c9d5002ebdc64be9e63b0c0822023d17ce618111a65c941',
'legacy-tempo-post-173218-20260616' => '43612c358c7573475cea24ef4825c5b7c7925306fa5861051220eed3f4e333b5',
'legacy-tempo-post-173213-20260616' => '0ba605ae43dec7f685610f828df884bc82cb4da67131fc9783b5cdbe4cc6d9f0',
];
public function handle(): int
{
$staging = storage_path('app/private/document-import-staging');
$manifestPath = realpath($staging.DIRECTORY_SEPARATOR.$this->argument('manifest'));
$stagingReal = realpath($staging);
if (! $manifestPath || ! $stagingReal || ! str_starts_with(strtolower($manifestPath), strtolower($stagingReal.DIRECTORY_SEPARATOR)) || ! is_file($manifestPath)) {
$this->error('Manifest is missing or outside the private document-import staging directory.');
return self::FAILURE;
}
foreach ([
'documents' => ['id', 'source_system', 'external_id', 'version_no', 'data_classification', 'file_name', 'media_type', 'storage_key', 'sha256', 'status', 'size_bytes', 'issuer', 'issued_on', 'uploaded_by', 'rights_status', 'original_source', 'malware_scan_status'],
'portal_resources' => ['document_id', 'title', 'category', 'topic', 'published', 'source_reference', 'approved_by', 'description', 'document_type', 'document_number', 'document_date', 'source_url', 'language', 'version_label', 'source_confirmation', 'legal_status', 'rights_status', 'document_identity_key', 'metadata_version', 'created_by'],
] as $table => $columns) {
if (! Schema::hasColumns($table, $columns)) {
$this->error('Document-library schema is incomplete; run the documented schema compatibility check before importing.');
return self::FAILURE;
}
}
try {
$manifestBytes = (string) file_get_contents($manifestPath);
$manifest = json_decode($manifestBytes, true, 512, JSON_THROW_ON_ERROR);
} catch (Throwable) {
$this->error('Manifest is not valid JSON.');
return self::FAILURE;
}
if (! is_array($manifest) || ! isset($manifest['documents']) || ! is_array($manifest['documents']) || count($manifest['documents']) > 100) {
$this->error('Manifest must contain at most 100 documents in a documents array.');
return self::FAILURE;
}
$apply = (bool) $this->option('apply');
$actorId = $this->option('actor');
$pilot = (bool) $this->option('pilot-agronegocio');
$sectors = (bool) $this->option('sectors-lot20');
$trio = (bool) $this->option('sectors-trio');
if ((int) $pilot + (int) $sectors + (int) $trio > 1) {
$this->error('Select exactly one named batch; agribusiness scope is unchanged.');
return self::FAILURE;
}
if ($pilot || $sectors || $trio) {
try {
$this->validatePilot($manifestPath, $manifestBytes, $manifest, $stagingReal, $apply, $actorId, $sectors, $trio);
} catch (Throwable $exception) {
$this->error('Pilot refused before import: '.$exception->getMessage());
return self::FAILURE;
}
}
if ($apply && ! $pilot && ! $sectors && ! $trio) {
// CLI attribution is not an authenticated active installation. Rehearsals stay isolated.
$target = DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
if (! app()->environment('testing') || ! str_ends_with($target->database, '_test') || (int) $target->port !== 55441
|| config('database.connections.pgsql.host') !== '127.0.0.1' || config('database.connections.pgsql.url')) {
$this->error('CLI apply is restricted to isolated QA. Active imports require the authenticated activation mechanism; --actor is attribution only.');
return self::FAILURE;
}
}
if ($apply && (! is_numeric($actorId) || ! DB::table('users')->where('id', (int) $actorId)->exists())) {
$this->error('Apply requires --actor=<existing-user-id>; no author identity will be fabricated.');
return self::FAILURE;
}
if ($apply && ! User::findOrFail((int) $actorId)->can('manage-portal')) {
$this->error('QA import requires an authorized library manager; no permission is granted by naming an account.');
return self::FAILURE;
}
$disk = Storage::disk('local');
$seenHashes = [];
$seenExternalIds = [];
$dispatch = [];
$failures = 0;
foreach ($manifest['documents'] as $index => $record) {
$label = is_array($record) ? (string) ($record['external_id'] ?? '#'.($index + 1)) : '#'.($index + 1);
try {
$validated = $this->validateRecord($record, $stagingReal);
if (($pilot || $sectors || $trio) && ! hash_equals(($trio ? self::TRIO_HASHES : ($sectors ? self::SECTORS_HASHES : self::PILOT_HASHES))[$validated['external_id']] ?? '', $validated['sha256'])) {
throw new RuntimeException('Approved pilot bytes changed after preview validation.');
}
$hash = $validated['sha256'];
if (isset($seenHashes[$hash])) {
$this->line($label.' | duplicate bytes within batch; references item '.$seenHashes[$hash]);
continue;
}
$seenHashes[$hash] = $label;
if (isset($seenExternalIds[$validated['external_id']])) {
$this->warn($label.' | external_id already occurs in this batch with different bytes; identity review required');
continue;
}
$seenExternalIds[$validated['external_id']] = $hash;
$existing = DB::table('documents')->where('sha256', $hash)->whereNull('deleted_at')->first();
if ($existing) {
$this->line($label.' | already imported by SHA-256; document='.$existing->id.'; no duplicate');
continue;
}
$externalMatch = DB::table('documents')->where('source_system', 'document-batch-import')
->where('external_id', $validated['external_id'])->whereNull('deleted_at')->first();
if ($externalMatch) {
$this->warn($label.' | external_id already belongs to document '.$externalMatch->id.' with different bytes; version review required');
continue;
}
$identity = $this->identity($validated);
$identityMatch = DB::table('portal_resources')->where('document_identity_key', $identity)->first();
if ($identityMatch) {
$this->warn($label.' | identity matches resource '.$identityMatch->id.' but bytes differ; version review required');
continue;
}
$this->line($label.' | '.($apply ? 'will import as private draft' : 'preview: private draft')." | bytes={$validated['size_bytes']} | sha256={$hash}");
if (! $apply) {
continue;
}
$documentId = (string) Str::uuid();
$storageKey = 'portal-documents/'.$documentId;
$source = fopen($validated['source_path'], 'rb');
if (! is_resource($source)) {
throw new RuntimeException('Source could not be opened.');
}
try {
$stored = $disk->put($storageKey, $source);
} finally {
fclose($source);
}
$storedPath = $disk->path($storageKey);
$storedHash = $stored && is_file($storedPath) ? hash_file('sha256', $storedPath) : false;
if (! $stored || ! is_string($storedHash) || ! hash_equals($hash, $storedHash)) {
$disk->delete($storageKey);
throw new RuntimeException('Private copy failed the checksum verification.');
}
try {
$resourceId = DB::transaction(function () use ($validated, $documentId, $storageKey, $hash, $identity, $actorId): int {
DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', ['document-batch:'.$hash]);
DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', ['document-batch-identity:'.$identity]);
if (DB::table('documents')->where('sha256', $hash)->whereNull('deleted_at')->exists()) {
throw new RuntimeException('Matching bytes were registered concurrently; rerun preview.');
}
if (DB::table('portal_resources')->where('document_identity_key', $identity)->exists()) {
throw new RuntimeException('A matching document identity was registered concurrently; version review required.');
}
$now = now();
DB::table('documents')->insert([
'id' => $documentId, 'source_system' => 'document-batch-import', 'external_id' => $validated['external_id'],
'version_no' => 1, 'data_classification' => 'internal', 'file_name' => $validated['file_name'],
'media_type' => $validated['media_type'], 'storage_key' => $storageKey, 'sha256' => $hash,
'status' => 'quarantined', 'size_bytes' => $validated['size_bytes'], 'issuer' => $validated['issuer'],
'issued_on' => $validated['document_date'], 'uploaded_by' => (int) $actorId, 'rights_status' => 'unconfirmed',
'original_source' => $validated['source_url'], 'malware_scan_status' => 'pending',
'created_at' => $now, 'updated_at' => $now,
]);
$resourceId = DB::table('portal_resources')->insertGetId([
'document_id' => $documentId, 'title' => $validated['title'], 'category' => $validated['category'],
'topic' => $validated['topic'], 'legislation' => $validated['legislation'], 'essential' => false,
'published' => false, 'source_reference' => $validated['source_reference'], 'approved_by' => null,
'description' => $validated['description'], 'document_type' => $validated['document_type'],
'document_number' => $validated['document_number'], 'document_date' => $validated['document_date'],
'source_url' => $validated['source_url'], 'language' => $validated['language'],
'version_label' => $validated['version_label'], 'territorial_scope' => 'unknown',
'source_confirmation' => 'unconfirmed', 'legal_status' => 'unknown', 'rights_status' => 'unconfirmed',
'document_identity_key' => $identity, 'metadata_version' => 1, 'created_by' => (int) $actorId,
'last_reviewed_by' => null, 'independent_review' => false, 'created_at' => $now, 'updated_at' => $now,
]);
$resource = DB::table('portal_resources')->where('id', $resourceId)->first();
DB::table('portal_resource_revisions')->insert([
'resource_id' => $resourceId, 'revision_no' => 1, 'change_type' => 'created',
'snapshot' => json_encode(['resource' => $resource, 'associations' => []], JSON_THROW_ON_ERROR),
'actor_user_id' => (int) $actorId, 'created_at' => $now,
]);
DB::table('audit_events')->insert([
'id' => (string) Str::uuid(), 'source_system' => 'document-batch-import',
'external_id' => $validated['external_id'], 'data_classification' => 'internal',
'actor_user_id' => (int) $actorId, 'entity_type' => 'document', 'entity_id' => $documentId,
'action' => 'private_document_batch_imported',
'after_data' => json_encode(['sha256' => $hash, 'resource_id' => $resourceId, 'published' => false], JSON_THROW_ON_ERROR),
'occurred_at' => $now, 'created_at' => $now,
]);
return $resourceId;
});
$dispatch[] = $documentId;
$this->info($label.' | private draft created | resource='.$resourceId.'; pending scan; not published');
} catch (Throwable $exception) {
$disk->delete($storageKey);
throw $exception;
}
} catch (Throwable $exception) {
$this->error($label.' | not imported: '.$exception->getMessage());
$failures++;
}
}
foreach ($dispatch as $documentId) {
ScanDocumentForMalware::dispatch($documentId)->afterCommit();
}
$this->line($apply ? 'New scans queued: '.count($dispatch).'.' : 'Preview only; database and storage unchanged.');
return $failures > 0 ? self::FAILURE : self::SUCCESS;
}
/** The extra CLI path is a named, byte-bound pilot; it is not a general active import gate. */
private function validatePilot(string $manifestPath, string $manifestBytes, array $manifest, string $staging, bool $apply, mixed $actorId, bool $sectors = false, bool $trio = false): void
{
$allowed = $trio ? self::TRIO_HASHES : ($sectors ? self::SECTORS_HASHES : self::PILOT_HASHES);
$manifestName = $trio ? 'legacy-trio-20261015/manifest.json' : ($sectors ? 'legacy-setores-lote20-20261015/manifest.json' : 'legacy-agronegocio/manifest.json');
$manifestHash = $trio ? self::TRIO_MANIFEST_SHA256 : ($sectors ? self::SECTORS_MANIFEST_SHA256 : self::PILOT_MANIFEST_SHA256);
if ($manifestPath !== realpath($staging.'/'.$manifestName)
|| ! hash_equals($manifestHash, hash('sha256', $manifestBytes))
|| count($manifest['documents']) !== count($allowed)) {
throw new RuntimeException('Only the exact selected manifest and its fixed resource list are allowed.');
}
$target = DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
$host = config('database.connections.pgsql.host');
$qa = app()->environment('testing') && str_ends_with($target->database, '_test') && (int) $target->port === 55441;
$active = ! app()->environment('testing') && $target->database === 'investemoz' && (int) $target->port === 5432;
if ((! $qa && ! $active) || ! in_array($host, ['127.0.0.1', 'localhost'], true) || config('database.connections.pgsql.url')) {
throw new RuntimeException('Pilot target identity is not permitted.');
}
if ($apply || $actorId !== null) {
$actor = is_string($actorId) && preg_match('/^[1-9][0-9]*$/D', $actorId) ? User::find((int) $actorId) : null;
if (! $actor || ! $actor->can('manage-portal')) {
throw new RuntimeException('A real existing account with effective manage-portal authorization is required.');
}
}
if ($active && $apply) {
$this->validatePilotBackup($this->option('backup-proof'));
}
if ($trio) {
$images = DB::table('documents as d')->join('portal_resources as r', 'r.document_id', '=', 'd.id')
->where('d.external_id', 'legacy-tempo-image-28f41f12e10ab345')->where('d.sha256', self::TRIO_REUSED_IMAGE_SHA256)
->whereNull('d.deleted_at')->where('d.status', 'active')->where('d.data_classification', 'public')
->where('d.rights_status', 'authorized')->where('r.rights_status', 'authorized')->where('r.published', true)
->where('d.malware_scan_status', 'clean')->whereColumn('d.sha256', 'd.malware_scanned_sha256')->get(['d.storage_key']);
if ($images->count() !== 1 || ! Storage::disk('local')->exists($images[0]->storage_key)
|| ! hash_equals(self::TRIO_REUSED_IMAGE_SHA256, hash_file('sha256', Storage::disk('local')->path($images[0]->storage_key)))) {
throw new RuntimeException('The shared image must already exist once, published, authorized and clean with the exact original bytes; no image is imported.');
}
}
foreach ($manifest['documents'] as $record) {
$entry = $this->validateRecord($record, $staging);
if (! hash_equals($allowed[$entry['external_id']] ?? '', $entry['sha256'])) {
throw new RuntimeException('Article or image differs from the approved SHA-256.');
}
$existing = DB::table('documents')->where('sha256', $entry['sha256'])->whereNull('deleted_at')->first();
if ($existing && ($existing->external_id !== $entry['external_id']
|| ! DB::table('portal_resources')->where('document_id', $existing->id)->exists())) {
throw new RuntimeException('Matching bytes have another identity or no library resource; reconcile explicitly.');
}
if (DB::table('documents')->where('external_id', $entry['external_id'])->whereNull('deleted_at')
->where('sha256', '!=', $entry['sha256'])->exists()
|| (! $existing && DB::table('portal_resources')->where('document_identity_key', $this->identity($entry))->exists())) {
throw new RuntimeException('Existing identity/version conflicts with this pilot; no overwrite or partial preflight import.');
}
}
}
private function validatePilotBackup(mixed $path): void
{
if (! is_string($path) || ! is_file($path)) {
throw new RuntimeException('Active pilot apply requires a fresh verified backup/restoration receipt.');
}
$proof = json_decode((string) file_get_contents($path), true, 512, JSON_THROW_ON_ERROR);
$verified = strtotime($proof['verified_at'] ?? '');
if (! ($proof['restore_verified'] ?? false) || ! ($proof['files_verified'] ?? false)
|| ($proof['target']['database'] ?? '') !== 'investemoz' || (int) ($proof['target']['port'] ?? 0) !== 5432
|| ! str_ends_with($proof['restore_target']['database'] ?? '', '_test') || (int) ($proof['restore_target']['port'] ?? 0) !== 55441
|| $verified === false || $verified > time() || $verified < time() - 86400
|| ! is_file($proof['dump_path'] ?? '') || ! is_file($proof['files_path'] ?? '')
|| ! hash_equals($proof['sha256'] ?? '', hash_file('sha256', $proof['dump_path']))
|| ! hash_equals($proof['files_sha256'] ?? '', hash_file('sha256', $proof['files_path']))) {
throw new RuntimeException('Backup/restoration identity, freshness or integrity does not match the active pilot.');
}
}
/** @return array<string, mixed> */
private function validateRecord(mixed $record, string $stagingRoot): array
{
if (! is_array($record)) {
throw new RuntimeException('Each documents entry must be an object.');
}
foreach (['external_id', 'relative_path', 'title', 'issuer'] as $required) {
if (! isset($record[$required]) || ! is_string($record[$required]) || trim($record[$required]) === '') {
throw new RuntimeException('Required manifest field is missing: '.$required.'.');
}
}
if (strlen($record['external_id']) > 120 || strlen($record['title']) > 250 || strlen($record['issuer']) > 180) {
throw new RuntimeException('A metadata value exceeds its permitted length.');
}
if (isset($record['file_name']) && (! is_string($record['file_name']) || strlen(basename($record['file_name'])) > 255)) {
throw new RuntimeException('File name exceeds its permitted length.');
}
if (isset($record['document_number']) && (! is_string($record['document_number']) || strlen($record['document_number']) > 120)) {
throw new RuntimeException('Document number exceeds its permitted length.');
}
if (isset($record['document_date'])) {
if (! is_string($record['document_date']) || ! preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $record['document_date'], $dateParts)
|| ! checkdate((int) $dateParts[2], (int) $dateParts[3], (int) $dateParts[1])) {
throw new RuntimeException('Document date must use a valid YYYY-MM-DD value.');
}
}
if (isset($record['document_type']) && ! in_array($record['document_type'], ['legislation', 'procedure', 'official_form', 'digital_adaptation', 'guidance_template', 'guide', 'study', 'reference_data', 'unknown'], true)) {
throw new RuntimeException('Document type is not supported by the catalogue.');
}
$path = realpath($stagingRoot.DIRECTORY_SEPARATOR.$record['relative_path']);
if (! $path || ! str_starts_with(strtolower($path), strtolower($stagingRoot.DIRECTORY_SEPARATOR)) || ! is_file($path)) {
throw new RuntimeException('Source file is missing or outside the private staging directory.');
}
$size = filesize($path);
if (! is_int($size) || $size < 1 || $size > self::MAX_BYTES) {
throw new RuntimeException('File size is outside the 1 byte to 10 MiB import limit.');
}
$mime = (new \finfo(FILEINFO_MIME_TYPE))->file($path);
$allowed = ['application/pdf', 'application/json', 'text/plain', 'text/csv', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'image/png', 'image/jpeg', 'image/webp'];
if (! is_string($mime) || ! in_array($mime, $allowed, true)) {
throw new RuntimeException('Detected media type is not supported by the document library.');
}
$sha = hash_file('sha256', $path);
if (! is_string($sha)) {
throw new RuntimeException('File hash could not be calculated.');
}
$sourceUrl = $record['source_url'] ?? null;
if ($sourceUrl !== null && (! is_string($sourceUrl) || strlen($sourceUrl) > 2000 || ! filter_var($sourceUrl, FILTER_VALIDATE_URL) || strtolower((string) parse_url($sourceUrl, PHP_URL_SCHEME)) !== 'https')) {
throw new RuntimeException('Source URL, when supplied, must be a valid HTTPS URL.');
}
return [
...$record, 'source_path' => $path, 'file_name' => basename((string) ($record['file_name'] ?? $path)),
'size_bytes' => $size, 'media_type' => $mime, 'sha256' => $sha,
'category' => (string) ($record['category'] ?? 'general'), 'topic' => (string) ($record['topic'] ?? 'unclassified'),
'legislation' => (bool) ($record['legislation'] ?? false), 'document_type' => (string) ($record['document_type'] ?? 'unknown'),
'document_number' => $record['document_number'] ?? null, 'document_date' => $record['document_date'] ?? null,
'language' => (string) ($record['language'] ?? 'unknown'), 'version_label' => $record['version_label'] ?? null,
'source_url' => $record['source_url'] ?? null, 'source_reference' => $record['source_reference'] ?? 'Origem por completar.',
'description' => $record['description'] ?? 'Documento importado para revisão documental.',
];
}
/** @param array<string, mixed> $record */
private function identity(array $record): string
{
return hash('sha256', mb_strtolower(implode('|', array_map('trim', [
(string) $record['issuer'], (string) ($record['document_number'] ?? ''), (string) ($record['document_date'] ?? ''),
(string) $record['language'], (string) ($record['version_label'] ?? $record['title']),
]))));
}
}
@@ -0,0 +1,119 @@
<?php
namespace App\Console\Commands;
use App\Services\InvestmentZones;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use RuntimeException;
class ImportIndustrialParkCandidates extends Command
{
protected $signature = 'investment-zones:import-industrial-candidates {--apply : Create missing private drafts} {--actor= : Existing eligible manager user ID required with --apply}';
protected $description = 'Reconcile and optionally create source-backed industrial-park/ZFI drafts without publishing';
public function handle(): int
{
$document = json_decode($this->readSource(database_path('data/industrial-park-candidate-drafts.json')), true, flags: JSON_THROW_ON_ERROR);
if (($document['source_document_attached'] ?? null) !== true
|| ($document['source_document_reconciled'] ?? null) !== true) {
$this->error('The source document must be attached and reconciled before importing drafts. No records written.');
return self::FAILURE;
}
$rows = [];
foreach ($document['records'] as $record) {
$existing = DB::table('investment_zones')->where('code', $record['code'])->first(['id', 'name', 'publication_status']);
$territories = [];
foreach ($record['territories'] as $territory) {
$expected = $this->normalize($territory['name']);
$matches = DB::table('geo_locations')->whereNull('deleted_at')->where('location_type', $territory['type'])
->get(['id', 'name'])->filter(fn ($node) => $this->normalize($node->name) === $expected);
if ($matches->count() !== 1) {
throw new RuntimeException("Territory {$territory['name']} ({$territory['type']}) matched {$matches->count()} rows; approximate matching is disabled.");
}
$territories[] = [...$territory, 'geo_location_id' => $matches->first()->id, 'resolved_name' => $matches->first()->name];
}
$rows[] = ['record' => $record, 'existing' => $existing, 'territories' => $territories];
}
$this->table(['Code', 'Action', 'Territory IDs resolved', 'Areas'], array_map(fn ($row) => [
$row['record']['code'], $row['existing'] ? 'reconcile/skip-existing' : ($this->option('apply') ? 'create-private-draft' : 'would-create-private-draft'),
implode('; ', array_map(fn ($t) => $t['resolved_name'], $row['territories'])), count($row['record']['areas']),
], $rows));
if (! $this->option('apply')) {
$this->info('Dry run only. No writes made; every created record remains a private draft, with legal regime/availability not implied by the source.');
return self::SUCCESS;
}
if (! Schema::hasColumn('investment_zones', 'source_evidence')) {
$this->error('The additive industrial-park schema is not migrated in this database. No records written.');
return self::FAILURE;
}
$actor = filter_var($this->option('actor'), FILTER_VALIDATE_INT);
$user = $actor ? DB::table('users')->where('id', $actor)->first(['id', 'email', 'email_verified_at', 'two_factor_confirmed_at']) : null;
if (! $user || ! $user->email_verified_at || ! $user->two_factor_confirmed_at
|| ! in_array(mb_strtolower($user->email), config('portal.managers', []), true)) {
$this->error('An existing verified, MFA-enabled configured manager is required. No records written.');
return self::FAILURE;
}
$created = DB::transaction(function () use ($rows, $actor, $document): array {
$created = [];
foreach ($rows as $row) {
if ($row['existing']) {
continue;
}
$record = $row['record'];
$source = $record['source'];
$sourceReference = $source['url'].' — '.$source['title'];
$locations = array_map(fn ($location) => ['geo_location_id' => $location['geo_location_id'], 'scope_type' => $location['scope_type'],
'source_reference' => $sourceReference, 'legal_source_id' => null], $row['territories']);
$first = $row['territories'][0]['geo_location_id'] ?? null;
$zone = app(InvestmentZones::class)->save(null, [
'code' => $record['code'], 'name' => $record['name'], 'category' => $record['category'],
'physical_category' => $record['physical_category'], 'legal_regime' => $record['legal_regime'],
'development_status' => $record['development_status'], 'availability_status' => $record['availability_status'],
'operational_status' => 'unknown', 'description' => $record['source']['supports'].(! empty($record['territory_note']) ? ' '.$record['territory_note'] : ''),
'source_evidence' => [[...$source, 'source_date' => null]],
'area_components' => array_map(fn ($area) => [...$area, 'source_reference' => $sourceReference.' — '.$area['source_reference'], 'source_date' => null], $record['areas']),
'area_value' => null, 'area_unit' => null, 'activities' => [], 'resources' => [], 'aliases' => [],
'sources_consulted_at' => $document['consulted_at'],
'legal_source_id' => null, 'parent_zone_id' => null, 'primary_territory_id' => $first,
'territory_version' => $first ? 1 : 0, 'locations' => $locations,
], (int) $actor);
$created[] = [$zone->code, $zone->id];
}
return $created;
});
$this->info('Private drafts created: '.count($created).'. Existing codes skipped. No legal sources were approved and no records were published.');
foreach ($created as [$code, $id]) {
$this->line($code.' '.$id);
}
return self::SUCCESS;
}
private function normalize(string $value): string
{
return Str::upper(Str::ascii(trim($value)));
}
private function readSource(string $path): string
{
$bytes = file_get_contents($path);
if ($bytes === false) {
throw new RuntimeException('Source file is unavailable: '.$path);
}
return $bytes;
}
}
@@ -5,54 +5,18 @@ namespace App\Console\Commands;
use App\Models\InvestmentDossier;
use Database\Seeders\InvestmentPortfolioSeeder;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
class ImportInvestmentPortfolio extends Command
{
protected $signature = 'portfolio:import {--approval= : Recorded approval from the document owner}';
protected $signature = 'portfolio:import';
protected $description = 'Import the v4 portfolio and optionally publish unchanged documentary dossiers with owner approval';
protected $description = 'Import the documentary portfolio as private drafts; publication uses the authenticated review workflow';
public function handle(): int
{
$approval = trim((string) $this->option('approval'));
if ($approval !== '' && (mb_strlen($approval) < 15 || mb_strlen($approval) > 3000)) {
$this->error('A autorização deve conter entre 15 e 3000 caracteres.');
return self::FAILURE;
}
$data = json_decode(File::get(database_path('data/investment-portfolio.json')), true, 512, JSON_THROW_ON_ERROR);
$published = 0;
$preserved = 0;
DB::transaction(function () use ($data, $approval, &$published, &$preserved) {
app(InvestmentPortfolioSeeder::class)->run();
foreach (['projects' => ['project_id', 'projects'], 'clusters' => ['cluster_id', 'economic_clusters'], 'corridors' => ['corridor_id', 'development_corridors']] as $kind => [$foreign, $table]) {
foreach ($data[$kind] as $item) {
$entity = DB::table($table)->where('code', $item['code'])->where('source_system', 'upgrade-v4')->whereNull('deleted_at')->value('id');
$dossier = InvestmentDossier::where($foreign, $entity)->lockForUpdate()->first();
// Approval of this document never approves subsequent human edits or restores archived records.
if (! $dossier || $dossier->title !== $item['name'] || $dossier->content != [...$item, 'notice' => $data['notice']] || $dossier->editor_id) {
$preserved++;
continue;
}
if ($approval === '' || $dossier->publication_status === 'published') {
continue;
}
$dossier->update(['version_no' => $dossier->version_no + 1, 'publication_status' => 'published', 'published_at' => now(),
'approval_basis' => 'document_owner', 'approval_evidence' => $approval]);
DB::table('audit_events')->insert(['source_system' => 'portfolio-import', 'entity_type' => 'investment_dossiers',
'entity_id' => $dossier->id, 'action' => 'owner_approved', 'after_data' => json_encode([
'code' => $item['code'], 'approval_basis' => 'document_owner',
'approval_sha256' => hash('sha256', $approval),
'dataset_sha256' => hash_file('sha256', database_path('data/investment-portfolio.json')),
], JSON_THROW_ON_ERROR)]);
$published++;
}
}
});
$this->info("Carteira importada: 15 projectos, 8 clusters, 6 corredores. Publicados: {$published}. Fichas alteradas/arquivadas preservadas: {$preserved}.");
app(InvestmentPortfolioSeeder::class)->run();
$drafts = InvestmentDossier::where('publication_status', 'draft')->count();
$this->info("Carteira importada/actualizada. Dossiers em rascunho privado: {$drafts}. Nenhuma publicação foi realizada.");
return self::SUCCESS;
}
@@ -0,0 +1,133 @@
<?php
namespace App\Console\Commands;
use App\Services\InvestmentZones;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use RuntimeException;
class ImportInvestmentZones extends Command
{
protected $signature = 'investment-zones:import-initial {--apply : Create missing records as private drafts} {--actor= : Existing manager user ID required with --apply}';
protected $description = 'Import documented initial ZEE, ZFI and industrial park references without overwriting or publishing existing records';
public function handle(): int
{
$path = database_path('data/investment-zones-initial.json');
$document = json_decode($this->readSource($path), true, flags: JSON_THROW_ON_ERROR);
$rows = [];
foreach ($document['records'] as $record) {
$existing = DB::table('investment_zones')->where('code', $record['code'])->first(['id', 'name', 'category', 'publication_status']);
$territories = [];
foreach ($record['territories'] as $territory) {
$expected = $this->normalize($territory['name']);
$matches = DB::table('geo_locations')->whereNull('deleted_at')->where('location_type', $territory['type'])
->get(['id', 'name', 'data_classification'])->filter(fn ($node) => $this->normalize($node->name) === $expected);
if ($matches->count() !== 1) {
throw new RuntimeException("Territory reference {$territory['name']} ({$territory['type']}) matched {$matches->count()} master records; no approximate match or new territory was created.");
}
$territories[] = [...$territory, 'geo_location_id' => $matches->first()->id, 'resolved_name' => $matches->first()->name];
}
$rows[] = ['code' => $record['code'], 'name' => $record['name'], 'action' => $existing ? 'skip-existing' : ($this->option('apply') ? 'create-draft' : 'would-create-draft'), 'existing' => $existing, 'territories' => $territories];
}
if (! $this->option('apply')) {
$this->table(['Code', 'Name', 'Action', 'Territories'], array_map(fn ($row) => [$row['code'], $row['name'], $row['action'], implode('; ', array_column($row['territories'], 'resolved_name'))], $rows));
$this->info('Dry run: no records written. Sources are official references; records remain private drafts pending review.');
return self::SUCCESS;
}
$actor = filter_var($this->option('actor'), FILTER_VALIDATE_INT);
$user = $actor ? DB::table('users')->where('id', $actor)->first(['id', 'email', 'email_verified_at', 'two_factor_confirmed_at']) : null;
if (! $user || ! $user->email_verified_at || ! $user->two_factor_confirmed_at
|| ! in_array(mb_strtolower($user->email), config('portal.managers', []), true)) {
$this->error('An existing, verified, MFA-enabled portal manager is required with --apply --actor=.');
return self::FAILURE;
}
$summary = DB::transaction(function () use ($rows, $document, $actor): array {
$created = [];
$skipped = [];
foreach ($rows as $row) {
if ($row['existing']) {
$skipped[] = $row['code'];
continue;
}
$record = collect(is_array($document['records'] ?? null) ? $document['records'] : [])->firstWhere('code', $row['code']);
$requiresLegalSource = in_array($record['category'], ['zee', 'zfi'], true);
$source = $requiresLegalSource
? DB::table('investment_legal_sources')->where('url', $record['source']['url'])->where('diploma', $record['source']['diploma'])->first()
: null;
$sourceId = $requiresLegalSource ? ($source->id ?? (string) Str::uuid()) : null;
if ($requiresLegalSource && ! $source) {
DB::table('investment_legal_sources')->insert([
'id' => $sourceId, ...$record['source'], 'validation_status' => 'pending', 'owner_id' => $actor,
'version_no' => 1, 'created_at' => now(), 'updated_at' => now(),
]);
}
$parentId = null;
if (isset($record['parent_code'])) {
$parentId = DB::table('investment_zones')->where('code', $record['parent_code'])->value('id');
if (! $parentId) {
throw new RuntimeException("Parent zone {$record['parent_code']} must be imported before {$record['code']}.");
}
}
$locations = array_map(fn ($location) => [
'geo_location_id' => $location['geo_location_id'], 'scope_type' => $location['scope_type'],
'source_reference' => $record['source']['url'].' — '.($record['source']['diploma'] ?? $record['source']['title']), 'legal_source_id' => $sourceId,
], $row['territories']);
$firstLocation = $row['territories'][0]['geo_location_id'] ?? null;
$zone = app(InvestmentZones::class)->save(null, [
'code' => $record['code'], 'category' => $record['category'], 'physical_category' => $record['physical_category'],
'legal_regime' => $record['legal_regime'], 'operational_status' => $record['operational_status'],
'development_status' => $record['development_status'] ?? match ($record['operational_status']) {
'operational' => 'operational', 'implementation' => 'in_development', 'planned' => 'planned', default => 'unknown'
},
'availability_status' => $record['availability_status'] ?? 'not_informed',
'name' => $record['name'], 'name_en' => $record['name_en'], 'description' => $record['description'], 'description_en' => $record['description_en'],
'area_value' => $record['area_value'], 'area_unit' => $record['area_unit'], 'area_components' => $record['area_components'] ?? [],
'source_evidence' => [[
'title' => $record['source']['title'], 'url' => $record['source']['url'],
'source_type' => str_contains($record['source']['url'], 'inm.gov.mz') ? 'government' : (str_contains($record['source']['url'], 'apiex.gov.mz') ? 'institutional' : 'promoter'),
'supports' => $record['source']['supports'] ?? 'Referência institucional/promocional; não comprova regime jurídico, incentivos ou disponibilidade.',
'source_date' => $record['source']['source_date'] ?? null,
]], 'aliases' => $record['aliases'],
'activities' => $record['activities'], 'resources' => [], 'historical_territory' => $record['historical_territory'] ?? null,
'data_as_of' => null, 'sources_consulted_at' => $document['consulted_at'], 'operator_id' => null,
'legal_source_id' => $sourceId, 'parent_zone_id' => $parentId, 'primary_territory_id' => $firstLocation,
'territory_version' => $firstLocation ? 1 : 0, 'province' => null, 'district' => null, 'locations' => $locations,
], (int) $actor);
$created[] = ['code' => $record['code'], 'id' => $zone->id, 'source' => $source?->id ? 'reused' : 'created', 'locations_pending' => count($locations)];
}
return compact('created', 'skipped');
});
$this->table(['Code', 'ID', 'Source', 'Pending locations'], array_map(fn ($row) => [$row['code'], $row['id'], $row['source'], $row['locations_pending']], $summary['created']));
$this->info('Created drafts: '.count($summary['created']).'; skipped without overwrite: '.count($summary['skipped']).'; publication: none.');
$this->line('Skipped: '.implode(', ', $summary['skipped']));
return self::SUCCESS;
}
private function normalize(string $value): string
{
return Str::upper(Str::ascii(trim($value)));
}
private function readSource(string $path): string
{
$bytes = file_get_contents($path);
if ($bytes === false) {
throw new RuntimeException('Source file is unavailable: '.$path);
}
return $bytes;
}
}
@@ -0,0 +1,312 @@
<?php
namespace App\Console\Commands;
use App\Jobs\ScanDocumentForMalware;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use RuntimeException;
use Throwable;
class ImportLegalSourceArchive extends Command
{
protected $signature = 'documents:import-legal-source-archive {--apply : Copy verified archive files into private storage and register draft metadata}';
protected $description = 'Idempotently imports the known local legal-source PDFs as private, unpublished library drafts';
public function handle(): int
{
$records = $this->records();
$apply = (bool) $this->option('apply');
$disk = Storage::disk('local');
$dispatch = [];
$failures = 0;
foreach ($records as $record) {
$sourcePath = base_path($record['archive_path']);
$realSource = realpath($sourcePath);
$approvedRoots = array_filter([
realpath(base_path('docs/investmoz/simulations/source-archive')),
realpath(storage_path('app/private/geo-ine-source-20260928')),
]);
if (! $realSource && ($record['optional'] ?? false)) {
$this->warn($record['external_id'].': approved local source copy is not present; skipped without changing records.');
continue;
}
$withinApprovedRoot = $realSource && collect($approvedRoots)->contains(fn (string $root) => str_starts_with(strtolower($realSource), strtolower($root.DIRECTORY_SEPARATOR)));
if (! $realSource || ! $withinApprovedRoot || ! is_file($realSource)) {
$this->error($record['external_id'].': archive file missing or outside the approved source archive.');
$failures++;
continue;
}
$hash = hash_file('sha256', $realSource);
$size = filesize($realSource);
$header = file_get_contents($realSource, false, null, 0, 5);
if (! is_string($hash) || ! hash_equals($record['expected_sha256'], strtolower($hash)) || $size !== $record['size_bytes'] || $header !== '%PDF-') {
$this->error($record['external_id'].': checksum, size, or PDF signature differs from the reviewed manifest.');
$failures++;
continue;
}
$identity = hash('sha256', mb_strtolower(implode('|', array_map('trim', [$record['issuer'], $record['document_number'], $record['document_date'], $record['language'], $record['version_label']]))));
$existing = DB::table('documents')->where('sha256', $hash)->whereNull('deleted_at')->first();
if ($existing) {
$storedPath = $disk->path($existing->storage_key);
$storedReal = realpath($storedPath);
$storageRoot = realpath($disk->path(''));
if (! $storedReal || ! $storageRoot || ! str_starts_with(strtolower($storedReal), strtolower($storageRoot.DIRECTORY_SEPARATOR))
|| ! is_file($storedReal) || ! hash_equals($hash, (string) hash_file('sha256', $storedReal))) {
$this->error($record['external_id'].': matching hash exists but the stored copy could not be verified; no changes made.');
$failures++;
continue;
}
$resource = DB::table('portal_resources')->where('document_id', $existing->id)->first();
if (! $resource) {
$this->error($record['external_id'].': document hash already exists without its expected library record; manual reconciliation required.');
$failures++;
continue;
}
if (! hash_equals($identity, (string) $resource->document_identity_key)) {
$this->error($record['external_id'].': identical bytes exist under a different document identity; manual reconciliation required.');
$failures++;
continue;
}
$missingEvidence = collect(is_array($record['associations'] ?? null) ? $record['associations'] : [])->filter(fn (array $association) => $association['target_type'] === 'requirement')
->reject(fn (array $association) => DB::table('portal_resource_links')->where('resource_id', $resource->id)
->where('target_type', 'requirement')->where('target_key', $association['target_key'])->exists())->values();
if ($apply && $missingEvidence->isNotEmpty()) {
DB::transaction(function () use ($resource, $missingEvidence): void {
$locked = DB::table('portal_resources')->where('id', $resource->id)->lockForUpdate()->first();
$now = now();
foreach ($missingEvidence as $association) {
DB::table('portal_resource_links')->insertOrIgnore(['resource_id' => $resource->id, ...$association,
'created_at' => $now, 'updated_at' => $now]);
}
$inserted = DB::table('portal_resource_links')->where('resource_id', $resource->id)->where('target_type', 'requirement')
->whereIn('target_key', $missingEvidence->pluck('target_key'))->count();
if ($inserted > 0) {
$version = (int) $locked->metadata_version + 1;
DB::table('portal_resources')->where('id', $resource->id)->update(['metadata_version' => $version, 'updated_at' => $now]);
$links = DB::table('portal_resource_links')->where('resource_id', $resource->id)->orderBy('id')->get();
$snapshot = DB::table('portal_resources')->where('id', $resource->id)->first();
DB::table('portal_resource_revisions')->insert(['resource_id' => $resource->id, 'revision_no' => $version,
'change_type' => 'evidence_linked',
'snapshot' => json_encode(['resource' => $snapshot, 'associations' => $links], JSON_THROW_ON_ERROR),
'actor_user_id' => null, 'created_at' => $now]);
}
});
}
$this->line($record['external_id'].' | reused/reconciled | document='.$existing->id.' | published='.($resource->published ? 'yes' : 'no'));
continue;
}
$identityConflict = DB::table('portal_resources')->where('document_identity_key', $identity)->exists();
if ($identityConflict) {
$this->error($record['external_id'].': document identity exists with different bytes; manual version reconciliation required.');
$failures++;
continue;
}
$this->line($record['external_id'].' | '.($apply ? 'importing private draft' : 'would import')." | sha256={$hash} | bytes={$size}");
if (! $apply) {
continue;
}
$documentId = (string) Str::uuid();
$storageKey = 'portal-documents/'.$documentId;
$sourceHandle = fopen($realSource, 'rb');
if (! is_resource($sourceHandle)) {
$this->error($record['external_id'].': source could not be opened.');
$failures++;
continue;
}
try {
$stored = $disk->put($storageKey, $sourceHandle);
} finally {
fclose($sourceHandle);
}
$storedPath = $disk->path($storageKey);
$storedHash = $stored && is_file($storedPath) ? hash_file('sha256', $storedPath) : false;
if (! $stored || ! is_string($storedHash) || ! hash_equals($hash, $storedHash)) {
$disk->delete($storageKey);
$this->error($record['external_id'].': private copy failed the post-copy checksum; no database record created.');
$failures++;
continue;
}
try {
$result = DB::transaction(function () use ($record, $documentId, $storageKey, $hash, $size, $identity): array {
DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', ['legal-source-archive:'.$hash]);
if (DB::table('documents')->where('sha256', $hash)->whereNull('deleted_at')->exists()) {
throw new RuntimeException('A matching document was registered concurrently; rerun the import to reconcile it.');
}
if (DB::table('portal_resources')->where('document_identity_key', $identity)->exists()) {
throw new RuntimeException('A matching document identity was registered concurrently; manual reconciliation required.');
}
$now = now();
DB::table('documents')->insert([
'id' => $documentId, 'source_system' => 'legal-source-archive', 'external_id' => $record['external_id'],
'version_no' => 1, 'data_classification' => 'internal', 'file_name' => $record['file_name'],
'media_type' => 'application/pdf', 'storage_key' => $storageKey, 'sha256' => $hash,
'status' => 'quarantined', 'size_bytes' => $size, 'issuer' => $record['issuer'],
'original_source' => $record['source_url'], 'issued_on' => $record['document_date'],
'uploaded_by' => null, 'rights_status' => 'unconfirmed', 'malware_scan_status' => 'pending',
'created_at' => $now, 'updated_at' => $now,
]);
$resourceId = DB::table('portal_resources')->insertGetId([
'document_id' => $documentId, 'title' => $record['title'], 'category' => $record['category'] ?? 'legislation',
'topic' => $record['topic'], 'legislation' => $record['is_legislation'] ?? true, 'essential' => false, 'published' => false,
'source_reference' => $record['source_reference'], 'approved_by' => null, 'description' => $record['description'],
'document_type' => $record['document_type'] ?? 'legislation', 'document_number' => $record['document_number'],
'document_date' => $record['document_date'], 'source_url' => $record['source_url'],
'language' => $record['language'], 'version_label' => $record['version_label'],
'territorial_scope' => 'national', 'source_confirmation' => $record['source_confirmation'],
'legal_status' => $record['legal_status'], 'rights_status' => 'unconfirmed', 'last_reviewed_at' => null,
'document_identity_key' => $identity, 'metadata_version' => 1, 'created_by' => null,
'last_reviewed_by' => null, 'independent_review' => false, 'created_at' => $now, 'updated_at' => $now,
]);
foreach ($record['associations'] as $association) {
$association['review_status'] ??= 'pending';
DB::table('portal_resource_links')->insertOrIgnore([
'resource_id' => $resourceId, ...$association, 'created_at' => $now, 'updated_at' => $now,
]);
}
$links = DB::table('portal_resource_links')->where('resource_id', $resourceId)->orderBy('id')->get();
$resource = DB::table('portal_resources')->where('id', $resourceId)->first();
DB::table('portal_resource_revisions')->insert([
'resource_id' => $resourceId, 'revision_no' => 1, 'change_type' => 'created',
'snapshot' => json_encode(['resource' => $resource, 'associations' => $links], JSON_THROW_ON_ERROR),
'actor_user_id' => null, 'created_at' => $now,
]);
return ['document_id' => $documentId, 'resource_id' => $resourceId];
});
$dispatch[] = $result['document_id'];
$this->info($record['external_id'].' | created private draft | document='.$result['document_id'].' | resource='.$result['resource_id']);
} catch (Throwable $exception) {
$disk->delete($storageKey);
$this->error($record['external_id'].': import rolled back ('.$exception->getMessage().').');
$failures++;
}
}
foreach ($dispatch as $documentId) {
ScanDocumentForMalware::dispatch($documentId)->afterCommit();
}
$this->line($apply ? 'New malware scans queued: '.count($dispatch).'.' : 'Preview only; no files or database rows were changed.');
return $failures > 0 ? self::FAILURE : self::SUCCESS;
}
/** @return array<int, array<string, mixed>> */
private function records(): array
{
$simulatorEvidence = fn (string $basis): array => [
['target_type' => 'module', 'target_key' => 'simulator', 'relationship' => 'supports', 'applicability' => 'evidence',
'condition_text' => 'Consulta documental; a fonte não determina por si só a regra fiscal aplicável.', 'basis_reference' => $basis],
];
return [
[
'external_id' => 'inm-br250-supplement-2-law-22-2022-v1',
'archive_path' => 'docs/investmoz/simulations/source-archive/Lei-22-2022-IVA-republicada-BR-250-2-suplemento.pdf',
'file_name' => 'Lei-22-2022-IVA-republicada-BR-250-2-suplemento.pdf',
'expected_sha256' => 'ea1b0dc6f5cf6e595f6d9a19e7042f398999e0087caef1785f6e8fac571b5f53', 'size_bytes' => 1834284,
'title' => 'Lei n.º 22/2022, de 28 de Dezembro — alteração ao Código do IVA (republicação)',
'document_number' => 'Lei n.º 22/2022', 'document_date' => '2022-12-28', 'issuer' => 'Assembleia da República',
'language' => 'pt', 'version_label' => '2.º Suplemento do BR n.º 250/2022; diploma alterador, não consolidação integral',
'topic' => 'iva', 'legal_status' => 'amended', 'source_confirmation' => 'official_confirmed',
'source_url' => 'https://www.inm.gov.mz/pt-br/content/assembleia-da-rep%C3%BAblica-suplemento-n%C2%BA-2-de-281222-pag-2252-25-42-br-n%C2%BA-250-boletim-da',
'source_reference' => 'INM, 2.º Suplemento do BR n.º 250 e catálogo oficial AT, registo Lei 22 IVA 2022. O ficheiro local foi descarregado novamente do endpoint AT em 2026-10-06; tamanho e SHA-256 coincidem exactamente. O artigo 17(1) está citado na revisão documental.',
'description' => 'Diploma alterador do CIVA, sem afirmar consolidação ou vigência integral actual. O texto oficial está identificado; direitos de reprodução da composição PDF continuam por rever, pelo que o ficheiro permanece privado.',
'associations' => [...$simulatorEvidence('Lei n.º 22/2022, art. 17(1), conforme revisão documental em docs/investmoz/simulations/primary-text-review-2025-2026.md; consultar alterações posteriores antes de determinar o período.'),
['target_type' => 'theme', 'target_key' => 'iva', 'relationship' => 'cites', 'applicability' => 'evidence', 'condition_text' => null,
'basis_reference' => 'Lei n.º 22/2022, art. 17(1); efeito temporal e alterações posteriores devem ser considerados.'],
['target_type' => 'requirement', 'target_key' => 'fiscal.iva.lei_22_2022', 'relationship' => 'cites', 'applicability' => 'evidence', 'review_status' => 'confirmed',
'condition_text' => 'Evidência documental; a citação não activa nem determina cálculos fiscais.', 'basis_reference' => 'Lei n.º 22/2022, art. 17(1), p. 3 do PDF oficial arquivado; a redacção diz 16%. Conferência da citação concluída; aplicação temporal e aprovação fiscal continuam separadas.']],
],
[
'external_id' => 'inm-br248-supplement-1-2025-v1',
'archive_path' => 'docs/investmoz/simulations/source-archive/BR-248-I-Suplemento-1-2025.pdf',
'file_name' => 'BR-248-I-Suplemento-1-2025.pdf',
'expected_sha256' => 'ccf2a8deeca820f2dd076b4b2141b1efe3f6a806568c3a587ca59a8e92f4c251', 'size_bytes' => 440018,
'title' => 'Boletim da República n.º 248/2025 — Suplemento n.º 1',
'document_number' => 'BR n.º 248/2025, Suplemento n.º 1', 'document_date' => '2025-12-29', 'issuer' => 'Imprensa Nacional de Moçambique',
'language' => 'pt', 'version_label' => 'Suplemento n.º 1; edição arquivada com 21 páginas',
'topic' => 'fiscalidade', 'legal_status' => 'unknown', 'source_confirmation' => 'official_confirmed',
'source_url' => 'https://inm.gov.mz/pt-br/content/suplemento-n%C2%BA-1-de-291225-pag-2180-1-20-br-n%C2%BA-248-boletim-da-rep%C3%BAblica-i-serie',
'source_reference' => 'Ficha INM do Suplemento n.º 1 do BR 248 e catálogo oficial AT. O ficheiro local foi descarregado novamente do endpoint WebDAV AT em 2026-10-06; tamanho e SHA-256 coincidem exactamente. Inclui diplomas fiscais identificados no registo de fontes; artigo, vigência e situação de cada lei devem ser consultados individualmente.',
'description' => 'Edição que reúne diplomas sobre ICE, Pauta Aduaneira, ISPC, IVA, IRPS e IRPC. O estado de vigência é mantido por confirmar por diploma e dispositivo. O texto oficial está identificado; direitos de reprodução da composição PDF continuam por rever, pelo que o ficheiro permanece privado.',
'associations' => [...$simulatorEvidence('BR n.º 248/2025, Suplemento n.º 1; esta ligação é evidência documental, não confirmação de vigência ou obrigação.'),
...array_map(fn (string $theme) => ['target_type' => 'theme', 'target_key' => $theme, 'relationship' => 'cites', 'applicability' => 'evidence', 'condition_text' => null,
'review_status' => 'pending', 'basis_reference' => 'Associação temática antiga; artigo e alcance por diploma ainda não foram especificados.'],
['ice', 'aduaneiro', 'ispc', 'iva', 'irps', 'irpc']),
...array_map(fn (array $item) => ['target_type' => 'requirement', 'target_key' => $item[0], 'relationship' => 'cites',
'applicability' => 'evidence', 'review_status' => 'confirmed', 'condition_text' => 'Citação localizada no texto primário; não confirma interpretação, vigência integral, elegibilidade nem regra executável.',
'basis_reference' => $item[1].' — '.$item[2].' BR n.º 248/2025, Suplemento n.º 1, Lei de 29-12-2025.'], [
['fiscal.ice.lei_7_2025', 'Lei n.º 7/2025 (ICE)', 'arts. 1–5 e tabela anexa; pp. 1–2. Art. 1 prorroga as taxas para 2027; art. 5 indica entrada em vigor em 01-01-2026.'],
['fiscal.customs.lei_8_2025', 'Lei n.º 8/2025 (Pauta Aduaneira)', 'art. 1 altera as Instruções Preliminares, arts. 21(2), 22 e 27; arts. 2–5 e tabela anexa; pp. 2–7.'],
['fiscal.ispc.lei_9_2025', 'Lei n.º 9/2025 (ISPC)', 'arts. 1–7 e republicação anexa da Lei n.º 5/2009; pp. 8–11.'],
['fiscal.iva.lei_10_2025', 'Lei n.º 10/2025 (IVA)', 'arts. 1–4; alterações do CIVA identificadas no diploma, incluindo arts. 18, 19, 21, 23, 25-A, 27 e 32–34; pp. 12–15.'],
['fiscal.irps.lei_11_2025', 'Lei n.º 11/2025 (IRPS)', 'arts. 1–4; alterações do CIRPS, incluindo arts. 21, 22, 48, 50, 54-A, 57, 65 e 65-A; pp. 16–18.'],
['fiscal.irpc.lei_12_2025', 'Lei n.º 12/2025 (IRPC)', 'arts. 1–4; alterações do CIRPC, incluindo arts. 3, 5, 20, 62, 67, 75 e aditamento do art. 61-A; pp. 19–20.'],
]),
],
],
[
'external_id' => 'inm-br89-series-1-2017-iva-rectification-v1',
'archive_path' => 'docs/investmoz/simulations/source-archive/BR-89-I-Serie-2017-06-08.pdf',
'file_name' => 'BR-89-I-Serie-2017-06-08.pdf',
'expected_sha256' => '11f0cab15eb6e51010952203ffa24fdc533a24fd642e19525fe4f46b17a1f5e5', 'size_bytes' => 9456358,
'title' => 'Boletim da República n.º 89, I Série (08-06-2017) — aviso de rectificação da Lei n.º 13/2016',
'document_number' => 'BR n.º 89/2017', 'document_date' => '2017-06-08', 'issuer' => 'Imprensa Nacional de Moçambique',
'language' => 'pt', 'version_label' => 'Exemplar local de 16 páginas; autenticidade binária pendente',
'topic' => 'iva', 'legal_status' => 'unknown', 'source_confirmation' => 'identified',
'source_url' => 'https://www.inm.gov.mz/pt-br/content/assembleia-da-rep%C3%BAblica-br-n%C2%BA-89-de-080617-boletim-da-rep%C3%BAblica-i-serie-p%C3%A1g-569',
'source_reference' => 'Ficha oficial INM identifica o aviso de rectificação da Lei n.º 13/2016 na p. 569. O exemplar local não tem assinatura digital validável; cadeia de custódia e efeito temporal por confirmar, conforme docs/investmoz/simulations/official-source-register.md.',
'description' => 'Aviso de rectificação ligado ao CIVA de 2016. A identidade do aviso é corroborada pela ficha INM; autenticidade do ficheiro local, anexos corrigidos e efeito temporal permanecem por confirmar. Cópia mantida em privado.',
'associations' => [...$simulatorEvidence('Ficha INM: aviso de rectificação da Lei n.º 13/2016, p. 569; exemplar local e efeito temporal por confirmar.'),
['target_type' => 'theme', 'target_key' => 'iva', 'relationship' => 'cites', 'applicability' => 'evidence', 'condition_text' => null, 'review_status' => 'pending',
'basis_reference' => 'Aviso de rectificação da Lei n.º 13/2016, BR n.º 89/2017, p. 569; exemplar local, anexos corrigidos e efeito temporal ainda não autenticados.'],
['target_type' => 'requirement', 'target_key' => 'fiscal.iva.lei_13_2016_rectification_2017', 'relationship' => 'cites', 'applicability' => 'evidence', 'review_status' => 'pending',
'condition_text' => 'Relação candidata pendente de conferência da cópia e dos anexos rectificados.',
'basis_reference' => 'Aviso de rectificação da Lei n.º 13/2016, BR n.º 89/2017, p. 569; ficha INM identifica o aviso, mas a cópia local e os anexos não foram autenticados.']],
],
[
'external_id' => 'ine-codificador-divisao-politico-administrativa-2012-v1',
'optional' => true,
'archive_path' => 'storage/app/private/geo-ine-source-20260928/original.pdf',
'file_name' => 'INE-Codificador-Divisao-Politico-Administrativa-2012.pdf',
'expected_sha256' => '5c69b6f189a1d3f5307e0cf7dd8224554705158a682637d3105afad615db26e2', 'size_bytes' => 67067,
'title' => 'Codificador da Divisão Político-Administrativa (INE, 07-06-2012)',
'document_number' => 'Codificador DPA 2012', 'document_date' => '2012-06-07', 'issuer' => 'Instituto Nacional de Estatística (INE)',
'language' => 'pt', 'version_label' => 'Edição 2012; actualidade das designações por confirmar',
'topic' => 'território', 'category' => 'Dados de referência', 'document_type' => 'reference_data',
'is_legislation' => false, 'legal_status' => 'unknown', 'source_confirmation' => 'official_confirmed',
'source_url' => 'https://www.ine.gov.mz/documents/20119/44337/Codificador%20da%20Divisao%20Politico%20administrativa.pdf/14aed3d0-9296-c038-dbac-041a5cf40f1d',
'source_reference' => 'PDF disponibilizado no portal oficial do INE; download efectuado em 2026-10-06, 67.067 bytes. SHA-256 coincidente com o original privado e com a cópia oficial obtida no portal INE. A data impressa é 07-06-2012; a vigência territorial actual e os direitos de reutilização do ficheiro permanecem por confirmar.',
'description' => 'Documento de referência com códigos e designações político-administrativas na edição indicada. A extracção anterior encontrou colunas com alinhamento ambíguo, sem autorizar correspondências automáticas de postos e distritos; não usar esta edição como catálogo territorial actual nem como fonte de relações não revistas.',
'territorial_scope' => 'national', 'associations' => [],
],
];
}
}
@@ -0,0 +1,50 @@
<?php
namespace App\Console\Commands;
use App\Models\InvestmentLegalSource;
use App\Models\User;
use App\Services\SimulationTaxParameterResolver;
use App\Services\SimulationTaxRuleExpression;
use App\Services\SimulationTaxRuleGovernance;
use App\Support\IsolatedQaDatabase;
use Illuminate\Console\Command;
/** Explicit registration: never run during migration, deployment or document import. */
class RegisterFiscalQaDraft extends Command
{
protected $signature = 'fiscal:qa-register-iva {actor : Existing QA editor ID} {source : Existing accepted legal source UUID}';
protected $description = 'Cadastrar apenas o rascunho IVA do primeiro lote numa base QA isolada, sem publicação';
public function handle(SimulationTaxRuleGovernance $governance): int
{
abort_unless(IsolatedQaDatabase::allowsSchemaChanges(), 403, 'O cadastro deste lote só é permitido na base QA isolada.');
$actor = User::findOrFail($this->argument('actor'));
$source = InvestmentLegalSource::findOrFail($this->argument('source'));
abort_unless($source->validation_status === 'validated' && $source->diploma === 'Lei 22/2022'
&& $source->article === 'Artigo 17(1)', 422, 'Seleccione a referência aceite da taxa geral, Lei 22/2022, artigo 17(1).');
$payload = json_decode(file_get_contents(database_path('qa/iva-general-net-v1.json')), true, flags: JSON_THROW_ON_ERROR);
$actualHash = hash_file('sha256', base_path('docs/investmoz/simulations/source-archive/Lei-22-2022-IVA-republicada-BR-250-2-suplemento.pdf'));
abort_unless(hash_equals($payload['source_sha256'], $actualHash), 422, 'O exemplar não corresponde ao hash da ficha.');
$payload['legal_source_id'] = (string) $source->id;
app(SimulationTaxRuleExpression::class)->validate($payload['rule_definition']);
app(SimulationTaxParameterResolver::class)->validateParameters($payload['parameters']);
$existing = $governance->listing($actor, ['rule_code' => $payload['rule_code']])->getCollection()
->firstWhere('rule_version', 1);
if ($existing) {
foreach ($payload as $field => $value) {
$stored = $existing->attributesToArray()[$field] ?? null;
abort_unless($stored == $value, 409, 'O cadastro existente difere desta ficha; não será sobrescrito.');
}
abort_unless($existing->status === 'draft', 409, 'A versão existente já não é um rascunho.');
$this->line(json_encode(['id' => $existing->id, 'status' => 'draft', 'reused' => true], JSON_THROW_ON_ERROR));
return self::SUCCESS;
}
$rule = $governance->create($actor, $payload);
$this->line(json_encode(['id' => $rule->id, 'status' => $rule->status, 'reused' => false], JSON_THROW_ON_ERROR));
return self::SUCCESS;
}
}
@@ -0,0 +1,42 @@
<?php
namespace App\Console\Commands;
use App\Jobs\ScanDocumentForMalware;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class ScanPendingDocuments extends Command
{
protected $signature = 'documents:scan-pending {--apply : Dispatch scans to the queue} {--limit=100 : Maximum documents to process in this batch}';
protected $description = 'List or dispatch a controlled batch of document malware scans';
public function handle(): int
{
$limit = max(1, min(1000, (int) $this->option('limit')));
$ids = DB::table('documents')->whereNull('deleted_at')->where('status', '!=', 'deleted')
->where(function ($query): void {
$query->whereIn('malware_scan_status', ['pending', 'inconclusive', 'failed'])
->orWhere(fn ($stale) => $stale->where('malware_scan_status', 'scanning')->where('updated_at', '<', now()->subMinutes(15)));
})->orderBy('created_at')->limit($limit)->pluck('id');
$this->info(count($ids).' document(s) eligible for this batch.');
if (! $this->option('apply')) {
$this->comment('Dry run only. Add --apply to dispatch scans.');
return self::SUCCESS;
}
foreach ($ids as $id) {
if (DB::table('documents')->where('id', $id)->where('malware_scan_status', 'scanning')->exists()) {
DB::table('documents')->where('id', $id)->update(['malware_scan_status' => 'pending', 'status' => 'quarantined', 'updated_at' => now()]);
}
ScanDocumentForMalware::dispatch((string) $id);
}
$this->info('Queued '.$ids->count().' scan(s). No editorial publication state was changed.');
return self::SUCCESS;
}
}
+7 -1
View File
@@ -2,6 +2,7 @@
namespace App\Http\Controllers;
use App\Jobs\ScanDocumentForMalware;
use App\Models\AiRun;
use App\Models\Opportunity;
use App\Services\Ai\AiGateway;
@@ -25,6 +26,7 @@ final class AiController extends Controller
'controls' => DB::table('ai_controls')->orderBy('capability')->get(),
'sources' => DB::table('ai_sources')->orderByDesc('created_at')->limit(50)->get(['id', 'title', 'language', 'status', 'nature', 'valid_until', 'submitted_by']),
'documents' => DB::table('documents')->where('status', 'active')->whereNull('deleted_at')->where('media_type', 'text/plain')
->where('malware_scan_status', 'clean')->whereColumn('malware_scanned_sha256', 'sha256')
->whereIn('data_classification', ['public', 'internal'])->limit(100)->get(['id', 'file_name']),
'runs' => AiRun::orderByDesc('created_at')->limit(20)->get(['id', 'capability', 'mode', 'review_status', 'review_requested', 'created_at']),
'today' => now()->toDateString(),
@@ -118,7 +120,8 @@ final class AiController extends Controller
DB::transaction(function () use ($request, $file, $text, $id, $key): void {
DB::table('documents')->insert(['id' => $id, 'file_name' => basename($file->getClientOriginalName()),
'media_type' => 'text/plain', 'storage_key' => $key, 'sha256' => hash('sha256', $text),
'status' => 'active', 'data_classification' => 'internal']);
'size_bytes' => strlen($text), 'uploaded_by' => $request->user()->id,
'status' => 'quarantined', 'data_classification' => 'internal']);
$this->audit('documents', $id, 'plain_text_registered', $request->user()->id);
});
} catch (\Throwable $exception) {
@@ -126,6 +129,8 @@ final class AiController extends Controller
throw $exception;
}
ScanDocumentForMalware::dispatch($id)->afterCommit();
return $request->expectsJson() ? response()->json(['document_id' => $id], 201)
: back()->with('success', 'Texto guardado no cadastro documental. Registe a fonte e solicite revisão antes de o pesquisar.');
}
@@ -140,6 +145,7 @@ final class AiController extends Controller
if ($data['status'] === 'approved') {
abort_if((int) $source->submitted_by === $request->user()->id, 403, 'Outra pessoa deve validar a autorização.');
$valid = DB::table('documents')->where('id', $source->document_id)->where('status', 'active')
->where('malware_scan_status', 'clean')->whereColumn('malware_scanned_sha256', 'sha256')
->whereNull('deleted_at')->whereIn('data_classification', ['public', 'internal'])
->where('version_no', $source->document_version)->where('sha256', $source->document_checksum)->exists();
abort_unless($valid && $source->valid_until >= now()->toDateString(), 422, 'Fonte alterada, expirada ou sem autorização.');
@@ -20,10 +20,8 @@ class HomeController extends Controller
$locale = $validated['locale'] ?? 'pt';
$data = Cache::remember("portal_home_v2_{$locale}", now()->addMinutes(10), function () {
$sectors = Sector::withCount([
'opportunities as opportunities_count' => fn ($q) => $q->published(),
])->where('is_active', true)->orderBy('sort_order')->get();
$data = Cache::remember("portal_home_sector_organization_v1_{$locale}", now()->addMinutes(10), function () {
$sectors = \App\Services\SectorOrganization::catalog();
$featured = Opportunity::with('sector:id,name,slug')
->published()->where('is_featured', true)
@@ -31,14 +29,14 @@ class HomeController extends Controller
return [
'sectors' => $sectors->map(fn ($s) => [
'name' => $s->name,
'id' => $s->id, 'specializations' => $s->specializations, 'name' => $s->name,
'slug' => $s->slug,
'shortDescription' => $s->short_description,
'opportunitiesCount' => $s->opportunities_count,
'url' => "/sectores/{$s->slug}",
])->values()->all(),
'featuredOpportunities' => $featured->map(fn ($o) => [
'reference' => $o->reference,
'id' => $o->id, 'reference' => $o->reference,
'title' => $o->title,
'sector' => $o->sector?->name,
'province' => $o->province,
@@ -51,7 +49,7 @@ class HomeController extends Controller
])->values()->all(),
'stats' => [
'opportunities' => Opportunity::published()->count(),
'sectors' => Sector::where('is_active', true)->count(),
'sectors' => $sectors->count(),
'provinces' => Opportunity::published()->distinct('province')->count('province'),
],
];
@@ -23,8 +23,9 @@ class InterestRequestController extends Controller
{
public function store(StoreInterestRequestRequest $request, InterestSubmission $submission): JsonResponse
{
$validated = $request->validated();
$validated = [...$request->validated(), '_actor_id' => $request->user()->id];
$key = $request->header('Idempotency-Key');
abort_unless(is_string($key), 422, 'Identificador do envio em falta.');
Validator::make(['idempotency_key' => $key], ['idempotency_key' => [$key === null ? 'nullable' : 'required', 'uuid']])->validate();
try {
@@ -53,6 +54,7 @@ class InterestRequestController extends Controller
'phone' => $validated['phone'] ?? null,
'investor_type' => $validated['investorType'],
'country' => $validated['country'],
'metadata' => ['portal_user_id' => $request->user()->id, 'declared_origin_country_code' => $validated['declared_origin_country_code'] ?? null],
'sector_interest' => $validated['sectorInterest'],
'opportunity_reference' => $validated['opportunityReference'] ?? null,
'investment_range' => $validated['investmentRange'] ?? null,
@@ -76,6 +78,19 @@ class InterestRequestController extends Controller
}
}
$row = \App\Models\InvestmentSubmission::create([
'user_id' => $request->user()->id, 'opportunity_id' => $opportunity?->id,
'interest_request_id' => $interest->id, 'status' => 'submitted', 'version' => 1,
'payload' => ['simple_participation' => true, 'opportunity_title' => $opportunity?->title ?? $business?->title, 'opportunity_reference' => $opportunity?->reference, 'phone' => $validated['phone'] ?? null, 'declared_origin_country_code' => $validated['declared_origin_country_code'] ?? null, 'full_name' => $validated['fullName'], 'email' => $validated['email'],
'objective' => $validated['message'], 'sector' => $validated['sectorInterest'],
'country' => $validated['country'], 'existing_business_id' => $business?->id,
'modality' => $validated['modality'] ?? null],
]);
\Illuminate\Support\Facades\DB::table('investment_submission_events')->insert([
'submission_id' => $row->id, 'actor_id' => $request->user()->id,
'action' => 'submitted', 'version' => 1, 'created_at' => now(),
]);
$interest->forceFill(['metadata' => [...$interest->metadata, 'submission_id' => $row->id]])->save();
return $interest;
});
@@ -84,7 +99,8 @@ class InterestRequestController extends Controller
'message' => 'Manifestação de interesse registada com sucesso.',
'data' => [
'reference' => $interestRequest->reference,
'status' => 'received',
'status' => $interestRequest->status,
'tracking_url' => '/investidor/manifestacoes',
],
], 201);
} catch (IdempotencyConflict) {
@@ -36,6 +36,7 @@ class OpportunityController extends Controller
$term = $validated['q'];
$query->where(fn ($q) => $q
->whereLike('title', "%{$term}%")
->orWhereLike('description', "%{$term}%")
->orWhereLike('summary', "%{$term}%")
->orWhereLike('reference', "%{$term}%")
->orWhereLike('province', "%{$term}%")
@@ -46,7 +47,7 @@ class OpportunityController extends Controller
if (! empty($validated['sector'])) {
$sector = $validated['sector'];
$query->whereHas('sector', fn ($q) => $q->where('slug', $sector));
\App\Services\SectorOrganization::constrain($query, $sector);
}
if (! empty($validated['province'])) {
@@ -54,7 +55,7 @@ class OpportunityController extends Controller
}
if (! empty($validated['maturity_level'])) {
$query->where('maturity_level', $validated['maturity_level']);
\App\Services\OpportunityMaturity::constrainSearch($query, 'historical:'.$validated['maturity_level']);
}
$results = $query->orderByDesc('is_featured')
@@ -3,19 +3,25 @@
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Services\CountryCatalogue;
use Illuminate\Database\Query\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class ReferenceCatalogController extends Controller
{
private const CATALOGS = ['authorities', 'jurisdictions', 'economic-activities'];
private const CATALOGS = ['authorities', 'jurisdictions', 'economic-activities', 'countries'];
public function index(Request $request, string $catalog): JsonResponse
{
abort_unless(in_array($catalog, self::CATALOGS, true), 404);
$catalog = $this->validatedCatalog($catalog);
if ($catalog === 'countries') {
return $this->countries($request);
}
$rules = [
'q' => ['nullable', 'string', 'max:100'],
@@ -32,10 +38,13 @@ class ReferenceCatalogController extends Controller
}
if ($catalog === 'economic-activities') {
$rules['sector_id'] = ['nullable', 'integer', 'exists:sectors,id'];
$rules['origin'] = ['nullable', 'in:portal,cae'];
}
$data = $request->validate($rules);
$versionQuery = DB::table('reference_catalog_versions')->where('catalog_code', $catalog)->where('locale', 'pt-MZ');
$cae = $catalog === 'economic-activities' && ($data['origin'] ?? 'portal') === 'cae';
$versionQuery = DB::table('reference_catalog_versions')->where('catalog_code', $cae ? 'economic-activities-cae' : $catalog)->where('locale', 'pt-MZ');
if ($cae && ! app(\App\Services\EconomicReferenceCatalog::class)->available()) { $versionQuery->whereRaw('false'); }
if (isset($data['version'])) {
$versionQuery->where('version_no', $data['version'])->whereIn('status', ['published', 'retired']);
} else {
@@ -71,7 +80,11 @@ class ReferenceCatalogController extends Controller
};
$page = $query->orderBy('code')->orderBy('id')->paginate($data['per_page'] ?? 20)->withQueryString();
$items = collect($page->items())->map(fn (object $row): array => $this->present($catalog, $row))->values();
$items = collect($page->items())->map(fn (object $row): array => $this->present($catalog, (array) $row))->values();
if ($cae) {
$paths = app(\App\Services\EconomicReferenceCatalog::class)->activities()->keyBy('id');
$items = $items->map(fn ($row) => [...$row, 'level' => $paths[$row['id']]->level, 'path' => $paths[$row['id']]->path, 'source_version' => $paths[$row['id']]->source_version, 'edition' => 'not_determined', 'divergence' => '600/579 documentalmente aberta']);
}
return response()->json([
'data' => $items,
@@ -89,6 +102,21 @@ class ReferenceCatalogController extends Controller
->header('ETag', 'W/"catalog-'.substr(hash('sha256', $catalog.'|'.$version->version_no.'|'.json_encode($data, JSON_THROW_ON_ERROR)), 0, 32).'"');
}
private function countries(Request $request): JsonResponse
{
$data = $request->validate(['q' => 'nullable|string|max:100', 'page' => 'nullable|integer|min:1', 'per_page' => 'nullable|integer|min:1|max:100', 'parent_id' => 'prohibited', 'version' => 'prohibited']);
$term = strtolower(Str::ascii(trim($data['q'] ?? '')));
$items = collect(app(CountryCatalogue::class)->all())
->filter(fn (array $country) => $term === '' || str_contains(strtolower(Str::ascii(implode(' ', $country))), $term))
->values()->map(fn (array $country) => ['id' => $country['code'], 'value' => $country['code'], 'code' => $country['code'], 'name' => $country['name'], 'label' => $country['name']]);
$page = (int) ($data['page'] ?? 1);
$perPage = (int) ($data['per_page'] ?? 50);
$result = new LengthAwarePaginator($items->forPage($page, $perPage)->values(), $items->count(), $perPage, $page, ['path' => $request->url(), 'query' => $request->query()]);
return response()->json([...$result->toArray(), 'meta' => ['catalog' => 'countries', 'source' => 'Local ISO 3166 catalogue', 'sha256' => hash_file('sha256', resource_path('data/iso3166.tab'))]])
->header('Cache-Control', 'public, max-age=300');
}
/** @param array<string,mixed> $data */
private function authorities(string $versionId, array $data): Builder
{
@@ -141,13 +169,15 @@ class ReferenceCatalogController extends Controller
->where('valid_from', '<=', today()->toDateString())
->where(fn (Builder $builder) => $builder->whereNull('valid_to')->orWhere('valid_to', '>', today()->toDateString()))
->select(['id', 'code', 'label_pt_mz as name', 'parent_id', 'sector_id', 'version_no']);
if (empty($data['version'])) $query->where('id', '!=', \App\Services\SectorOrganization::LEGACY_ACTIVITY_ID);
if (array_key_exists('parent_id', $data) && $data['parent_id'] !== null) {
$query->where('parent_id', $data['parent_id']);
} else {
$query->whereNull('parent_id');
}
if (! empty($data['sector_id'])) {
$query->where('sector_id', $data['sector_id']);
$sector = \App\Models\Sector::find($data['sector_id']);
$query->whereIn('sector_id', $sector ? \App\Models\Sector::whereIn('slug', \App\Services\SectorOrganization::slugs($sector->slug))->pluck('id') : []);
}
if (! empty($data['q'])) {
$this->search($query, $data['q'], ['code', 'label_pt_mz']);
@@ -156,32 +186,47 @@ class ReferenceCatalogController extends Controller
return $query;
}
/** @param list<string> $columns */
/** @param list<literal-string> $columns */
private function search(Builder $query, string $search, array $columns): void
{
$term = '%'.addcslashes($search, '%_\\').'%';
$query->where(function (Builder $nested) use ($columns, $term): void {
foreach ($columns as $index => $column) {
$method = $index === 0 ? 'whereRaw' : 'orWhereRaw';
$nested->{$method}($column.' ILIKE ?', [$term]);
if ($index === 0) {
$nested->whereRaw($column.' ILIKE ?', [$term]);
} else {
$nested->orWhereRaw($column.' ILIKE ?', [$term]);
}
}
});
}
/** @return array<string,mixed> */
private function present(string $catalog, object $row): array
/** @return 'authorities'|'jurisdictions'|'economic-activities'|'countries' */
private function validatedCatalog(string $catalog): string
{
abort_unless(in_array($catalog, self::CATALOGS, true), 404);
return $catalog;
}
/**
* @param 'authorities'|'jurisdictions'|'economic-activities' $catalog
* @param array<string, mixed> $row
* @return array<string,mixed>
*/
private function present(string $catalog, array $row): array
{
return match ($catalog) {
'jurisdictions' => ['id' => $row->id, 'value' => $row->id, 'code' => $row->code, 'name' => $row->name,
'label' => $row->name, 'locale' => 'pt-MZ', 'parent_id' => $row->parent_id, 'type' => $row->location_type,
'source_code' => $row->source_code, 'version_no' => $row->version_no],
'economic-activities' => ['id' => $row->id, 'value' => $row->code, 'code' => $row->code, 'name' => $row->name,
'label' => $row->name, 'locale' => 'pt-MZ', 'parent_id' => $row->parent_id, 'sector_id' => $row->sector_id,
'version_no' => $row->version_no],
'authorities' => ['id' => $row->id, 'value' => $row->code, 'code' => $row->code, 'name' => $row->name,
'label' => $row->name, 'locale' => 'pt-MZ', 'authority_type' => $row->authority_type,
'organization_id' => $row->organization_id, 'jurisdiction_id' => $row->jurisdiction_id,
'jurisdiction_name' => $row->jurisdiction_name, 'version_no' => $row->version_no],
'jurisdictions' => ['id' => $row['id'], 'value' => $row['id'], 'code' => $row['code'], 'name' => $row['name'],
'label' => $row['name'], 'locale' => 'pt-MZ', 'parent_id' => $row['parent_id'], 'type' => $row['location_type'],
'source_code' => $row['source_code'], 'version_no' => $row['version_no']],
'economic-activities' => ['id' => $row['id'], 'value' => $row['code'], 'code' => $row['code'], 'name' => $row['name'],
'label' => $row['name'], 'locale' => 'pt-MZ', 'parent_id' => $row['parent_id'], 'sector_id' => $row['sector_id'],
'version_no' => $row['version_no']],
'authorities' => ['id' => $row['id'], 'value' => $row['code'], 'code' => $row['code'], 'name' => $row['name'],
'label' => $row['name'], 'locale' => 'pt-MZ', 'authority_type' => $row['authority_type'],
'organization_id' => $row['organization_id'], 'jurisdiction_id' => $row['jurisdiction_id'],
'jurisdiction_name' => $row['jurisdiction_name'], 'version_no' => $row['version_no']],
};
}
}
@@ -10,22 +10,12 @@ class SectorController extends Controller
{
public function index(): JsonResponse
{
$sectors = Sector::withCount([
'opportunities as opportunities_count' => fn ($q) => $q->published(),
])
->where('is_active', true)
->orderBy('sort_order')
->get()
->map(fn ($s) => [
'id' => $s->id,
'name' => $s->name,
'slug' => $s->slug,
'shortDescription' => $s->short_description,
'description' => $s->description,
'icon' => $s->icon,
'opportunitiesCount' => $s->opportunities_count,
'url' => "/sectores/{$s->slug}",
]);
$sectors = \App\Services\SectorOrganization::catalog()->map(fn ($s) => [
'id' => $s->id, 'name' => $s->name, 'slug' => $s->slug,
'shortDescription' => $s->short_description, 'description' => $s->description,
'icon' => $s->icon, 'opportunitiesCount' => $s->opportunities_count,
'url' => $s->url, 'specializations' => $s->specializations,
]);
return response()->json(['success' => true, 'data' => $sectors]);
}
@@ -35,6 +25,7 @@ class SectorController extends Controller
$sector = Sector::with(['opportunities' => fn ($q) => $q->published()])
->where('slug', $slug)->firstOrFail();
return response()->json(['success' => true, 'data' => $sector]);
$sector->setRelation('opportunities', \App\Services\SectorOrganization::opportunities($sector)->get());
return response()->json(['success' => true, 'data' => $sector, 'canonical_url' => \App\Services\SectorOrganization::href($slug), 'specialization_of' => $slug === 'fintech' ? 'mercado-financeiro' : null]);
}
}
@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
use App\Http\Requests\StoreSimulationRequest;
use App\Http\Requests\UpdateSimulationScenarioRequest;
use App\Models\QuestionnaireVersion;
use App\Models\Simulation;
use App\Models\SimulationJob;
use App\Policies\SimulationPolicy;
use App\Services\SimulationExecution;
@@ -36,11 +37,17 @@ class SimulationController extends Controller
$token = $actorId === null ? $request->header('X-Simulation-Token') : null;
abort_if($actorId === null && (! is_string($token) || strlen($token) < 32 || strlen($token) > 256), 422, 'X-Simulation-Token opaco gerado pelo cliente é obrigatório para execução anónima.');
$validated = $request->validated();
$data = ['base' => (string) $validated['base'], 'rate' => (string) $validated['rate'],
'reduction' => (string) ($validated['reduction'] ?? '0'), 'organization_id' => $validated['organization_id'] ?? null];
$organizationId = $data['organization_id'] ?? null;
$organizationId = $validated['organization_id'] ?? null;
$execution->authorizeOrganization($actorId, $organizationId);
$result = $execution->create($data, $actorId, $organizationId, $token, $key);
if (($validated['scenario_kind'] ?? null) === 'fiscal_rule_published') {
$input = array_intersect_key($validated, array_flip(['base', 'currency', 'reference_date', 'operation', 'scope_declared', 'monthly_sales', 'sales_vat_basis']));
$input['scope_declared'] = (bool) $input['scope_declared'];
$result = $execution->createPublishedFiscal($input, $actorId, $organizationId, $token, $key);
} else {
$data = ['base' => (string) $validated['base'], 'rate' => (string) $validated['rate'],
'reduction' => (string) ($validated['reduction'] ?? '0')];
$result = $execution->create($data, $actorId, $organizationId, $token, $key);
}
$simulation = $result['simulation'];
$body = ['data' => $simulation, 'replayed' => $result['replayed']];
if ($actorId === null) {
@@ -65,6 +72,56 @@ class SimulationController extends Controller
return response()->json(['data' => $simulation])->header('ETag', $execution->etag($simulation));
}
public function history(Request $request, SimulationExecution $execution): JsonResponse
{
$user = $request->user();
abort_unless($user !== null, 401);
abort_unless($user->hasVerifiedEmail(), 403, 'Verifique o correio electrónico para consultar o histórico.');
$perPage = min(50, max(1, $request->integer('per_page', 20)));
$page = max(1, $request->integer('page', 1));
$history = DB::transaction(function () use ($execution, $user, $perPage, $page) {
$execution->setContext($user->id, null);
return Simulation::query()
->select([
'simulations.id', 'simulations.organization_id', 'visible_organization.name as organization_name',
'simulations.status', 'simulations.version_no', 'simulations.created_at', 'simulations.updated_at',
])
->leftJoin('organizations as visible_organization', 'visible_organization.id', '=', 'simulations.organization_id')
->where(function ($scope) use ($user) {
$scope->where(fn ($personal) => $personal->whereNull('simulations.organization_id')->where('simulations.owner_user_id', $user->id))
->orWhere(fn ($organization) => $organization->whereNotNull('simulations.organization_id')->whereExists(
fn ($membership) => $membership->selectRaw('1')->from('memberships')
->whereColumn('memberships.organization_id', 'simulations.organization_id')
->where('memberships.user_id', $user->id)->whereNull('memberships.deleted_at'),
));
})
->withExists(['reports as has_ready_report' => fn ($query) => $query->where('status', 'ready')])
->orderByDesc('simulations.created_at')->orderByDesc('simulations.id')
->paginate($perPage, ['*'], 'page', $page);
});
return response()->json([
'data' => $history->getCollection()->map(fn (Simulation $simulation): array => [
'id' => $simulation->id,
'scope' => $simulation->organization_id === null ? 'personal' : 'organization',
'organization_name' => $simulation->getAttribute('organization_name'),
'status' => $simulation->status,
'version_no' => $simulation->version_no,
'created_at' => $simulation->created_at?->toISOString(),
'updated_at' => $simulation->updated_at?->toISOString(),
'has_ready_report' => (bool) $simulation->getAttribute('has_ready_report'),
]),
'meta' => [
'current_page' => $history->currentPage(),
'last_page' => $history->lastPage(),
'per_page' => $history->perPage(),
'total' => $history->total(),
],
])->header('Cache-Control', 'no-store, private');
}
public function updateScenario(UpdateSimulationScenarioRequest $request, string $id, string $scenarioId, SimulationExecution $execution): JsonResponse
{
abort_unless(Str::isUuid($id) && Str::isUuid($scenarioId), 404);
@@ -3,11 +3,16 @@
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\AttachSimulationToDraftRequest;
use App\Http\Requests\ClaimSimulationFormDraftRequest;
use App\Http\Requests\LinkSimulationDraftBusinessRequest;
use App\Http\Requests\StoreSimulationFormDraftRequest;
use App\Models\SimulationFormDraft;
use App\Policies\SimulationPolicy;
use App\Services\SimulationExecution;
use App\Services\SimulationFormDrafts;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class SimulationFormDraftController extends Controller
@@ -16,13 +21,65 @@ class SimulationFormDraftController extends Controller
{
$key = $request->header('Idempotency-Key');
abort_unless(is_string($key) && strlen($key) >= 16 && strlen($key) <= 128, 428, 'Idempotency-Key é obrigatório.');
$result = $drafts->create($request->validated(), $request, $key);
$result = $drafts->create($request->snapshots(), $request, $key);
$draft = $result['draft'];
return response()->json(['data' => ['id' => $draft->id, 'version_no' => $draft->version_no, 'replayed' => $result['replayed']]], $result['replayed'] ? 200 : 201)
return response()->json(['data' => ['id' => $draft->id, 'version_no' => $draft->version_no, 'replayed' => $result['replayed']], 'expires_at' => $draft->expires_at?->toISOString()], $result['replayed'] ? 200 : 201)
->header('ETag', $drafts->etag($draft))->header('Cache-Control', 'no-store, private');
}
public function index(Request $request, SimulationFormDrafts $drafts): JsonResponse
{
abort_unless($request->user() !== null, 401);
return response()->json(['data' => $drafts->ownedDrafts($request->user())])->header('Cache-Control', 'no-store, private');
}
public function claimPreview(Request $request, SimulationFormDrafts $drafts): JsonResponse
{
abort_unless($request->user() !== null, 401);
return response()->json(['data' => $drafts->claimPreview($request)])->header('Cache-Control', 'no-store, private');
}
public function claim(ClaimSimulationFormDraftRequest $request, SimulationFormDrafts $drafts): JsonResponse
{
$result = $drafts->claim($request, $request->validated('choice'), $request->validated('existing_draft_id'));
$draft = $result['draft'];
return response()->json(['data' => $drafts->present($draft, true), 'choice' => $result['choice'], 'replayed' => $result['replayed'] ?? false, 'expires_at' => $draft->expires_at?->toISOString()])
->header('ETag', $drafts->etag($draft))->header('Cache-Control', 'no-store, private');
}
public function linkableBusinesses(Request $request, SimulationFormDrafts $drafts): JsonResponse
{
abort_unless($request->user()?->hasVerifiedEmail(), 403);
return response()->json(['data' => $drafts->linkableBusinesses($request->user())])->header('Cache-Control', 'no-store, private');
}
public function businessLink(Request $request, string $id, SimulationFormDrafts $drafts): JsonResponse
{
abort_unless(Str::isUuid($id) && $request->user()?->hasVerifiedEmail(), 404);
return response()->json(['data' => $drafts->activeBusinessLink($request->user(), $id)])->header('Cache-Control', 'no-store, private');
}
public function linkBusiness(LinkSimulationDraftBusinessRequest $request, string $id, SimulationFormDrafts $drafts): JsonResponse
{
abort_unless(Str::isUuid($id), 404);
$result = $drafts->linkBusiness($request->user(), $id, $request->validated('business_id'));
return response()->json(['data' => $result], isset($result['replayed']) ? 200 : 201)->header('Cache-Control', 'no-store, private');
}
public function unlinkBusiness(Request $request, string $id, SimulationFormDrafts $drafts): JsonResponse
{
abort_unless(Str::isUuid($id) && $request->user()?->hasVerifiedEmail(), 404);
return response()->json(['data' => $drafts->unlinkBusiness($request->user(), $id)])->header('Cache-Control', 'no-store, private');
}
public function current(Request $request, SimulationFormDrafts $drafts): JsonResponse
{
$draft = $drafts->current($request);
@@ -30,7 +87,14 @@ class SimulationFormDraftController extends Controller
return response()->json(['data' => null], 200)->header('Cache-Control', 'no-store, private');
}
return response()->json(['data' => $drafts->present($draft)])->header('ETag', $drafts->etag($draft))->header('Cache-Control', 'no-store, private');
return response()->json(['data' => $drafts->present($draft, $request->user() !== null)])->header('ETag', $drafts->etag($draft))->header('Cache-Control', 'no-store, private');
}
public function forgetCurrent(Request $request, SimulationFormDrafts $drafts): JsonResponse
{
$drafts->forgetCurrent($request);
return response()->json([], 204)->header('Cache-Control', 'no-store, private');
}
public function show(Request $request, string $id, SimulationFormDrafts $drafts): JsonResponse
@@ -38,18 +102,82 @@ class SimulationFormDraftController extends Controller
abort_unless(Str::isUuid($id), 404);
$draft = $drafts->find($id, $request);
return response()->json(['data' => $drafts->present($draft)])->header('ETag', $drafts->etag($draft))->header('Cache-Control', 'no-store, private');
return response()->json(['data' => $drafts->present($draft, $request->user() !== null)])->header('ETag', $drafts->etag($draft))->header('Cache-Control', 'no-store, private');
}
public function update(StoreSimulationFormDraftRequest $request, string $id, SimulationFormDrafts $drafts): JsonResponse
{
abort_unless(Str::isUuid($id), 404);
$draft = $drafts->update($id, $request->validated(), $request);
$draft = $drafts->update($id, $request->snapshots(), $request);
return response()->json(['data' => ['id' => $draft->id, 'version_no' => $draft->version_no]])
return response()->json(['data' => ['id' => $draft->id, 'version_no' => $draft->version_no], 'expires_at' => $draft->expires_at?->toISOString()])
->header('ETag', $drafts->etag($draft))->header('Cache-Control', 'no-store, private');
}
public function renew(Request $request, string $id, SimulationFormDrafts $drafts): JsonResponse
{
abort_unless(Str::isUuid($id), 404);
$draft = $drafts->renew($id, $request);
return response()->json(['data' => ['id' => $draft->id, 'version_no' => $draft->version_no], 'expires_at' => $draft->expires_at?->toISOString()])
->header('ETag', $drafts->etag($draft))->header('Cache-Control', 'no-store, private');
}
public function attachSimulation(AttachSimulationToDraftRequest $request, string $id, SimulationFormDrafts $drafts, SimulationExecution $execution): JsonResponse
{
abort_unless(Str::isUuid($id), 404);
$validated = $request->validated();
$simulation = $execution->find($validated['simulation_id'], $request);
$tokenOwned = $simulation->anonymous_token_hash !== null
&& hash_equals($simulation->anonymous_token_hash, (string) $execution->tokenHash($request));
if ($request->user()) {
$policy = app(SimulationPolicy::class);
$canTransfer = $simulation->organization_id === null
? $policy->view($request->user(), $simulation)
: $policy->update($request->user(), $simulation);
abort_unless($canTransfer || $tokenOwned, 404);
} else {
abort_unless($tokenOwned, 404);
}
abort_unless($simulation->status === 'completed' && is_array($simulation->result_snapshot), 409, 'Só é possível reutilizar uma simulação concluída.');
$report = $simulation->reports->where('status', 'ready')->sortByDesc('created_at')->first();
abort_unless($report !== null && is_array($report->report_snapshot), 409, 'O relatório versionado da simulação ainda não está disponível.');
abort_unless(preg_match('/^[a-f0-9]{64}$/D', $report->report_sha256) === 1, 409,
'O relatório da simulação não tem um hash verificável.');
$canonicalSnapshots = DB::selectOne('select CAST(? AS jsonb)::text as report_canonical, CAST(? AS jsonb)::text as result_canonical', [
json_encode($report->report_snapshot, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
json_encode($simulation->result_snapshot, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
]);
abort_unless(hash_equals($report->report_sha256, hash('sha256', $canonicalSnapshots->report_canonical))
&& hash_equals($canonicalSnapshots->report_canonical, $canonicalSnapshots->result_canonical), 409,
'O relatório não corresponde ao resultado versionado da simulação.');
$inputSnapshot = array_intersect_key($simulation->input_snapshot, array_flip(['base', 'rate', 'reduction']));
$sourceSnapshot = [
'simulation_id' => $simulation->id,
'source_version_no' => $simulation->version_no,
'source_created_at' => $simulation->created_at?->toISOString(),
'source_updated_at' => $simulation->updated_at?->toISOString(),
'input_snapshot' => $inputSnapshot,
'calculation_snapshot' => $simulation->calculation_snapshot,
'result_snapshot' => $simulation->result_snapshot,
'result_sha256' => $report->report_sha256,
'report_id' => $report->id,
'report_version_no' => $report->version_no,
'report_sha256' => $report->report_sha256,
'report_snapshot' => $report->report_snapshot,
'consented_at' => now()->toISOString(),
];
$attached = $drafts->attachSimulation($id, $sourceSnapshot, $request);
$draft = $attached['draft'];
return response()->json([
'data' => $drafts->present($draft, $request->user() !== null),
'replayed' => $attached['replayed'],
'expires_at' => $draft->expires_at?->toISOString(),
])->header('ETag', $drafts->etag($draft))->header('Cache-Control', 'no-store, private');
}
public function destroy(Request $request, string $id, SimulationFormDrafts $drafts): JsonResponse
{
abort_unless(Str::isUuid($id), 404);
@@ -0,0 +1,70 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Jobs\ScanDocumentForMalware;
use App\Services\SimulationFormDrafts;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class SimulationOfferDocumentController extends Controller
{
public function store(Request $request, string $id, SimulationFormDrafts $drafts)
{
abort_unless(Str::isUuid($id), 404);
$drafts->find($id, $request);
// Same private document infrastructure and technical file limit as investment attachments.
$request->validate(['file' => ['required', 'file', 'mimetypes:application/pdf,text/plain,image/png,image/jpeg', 'max:10240']]);
$file = $request->file('file');
$hash = hash_file('sha256', $file->getRealPath());
$existing = DB::table('documents')->where('source_system', 'simulation-fiscal-offer')->where('external_id', $id)->where('sha256', $hash)->whereNull('deleted_at')->first();
if ($existing) {
return response()->json(['data' => $this->metadata($id, $existing)], 200)->header('Cache-Control', 'private, no-store');
}
$documentId = (string) Str::uuid();
$key = $file->storeAs('simulation-offers/'.$id, $documentId, 'local');
abort_unless(is_string($key), 500);
try {
DB::table('documents')->insert(['id' => $documentId, 'source_system' => 'simulation-fiscal-offer', 'external_id' => $id,
'file_name' => basename($file->getClientOriginalName()), 'media_type' => $file->getMimeType(), 'storage_key' => $key,
'sha256' => $hash, 'size_bytes' => $file->getSize(), 'uploaded_by' => $request->user()->id,
'status' => 'quarantined', 'data_classification' => 'confidential']);
} catch (\Throwable $e) {
Storage::disk('local')->delete($key);
throw $e;
}
ScanDocumentForMalware::dispatch($documentId)->afterCommit();
return response()->json(['data' => $this->metadata($id, DB::table('documents')->where('id', $documentId)->first())], 201)->header('Cache-Control', 'private, no-store');
}
public function index(Request $request, string $id, SimulationFormDrafts $drafts)
{
abort_unless(Str::isUuid($id), 404);
$drafts->find($id, $request);
$documents = DB::table('documents')->where('source_system', 'simulation-fiscal-offer')->where('external_id', $id)->whereNull('deleted_at')->get();
return response()->json(['data' => $documents->map(fn ($doc) => $this->metadata($id, $doc))])->header('Cache-Control', 'private, no-store');
}
public function download(Request $request, string $id, string $document, SimulationFormDrafts $drafts)
{
abort_unless(Str::isUuid($id) && Str::isUuid($document), 404);
$drafts->find($id, $request);
$doc = DB::table('documents')->where('id', $document)->where('source_system', 'simulation-fiscal-offer')->where('external_id', $id)->whereNull('deleted_at')->first();
abort_unless($doc, 404);
abort_unless($doc->status === 'active' && $doc->malware_scan_status === 'clean' && hash_equals(trim($doc->sha256), (string) $doc->malware_scanned_sha256), 409, 'Anexo conservado; descarga disponível após análise antimalware concluída.');
$root = realpath(Storage::disk('local')->path('simulation-offers/'.$id));
$path = realpath(Storage::disk('local')->path($doc->storage_key));
abort_unless($root && $path && str_starts_with(strtolower($path), strtolower($root.DIRECTORY_SEPARATOR)) && is_file($path), 404);
abort_unless(hash_equals(trim($doc->sha256), hash_file('sha256', $path)), 409, 'Conteúdo do anexo diverge do hash registado.');
return response()->download($path, basename($doc->file_name), ['Cache-Control' => 'private, no-store', 'X-Content-Type-Options' => 'nosniff']);
}
private function metadata(string $id, object $doc): array
{
return ['id' => $doc->id, 'draftId' => $id, 'name' => $doc->file_name, 'sha256' => trim($doc->sha256),
'scan' => $doc->malware_scan_status, 'url' => '/api/v1/simulation-drafts/'.$id.'/offer-documents/'.$doc->id];
}
}
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\ResolveSimulationTaxParametersRequest;
use App\Services\SimulationTaxParameterResolver;
use Illuminate\Http\JsonResponse;
class SimulationTaxParameterController extends Controller
{
public function resolve(ResolveSimulationTaxParametersRequest $request, SimulationTaxParameterResolver $resolver): JsonResponse
{
$input = $request->validated();
$data = $resolver->resolvePublishedForPeriod(
$input['component'],
$input['period_start'],
$input['period_end'],
$input['context'] ?? [],
$input['component'] === 'IVA',
);
return response()->json([
'component' => $input['component'],
'period' => ['start' => $input['period_start'], 'end' => $input['period_end']],
'assumptions' => $input['context'] ?? [],
'parameters' => $input['component'] === 'IVA' ? $data['parameters'] : $data,
'diagnostics' => $input['component'] === 'IVA' ? $data['diagnostics'] : [],
])->header('Cache-Control', 'no-store');
}
}
@@ -0,0 +1,73 @@
<?php
namespace App\Http\Controllers;
use App\Models\Opportunity;
use App\Services\CorridorAgribusinessOpportunities;
use App\Services\OpportunityManagement;
use App\Services\OpportunityPromotion;
use App\Services\PortalProfileAccess;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
final class CorridorAgribusinessController extends Controller
{
private function manifest(CorridorAgribusinessOpportunities $service): string
{
return hash('sha256', json_encode($service->entries(), JSON_THROW_ON_ERROR));
}
public function show(Request $request, CorridorAgribusinessOpportunities $service)
{
$access = app(PortalProfileAccess::class);
abort_unless($access->internalAssignment($request->user(), 'investment_project') || $access->has($request->user(), 'publisher'), 403);
$records = Opportunity::withTrashed()->whereIn('management_creation_key', array_column($service->entries(), 'key'))->get()->keyBy('management_creation_key');
foreach ($records as $record) abort_unless($access->canView($request->user(), $record), 403);
return Inertia::render('management/corridor-agribusiness', ['manifestHash' => $this->manifest($service),
'canRegister' => (bool) $access->internalAssignment($request->user(), 'investment_project'),
'entries' => array_map(function ($entry) use ($records) {
$r = $records->get($entry['key']);
return [...$entry, 'id' => $r?->id, 'status' => $r?->status, 'href' => $r ? '/oportunidades/'.$r->slug : null];
}, $service->entries())]);
}
public function register(Request $request, CorridorAgribusinessOpportunities $service)
{
$data = $request->validate(['manifest_hash' => 'required|string|size:64', 'confirm' => 'required|accepted']);
abort_unless(hash_equals($this->manifest($service), $data['manifest_hash']), 409, 'A fonte mudou; recupere a prévia.');
$access = app(PortalProfileAccess::class);
$grant = $access->internalAssignment($request->user(), 'investment_project');
abort_unless($grant && $access->mfa($request->user()), 403, 'Use a designação existente e MFA recente.');
return response()->json(['records' => $service->register($request->user(), $grant->organization_id)]);
}
public function preview(Request $request, CorridorAgribusinessOpportunities $service)
{
$previews = [];
foreach ($service->entries() as $entry) {
$record = Opportunity::where('management_creation_key', $entry['key'])->firstOrFail();
$input = data_get($record->workflow_context, 'promotional_summary.input');
abort_unless(is_array($input), 409, 'Complete a selecção pública na divulgação individual.');
$previews[] = ['id' => $record->id, ...app(OpportunityPromotion::class)->preview($request->user(), $record,
[...$input, 'source_date' => $input['source_date'] ?: null, 'action' => 'disclose', 'etag' => app(OpportunityManagement::class)->etag($record)])];
}
return response()->json(['previews' => $previews]);
}
public function disclose(Request $request, CorridorAgribusinessOpportunities $service)
{
$data = $request->validate(['confirm' => 'required|accepted', 'reason' => 'required|string|min:20|max:1000',
'previews' => 'required|array|size:34', 'previews.*.id' => 'required|integer|distinct',
'previews.*.token' => 'required|string|max:300000', 'previews.*.operation_key' => 'required|uuid|distinct']);
$expected = Opportunity::whereIn('management_creation_key', array_column($service->entries(), 'key'))->pluck('id')->sort()->values()->all();
$supplied = array_column($data['previews'], 'id'); sort($supplied);
abort_unless($expected === $supplied && count($expected) === 34, 409, 'A prévia deve identificar exactamente as 34 fichas deste lote.');
return response()->json(DB::transaction(function () use ($request, $data) {
$result = [];
foreach ($data['previews'] as $preview) $result[] = app(OpportunityPromotion::class)->confirm($request->user(), $preview['id'],
[...$preview, 'confirm' => true, 'reason' => $data['reason']]);
return ['records' => $result];
}));
}
}
+7 -2
View File
@@ -9,6 +9,8 @@ use App\Models\Investor;
use App\Models\Lead;
use App\Models\User;
use App\Services\ConvertInterestToLead;
use App\Services\CountryCatalogue;
use App\Services\DeclaredOriginStatistics;
use App\Services\PortalAudit;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\RedirectResponse;
@@ -77,7 +79,8 @@ class CrmController extends Controller
{
$filters = $request->validate(['q' => ['nullable', 'string', 'max:150'],
'status' => ['nullable', Rule::in(['new', 'qualified', 'disqualified', 'converted'])],
'owner_user_id' => ['nullable', 'integer', 'exists:users,id']]);
'owner_user_id' => ['nullable', 'integer', 'exists:users,id'],
'origin' => ['nullable', Rule::in(['unknown', ...array_column(app(CountryCatalogue::class)->all(), 'code')])]]);
$query = Lead::with(['investor:id,legal_name', 'owner:id,name'])
->withCount(['tasks as overdue_tasks_count' => fn ($q) => $q->whereIn('status', ['open', 'in_progress'])->where('due_at', '<', now())]);
if ($term = $filters['q'] ?? null) {
@@ -92,7 +95,9 @@ class CrmController extends Controller
}
return Inertia::render('crm/leads', ['leads' => $query->latest()->orderBy('id')->paginate(20)->withQueryString(),
'filters' => $filters, 'owners' => $this->owners()]);
'filters' => $filters, 'owners' => $this->owners(),
'countries' => app(CountryCatalogue::class)->all(),
'originStatistics' => app(DeclaredOriginStatistics::class)->forManager($request->user(), $filters['origin'] ?? null)]);
}
public function prepare(InterestRequest $interest): Response|RedirectResponse
+20 -3
View File
@@ -5,7 +5,10 @@ namespace App\Http\Controllers;
use App\Models\InterestRequest;
use App\Models\Opportunity;
use App\Models\Sector;
use App\Services\InvestorPath;
use App\Services\PortalProfileAccess;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Schema;
use Inertia\Inertia;
use Inertia\Response;
@@ -14,18 +17,32 @@ class DashboardController extends Controller
public function __invoke(Request $request): Response
{
if (! $request->user()->can('manage-portal')) {
return Inertia::render('dashboard', ['summary' => null]);
$path = app(InvestorPath::class);
$contexts = $path->contexts($request->user());
$current = $contexts ? $path->view($request->user(), $contexts[0]['id']) : null;
return Inertia::render('dashboard', ['summary' => null, 'investorPath' => $path->available() ? [
'contexts' => $contexts,
'current' => $current ? ['id' => $current['case']['id'], 'next' => $current['next'],
'pending' => count(array_filter($current['steps'], fn ($step) => ! in_array($step['state'], ['completed', 'approved', 'dispensed'], true))),
'documents' => count($current['documents']), 'results' => count($current['results']), 'tasks' => count($current['tasks'])] : null,
] : null]);
}
$counts = InterestRequest::query()->selectRaw('status, count(*) as total')
->groupBy('status')->pluck('total', 'status');
$drafts = Opportunity::where('status', 'draft');
if (Schema::hasColumn('opportunities', 'owner_user_id')) {
$drafts = app(PortalProfileAccess::class)->scope($drafts, $request->user());
}
return Inertia::render('dashboard', ['summary' => [
'canCreateOpportunity' => app(PortalProfileAccess::class)->legacyWriteAllowed($request->user()),
'requestsByStatus' => collect(['received', 'in_review', 'contacted', 'closed'])
->mapWithKeys(fn ($status) => [$status => (int) ($counts[$status] ?? 0)]),
'publishedOpportunities' => Opportunity::published()->count(),
'draftOpportunities' => Opportunity::where('status', 'draft')->count(),
'activeSectors' => Sector::where('is_active', true)->count(),
'draftOpportunities' => $drafts->count(),
'activeSectors' => Sector::catalog()->count(),
'recentRequests' => InterestRequest::orderByDesc('id')->limit(5)
->get(['id', 'reference', 'sector_interest', 'status', 'created_at']),
]]);
@@ -0,0 +1,28 @@
<?php
namespace App\Http\Controllers;
use App\Jobs\ScanDocumentForMalware;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class DocumentMalwareController extends Controller
{
public function retry(Request $request, string $id): RedirectResponse
{
DB::transaction(function () use ($id, $request): void {
$document = app(\App\Services\DocumentManagementAccess::class)->documents($request->user())->where('id', $id)->lockForUpdate()->first();
abort_unless($document && $document->status !== 'deleted' && $document->deleted_at === null, 404);
abort_unless(in_array($document->malware_scan_status, ['pending', 'inconclusive', 'failed'], true), 422,
'Só é possível repetir uma análise pendente, inconclusiva ou falhada. Para substituir um ficheiro, carregue uma nova versão.');
DB::table('documents')->where('id', $id)->update([
'status' => 'quarantined', 'malware_scan_status' => 'pending', 'malware_failure_reason' => null, 'updated_at' => now(),
]);
ScanDocumentForMalware::dispatch($id)->afterCommit();
});
return back()->with('success', 'Nova análise colocada na fila. O ficheiro permanece privado até haver resultado.');
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers;
use App\Services\EconomicReferenceInstallation;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class EconomicReferenceController extends Controller
{
public function preview(Request $request, EconomicReferenceInstallation $service): JsonResponse|Response
{
$plan = $service->preview($request->user());
return $request->expectsJson() ? response()->json(['plan' => $plan]) : Inertia::render('management/economic-references', ['plan' => $plan]);
}
public function apply(Request $request, string $operation, EconomicReferenceInstallation $service): JsonResponse
{
return response()->json(['receipt' => $service->apply($request->user(), $operation, $request->all())]);
}
}
@@ -0,0 +1,162 @@
<?php
namespace App\Http\Controllers;
use App\Services\VerifiedDocumentBinary;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
class EditorialArticleController extends Controller
{
public function installation(Request $request, \App\Services\EditorialInstallation $installation)
{
$plan = $installation->preview($request->user());
return $request->expectsJson() ? response()->json(['plan' => $plan])->header('Cache-Control', 'private, no-store')
: response()->view('management.editorial-installation', ['plan' => $plan, 'operationKey' => (string) \Illuminate\Support\Str::uuid(), 'receipt' => null])->header('Cache-Control', 'private, no-store');
}
public function install(Request $request, \App\Services\EditorialInstallation $installation)
{
$receipt = $installation->apply($request->user(), $request->all());
return $request->expectsJson() ? response()->json(['receipt' => $receipt])->header('Cache-Control', 'private, no-store')
: response()->view('management.editorial-installation', ['plan' => $installation->preview($request->user()), 'operationKey' => (string) \Illuminate\Support\Str::uuid(), 'receipt' => $receipt])->header('Cache-Control', 'private, no-store');
}
public function showMfa(Request $request)
{
return $this->confirmMfa($request);
}
public function confirmMfa(Request $request)
{
$actor = $request->user();
abort_unless($actor->two_factor_secret !== null && $actor->two_factor_confirmed_at !== null, 403, 'MFA configurado e confirmado necessário.');
if (app(\App\Services\PortalProfileAccess::class)->mfa($actor)) {
return redirect()->route('editorial.installation');
}
if ($request->isMethod('get')) {
return response()->view('management.editorial-mfa')->header('Cache-Control', 'private, no-store');
}
abort_if(array_diff(array_keys($request->all()), ['code', 'recovery_code', '_token']), 422, 'A conta e o destino são obtidos da sessão e do servidor.');
$data = $request->validate(['code' => 'nullable|string|max:32', 'recovery_code' => 'nullable|string|max:255']);
try {
$recovery = collect($actor->recoveryCodes())->first(fn ($code) => isset($data['recovery_code']) && hash_equals($code, $data['recovery_code']));
$valid = $recovery !== null || (!empty($data['code']) && app(\Laravel\Fortify\Contracts\TwoFactorAuthenticationProvider::class)->verify(
\Laravel\Fortify\Fortify::currentEncrypter()->decrypt($actor->two_factor_secret), $data['code']
));
} catch (\Illuminate\Contracts\Encryption\DecryptException $e) {
$valid = false;
}
if (!$valid) {
event(new \Laravel\Fortify\Events\TwoFactorAuthenticationFailed($actor));
throw ValidationException::withMessages(['code' => 'Não foi possível confirmar o código MFA.']);
}
if ($recovery !== null) {
$actor->replaceRecoveryCode($recovery);
}
event(new \Laravel\Fortify\Events\ValidTwoFactorAuthenticationCodeProvided($actor));
$request->session()->regenerate();
return redirect()->route('editorial.installation')->header('Cache-Control', 'private, no-store');
}
private function installed(): void
{
abort_unless(Schema::hasColumn('portal_resources', 'editorial_content'), 503, 'Editor editorial ainda não instalado.');
}
private function publicResources()
{
return DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')
->where('r.published', true)->where('r.rights_status', 'authorized')->where('d.rights_status', 'authorized')
->where('d.status', 'active')->where('d.data_classification', 'public')->whereNull('d.deleted_at')
->where('d.malware_scan_status', 'clean')->whereColumn('d.sha256', 'd.malware_scanned_sha256');
}
public function edit(int $id)
{
$this->installed();
app(\App\Services\DocumentManagementAccess::class)->assertEditableResource(request()->user(), $id);
$r = DB::table('portal_resources')->find($id);
abort_unless($r, 404);
$reading = app(\App\Services\EditorialReading::class);
$original = $reading->original($r);
$content = $reading->content($r, $original);
return Inertia::render('portal/article-editor', ['resource' => ['id' => $r->id, 'title' => $r->title, 'metadata_version' => $r->metadata_version, 'published' => (bool) $r->published],
'content' => $content, 'source' => $original ? array_intersect_key($original, array_flip(['source_url', 'published_at', 'modified_at', 'legacy_id', 'image_source_url'])) : null, 'images' => $this->publicResources()->whereIn('d.media_type', ['image/png', 'image/jpeg', 'image/webp'])->get(['r.id', 'r.title']),
'revisions' => DB::table('portal_resource_revisions')->where('resource_id', $id)->orderByDesc('revision_no')->get(['revision_no', 'change_type', 'created_at'])])->toResponse(request())->header('Cache-Control', 'private, no-store');
}
public function save(Request $request, int $id)
{
$this->installed();
$data = $this->validateContent($request);
DB::transaction(function () use ($request, $id, $data) {
$r = DB::table('portal_resources')->where('id', $id)->lockForUpdate()->first();
abort_unless($r, 404);
app(\App\Services\DocumentManagementAccess::class)->assertEditableResource($request->user(), $id);
abort_unless((int) $r->metadata_version === (int) $data['metadata_version'], 409, 'O artigo foi alterado noutra sessão. Recarregue antes de editar.');
if (! empty($data['image_resource_id'])) {
abort_unless($this->publicResources()->where('r.id', $data['image_resource_id'])->whereIn('d.media_type', ['image/png', 'image/jpeg', 'image/webp'])->exists(), 422, 'Escolha uma imagem pública disponível na biblioteca.');
}
$body = trim($data['body']);
if ($body === '') {
throw ValidationException::withMessages(['body' => 'Escreva o corpo do artigo.']);
}
$content = $this->edition($r, $data);
$revision = (int) $r->metadata_version + 1;
DB::table('portal_resources')->where('id', $id)->update(['editorial_content' => json_encode($content, JSON_THROW_ON_ERROR), 'published' => false, 'approved_by' => null, 'metadata_version' => $revision, 'last_reviewed_by' => $request->user()->id, 'last_reviewed_at' => now(), 'updated_at' => now()]);
$resource = DB::table('portal_resources')->find($id);
$links = DB::table('portal_resource_links')->where('resource_id', $id)->orderBy('id')->get();
DB::table('portal_resource_revisions')->insert(['resource_id' => $id, 'revision_no' => $revision, 'change_type' => 'metadata_updated', 'snapshot' => json_encode(['resource' => $resource, 'associations' => $links], JSON_THROW_ON_ERROR), 'actor_user_id' => $request->user()->id, 'created_at' => now()]);
DB::table('audit_events')->insert(['source_system' => 'portal-management', 'actor_user_id' => $request->user()->id, 'entity_type' => 'portal_resources', 'entity_id' => null, 'action' => 'editorial_updated', 'before_data' => json_encode(['record_id' => $id, 'metadata_version' => $r->metadata_version]), 'after_data' => json_encode(['record_id' => $id, 'metadata_version' => $revision, 'published' => false])]);
});
return redirect('/management/portal-services/resources/'.$id.'/article')->with('success', 'Artigo guardado como rascunho. Publique expressamente no acervo quando estiver pronto.');
}
private function validateContent(Request $request): array
{
return $request->validate(['body' => ['required', 'string', 'max:100000'], 'image_resource_id' => ['nullable', 'integer'], 'image_mode' => ['nullable', 'in:original,selected,none'], 'metadata_version' => ['required', 'integer', 'min:1']]);
}
private function edition(object $resource, array $data): array
{
$reading = app(\App\Services\EditorialReading::class);
$original = $reading->original($resource);
$mode = $data['image_mode'] ?? (!empty($data['image_resource_id']) ? 'selected' : 'none');
if ($mode === 'selected') abort_unless(!empty($data['image_resource_id']) && $reading->publicResources()->where('r.id', $data['image_resource_id'])->whereIn('d.media_type', ['image/png', 'image/jpeg', 'image/webp'])->exists(), 422, 'Escolha uma imagem pública disponível na biblioteca.');
return ['format' => 1, 'body' => trim($data['body']), 'image_mode' => $mode, 'image_resource_id' => $mode === 'selected' ? (int)$data['image_resource_id'] : null,
'preserve_original_html' => $original && trim($data['body']) === $reading->plain($original['content_html'])];
}
public function preview(Request $request, int $id)
{
$this->installed();
app(\App\Services\DocumentManagementAccess::class)->assertEditableResource($request->user(), $id);
$resource = DB::table('portal_resources')->find($id); abort_unless($resource, 404);
return Inertia::render('portal/article', ['article' => app(\App\Services\EditorialReading::class)->reading($resource, null, true)])
->toResponse($request)->header('Cache-Control', 'private, no-store')->header('X-Robots-Tag', 'noindex, nofollow');
}
public function previewContent(Request $request, int $id)
{
$this->installed();
app(\App\Services\DocumentManagementAccess::class)->assertEditableResource($request->user(), $id);
$resource = DB::table('portal_resources')->find($id); abort_unless($resource, 404);
$data = $this->validateContent($request);
abort_unless((int)$resource->metadata_version === (int)$data['metadata_version'], 409, 'A revisão mudou; recupere antes de rever a prévia.');
return response()->json(['article' => app(\App\Services\EditorialReading::class)->reading($resource, $this->edition($resource, $data), true)])
->header('Cache-Control', 'private, no-store')->header('X-Robots-Tag', 'noindex, nofollow');
}
public function show(Request $request, int $id)
{
$this->installed();
$resource = app(\App\Services\EditorialReading::class)->publicResources()->where('r.id', $id)->first(['r.*']);
abort_unless($resource, 404);
return Inertia::render('portal/article', ['article' => app(\App\Services\EditorialReading::class)->reading($resource)])->toResponse($request)->header('Cache-Control', 'private, no-store');
}
}
+24 -6
View File
@@ -12,6 +12,7 @@ use App\Models\Skill;
use App\Models\TrainingCapacity;
use App\Models\TrainingProvider;
use App\Services\EducationGapCalculator;
use App\Services\InvestmentGeography;
use App\Services\PortalAudit;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -28,17 +29,22 @@ class EducationController extends Controller
'q' => ['nullable', 'string', 'max:150'],
'status' => ['nullable', Rule::in(['submitted', 'reviewed', 'rejected', 'expired'])],
'geo_location_id' => ['nullable', 'uuid', Rule::exists('geo_locations', 'id')->whereNull('deleted_at')],
'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid'],
'demand_page' => ['nullable', 'integer', 'min:1'],
'capacity_page' => ['nullable', 'integer', 'min:1'],
]);
$demands = LabourDemand::query();
$capacities = TrainingCapacity::query();
$territoryIds = [];
foreach ([$demands, $capacities] as $query) {
if ($term = trim($filters['q'] ?? '')) {
$query->whereLike('qualification', '%'.addcslashes($term, '%_\\').'%');
}
if ($territory = $filters['geo_location_id'] ?? null) {
$query->where('geo_location_id', $territory);
$path = app(InvestmentGeography::class)->selection($filters);
$territory = $filters['geo_location_id'] ?? ($path !== [] ? $path[array_key_last($path)]->id : null);
if ($territory) {
$territoryIds = app(InvestmentGeography::class)->descendants($territory, includeNonPublic: true);
$query->whereIn('geo_location_id', $territoryIds);
}
if (($filters['status'] ?? null) === 'expired') {
$query->where('valid_until', '<', now()->toDateString());
@@ -47,6 +53,17 @@ class EducationController extends Controller
}
}
$territorialCapacities = clone $capacities;
$providers = TrainingProvider::where('status', '!=', 'inactive');
$courses = Course::where('status', '!=', 'retired')
->whereIn('provider_id', TrainingProvider::where('status', '!=', 'inactive')->select('id'));
if ($territoryIds !== []) {
// Provider and course location is evidenced by their submitted training capacity.
// Do not infer locations from provider names, addresses, or unrelated records.
$courses->whereIn('id', $territorialCapacities->select('course_id'));
$providers->whereIn('id', (clone $courses)->select('provider_id'));
}
return Inertia::render('education/index', [
'demands' => $demands->latest()->orderBy('id')->paginate(20, ['*'], 'demand_page')->withQueryString(),
'capacities' => $capacities->latest()->orderBy('id')->paginate(20, ['*'], 'capacity_page')->withQueryString(),
@@ -59,8 +76,8 @@ class EducationController extends Controller
],
'skills' => Skill::orderBy('name')->get(['id', 'code', 'name']),
'occupations' => Occupation::orderBy('name')->get(['id', 'code', 'name']),
'providers' => TrainingProvider::where('status', '!=', 'inactive')->orderBy('name')->get(['id', 'name']),
'courses' => Course::where('status', '!=', 'retired')->whereIn('provider_id', TrainingProvider::where('status', '!=', 'inactive')->select('id'))->orderBy('title')->get(['id', 'title', 'provider_id']),
'providers' => $providers->orderBy('name')->get(['id', 'name']),
'courses' => $courses->orderBy('title')->get(['id', 'title', 'provider_id']),
'projects' => Project::orderBy('name')->get(['id', 'code', 'name']),
'territories' => DB::table('geo_locations')->whereNull('deleted_at')
->whereIn('location_type', ['province', 'district', 'admin_post', 'locality'])
@@ -98,7 +115,8 @@ class EducationController extends Controller
$data = $request->validate([
'occupation_id' => ['required', 'uuid', Rule::exists('occupations', 'id')->whereNull('deleted_at')],
'skill_id' => ['required', 'uuid', Rule::exists('skills', 'id')->whereNull('deleted_at')],
'geo_location_id' => ['required', 'uuid', Rule::exists('geo_locations', 'id')->whereNull('deleted_at')],
'geo_location_id' => ['required', 'uuid', Rule::exists('geo_locations', 'id')->whereNull('deleted_at')
->whereIn('location_type', ['province', 'district', 'admin_post', 'locality'])],
$demand ? 'project_id' : 'course_id' => ['required', 'uuid', $demand
? Rule::exists('projects', 'id')->whereNull('deleted_at')
: Rule::exists('courses', 'id')->whereNull('deleted_at')->whereNot('status', 'retired')
@@ -149,7 +167,7 @@ class EducationController extends Controller
return Inertia::render('education/analysis', ['demand' => $demand, 'candidates' => $candidates,
'proposal' => EducationInvestmentProposal::where('labour_demand_id', $id)->first(),
'sectors' => Sector::orderBy('name')->get(['id', 'name']),
'sectors' => Sector::choices()->orderBy('name')->get(['id', 'name', 'slug']),
'territoryPath' => app(InvestmentGeography::class)->path($demand->getAttribute('geo_location_id'), true),
'eligible' => $eligible, 'gap' => $calculator->observed((int) $demand->getAttribute('quantity'), null, $eligible, $current)]);
}
@@ -12,6 +12,7 @@ use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use LogicException;
class EducationInvestmentController extends Controller
{
@@ -44,9 +45,13 @@ class EducationInvestmentController extends Controller
return;
}
$territory = app(InvestmentGeography::class)->selection($data, true);
$lastTerritoryKey = array_key_last($territory);
if ($lastTerritoryKey === null) {
throw new LogicException('A proposta educativa exige uma localização territorial.');
}
$province = $territory[0];
$district = $territory[1] ?? null;
$primaryTerritory = $territory[array_key_last($territory)];
$primaryTerritory = $territory[$lastTerritoryKey];
$reference = 'EDU-'.Str::uuid();
$opportunity = new Opportunity;
PortalAudit::save($opportunity, [
@@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers;
use App\Services\ExistingBusinessClassification;
use App\Services\Mne\BusinessRepository;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class ExistingBusinessClassificationController extends Controller
{
public function show(Request $request, string $id, ExistingBusinessClassification $service): Response|JsonResponse
{
$props = ['supplement' => $service->privateView($request->user(), app(BusinessRepository::class)->find($id))];
return $request->expectsJson() ? response()->json($props) : Inertia::render('investment/business-classification', $props);
}
public function act(Request $request, string $id, ExistingBusinessClassification $service): JsonResponse
{
$business = $service->act($request->user(), $id, $request->all());
return response()->json(['supplement' => $service->privateView($request->user(), $business)]);
}
}
@@ -5,18 +5,23 @@ namespace App\Http\Controllers;
use App\Models\ExistingBusiness;
use App\Models\ExistingBusinessObject;
use App\Models\Sector;
use App\Policies\ExistingBusinessPolicy;
use App\Services\BusinessFormSchema;
use App\Services\CountryCatalogue;
use App\Services\ExistingBusinessDrafts;
use App\Services\ExistingBusinessIntake;
use App\Services\ExistingBusinessObjects;
use App\Services\ExistingBusinessVerification;
use App\Services\InvestmentGeography;
use App\Services\Mne\BusinessRepository;
use App\Services\Mne\CompatibilityShadow;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
@@ -31,10 +36,27 @@ class ExistingBusinessController extends Controller
$query->where('owner_user_id', '<>', $request->user()->id)->whereIn('status', [
'submitted', 'triage', 'in_review', 'needs_information', 'approval_l1', 'approval_l2', 'approved',
]);
app(ExistingBusinessPolicy::class)->scopeReviewQueue($query, $request->user());
} else {
$query->where('owner_user_id', $request->user()->id);
}
$props = ['records' => $query->orderByDesc('updated_at')->paginate(20)->through(fn ($row) => $this->record($row)),
$filters = $request->validate(['country' => ['nullable', app(CountryCatalogue::class)->rule()], 'origin' => ['nullable', Rule::in([...array_column(app(CountryCatalogue::class)->all(), 'code'), 'unknown'])], 'page' => ['nullable', 'integer', 'min:1']]);
$query->orderByDesc('updated_at');
if (! empty($filters['country']) || ! empty($filters['origin'])) {
// The private payload is encrypted: filter only the already-authorized collection.
$matches = $query->get()->filter(function ($business) use ($filters) {
$payload = $business->payload;
$origin = $payload['declared_origin_country_code'] ?? null;
$validOrigin = in_array($origin, array_column(app(CountryCatalogue::class)->all(), 'code'), true) ? $origin : 'unknown';
return (empty($filters['country']) || ($payload['country'] ?? null) === $filters['country']) && (empty($filters['origin']) || $validOrigin === $filters['origin']);
})->values();
$page = (int) ($filters['page'] ?? 1);
$records = new LengthAwarePaginator($matches->forPage($page, 20)->values(), $matches->count(), 20, $page, ['path' => $request->url(), 'query' => $request->query()]);
} else {
$records = $query->paginate(20)->withQueryString();
}
$props = ['records' => $records->through(fn ($row) => $this->record($row)), 'filters' => $filters, 'countries' => app(CountryCatalogue::class)->all(),
'management' => $management, 'modalities' => ExistingBusinessDrafts::MODALITIES];
return $request->expectsJson() ? response()->json($props) : Inertia::render('investment/businesses', $props);
@@ -45,7 +67,7 @@ class ExistingBusinessController extends Controller
$business = app(BusinessRepository::class)->find($id);
Gate::authorize('view', $business);
app(CompatibilityShadow::class)->schedule($id);
$props = ['sectors' => Sector::where('is_active', true)->orderBy('name')->get(['id', 'name']), 'formSchema' => BusinessFormSchema::fields(), 'record' => $this->record($business), 'canEdit' => $request->user()->can('update', $business),
$props = ['countries' => app(CountryCatalogue::class)->all(), 'sectors' => Sector::choices()->orderBy('name')->get(['id', 'name', 'slug']), 'formSchema' => BusinessFormSchema::fields(), 'modalityFormType' => \App\Services\ExistingBusinessProfileCategory::formType($business->modality), 'record' => $this->record($business), 'canEdit' => $request->user()->can('update', $business),
'owner' => $request->user()->can('owns', $business), 'canReview' => $request->user()->can('review', $business),
'canTriage' => $request->user()->can('triage', $business),
'canApproveLevelOne' => $request->user()->can('approveLevelOne', $business),
@@ -100,6 +122,7 @@ class ExistingBusinessController extends Controller
'id', 'code', 'modality', 'status', 'classification', 'version_no', 'sector_id', 'public_title', 'public_slug',
'activity_code', 'stage', 'objective', 'currency', 'amount_min', 'amount_max', 'available_percentage',
'funds_use', 'submitted_at', 'published_at', 'expires_at', 'next_action', 'next_action_due_at', 'assigned_to',
]), 'payload' => $business->payload];
]), 'payload' => $business->payload,
'territory_paths' => app(InvestmentGeography::class)->displayPaths($business->payload)];
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers;
use App\Services\InvestmentComplements;
use Illuminate\Http\Request;
final class InvestmentComplementController extends Controller
{
public function index(Request $request, InvestmentComplements $service)
{
$data = $request->validate(['origin' => [\Illuminate\Validation\Rule::requiredIf(!$request->has('contexts')), 'nullable', 'string', 'max:100'], 'territory_id' => 'nullable|uuid',
'contexts' => 'sometimes|array|max:100', 'contexts.*' => 'array:origin,territory_id', 'contexts.*.origin' => 'required|string|max:100', 'contexts.*.territory_id' => 'nullable|uuid']);
$items = [];
$issues = [];
foreach ($data['contexts'] ?? [['origin' => $data['origin'], 'territory_id' => $data['territory_id'] ?? null]] as $context) {
try {
$items = [...$items, ...$service->options($context['origin'], $context['territory_id'] ?? null)];
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException|\Symfony\Component\HttpKernel\Exception\HttpException $e) {
$status = $e instanceof \Symfony\Component\HttpKernel\Exception\HttpException ? $e->getStatusCode() : 404;
if (!isset($data['contexts']) || $status !== 404) throw $e;
$issues[] = ['origin' => $context['origin'], 'message' => 'Uma referência escolhida deixou de estar disponível para consulta pública. A relação guardada conserva-se no dossier; os outros complementos continuam disponíveis.'];
}
}
return response()->json(isset($data['contexts']) ? ['items' => $items, 'issues' => $issues] : $items)->header('Cache-Control', 'private, no-store');
}
}
@@ -10,6 +10,7 @@ use App\Services\InvestmentZones;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response;
@@ -24,9 +25,11 @@ class InvestmentDiscoveryController extends Controller
$filters['education'] = true;
}
return Inertia::render('investment/catalogue', ['filters' => $filters, 'catalogueUrl' => $request->routeIs('investment.education') ? '/educacao' : '/oportunidades', 'references' => $catalogue->references(),
return Inertia::render('investment/catalogue', ['filters' => $filters, 'catalogueUrl' => $request->routeIs('investment.education') ? '/educacao' : '/oportunidades', 'references' => $catalogue->references(false, $filters),
'corridorGroups' => ($filters['view'] ?? '') === 'corridors' ? app(\App\Services\OpportunityCorridorExplorer::class)->groups($filters) : [],
'territorySelection' => app(InvestmentGeography::class)->selection($filters),
'results' => $catalogue->query($filters)->paginate(12)->withQueryString(),
'guides' => app(\App\Services\SectorInvestment::class)->guides(\App\Models\Sector::where('slug', $filters['sector'] ?? '')->where('is_active', true)->whereNotNull('published_at')->first()),
'results' => $catalogue->query($filters)->paginate(12)->withQueryString()->through(fn ($record) => $catalogue->presentLocation($record)),
'favourites' => $request->user() ? DB::table('opportunity_favourites')->where('user_id', $request->user()->id)->pluck('opportunity_id') : [],
'territorial' => $this->territorial($filters)]);
}
@@ -41,11 +44,7 @@ class InvestmentDiscoveryController extends Controller
$path = app(InvestmentGeography::class)->selection($filters);
if ($path !== []) {
$ids = app(InvestmentGeography::class)->descendants($path[array_key_last($path)]->id);
$query->where(function ($query) use ($ids) {
foreach (['project_id' => 'projects', 'cluster_id' => 'economic_clusters', 'corridor_id' => 'development_corridors'] as $foreign => $table) {
$query->orWhereIn($foreign, DB::table($table)->whereNull('deleted_at')->whereIn('primary_territory_id', $ids)->select('id'));
}
});
app(InvestmentGeography::class)->constrainDossiersToTerritories($query, $ids);
}
if (in_array($filters['dimension'] ?? null, ['zee', 'zfi'], true)) {
return $query->whereRaw('false')->get();
@@ -53,13 +52,16 @@ class InvestmentDiscoveryController extends Controller
if (in_array($filters['dimension'] ?? null, ['cluster', 'corridor'], true)) {
$query->whereNotNull($filters['dimension'].'_id');
}
foreach (['zee_id', 'zfi_id', 'park_id', 'education', 'facets', 'sector', 'province', 'district', 'geo_location_id', 'min_amount', 'max_amount', 'currency', 'before', 'maturity_level'] as $field) {
if (! empty($filters['geo_location_id'])) {
app(InvestmentGeography::class)->constrainDossiersToTerritories($query, app(InvestmentGeography::class)->descendants($filters['geo_location_id']));
}
foreach (['association_type', 'association_target', 'association_state', 'association_purpose', 'authority_id', 'chain_id', 'subsector_id', 'category', 'publication_state', 'zee_id', 'zfi_id', 'park_id', 'education', 'facets', 'sector', 'province', 'district', 'min_amount', 'max_amount', 'currency', 'before', 'maturity_level', 'preparation'] as $field) {
if (isset($filters[$field]) && $filters[$field] !== '' && $filters[$field] !== false && $filters[$field] !== []) {
return $query->whereRaw('false')->get();
}
}
if (! empty($filters['q'])) {
$query->where('title', 'ilike', '%'.$filters['q'].'%');
$query->where(fn ($q) => $q->where('title', 'ilike', '%'.$filters['q'].'%')->orWhereRaw('content::text ILIKE ?', ['%'.$filters['q'].'%']));
}
foreach (['cluster_id', 'corridor_id', 'project_id'] as $field) {
if (! empty($filters[$field])) {
@@ -72,19 +74,29 @@ class InvestmentDiscoveryController extends Controller
public function search(Request $request, InvestmentCatalogue $catalogue): JsonResponse
{
return response()->json($catalogue->query($catalogue->filters($request))->paginate($request->integer('per_page', 12))->withQueryString());
return response()->json($catalogue->query($catalogue->filters($request))->paginate($request->integer('per_page', 12))->withQueryString()->through(fn ($record) => $catalogue->presentLocation($record)));
}
public function references(InvestmentCatalogue $catalogue): JsonResponse
public function references(Request $request, InvestmentCatalogue $catalogue): JsonResponse
{
return response()->json($catalogue->references());
return response()->json($catalogue->references(false, $catalogue->filters($request)));
}
public function detail(Request $request, string $slug, InvestmentCatalogue $catalogue): Response
{
$record = Opportunity::published()->with('sector:id,name,slug')->where('slug', $slug)->firstOrFail();
$data = $request->validate(['zone_id' => ['nullable', 'uuid']]);
$data = $request->validate(['zone_id' => ['nullable', 'uuid'], 'from_dossier' => ['nullable', 'uuid']]);
$dossierOrigin = isset($data['from_dossier']) ? InvestmentPortfolioController::visible()->whereKey($data['from_dossier'])->firstOrFail() : null;
if ($dossierOrigin) {
$linked = in_array($record->id, app(\App\Services\CorridorAgribusinessOpportunities::class)->portfolioOpportunityIds($dossierOrigin), true);
foreach (['projects' => 'project_id', 'clusters' => 'cluster_id', 'corridors' => 'corridor_id'] as $relation => $foreign) {
if ($dossierOrigin->$foreign && DB::table('opportunity_'.$relation)->where('opportunity_id', $record->id)->where($foreign, $dossierOrigin->$foreign)->exists()) {
$linked = true;
}
}
abort_unless($linked, 404);
}
$origin = null;
if (! empty($data['zone_id'])) {
$origin = app(InvestmentZones::class)->links()->join('investment_item_opportunities as m', 'm.item_id', '=', 'zl.item_id')
@@ -92,7 +104,13 @@ class InvestmentDiscoveryController extends Controller
abort_unless($origin !== null, 404);
}
return Inertia::render('investment/detail', ['record' => $record, 'context' => $catalogue->context($record->id), 'zone_origin' => $origin]);
$returnFilters = $request->query();
unset($returnFilters['zone_id'], $returnFilters['from_dossier']);
return Inertia::render('investment/detail', ['complements' => app(\App\Services\InvestmentComplements::class)->options('opportunity:'.$record->id), 'record' => $record, 'context' => $catalogue->context($record->id), 'zone_origin' => $origin,
'guides' => app(\App\Services\SectorInvestment::class)->guides($record->sector),
'dossierOrigin' => $dossierOrigin ? ['id' => $dossierOrigin->id, 'title' => $dossierOrigin->title] : null,
'returnUrl' => $returnFilters === [] ? '/oportunidades' : '/oportunidades?'.http_build_query($returnFilters)]);
}
public function detailApi(string $slug, InvestmentCatalogue $catalogue): JsonResponse
@@ -114,7 +132,24 @@ class InvestmentDiscoveryController extends Controller
public function map(Request $request, InvestmentCatalogue $catalogue): JsonResponse
{
return response()->json($catalogue->map($catalogue->filters($request)));
$filters = $catalogue->filters($request);
$context = $this->territorial($filters)->map(fn ($d) => ['id' => 'dossier:'.$d->id, 'title' => $d->title, 'kind' => $d->cluster_id ? 'Cluster' : ($d->corridor_id ? 'Corredor' : 'Projeto âncora'), 'url' => '/carteira/'.$d->id]);
$opportunityOnlyFilters = array_filter(Arr::only($filters, ['association_type', 'association_target', 'association_state', 'association_purpose', 'authority_id', 'chain_id', 'subsector_id', 'category', 'publication_state', 'education', 'sector', 'facets', 'project_id', 'cluster_id', 'corridor_id', 'min_amount', 'max_amount', 'currency', 'before', 'maturity_level', 'preparation']), fn ($value) => $value !== null && $value !== '' && $value !== false && $value !== []);
if ($opportunityOnlyFilters === [] && ! in_array($filters['dimension'] ?? null, ['cluster', 'corridor'], true)) {
$zoneFilters = $filters;
if (in_array($filters['dimension'] ?? null, ['zee', 'zfi'], true)) {
$zoneFilters['category'] = $filters['dimension'];
}
$zones = app(InvestmentZones::class)->search($zoneFilters);
foreach (['zee_id', 'zfi_id', 'park_id'] as $key) {
if (! empty($filters[$key])) {
$zones->where('id', $filters[$key]);
}
}
$context = $context->concat($zones->orderBy('name')->limit(30)->get(['id', 'name', 'category'])->map(fn ($z) => ['id' => 'zone:'.$z->id, 'title' => $z->name, 'kind' => ['zee' => 'ZEE', 'zfi' => 'ZFI', 'industrial_park' => 'Parque industrial'][$z->category] ?? $z->category, 'url' => '/zonas/'.$z->id]));
}
return response()->json([...$catalogue->map($filters), 'territorial_context' => $context->values(), 'context_limit_per_collection' => 30]);
}
public function compare(Request $request, InvestmentCatalogue $catalogue): JsonResponse
@@ -40,9 +40,12 @@ class InvestmentLegalSourceController extends Controller
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1']]);
return DB::transaction(function () use ($request, $id, $action, $data) {
DB::select('select pg_advisory_xact_lock(261022, hashtext(?))', [$id]);
$source = InvestmentLegalSource::whereKey($id)->lockForUpdate()->firstOrFail();
abort_if((int) $source->version_no !== (int) $data['version_no'], 409, 'Versão alterada.');
if ($action === 'validate') {
abort_if(str_contains(mb_strtolower($source->article), 'pending') || str_contains(mb_strtolower($source->article), 'pendente')
|| str_contains(mb_strtolower($source->article), 'a rever'), 422, 'Indique o artigo e confirme o texto oficial antes da validação.');
abort_if((int) $source->owner_id === $request->user()->id, 403, 'Outra pessoa deve validar a fonte.');
abort_unless($source->validation_status === 'pending', 422, 'Crie nova referência para substituir fonte revogada.');
}
@@ -3,10 +3,15 @@
namespace App\Http\Controllers;
use App\Models\InvestmentDossier;
use App\Models\InvestmentSubmission;
use App\Models\Opportunity;
use App\Models\Project;
use App\Services\InvestmentGeography;
use App\Services\InvestmentRelations;
use App\Services\InvestmentZones;
use App\Services\PortalAudit;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -17,9 +22,15 @@ class InvestmentPortfolioController extends Controller
{
public static function related(InvestmentDossier $first, InvestmentDossier $second): bool
{
foreach ([[$first, $second], [$second, $first]] as [$cluster, $project]) {
if ($cluster->cluster_id && $project->project_id && app(InvestmentRelations::class)->memberships()
->where('cp.cluster_id', $cluster->cluster_id)->where('cp.project_id', $project->project_id)->exists()) {
return true;
}
}
foreach ([['corridor_projects', 'corridor_id', 'project_id'], ['corridor_clusters', 'corridor_id', 'cluster_id'], ['cluster_projects', 'cluster_id', 'project_id']] as [$table, $left, $right]) {
foreach ([[$first, $second], [$second, $first]] as [$a, $b]) {
if ($a->$left && $b->$right && DB::table($table)->where($left, $a->$left)->where($right, $b->$right)->whereNull('deleted_at')->exists()) {
if ($a->$left && $b->$right && self::currentRelation(DB::table($table)->where($left, $a->$left)->where($right, $b->$right))->exists()) {
return true;
}
}
@@ -28,6 +39,13 @@ class InvestmentPortfolioController extends Controller
return false;
}
private static function currentRelation(QueryBuilder $query): QueryBuilder
{
return $query->whereNull('deleted_at')->where('validation_status', 'validated')
->whereDate('valid_from', '<=', today()->toDateString())
->where(fn ($q) => $q->whereNull('valid_until')->orWhereDate('valid_until', '>=', today()->toDateString()));
}
/** @return Builder<InvestmentDossier> */
public static function visible(): Builder
{
@@ -42,17 +60,30 @@ class InvestmentPortfolioController extends Controller
public function index(Request $request): Response
{
$management = $request->routeIs('management.portfolio*');
$filters = $request->validate(['q' => ['nullable', 'string', 'max:120'], 'kind' => ['nullable', 'in:corridor,cluster,project']]);
$filters = $request->validate(['q' => ['nullable', 'string', 'max:120'], 'kind' => ['nullable', 'in:corridor,cluster,project'],
'sector' => ['nullable', 'string', 'max:500'],
'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid']]);
$query = $management ? InvestmentDossier::query() : self::visible();
$sectorOptions = ($management ? InvestmentDossier::query() : self::visible())
->whereNotNull('content->sector')->get()->pluck('content.sector')
->filter(fn ($sector) => is_string($sector) && trim($sector) !== '')->unique()->sort()->values();
if (! empty($filters['sector'])) {
$query->where('content->sector', $filters['sector']);
}
if (! empty($filters['kind'])) {
$query->whereNotNull($filters['kind'].'_id');
}
if (! empty($filters['q'])) {
$query->where('title', 'ilike', '%'.$filters['q'].'%');
$query->where(fn ($q) => $q->where('title', 'ilike', '%'.$filters['q'].'%')->orWhereRaw('content::text ILIKE ?', ['%'.$filters['q'].'%']));
}
$territorySelection = app(InvestmentGeography::class)->selection($filters);
if ($territorySelection !== []) {
$territoryIds = app(InvestmentGeography::class)->descendants($territorySelection[array_key_last($territorySelection)]->id);
app(InvestmentGeography::class)->constrainDossiersToTerritories($query, $territoryIds);
}
return Inertia::render('portfolio/index', ['records' => $query->orderBy('title')->paginate(30)->withQueryString(),
'management' => $management, 'filters' => $filters]);
'management' => $management, 'filters' => $filters, 'sectors' => $sectorOptions, 'territorySelection' => $territorySelection]);
}
public function show(Request $request, string $id): Response
@@ -63,8 +94,8 @@ class InvestmentPortfolioController extends Controller
foreach ([['corridor_projects', 'corridor_id', 'project_id'], ['corridor_clusters', 'corridor_id', 'cluster_id'], ['cluster_projects', 'cluster_id', 'project_id']] as [$table, $left, $right]) {
foreach ([[$left, $right], [$right, $left]] as [$origin, $destination]) {
if ($record->$origin) {
$relatedIds[$destination] = array_merge($relatedIds[$destination] ?? [], DB::table($table)
->where($origin, $record->$origin)->whereNull('deleted_at')->pluck($destination)->all());
$relatedIds[$destination] = array_merge($relatedIds[$destination] ?? [], self::currentRelation(DB::table($table)
->where($origin, $record->$origin))->pluck($destination)->all());
}
}
}
@@ -81,6 +112,7 @@ class InvestmentPortfolioController extends Controller
$opportunities = Opportunity::published()->where(function ($query) use ($record) {
$query->whereRaw('false');
$query->orWhereIn('id', app(\App\Services\CorridorAgribusinessOpportunities::class)->portfolioOpportunityIds($record));
foreach (['clusters' => 'cluster_id', 'corridors' => 'corridor_id', 'projects' => 'project_id'] as $relation => $foreign) {
if ($record->$foreign) {
$query->orWhereIn('id', DB::table('opportunity_'.$relation)->where($foreign, $record->$foreign)->select('opportunity_id'));
@@ -98,10 +130,27 @@ class InvestmentPortfolioController extends Controller
}
}
return Inertia::render('portfolio/show', ['record' => $record, 'related' => $related, 'management' => $management, 'zones' => $zones,
$anchorProject = $record->project_id
? Project::whereKey($record->project_id)->whereNull('deleted_at')->where('status', '!=', 'draft')->first(['id', 'code', 'name', 'status', 'budget_amount', 'budget_currency', 'start_date', 'end_date'])
: null;
$existingSubmissions = ! $management && $request->user()
? InvestmentSubmission::where('user_id', $request->user()->id)->where('dossier_id', $record->id)->where('status', 'draft')
->orderByDesc('updated_at')->limit(10)->get(['id', 'version', 'updated_at'])
: collect();
return Inertia::render('portfolio/show', ['complements' => $management ? [] : app(\App\Services\InvestmentComplements::class)->options('portfolio:'.$record->id), 'record' => $record, 'related' => $related, 'management' => $management, 'zones' => $zones,
'anchorProject' => $anchorProject, 'existingSubmissions' => $existingSubmissions,
'geography' => app(InvestmentRelations::class)->dossier($record, $management),
'origin' => $origin ? ['id' => $origin->id, 'title' => $origin->title] : null, 'opportunities' => $opportunities, 'chains' => $chains]);
}
public function resumeInterest(Request $request, string $id): RedirectResponse
{
self::visible()->findOrFail($id);
$data = $request->validate(['catalogue_query' => ['nullable', 'string', 'max:4000']]);
return redirect('/carteira/'.$id.'?'.http_build_query([...$data, 'prepare_interest' => '1']));
}
public function save(Request $request, InvestmentDossier $dossier): RedirectResponse
{
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1'], 'title' => ['required', 'string', 'max:255'], 'source' => ['required', 'string', 'max:2000'],
@@ -114,7 +163,7 @@ class InvestmentPortfolioController extends Controller
'publication_status' => 'draft', 'published_at' => null, 'approval_evidence' => null, 'approval_basis' => 'manager'], $request->user()->id);
});
return back()->with('success', 'Rascunho guardado. Pode aprovar e publicar a ficha.');
return back()->with('success', 'Rascunho guardado. A publicacao aguarda um revisor diferente do editor.');
}
public function publish(Request $request, InvestmentDossier $dossier): RedirectResponse
@@ -123,6 +172,8 @@ class InvestmentPortfolioController extends Controller
DB::transaction(function () use ($request, $dossier, $data) {
$dossier = InvestmentDossier::whereKey($dossier->id)->lockForUpdate()->firstOrFail();
abort_if($dossier->version_no !== (int) $data['version_no'], 409, 'A ficha foi alterada. Recarregue a página e reveja a versão actual antes de publicar.');
abort_unless($dossier->editor_id !== null, 422, 'A ficha deve ser assumida e revista antes da publicacao.');
abort_if((int) $dossier->editor_id === (int) $request->user()->id, 403, 'A publicacao exige um revisor diferente do editor.');
PortalAudit::save($dossier, [...$data, 'version_no' => $dossier->version_no + 1, 'approval_basis' => 'manager', 'reviewer_id' => $request->user()->id, 'publication_status' => 'published', 'published_at' => now()], $request->user()->id);
});
@@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers;
use App\Services\InvestmentRelationshipRepair;
use App\Services\InvestmentTerritorialTraceability;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpKernel\Exception\HttpException;
class InvestmentRelationshipRepairController extends Controller
{
public function preview(InvestmentRelationshipRepair $repair): Response
{
return Inertia::render('management/relationship-repair', [...$repair->preview(), 'traceability' => app(InvestmentTerritorialTraceability::class)->preview()]);
}
public function apply(Request $request, InvestmentRelationshipRepair $repair): RedirectResponse|JsonResponse
{
if ($request->input('operation') === 'traceability') {
$data = $request->validate(['hash' => ['required', 'string', 'size:64'], 'reverse' => ['required', 'boolean'],
'reason' => ['required_if:reverse,true', 'nullable', 'string', 'max:1000'], 'confirm_reverse' => ['accepted_if:reverse,true']]);
try {
$result = app(InvestmentTerritorialTraceability::class)->apply($data['hash'], $request->user()->id, $data['reverse'], $data['reason'] ?? null);
} catch (HttpException $exception) {
if ($request->expectsJson()) {
throw $exception;
}
return back()->withErrors(['operation' => $exception->getMessage()]);
}
return $request->expectsJson() ? response()->json($result) : back()->with('success', $data['reverse'] ? 'Rastreabilidade revertida, histórico preservado.' : 'Rastreabilidade aplicada; validações e dados territoriais preservados.');
}
$data = $request->validate(['hash' => ['required', 'string', 'size:64'], 'row_id' => ['nullable', 'string', 'size:64'],
'validation_hash' => ['required_with:row_id', 'nullable', 'string', 'size:64']]);
$result = $repair->apply($data['hash'], $request->user()->id, $data['row_id'] ?? null, $data['validation_hash'] ?? null);
return $request->expectsJson() ? response()->json($result) : back()->with('success', $result['replayed'] ? 'Este plano já foi aplicado; nenhum duplicado criado.' : 'Associações documentadas aplicadas. Registos de oportunidades permanecem preparados sem publicação automática.');
}
}
@@ -3,6 +3,7 @@
namespace App\Http\Controllers;
use App\Models\ClusterProject;
use App\Models\DevelopmentCorridor;
use App\Models\EconomicCluster;
use App\Models\InvestmentGap;
use App\Models\Project;
@@ -21,7 +22,7 @@ class InvestmentStructureController extends Controller
public function index(Request $request, string $structure): Response
{
$filters = $request->validate(['q' => ['nullable', 'string', 'max:150']]);
$model = $structure === 'projects' ? Project::class : EconomicCluster::class;
$model = $this->model($structure);
$query = $model::query();
if ($term = $filters['q'] ?? null) {
$query->where(function ($q) use ($term): void {
@@ -33,9 +34,9 @@ class InvestmentStructureController extends Controller
'records' => $query->orderBy('name')->orderBy('id')->paginate(20)->withQueryString()]);
}
public function edit(string $structure, ?string $id = null): Response
public function edit(Request $request, string $structure, ?string $id = null): Response
{
$model = $structure === 'projects' ? Project::class : EconomicCluster::class;
$model = $this->model($structure);
$record = $id ? $model::findOrFail($id) : null;
return Inertia::render('structures/editor', ['structure' => $structure, 'record' => $record,
@@ -49,30 +50,36 @@ class InvestmentStructureController extends Controller
->whereNull('origins.deleted_at')->whereNull('destinations.deleted_at')->whereNull('value_chain_links.deleted_at')
->orderBy('origins.code')->orderBy('destinations.code')
->get(['value_chain_links.id', 'origins.name as origin', 'destinations.name as destination', 'link_type']) : [],
'viewerId' => $request->user()->id,
'memberships' => $structure === 'clusters' && $id ? DB::table('cluster_projects')
->join('projects', 'projects.id', '=', 'cluster_projects.project_id')
->where('cluster_id', $id)->whereNull('cluster_projects.deleted_at')->whereNull('projects.deleted_at')
->orderBy('projects.name')->get(['cluster_projects.id', 'project_id', 'role', 'projects.name']) : [],
->orderBy('projects.name')->get(['cluster_projects.id', 'cluster_projects.project_id', 'cluster_projects.role', 'cluster_projects.association_type', 'cluster_projects.source_reference', 'cluster_projects.evidence_reference', 'cluster_projects.valid_from', 'cluster_projects.valid_until', 'cluster_projects.validation_status', 'cluster_projects.version_no', 'cluster_projects.editor_id', 'projects.name']) : [],
]);
}
public function save(Request $request, string $structure, ?string $id = null): RedirectResponse
{
$isProject = $structure === 'projects';
$model = $isProject ? Project::class : EconomicCluster::class;
$isCluster = $structure === 'clusters';
$isCorridor = $structure === 'corridors';
$model = $this->model($structure);
$record = $id ? $model::findOrFail($id) : new $model;
$rules = [
'code' => ['required', 'string', 'max:100', Rule::unique($record->getTable(), 'code')->whereNull('deleted_at')->ignore($record->getKey())],
'name' => ['required', 'string', 'max:200'],
'status' => ['required', Rule::in($isProject ? ['draft', 'planned', 'active', 'suspended', 'completed', 'cancelled'] : ['planned', 'active', 'inactive'])],
'status' => ['required', Rule::in($isProject ? ['draft', 'planned', 'active', 'suspended', 'completed', 'cancelled'] : ($isCluster ? ['planned', 'active', 'inactive'] : ['proposed', 'active', 'closed']))],
];
if ($isProject || $isCorridor) {
$rules['description'] = ['nullable', 'string', 'max:10000'];
}
if ($isProject) {
$rules += ['description' => ['nullable', 'string', 'max:10000'],
$rules += [
'budget_amount' => ['nullable', 'required_with:budget_currency', 'regex:/^\d{1,18}(\.\d{1,2})?$/'],
'budget_currency' => ['nullable', 'required_with:budget_amount', 'regex:/^[A-Z]{3}$/'],
'start_date' => ['nullable', 'date_format:Y-m-d'],
'end_date' => ['nullable', 'date_format:Y-m-d', ...($request->filled('start_date') ? ['after_or_equal:start_date'] : [])]];
} else {
} elseif ($isCluster) {
$rules['sector'] = ['required', 'string', 'max:200'];
}
if (! $id) {
@@ -92,14 +99,52 @@ class InvestmentStructureController extends Controller
return redirect('/management/'.$structure.'/'.$record->getKey().'/edit')->with('success', 'Registo guardado.');
}
/** @return class-string<Project|EconomicCluster|DevelopmentCorridor> */
private function model(string $structure): string
{
return match ($structure) {
'projects' => Project::class,
'clusters' => EconomicCluster::class,
'corridors' => DevelopmentCorridor::class,
default => abort(404),
};
}
public function linkProject(Request $request, EconomicCluster $cluster): RedirectResponse
{
$data = $request->validate(['project_id' => ['required', 'uuid', Rule::exists('projects', 'id')->whereNull('deleted_at')],
'role' => ['required', Rule::in(['anchor', 'complementary', 'supporting'])]]);
'version_no' => ['required', 'integer', 'min:0'],
'role' => ['required', Rule::in(['anchor', 'complementary', 'supporting'])],
'association_type' => ['required', Rule::in(['official', 'influence', 'proximity', 'potential', 'historical'])],
'source_reference' => ['required', 'string', 'min:10', 'max:2000'],
'evidence_reference' => ['required', 'string', 'min:3', 'max:2000'],
'valid_from' => ['required', 'date_format:Y-m-d'],
'valid_until' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:valid_from']]);
DB::transaction(function () use ($cluster, $data, $request) {
EconomicCluster::whereKey($cluster->id)->lockForUpdate()->firstOrFail();
$link = ClusterProject::where('cluster_id', $cluster->id)->where('project_id', $data['project_id'])->first() ?? new ClusterProject;
PortalAudit::save($link, [...$data, 'cluster_id' => $cluster->id], $request->user()->id);
$link = ClusterProject::where('cluster_id', $cluster->id)->where('project_id', $data['project_id'])->whereNull('deleted_at')->lockForUpdate()->first();
if ($link) {
abort_if((int) $link->version_no !== (int) $data['version_no'], 409, 'Versao da associacao alterada.');
$changed = false;
foreach (['role', 'association_type', 'source_reference', 'evidence_reference', 'valid_from', 'valid_until'] as $field) {
$current = $link->getAttribute($field);
$current = $current instanceof \DateTimeInterface ? $current->format('Y-m-d') : $current;
if (($current ?: null) !== (($data[$field] ?? null) ?: null)) {
$changed = true;
break;
}
}
if (! $changed) {
return;
}
} else {
abort_if((int) $data['version_no'] !== 0, 409, 'A associacao foi alterada. Actualize a pagina.');
$link = new ClusterProject;
}
unset($data['version_no']);
$nextVersion = $link->exists ? ((int) $link->version_no + 1) : 1;
PortalAudit::save($link, [...$data, 'cluster_id' => $cluster->id, 'validation_status' => 'pending',
'editor_id' => $request->user()->id, 'validator_id' => null, 'version_no' => $nextVersion], $request->user()->id);
});
return back()->with('success', 'Associação ao projecto guardada.');
@@ -0,0 +1,85 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
class InvestmentStructureRelationController extends Controller
{
private const TABLES = [
'cluster-project' => ['cluster_projects', 'cluster_id', 'project_id', 'economic_clusters', 'projects'],
'corridor-project' => ['corridor_projects', 'corridor_id', 'project_id', 'development_corridors', 'projects'],
'corridor-cluster' => ['corridor_clusters', 'corridor_id', 'cluster_id', 'development_corridors', 'economic_clusters'],
];
public function store(Request $request, string $kind): RedirectResponse
{
[$table, $left, $right, $leftTable, $rightTable] = $this->definition($kind);
$data = $request->validate([
$left => ['required', 'uuid', Rule::exists($leftTable, 'id')->whereNull('deleted_at')],
$right => ['required', 'uuid', Rule::exists($rightTable, 'id')->whereNull('deleted_at')],
'association_type' => ['required', Rule::in(['official', 'influence', 'proximity', 'potential', 'historical'])],
'source_reference' => ['required', 'string', 'min:10', 'max:2000'],
'evidence_reference' => ['required', 'string', 'min:3', 'max:2000'],
'valid_from' => ['required', 'date_format:Y-m-d'],
'valid_until' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:valid_from'],
...($kind === 'cluster-project' ? ['role' => ['required', Rule::in(['anchor', 'complementary', 'supporting'])]] : []),
]);
DB::transaction(function () use ($data, $table, $left, $right, $leftTable, $request): void {
DB::table($leftTable)->where('id', $data[$left])->lockForUpdate()->firstOrFail();
$existing = DB::table($table)->where($left, $data[$left])->where($right, $data[$right])->whereNull('deleted_at')->lockForUpdate()->first();
abort_if($existing !== null, 409, 'Associacao ja registada.');
$values = [...$data, 'association_type' => $data['association_type'], 'validation_status' => 'pending',
'editor_id' => $request->user()->id, 'validator_id' => null, 'version_no' => 1,
'deleted_at' => null, 'updated_at' => now()];
$id = (string) Str::uuid();
DB::table($table)->insert([...$values, 'id' => $id, 'source_system' => 'portal-management']);
$this->audit($request, $table, $id, 'submitted', 1);
});
return back()->with('success', 'Associacao submetida para revisao independente.');
}
public function review(Request $request, string $kind, string $id, string $action): RedirectResponse
{
abort_unless(in_array($action, ['validate', 'revoke'], true), 404);
[$table] = $this->definition($kind);
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1']]);
DB::transaction(function () use ($request, $table, $id, $action, $data): void {
$relation = DB::table($table)->where('id', $id)->lockForUpdate()->first();
abort_unless($relation !== null, 404);
abort_if((int) $relation->version_no !== (int) $data['version_no'], 409, 'Versao da associacao alterada.');
if ($action === 'validate') {
abort_if((int) $relation->editor_id === (int) $request->user()->id, 403, 'A associacao exige revisao independente.');
abort_unless($relation->validation_status === 'pending' && $relation->source_reference && $relation->evidence_reference && $relation->valid_from, 422, 'Fonte, evidencia ou vigencia em falta.');
}
$nextVersion = (int) $relation->version_no + 1;
DB::table($table)->where('id', $id)->update(['validation_status' => $action === 'validate' ? 'validated' : 'revoked',
'validator_id' => $request->user()->id, 'version_no' => $nextVersion, 'updated_at' => now()]);
$this->audit($request, $table, $id, $action, $nextVersion);
});
return back()->with('success', $action === 'validate' ? 'Associacao validada.' : 'Associacao revogada.');
}
/** @return array{string,string,string,string,string} */
private function definition(string $kind): array
{
abort_unless(isset(self::TABLES[$kind]), 404);
return self::TABLES[$kind];
}
private function audit(Request $request, string $table, string $id, string $action, int $version): void
{
DB::table('audit_events')->insert(['source_system' => 'investment-structure-relations', 'entity_type' => $table,
'entity_id' => $id, 'actor_user_id' => $request->user()->id, 'action' => $action,
'after_data' => json_encode(['version_no' => $version, 'validation_status' => $action === 'validate' ? 'validated' : null], JSON_THROW_ON_ERROR)]);
}
}
@@ -2,17 +2,22 @@
namespace App\Http\Controllers;
use App\Jobs\ScanDocumentForMalware;
use App\Models\InterestRequest;
use App\Models\InvestmentSpecificNeed;
use App\Models\InvestmentSubmission;
use App\Models\Opportunity;
use App\Services\CountryCatalogue;
use App\Services\InterestSubmission;
use App\Services\InvestmentCatalogue;
use App\Services\InvestmentWorkspace;
use App\Services\InvestmentZones;
use App\Services\InvestorContextConsumers;
use App\Services\VirtualDesk;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
@@ -26,7 +31,14 @@ class InvestmentSubmissionController extends Controller
{
public function index(Request $request): Response|JsonResponse
{
$submissions = InvestmentSubmission::where('user_id', $request->user()->id)->latest()->paginate(20)->through(function ($row) {
$query = InvestmentSubmission::where('user_id', $request->user()->id);
if (! $request->user()->hasVerifiedEmail()) {
// The existing payload is encrypted; scope after decryption, never infer ownership from email.
$simpleIds = InvestmentSubmission::where('user_id', $request->user()->id)->where('status', 'submitted')->get(['id', 'payload'])
->filter(fn ($row) => ($row->payload['simple_participation'] ?? false) === true)->modelKeys();
$query->whereIn('id', $simpleIds);
}
$submissions = $query->latest()->paginate(20)->through(function ($row) {
$interest = $row->interest_request_id ? InterestRequest::find($row->interest_request_id) : null;
return ['id' => $row->id, 'status' => $row->status, 'version' => $row->version, 'created_at' => $row->created_at,
@@ -48,7 +60,10 @@ class InvestmentSubmissionController extends Controller
$company = DB::table('investment_company_declarations')->where('id', $data['company_id'])->where('user_id', $request->user()->id)->first();
abort_unless($company !== null, 404);
$companyData = json_decode(Crypt::decryptString($company->payload), true, 512, JSON_THROW_ON_ERROR);
$payload = [...$payload, 'organisation' => $companyData['name'], 'representative_role' => $companyData['role'], 'country' => $companyData['country']];
$payload = [...$payload, 'organisation' => $companyData['name'], 'representative_role' => $companyData['role'], 'incorporation_country_code' => $companyData['incorporation_country_code'] ?? null];
if (isset($companyData['organization_country_snapshot'])) {
$payload['organization_country_snapshot'] = $companyData['organization_country_snapshot'];
}
}
unset($data['company_id']);
if (! empty($data['opportunity_id'])) {
@@ -110,24 +125,58 @@ class InvestmentSubmissionController extends Controller
$target = $row->opportunity_id ? Opportunity::published()->find($row->opportunity_id) : null;
$dossier = $row->dossier_id ? InvestmentPortfolioController::visible()->find($row->dossier_id) : null;
$zoneOrigin = DB::table('investment_submission_zones')->where('submission_id', $id)->first(['zone_id', 'zone_version', 'intent']);
$data = ['submission' => $row->only(['id', 'payload', 'version', 'status', 'opportunity_id', 'dossier_id']), 'zone_origin' => $zoneOrigin,
'target' => ($target ? $target->title : null) ?? ($dossier ? $dossier->title : null), 'references' => [
$contextAvailable = app(VirtualDesk::class)->installed();
$boundCase = $contextAvailable ? DB::table('desk_cases')->where('submission_id', $id)->where('user_id', $request->user()->id)->whereNotNull('journey_id')->first() : null;
$contextChoices = $contextAvailable && (int) $row->user_id === (int) $request->user()->id && $row->status === 'draft' && ! $boundCase
? DB::table('desk_cases')->where('user_id', $request->user()->id)->whereNotNull('journey_id')->whereNull('closed_at')->whereNull('submission_id')->orderBy('title')->get(['id', 'title', 'version']) : [];
$data = ['contextDossierId' => $boundCase?->id, 'contextChoices' => $contextChoices, 'boundContextRevision' => $contextAvailable ? app(InvestorContextConsumers::class)->submissionRevision($id) : 0, 'countries' => app(CountryCatalogue::class)->all(), 'submission' => $row->only(['id', 'payload', 'version', 'status', 'opportunity_id', 'dossier_id']), 'zone_origin' => $zoneOrigin,
'target' => ($target ? $target->title : null) ?? ($dossier ? $dossier->title : null) ?? ($row->payload['opportunity_title'] ?? null), 'references' => [
...$catalogue->references(), 'options' => DB::table('investment_reference_options')->orderBy('label')->get()],
'needs' => InvestmentSpecificNeed::where('submission_id', $id)->get(),
'attachments' => DB::table('investment_submission_attachments as a')->join('documents as d', 'd.id', '=', 'a.document_id')
->where('a.submission_id', $id)->whereNull('d.deleted_at')->get(['a.id', 'd.file_name']),
'events' => DB::table('investment_submission_events')->where('submission_id', $id)->orderBy('id')->get(['action', 'version', 'created_at']),
'events' => DB::table('investment_submission_events')->where('submission_id', $id)->orderBy('id')->get(['action', 'version', 'created_at'])
->concat($interest ? DB::table('business_workflow_transitions')->where('interest_request_id', $interest->id)->orderBy('created_at')->get(['to_status', 'created_at'])->map(fn ($event) => ['action' => $event->to_status, 'version' => $row->version, 'created_at' => $event->created_at]) : [])->sortBy('created_at')->values(),
'revisions' => DB::table('investment_submission_revisions')->where('submission_id', $id)->orderByDesc('version')->get(['version', 'created_at']),
'messages' => DB::table('investment_messages')->where('submission_id', $id)->orderBy('created_at')->get(['id', 'sender_role', 'body', 'created_at'])->map(function ($message) {
$message->body = Crypt::decryptString($message->body);
return $message;
}),
'receipt' => $interest?->only(['reference', 'status']), 'management' => (int) $row->user_id !== $request->user()->id];
'receipt' => $interest?->only(['reference', 'status', 'next_action', 'next_action_due_at']), 'management' => (int) $row->user_id !== $request->user()->id];
return $request->expectsJson() ? response()->json($data) : Inertia::render('investment/submission', $data);
}
public function bindContext(Request $request, string $id): JsonResponse
{
abort_unless(app(VirtualDesk::class)->installed(), 422, 'O vínculo ao percurso do investimento não está disponível neste destino. A manifestação e a sua origem permanecem guardadas.');
abort_if(array_diff(array_keys($request->all()), ['case_id', 'case_version', 'version', '_token']), 422, 'Parâmetros de associação inválidos.');
$data = $request->validate(['case_id' => 'required|uuid', 'case_version' => 'required|integer|min:1', 'version' => 'required|integer|min:1']);
$result = DB::transaction(function () use ($request, $id, $data) {
// Serialize all links for this submission before locking the selected case.
$row = $this->owned($request, $id, true, true);
$desk = app(VirtualDesk::class);
$case = $desk->case($request->user(), $data['case_id'], true, true);
abort_unless((int) $case->user_id === (int) $request->user()->id && $case->journey_id, 403, 'Dossier próprio autorizado necessário.');
$existing = DB::table('desk_cases')->where('submission_id', $id)->first(['id']);
if ($existing && $existing->id === $case->id) {
return ['case_id' => $case->id, 'version' => $row->version, 'case_version' => (int) $case->version, 'replayed' => true];
}
abort_if($existing || $case->submission_id, 409, 'Existe um vínculo anterior; não foi substituído nem criado outro dossier.');
abort_unless($row->status === 'draft' && ! $case->closed_at && (int) $row->version === (int) $data['version'] && (int) $case->version === (int) $data['case_version'], 409, 'Rascunho ou dossier alterado. Recarregue antes de associar.');
DB::table('desk_cases')->where('id', $case->id)->update(['submission_id' => $id, 'version' => $case->version + 1, 'updated_at' => now()]);
$row->update(['version' => $row->version + 1]);
// Binding does not save form edits or assert that the context has been reviewed.
$this->event($row, $request, 'context_bound');
$desk->event($desk->case($request->user(), $case->id), $request->user(), 'manifestation_context_bound', ['submission_id' => $id, 'previous_case_version' => (int) $case->version]);
return ['case_id' => $case->id, 'version' => $row->version, 'case_version' => (int) $case->version + 1, 'replayed' => false];
}, 3);
return response()->json($result);
}
private function owned(Request $request, string $id, bool $lock = false, bool $ownerOnly = false): InvestmentSubmission
{
$query = InvestmentSubmission::whereKey($id);
@@ -143,10 +192,17 @@ class InvestmentSubmissionController extends Controller
{
$required = $submitting ? 'required' : 'nullable';
$data = $request->validate(['version' => ['required', 'integer', 'min:1'],
'payload' => ['required', 'array:full_name,email,phone,country,organisation,representative_role,experience,investment_capacity,profile_id,sector,objective,participation_id,partnership_id,estimated_amount,currency,horizon,location,cluster_ids,corridor_ids,project_ids,contact_consent,other_details'],
'payload' => ['required', 'array:context_opportunity,context_opportunity_snapshot,context_reuse,full_name,email,phone,country,residence_country_code,incorporation_country_code,organization_country_snapshot,declared_origin_country_code,organisation,representative_role,experience,investment_capacity,profile_id,sector,objective,participation_id,partnership_id,estimated_amount,currency,horizon,location,cluster_ids,corridor_ids,project_ids,contact_consent,other_details'],
'payload.organization_country_snapshot' => ['sometimes', 'array', 'max:10'],
'payload.residence_country_code' => ['nullable', 'string', app(CountryCatalogue::class)->rule()],
'payload.incorporation_country_code' => ['nullable', 'string', app(CountryCatalogue::class)->rule()],
'payload.declared_origin_country_code' => ['nullable', 'string', app(CountryCatalogue::class)->rule()],
'payload.context_opportunity' => ['nullable', 'string', 'max:100'],
'payload.context_opportunity_snapshot' => ['nullable', 'string', 'max:200000'],
'payload.context_reuse' => ['nullable', 'string', 'max:10000'],
'payload.other_details' => ['nullable', 'string', 'max:5000'],
'payload.full_name' => [$required, 'string', ...($submitting ? ['min:3'] : []), 'max:150'], 'payload.email' => [$required, $submitting ? 'email:rfc' : 'string', 'max:150'],
'payload.phone' => ['nullable', 'string', 'max:30'], 'payload.country' => [$required, 'string', 'max:100'],
'payload.phone' => ['nullable', 'string', 'max:30'], 'payload.country' => [$submitting ? 'required_without:payload.declared_origin_country_code' : 'nullable', 'nullable', 'string', 'max:100'],
'payload.organisation' => ['nullable', 'string', 'max:200'], 'payload.representative_role' => ['nullable', 'string', 'max:150'],
'payload.experience' => ['nullable', 'string', 'max:2000'], 'payload.investment_capacity' => ['nullable', 'string', 'max:1000'],
'payload.profile_id' => [$required, 'integer'], 'payload.sector' => [$required, 'string', 'max:150'],
@@ -193,6 +249,9 @@ class InvestmentSubmissionController extends Controller
}
}
$bound = DB::table('desk_cases')->where('submission_id', $request->route('id'))->where('user_id', $request->user()->id)->whereNotNull('journey_id')->first();
if ($bound) $data['payload'] = app(\App\Services\InvestmentConsumerScope::class)->bind($request->user(), $bound->id, $data['payload'], $submitting);
else unset($data['payload']['context_opportunity'], $data['payload']['context_opportunity_snapshot']);
return $data;
}
@@ -212,7 +271,7 @@ class InvestmentSubmissionController extends Controller
$version = DB::transaction(function () use ($request, $id, $data) {
$row = $this->owned($request, $id, true, true);
abort_unless($row->status === 'draft' && $row->version === (int) $data['version'], 409, 'Rascunho alterado. Recarregue antes de guardar.');
$row->update(['payload' => $data['payload'], 'version' => $row->version + 1]);
$row->update([...(isset($data['payload']['context_opportunity']) ? ['opportunity_id'=>(int)substr($data['payload']['context_opportunity'],12)] : []), 'payload' => $this->preserveCountrySource($row, $data['payload']), 'version' => $row->version + 1]);
InvestmentSpecificNeed::where('submission_id', $id)->delete();
foreach ($data['needs'] ?? [] as $need) {
InvestmentSpecificNeed::create([...$need, 'submission_id' => $id]);
@@ -225,6 +284,23 @@ class InvestmentSubmissionController extends Controller
return $request->expectsJson() ? response()->json(['version' => $version]) : back()->with('success', 'Rascunho guardado.');
}
/** @param array<string, mixed> $payload
* @return array<string, mixed> */
private function preserveCountrySource(InvestmentSubmission $row, array $payload): array
{
$source = $row->payload['organization_country_snapshot'] ?? null;
if (isset($payload['organization_country_snapshot']) && (! is_array($payload['organization_country_snapshot']) || ! is_array($source) ||
Arr::sortRecursive($payload['organization_country_snapshot']) !== Arr::sortRecursive($source))) {
throw ValidationException::withMessages(['payload.organization_country_snapshot' => 'A proveniência é recuperada no servidor; não pode ser substituída pelo formulário.']);
}
unset($payload['organization_country_snapshot']);
if ($source && ($row->payload['incorporation_country_code'] ?? null) === ($payload['incorporation_country_code'] ?? null)) {
$payload['organization_country_snapshot'] = $source;
}
return $payload;
}
public function submit(Request $request, string $id, InterestSubmission $intake): RedirectResponse|JsonResponse
{
// Replaying a completed submission returns only the owner's existing receipt.
@@ -239,11 +315,12 @@ class InvestmentSubmissionController extends Controller
return InterestRequest::findOrFail($row->interest_request_id)->reference;
}
abort_unless($row->version === (int) $data['version'], 409, 'Rascunho alterado. Recarregue.');
$opportunity = $row->opportunity_id ? Opportunity::published()->findOrFail($row->opportunity_id) : null;
$scopeKey = $data['payload']['context_opportunity'] ?? null;
$opportunity = $scopeKey ? Opportunity::published()->findOrFail(substr($scopeKey,12)) : ($row->opportunity_id ? Opportunity::published()->findOrFail($row->opportunity_id) : null);
if ($row->dossier_id) {
InvestmentPortfolioController::visible()->findOrFail($row->dossier_id);
}
$p = $data['payload'];
$p = $this->preserveCountrySource($row, $data['payload']);
$zoneOrigin = DB::table('investment_submission_zones')->where('submission_id', $id)->first(['zone_id', 'zone_version', 'intent']);
if ($zoneOrigin) {
$zone = app(InvestmentZones::class)->visible()->whereKey($zoneOrigin->zone_id)->lockForUpdate()->firstOrFail();
@@ -256,12 +333,18 @@ class InvestmentSubmissionController extends Controller
$interest = $intake->receive(['email' => $p['email'], 'fullName' => $p['full_name'], 'message' => $p['objective'], 'submission_id' => $id], $id,
fn () => InterestRequest::create(['reference' => 'IR-'.now()->year.'-'.Str::ulid(), 'opportunity_id' => $opportunity?->id,
'opportunity_reference' => $opportunity?->reference, 'full_name' => $p['full_name'], 'email' => $p['email'], 'phone' => $p['phone'] ?? null,
'country' => $p['country'], 'investor_type' => empty($p['organisation']) ? 'individual' : 'empresa',
'country' => $p['country'] ?? 'Não informado', 'investor_type' => empty($p['organisation']) ? 'individual' : 'empresa',
'sector_interest' => $p['sector'], 'message' => $p['objective'], 'privacy_consent' => true, 'consented_at' => now(),
'status' => 'received', 'source_page' => '/investidor/manifestacoes/'.$id,
'metadata' => ['submission_id' => $id, 'portal_user_id' => $request->user()->id, 'dossier_id' => $row->dossier_id,
'context_opportunity' => $p['context_opportunity'] ?? null,
'context_opportunity_snapshot' => $p['context_opportunity_snapshot'] ?? null,
'declared_origin_country_code' => $p['declared_origin_country_code'] ?? null,
'residence_country_code' => $p['residence_country_code'] ?? null,
'incorporation_country_code' => $p['incorporation_country_code'] ?? null,
...(isset($p['organization_country_snapshot']) ? ['organization_country_snapshot' => $p['organization_country_snapshot']] : []),
...($zoneOrigin ? ['zone_origin' => (array) $zoneOrigin] : [])]]));
$row->update(['payload' => $p, 'version' => $row->version + 1, 'interest_request_id' => $interest->id, 'status' => 'submitted']);
$row->update(['opportunity_id'=>$opportunity?->id, 'payload' => $p, 'version' => $row->version + 1, 'interest_request_id' => $interest->id, 'status' => 'submitted']);
InvestmentSpecificNeed::where('submission_id', $id)->delete();
foreach ($data['needs'] ?? [] as $need) {
InvestmentSpecificNeed::create([...$need, 'submission_id' => $id]);
@@ -293,7 +376,8 @@ class InvestmentSubmissionController extends Controller
abort_unless($key !== false, 500);
DB::table('documents')->insert(['id' => $documentId, 'source_system' => 'investor-submission', 'file_name' => basename($file->getClientOriginalName()),
'media_type' => $file->getMimeType(), 'storage_key' => $key, 'sha256' => hash_file('sha256', Storage::disk('local')->path($key)),
'status' => 'active', 'data_classification' => 'confidential']);
'size_bytes' => $file->getSize(), 'uploaded_by' => $request->user()->id,
'status' => 'quarantined', 'data_classification' => 'confidential']);
DB::table('investment_submission_attachments')->insert(['id' => (string) Str::uuid(), 'submission_id' => $id, 'document_id' => $documentId, 'created_at' => now(), 'updated_at' => now()]);
$row->increment('version');
$this->event($row, $request, 'attachment_added');
@@ -307,6 +391,8 @@ class InvestmentSubmissionController extends Controller
throw $e;
}
ScanDocumentForMalware::dispatch($documentId)->afterCommit();
return $request->expectsJson() ? response()->json(['version' => $version]) : back()->with('success', 'Anexo privado guardado.');
}
@@ -314,23 +400,34 @@ class InvestmentSubmissionController extends Controller
{
$this->owned($request, $id);
$doc = DB::table('investment_submission_attachments as a')->join('documents as d', 'd.id', '=', 'a.document_id')
->where('a.id', $attachment)->where('a.submission_id', $id)->where('d.status', 'active')->whereNull('d.deleted_at')->first(['d.file_name', 'd.storage_key', 'd.sha256']);
->where('a.id', $attachment)->where('a.submission_id', $id)->where('d.status', 'active')->whereNull('d.deleted_at')
->where('d.malware_scan_status', 'clean')->whereColumn('d.malware_scanned_sha256', 'd.sha256')
->first(['d.file_name', 'd.storage_key', 'd.sha256', 'd.malware_scanned_sha256']);
abort_unless($doc !== null, 404);
$root = realpath(Storage::disk('local')->path('investment-submissions/'.$id));
$path = realpath(Storage::disk('local')->path($doc->storage_key));
abort_unless($root && $path && str_starts_with(strtolower($path), strtolower($root.DIRECTORY_SEPARATOR)) && is_file($path), 404);
$hash = hash_file('sha256', $path);
abort_unless(is_string($hash) && hash_equals($doc->sha256, $hash), 404);
abort_unless(is_string($hash) && hash_equals($doc->sha256, $hash)
&& hash_equals($doc->sha256, (string) $doc->malware_scanned_sha256), 404);
return response()->download($path, basename($doc->file_name), ['Cache-Control' => 'private, no-store', 'X-Content-Type-Options' => 'nosniff']);
}
private function event(InvestmentSubmission $row, Request $request, string $action): void
{
$boundCase = app(VirtualDesk::class)->installed() ? DB::table('desk_cases')->where('submission_id', $row->id)->where('user_id', $request->user()->id)->whereNotNull('journey_id')->first() : null;
$binding = null;
if ($boundCase) {
$binding = app(InvestorContextConsumers::class)->submissionRevision($row->id);
if ($action === 'submitted' || $request->boolean('context_reviewed')) {
$binding = app(InvestorContextConsumers::class)->assertReviewed($request->user(), $boundCase->id, $request->all(), $binding);
}
}
app(InvestmentWorkspace::class)->snapshot($row);
DB::table('investment_submission_events')->insert(['submission_id' => $row->id, 'actor_id' => $request->user()->id, 'action' => $action, 'version' => $row->version, 'created_at' => now()]);
DB::table('audit_events')->insert(['source_system' => 'investment-submission', 'entity_type' => 'investment_submissions',
'entity_id' => $row->id, 'actor_user_id' => $request->user()->id, 'action' => $action,
'after_data' => json_encode(['version' => $row->version, 'status' => $row->status], JSON_THROW_ON_ERROR)]);
'after_data' => json_encode(['version' => $row->version, 'status' => $row->status, ...($binding !== null ? ['investment_context_revision' => $binding] : [])], JSON_THROW_ON_ERROR)]);
}
}
@@ -2,14 +2,40 @@
namespace App\Http\Controllers;
use App\Models\Opportunity;
use App\Services\InvestmentZones;
use App\Services\PortalProfileAccess;
use App\Services\TerritoryGeometry;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\ValidationException;
class InvestmentTerritoryController extends Controller
{
public function geometries(Request $request): JsonResponse
{
$data = $request->validate(['territory_id' => ['required', 'uuid']]);
$profile = $request->is('minhas-oportunidades/*');
$management = $request->is('management/*') || $profile;
$actor = $request->user();
$access = app(PortalProfileAccess::class);
// The general consumers use this same endpoint while authenticated.
// Reuse the read access already granted by the private cadastral routes.
$profileAccess = $actor && $actor->hasVerifiedEmail()
&& ($access->grants($actor) !== [] || $access->superuser($actor));
$internal = $profileAccess || ($actor?->hasVerifiedEmail() && $actor->can('manage-portal'));
if ($management) {
abort_unless($profile ? $profileAccess : $internal, 403);
}
return response()->json([
...app(TerritoryGeometry::class)->context($data['territory_id'], $internal),
'geometry_scope' => $internal ? 'authorized_internal' : 'public',
])->header('Cache-Control', 'private, no-store');
}
private const TABLES = ['opportunities' => 'opportunities', 'projects' => 'projects', 'clusters' => 'economic_clusters', 'corridors' => 'development_corridors', 'zones' => 'investment_zones'];
public function subjects(Request $request): JsonResponse
@@ -17,6 +43,9 @@ class InvestmentTerritoryController extends Controller
$data = $request->validate(['kind' => ['required', 'in:'.implode(',', array_keys(self::TABLES))], 'q' => ['nullable', 'string', 'max:100'], 'page' => ['nullable', 'integer', 'min:1']]);
$column = $data['kind'] === 'opportunities' ? 'title' : 'name';
$query = DB::table(self::TABLES[$data['kind']])->whereNull('deleted_at');
if ($data['kind'] === 'opportunities' && Schema::hasColumn('opportunities', 'owner_user_id')) {
$query->whereIn('id', app(PortalProfileAccess::class)->scope(Opportunity::query(), $request->user())->select('id'));
}
if (! empty($data['q'])) {
$query->whereRaw($column.' ILIKE ?', ['%'.addcslashes($data['q'], '%_\\').'%']);
}
@@ -26,6 +55,10 @@ class InvestmentTerritoryController extends Controller
public function options(Request $request): JsonResponse
{
if ($request->is('minhas-oportunidades/*')) {
$access = app(PortalProfileAccess::class);
abort_unless($access->grants($request->user()) !== [] || $access->superuser($request->user()), 403);
}
$data = $request->validate(['type' => ['required', 'in:province,district,admin_post,locality'], 'parent_id' => ['nullable', 'uuid'], 'q' => ['nullable', 'string', 'max:100'], 'page' => ['nullable', 'integer', 'min:1']]);
$query = DB::table('geo_locations')->whereNull('deleted_at')->where('location_type', $data['type'])
->whereIn('data_classification', ['public', 'internal']);
@@ -4,7 +4,9 @@ namespace App\Http\Controllers;
use App\Models\InvestmentSubmission;
use App\Models\User;
use App\Services\CountryCatalogue;
use App\Services\InvestmentWorkspace;
use App\Services\OrganizationCountry;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -18,9 +20,10 @@ class InvestmentWorkspaceController extends Controller
{
public function profile(Request $request, InvestmentWorkspace $workspace): Response|JsonResponse
{
$data = ['account' => $workspace->account($request->user()->id), 'companies' => DB::table('investment_company_declarations')
$data = ['organizationCountryAvailable' => app(OrganizationCountry::class)->available(), 'organizations' => app(OrganizationCountry::class)->represented($request->user()), 'countries' => app(CountryCatalogue::class)->all(), 'account' => $workspace->account($request->user()->id), 'companies' => DB::table('investment_company_declarations')
->where('user_id', $request->user()->id)->orderByDesc('created_at')->get(['id', 'version', 'payload'])->map(function ($company) {
$company->payload = json_decode(Crypt::decryptString($company->payload), true, 512, JSON_THROW_ON_ERROR);
$company->countrySource = $company->payload['organization_country_snapshot'] ?? null;
return $company;
})];
@@ -31,7 +34,8 @@ class InvestmentWorkspaceController extends Controller
public function saveProfile(Request $request): RedirectResponse|JsonResponse
{
$data = $request->validate(['version' => ['required', 'integer', 'min:0'],
'payload' => ['required', 'array:phone,country,sector,experience,investment_capacity'],
'payload' => ['required', 'array:phone,country,residence_country_code,sector,experience,investment_capacity'],
'payload.residence_country_code' => ['nullable', 'string', app(CountryCatalogue::class)->rule()],
'payload.phone' => ['nullable', 'string', 'max:30'], 'payload.country' => ['nullable', 'string', 'max:100'],
'payload.sector' => ['nullable', 'string', 'max:150'], 'payload.experience' => ['nullable', 'string', 'max:2000'],
'payload.investment_capacity' => ['nullable', 'string', 'max:1000']]);
@@ -53,8 +57,9 @@ class InvestmentWorkspaceController extends Controller
public function company(Request $request, ?string $id = null): RedirectResponse|JsonResponse
{
$data = $request->validate(['version' => ['required', 'integer', 'min:0'],
'payload' => ['required', 'array:name,country,registration,sector,role,description'],
'payload.name' => ['required', 'string', 'min:2', 'max:200'], 'payload.country' => ['required', 'string', 'max:100'],
'payload' => ['required', 'array:name,country,incorporation_country_code,registration,sector,role,description'],
'payload.incorporation_country_code' => ['nullable', 'string', app(CountryCatalogue::class)->rule()],
'payload.name' => ['required', 'string', 'min:2', 'max:200'], 'payload.country' => ['required_without:payload.incorporation_country_code', 'nullable', 'string', 'max:100'],
'payload.registration' => ['nullable', 'string', 'max:100'], 'payload.sector' => ['nullable', 'string', 'max:150'],
'payload.role' => ['required', 'string', 'max:150'], 'payload.description' => ['nullable', 'string', 'max:2000']]);
$result = DB::transaction(function () use ($request, $id, $data) {
@@ -65,9 +70,15 @@ class InvestmentWorkspaceController extends Controller
abort_if(! $id && DB::table('investment_company_declarations')->where('user_id', $request->user()->id)->count() >= 20, 422, 'Máximo de vinte empresas declaradas.');
$id ??= (string) Str::uuid();
$version = (int) $data['version'] + 1;
$previous = $row ? json_decode(Crypt::decryptString($row->payload), true, 512, JSON_THROW_ON_ERROR) : [];
$payload = [...$previous, ...$data['payload']];
if (($previous['incorporation_country_code'] ?? null) !== ($payload['incorporation_country_code'] ?? null)) {
unset($payload['organization_country_snapshot']);
}
$encrypted = Crypt::encryptString(json_encode($payload, JSON_THROW_ON_ERROR));
DB::table('investment_company_declarations')->updateOrInsert(['id' => $id], ['user_id' => $request->user()->id, 'version' => $version,
'payload' => Crypt::encryptString(json_encode($data['payload'], JSON_THROW_ON_ERROR)), 'created_at' => $row->created_at ?? now(), 'updated_at' => now()]);
$this->audit($request, 'company_declared', 'investment_company_declarations', $id, ['version' => $version]);
'payload' => $encrypted, 'created_at' => $row->created_at ?? now(), 'updated_at' => now()]);
$this->audit($request, 'company_declared', 'investment_company_declarations', $id, ['version' => $version, 'before_payload' => $row?->payload, 'after_payload' => $encrypted]);
return ['id' => $id, 'version' => $version];
});
@@ -75,6 +86,36 @@ class InvestmentWorkspaceController extends Controller
return $request->expectsJson() ? response()->json($result) : back()->with('success', 'Declaração de empresa guardada.');
}
public function organizationCountry(Request $request, string $id, OrganizationCountry $service): RedirectResponse|JsonResponse
{
$result = $service->save($request->user(), $id, $request->all());
return $request->expectsJson() ? response()->json($result) : back()->with('success', 'País de constituição guardado no cadastro mestre; submissões anteriores preservadas.');
}
public function reuseOrganizationCountry(Request $request, string $id, OrganizationCountry $service): RedirectResponse|JsonResponse
{
$data = $request->validate(['version' => 'required|integer|min:1', 'organization_id' => 'required|uuid', 'organization_version' => 'required|integer|min:1']);
$result = DB::transaction(function () use ($request, $id, $data, $service) {
$row = DB::table('investment_company_declarations')->where('id', $id)->where('user_id', $request->user()->id)->lockForUpdate()->first();
abort_unless($row !== null, 404);
abort_unless((int) $row->version === (int) $data['version'], 409, 'Declaração alterada. Recarregue.');
$snapshot = $service->snapshot($request->user(), $data['organization_id']);
abort_unless((int) $snapshot['organization_version'] === (int) $data['organization_version'], 409, 'País mestre alterado. Recarregue.');
$payload = json_decode(Crypt::decryptString($row->payload), true, 512, JSON_THROW_ON_ERROR);
$payload['incorporation_country_code'] = $snapshot['country'];
$payload['organization_country_snapshot'] = $snapshot;
$encrypted = Crypt::encryptString(json_encode($payload, JSON_THROW_ON_ERROR));
DB::table('investment_company_declarations')->where('id', $id)->update(['payload' => $encrypted, 'version' => $row->version + 1, 'updated_at' => now()]);
$this->audit($request, 'organization_country_reused', 'investment_company_declarations', $id, ['version' => $row->version + 1,
'before_payload' => $row->payload, 'after_payload' => $encrypted, 'organization_id' => $data['organization_id'], 'organization_version' => $snapshot['organization_version']]);
return ['version' => $row->version + 1, 'country' => $snapshot['country']];
});
return $request->expectsJson() ? response()->json($result) : back()->with('success', 'País mestre reutilizado nesta declaração; manifestações anteriores não alteradas.');
}
public function notifications(Request $request): Response|JsonResponse
{
$query = DB::table('investment_notifications')->where('user_id', $request->user()->id);
@@ -0,0 +1,206 @@
<?php
namespace App\Http\Controllers;
use App\Jobs\ScanDocumentForMalware;
use App\Models\InvestmentZone;
use App\Services\InvestmentZoneAccess;
use App\Services\InvestmentZones;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class InvestmentZoneAttachmentController extends Controller
{
private const ALLOWED_MIME_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'];
public function store(Request $request, string $id): JsonResponse
{
$zone = InvestmentZone::whereKey($id)->firstOrFail();
$data = $request->validate([
'file' => ['required', 'file', 'mimetypes:application/pdf,image/jpeg,image/png,image/webp', 'max:20480'],
'kind' => ['required', 'in:legal_instrument,map,photo,study,other'],
'caption' => ['required', 'string', 'max:255'],
'source_reference' => ['required', 'string', 'min:5', 'max:2000'],
'source_date' => ['nullable', 'date_format:Y-m-d'],
'rights_status' => ['required', 'in:pending,confirmed,restricted'],
'rights_basis' => ['nullable', 'string', 'max:2000'],
]);
$file = $request->file('file');
abort_unless(in_array($file->getMimeType(), self::ALLOWED_MIME_TYPES, true), 422, 'Formato de ficheiro não permitido.');
$this->validateFileContent($file->getRealPath(), $file->getMimeType());
abort_if(DB::table('investment_zone_attachments')->where('zone_id', $id)->count() >= 20, 422, 'Máximo de vinte anexos por zona.');
$attachmentId = (string) Str::uuid();
$documentId = (string) Str::uuid();
$key = null;
try {
$key = $file->storeAs('investment-zones/'.$id, $documentId, 'local');
abort_unless($key !== false, 500, 'Não foi possível guardar o ficheiro.');
$hash = hash_file('sha256', Storage::disk('local')->path($key));
abort_unless(is_string($hash), 500, 'Não foi possível verificar o ficheiro guardado.');
$attachment = DB::transaction(function () use ($request, $zone, $data, $file, $attachmentId, $documentId, $key, $hash): object {
DB::table('documents')->insert([
'id' => $documentId, 'source_system' => 'investment-zones', 'version_no' => 1,
'data_classification' => 'confidential', 'file_name' => basename($file->getClientOriginalName()),
'media_type' => $file->getMimeType(), 'storage_key' => $key, 'sha256' => $hash,
// Follow the portal's existing file policy: no antivirus engine is configured, so uploads stay quarantined.
'status' => 'quarantined', 'size_bytes' => $file->getSize(), 'uploaded_by' => $request->user()->id,
'created_at' => now(), 'updated_at' => now(),
]);
DB::table('investment_zone_attachments')->insert([
'id' => $attachmentId, 'zone_id' => $zone->id, 'document_id' => $documentId,
'kind' => $data['kind'], 'caption' => $data['caption'], 'source_reference' => $data['source_reference'],
'source_date' => $data['source_date'] ?? null, 'rights_status' => $data['rights_status'],
'rights_basis' => $data['rights_basis'] ?? null, 'personal_data_status' => 'unknown',
'review_status' => 'pending', 'uploaded_by' => $request->user()->id, 'version_no' => 1,
'created_at' => now(), 'updated_at' => now(),
]);
$this->event($attachmentId, $request->user()->id, 'uploaded', 1, ['kind' => $data['kind'], 'sha256' => $hash]);
return DB::table('investment_zone_attachments as a')->join('documents as d', 'd.id', '=', 'a.document_id')
->where('a.id', $attachmentId)->first(['a.*', 'd.file_name', 'd.media_type', 'd.size_bytes', 'd.sha256', 'd.malware_scan_status', 'd.malware_failure_reason']);
});
} catch (\Throwable $exception) {
if (is_string($key)) {
Storage::disk('local')->delete($key);
}
throw $exception;
}
ScanDocumentForMalware::dispatch($documentId)->afterCommit();
return response()->json($attachment, 201);
}
public function review(Request $request, string $id, string $action): JsonResponse
{
abort_unless(in_array($action, ['review', 'select-public', 'withdraw-public'], true), 404);
$rules = ['version_no' => ['required', 'integer', 'min:1']];
if ($action === 'review') {
$rules += ['decision' => ['required', 'in:reviewed,rejected'],
'personal_data_status' => ['required', 'in:none,present,unknown'],
'rights_status' => ['required', 'in:pending,confirmed,restricted'],
'rights_basis' => ['nullable', 'string', 'max:2000']];
}
$data = $request->validate($rules);
if ($action === 'select-public' || $action === 'withdraw-public') {
InvestmentZoneAccess::requireRole($request->user(), 'publishers');
}
return DB::transaction(function () use ($request, $id, $action, $data): JsonResponse {
$attachment = DB::table('investment_zone_attachments')->where('id', $id)->lockForUpdate()->first();
abort_unless($attachment !== null, 404);
abort_if((int) $attachment->version_no !== (int) $data['version_no'], 409, 'O anexo foi alterado. Recarregue antes de continuar.');
$version = (int) $attachment->version_no + 1;
$changes = ['version_no' => $version, 'updated_at' => now()];
$metadata = [];
if ($action === 'review') {
abort_if($data['rights_status'] === 'confirmed' && trim((string) ($data['rights_basis'] ?? $attachment->rights_basis)) === '', 422, 'Registe o fundamento dos direitos antes de os confirmar.');
$changes += [
'review_status' => $data['decision'], 'reviewed_by' => $request->user()->id, 'reviewed_at' => now(),
'independent_review' => (int) $attachment->uploaded_by !== (int) $request->user()->id,
'personal_data_status' => $data['personal_data_status'], 'rights_status' => $data['rights_status'],
'rights_basis' => $data['rights_basis'] ?? $attachment->rights_basis,
'publicly_selected' => false, 'publicly_selected_at' => null, 'publicly_selected_by' => null,
];
$metadata = ['decision' => $data['decision'], 'independent_review' => $changes['independent_review'], 'personal_data_status' => $data['personal_data_status'], 'rights_status' => $data['rights_status']];
} elseif ($action === 'select-public') {
abort_unless($attachment->review_status === 'reviewed' && $attachment->rights_status === 'confirmed'
&& trim((string) $attachment->rights_basis) !== '' && $attachment->personal_data_status === 'none', 422, 'O anexo precisa de revisão registada, fundamento dos direitos, confirmação de divulgação e verificação de ausência de dados pessoais.');
abort_unless(DB::table('documents')->where('id', $attachment->document_id)->where('status', 'active')
->where('malware_scan_status', 'clean')->whereColumn('malware_scanned_sha256', 'sha256')->exists(), 422,
'A análise antimalware tem de terminar sem ameaça antes da divulgação.');
abort_unless(app(InvestmentZones::class)->visible()->whereKey($attachment->zone_id)->exists(), 422, 'A zona deve estar publicada antes da divulgação do anexo.');
$changes += ['publicly_selected' => true, 'publicly_selected_at' => now(), 'publicly_selected_by' => $request->user()->id];
} else {
$changes += ['publicly_selected' => false, 'publicly_selected_at' => null, 'publicly_selected_by' => null];
}
DB::table('investment_zone_attachments')->where('id', $id)->update($changes);
$this->event($id, $request->user()->id, $action, $version, $metadata);
return response()->json(DB::table('investment_zone_attachments as a')->join('documents as d', 'd.id', '=', 'a.document_id')
->where('a.id', $id)->first(['a.*', 'd.file_name', 'd.media_type', 'd.size_bytes', 'd.sha256', 'd.malware_scan_status', 'd.malware_failure_reason']));
});
}
public function downloadPrivate(string $zoneId, string $id): BinaryFileResponse
{
$record = DB::table('investment_zone_attachments as a')->join('documents as d', 'd.id', '=', 'a.document_id')
->where('a.id', $id)->where('a.zone_id', $zoneId)->where('d.status', 'active')->whereNull('d.deleted_at')
->where('d.malware_scan_status', 'clean')->whereColumn('d.malware_scanned_sha256', 'd.sha256')
->first(['d.file_name', 'd.storage_key', 'd.sha256', 'd.media_type', 'd.malware_scanned_sha256']);
abort_unless($record !== null, 404);
return $this->verifiedDownload($zoneId, $record);
}
public function downloadPublic(string $zoneId, string $id): BinaryFileResponse
{
abort_unless(app(InvestmentZones::class)->visible()->whereKey($zoneId)->exists(), 404);
$record = DB::table('investment_zone_attachments as a')->join('documents as d', 'd.id', '=', 'a.document_id')
->where('a.id', $id)->where('a.zone_id', $zoneId)->where('a.publicly_selected', true)
->where('a.review_status', 'reviewed')->where('a.rights_status', 'confirmed')->where('a.personal_data_status', 'none')
->where('d.status', 'active')->whereNull('d.deleted_at')->where('d.malware_scan_status', 'clean')
->whereColumn('d.malware_scanned_sha256', 'd.sha256')->first(['d.file_name', 'd.storage_key', 'd.sha256', 'd.media_type', 'd.malware_scanned_sha256']);
abort_unless($record !== null, 404);
$extension = match ($record->media_type) {
'application/pdf' => 'pdf', 'image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp', default => 'bin'
};
return $this->verifiedDownload($zoneId, $record, str_starts_with($record->media_type, 'image/'), 'zone-attachment-'.$id.'.'.$extension);
}
private function verifiedDownload(string $zoneId, \stdClass $document, bool $inline = false, ?string $downloadName = null): BinaryFileResponse
{
$root = realpath(Storage::disk('local')->path('investment-zones/'.$zoneId));
$path = realpath(Storage::disk('local')->path($document->storage_key));
abort_unless($root && $path && str_starts_with(strtolower($path), strtolower($root.DIRECTORY_SEPARATOR)) && is_file($path), 404);
$hash = hash_file('sha256', $path);
abort_unless(is_string($hash) && hash_equals(trim($document->sha256), $hash)
&& hash_equals(trim($document->sha256), trim((string) $document->malware_scanned_sha256)), 404);
$headers = ['Cache-Control' => 'private, no-store', 'X-Content-Type-Options' => 'nosniff'];
$safeName = $downloadName ?? basename($document->file_name);
return $inline
? response()->file($path, $headers + ['Content-Type' => $document->media_type, 'Content-Disposition' => 'inline; filename="'.$safeName.'"'])
: response()->download($path, $safeName, $headers);
}
private function validateFileContent(string $path, string $mime): void
{
if ($mime === 'application/pdf') {
$handle = fopen($path, 'rb');
$signature = $handle ? fread($handle, 5) : false;
if (is_resource($handle)) {
fclose($handle);
}
abort_unless($signature === '%PDF-', 422, 'O conteúdo não corresponde a um PDF válido.');
return;
}
$image = @getimagesize($path);
abort_unless($image && $image[0] > 0 && $image[1] > 0 && $image[0] * $image[1] <= 40000000, 422, 'A imagem é inválida ou excede 40 megapíxeis.');
$expected = ['image/jpeg' => IMAGETYPE_JPEG, 'image/png' => IMAGETYPE_PNG, 'image/webp' => IMAGETYPE_WEBP][$mime] ?? null;
abort_unless($expected !== null && $image[2] === $expected, 422, 'O conteúdo da imagem não corresponde ao formato declarado.');
}
/** @param array<string,mixed> $metadata */
private function event(string $attachmentId, int $actor, string $action, int $version, array $metadata = []): void
{
DB::table('investment_zone_attachment_events')->insert([
'id' => (string) Str::uuid(), 'attachment_id' => $attachmentId, 'actor_id' => $actor,
'action' => $action, 'version_no' => $version, 'metadata' => json_encode($metadata, JSON_THROW_ON_ERROR), 'created_at' => now(),
]);
}
}
+201 -21
View File
@@ -7,14 +7,21 @@ use App\Models\InvestmentZone;
use App\Models\Opportunity;
use App\Services\InvestmentCatalogue;
use App\Services\InvestmentGeography;
use App\Services\InvestmentTerritorialTraceability;
use App\Services\InvestmentZoneAccess;
use App\Services\InvestmentZones;
use App\Services\InvestmentZoneOperationalProfile;
use App\Services\InvestmentZonePathContext;
use App\Services\InvestmentZoneProfileInstallation;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
class InvestmentZoneController extends Controller
{
@@ -22,8 +29,16 @@ class InvestmentZoneController extends Controller
{
$management = $request->is('management/*', 'api/v2/investment/admin/*');
$zone = ($management ? InvestmentZone::query() : $zones->visible())->findOrFail($id);
$publicFields = ['id', 'code', 'category', 'name', 'name_en', 'description', 'description_en', 'province', 'district', 'validation_status', 'updated_at'];
$source = DB::table('investment_legal_sources')->where('id', $zone->legal_source_id)->first(['title', 'url', 'diploma', 'article', 'competent_entity', 'valid_from', 'valid_until', 'version_no']);
$territoryPath = app(InvestmentGeography::class)->path($zone->primary_territory_id, $management);
$zone->setAttribute('province', collect($territoryPath)->firstWhere('location_type', 'province')->name ?? $zone->province);
$zone->setAttribute('district', collect($territoryPath)->firstWhere('location_type', 'district')->name ?? $zone->district);
$zone->setAttribute('territory_path', $territoryPath);
$zone->setAttribute('operational_details_public',app(\App\Services\InvestmentZoneOperationalDetails::class)->publicData($zone));
$publicFields = ['operational_details_public', 'version_no', 'published_at', 'id', 'code', 'category', 'physical_category', 'legal_regime', 'operational_status', 'development_status', 'availability_status', 'area_value', 'area_unit', 'area_components', 'source_evidence', 'activities', 'resources', 'aliases', 'historical_territory', 'data_as_of', 'sources_consulted_at', 'name', 'name_en', 'description', 'description_en', 'province', 'district', 'territory_path', 'validation_status', 'updated_at'];
$locations = DB::table('investment_zone_locations as zl')->join('geo_locations as g', 'g.id', '=', 'zl.geo_location_id')
->where('zl.zone_id', $id)->when(! $management, fn ($q) => $q->where('zl.validation_status', 'validated')->where('g.data_classification', 'public'))
->whereNull('g.deleted_at')->orderBy('g.location_type')->orderBy('g.name')->get(['g.id', 'g.name', 'g.location_type', 'g.parent_id', 'zl.scope_type', 'zl.source_reference', 'zl.validation_status']);
$source = DB::table('investment_legal_sources')->where('id', $zone->legal_source_id)->first(['title', 'url', 'diploma', 'article', 'competent_entity', 'valid_from', 'valid_until', 'validation_status', 'version_no']);
$operator = DB::table('organizations')->where('id', $zone->operator_id)->whereNull('deleted_at')
->when(! $management, fn ($q) => $q->where('data_classification', 'public'))->value('name');
$field = ['zee' => 'zee_id', 'zfi' => 'zfi_id', 'industrial_park' => 'park_id'][$zone->category] ?? null;
@@ -33,7 +48,28 @@ class InvestmentZoneController extends Controller
$relations = $zones->links(true)->where('zl.zone_id', $id)->whereNotNull('zl.'.$foreign);
$related = $related->merge(InvestmentPortfolioController::visible()->whereIn($foreign, $relations->select('zl.'.$foreign))->get());
}
$props = ['record' => $management ? $zone : $zone->only($publicFields), 'source' => $source, 'operator' => $operator, 'management' => $management,
$attachmentColumns = $management
? ['a.id', 'a.kind', 'a.caption', 'a.source_reference', 'a.source_date', 'a.rights_status', 'a.rights_basis', 'a.personal_data_status',
'a.review_status', 'a.reviewed_by', 'a.reviewed_at', 'a.independent_review', 'a.publicly_selected', 'a.publicly_selected_by',
'a.version_no', 'a.uploaded_by', 'a.created_at', 'd.id as document_id', 'd.file_name', 'd.media_type', 'd.size_bytes', 'd.sha256', 'd.status as file_status',
'd.malware_scan_status', 'd.malware_failure_reason', 'd.malware_scanned_at']
: ['a.id', 'a.kind', 'a.caption', 'a.source_reference', 'a.source_date', 'd.media_type', 'd.size_bytes'];
$attachments = DB::table('investment_zone_attachments as a')->join('documents as d', 'd.id', '=', 'a.document_id')
->where('a.zone_id', $id)->when(! $management, fn ($query) => $query->where('d.status', 'active'))
->when(! $management, fn ($query) => $query->where('d.malware_scan_status', 'clean')->whereColumn('d.malware_scanned_sha256', 'd.sha256'))
->whereNull('d.deleted_at')
->when(! $management, fn ($query) => $query->where('a.publicly_selected', true)->where('a.review_status', 'reviewed')
->where('a.rights_status', 'confirmed')->where('a.personal_data_status', 'none'))
->orderBy('a.created_at')->get($attachmentColumns);
$attachments->each(function ($attachment) use ($id, $management): void {
$attachment->download_url = $management
? url('/management/investment/zones/'.$id.'/attachments/'.$attachment->id)
: url('/zonas/'.$id.'/anexos/'.$attachment->id);
});
$props = ['record' => $management ? app(InvestmentZoneOperationalProfile::class)->management($zone) : app(InvestmentZoneOperationalProfile::class)->publicDetails($zone->only($publicFields)), 'source' => $source, 'operator' => $operator, 'management' => $management,
'profileAvailable' => $management && app(InvestmentZoneOperationalProfile::class)->installed(),
'zoneContext' => !$management && $zone->category === 'industrial_park' ? app(InvestmentZonePathContext::class)->context($id,(int)$zone->version_no) : null,
'zoneExtensionsAvailable' => Schema::hasColumn('investment_zones', 'source_evidence'),
'related' => $related->unique('id')->values(),
'relations' => $management ? DB::table('investment_zone_relations')->where('zone_id', $id)->orderByDesc('created_at')->limit(100)->get() : [],
'geometries' => $management ? DB::table('investment_zone_geometries')->where('zone_id', $id)->orderByDesc('created_at')->limit(100)->get() : [],
@@ -41,8 +77,12 @@ class InvestmentZoneController extends Controller
'dossiers' => InvestmentDossier::orderBy('title')->limit(200)->get(['id', 'title', 'project_id', 'cluster_id', 'corridor_id'])] : null,
'opportunities' => $opportunities,
'legalSources' => $management ? DB::table('investment_legal_sources')->orderByDesc('created_at')->limit(100)->get() : [],
'economicActivities' => $management ? app(\App\Services\EconomicReferenceCatalog::class)->activities() : [],
'operators' => $management ? DB::table('organizations')->whereNull('deleted_at')->orderBy('name')->limit(200)->get(['id', 'name']) : [],
'links' => $management ? DB::table('investment_zone_links')->where('zone_id', $id)->orderByDesc('created_at')->limit(100)->get() : [],
'locations' => $locations,
'territorialSources' => app(InvestmentTerritorialTraceability::class)->sources('investment_zones', $id),
'attachments' => $attachments,
'revisions' => $management ? DB::table('investment_zone_revisions')->where('zone_id', $id)->orderByDesc('version_no')->limit(20)->get(['version_no', 'action', 'sha256', 'created_at']) : []];
return $request->is('api/*') ? response()->json($props) : Inertia::render('investment/zone', $props);
@@ -50,31 +90,163 @@ class InvestmentZoneController extends Controller
public function index(Request $request, InvestmentZones $zones): Response|JsonResponse
{
$filters = $request->validate(['category' => ['nullable', 'in:zee,zfi,industrial_park'], 'q' => ['nullable', 'string', 'max:120'], 'page' => ['nullable', 'integer', 'min:1'],
$filters = $request->validate(['category' => ['nullable', 'in:zee,zfi,industrial_park'], 'physical_category' => ['nullable', 'in:economic_zone,industrial_park,agro_industrial_park,industrial_logistics_park,industrial_free_zone,integrated_tourism_area,other,unknown'],
'legal_regime' => ['nullable', 'in:zee,zfi,general,pending,not_applicable'], 'development_status' => ['nullable', 'in:announced,planned,in_development,partially_operational,operational,suspended,unknown'],
'availability_status' => ['nullable', 'in:confirmed,unavailable,reported_unverified,not_informed'], 'activity' => ['nullable', 'string', 'max:120'], 'q' => ['nullable', 'string', 'max:120'], 'page' => ['nullable', 'integer', 'min:1'],
'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid']]);
$management = $request->is('management/*', 'api/v2/investment/admin/*');
$query = $management ? InvestmentZone::query() : $zones->visible();
abort_if((! empty($filters['development_status']) || ! empty($filters['availability_status'])) && ! Schema::hasColumn('investment_zones', 'source_evidence'), 503,
'Os filtros de desenvolvimento e disponibilidade estarão disponíveis após a migração do catálogo de zonas.');
$path = app(InvestmentGeography::class)->selection($filters);
if ($path !== []) {
$query->whereIn('primary_territory_id', app(InvestmentGeography::class)->descendants($path[array_key_last($path)]->id));
}
$rows = $query->when($filters['category'] ?? null, fn ($q, $v) => $q->where('category', $v))
->when($filters['q'] ?? null, fn ($q, $v) => $q->where(fn ($q) => $q->where('name', 'ilike', '%'.$v.'%')->orWhere('code', 'ilike', '%'.$v.'%')))
->orderBy('name')->orderBy('id')->paginate(12, $management ? ['*'] : ['id', 'code', 'name', 'name_en', 'category', 'province', 'district', 'description', 'description_en', 'validation_status', 'updated_at'])->withQueryString();
$props = ['records' => $rows, 'filters' => $filters, 'management' => $management, 'territorySelection' => $path];
$publicColumns = ['primary_territory_id', 'version_no', 'id', 'code', 'name', 'name_en', 'category', 'physical_category', 'legal_regime', 'operational_status', 'development_status', 'availability_status', 'area_value', 'area_unit', 'area_components', 'source_evidence', 'activities', 'resources', 'aliases', 'data_as_of', 'sources_consulted_at', 'province', 'district', 'description', 'description_en', 'validation_status', 'updated_at'];
if(app(\App\Services\InvestmentZoneOperationalDetails::class)->installed()){$publicColumns[]='operational_details';}
$publicColumns = array_values(array_filter($publicColumns, fn ($column) => Schema::hasColumn('investment_zones', $column)));
$rows = $zones->search($filters, $management)->orderBy('name')->orderBy('id')
->paginate(12, $management ? ['*'] : $publicColumns)->withQueryString();
$rows->getCollection()->transform(function (InvestmentZone $zone) use ($management) {
$territoryPath = app(InvestmentGeography::class)->path($zone->primary_territory_id, $management);
$zone->setAttribute('province', collect($territoryPath)->firstWhere('location_type', 'province')->name ?? $zone->province);
$zone->setAttribute('district', collect($territoryPath)->firstWhere('location_type', 'district')->name ?? $zone->district);
$zone->setAttribute('territory_path', $territoryPath);
$zone->setAttribute('locations', DB::table('investment_zone_locations as zl')->join('geo_locations as g', 'g.id', '=', 'zl.geo_location_id')
->where('zl.zone_id', $zone->id)->whereNull('g.deleted_at')
->when(! $management, fn ($q) => $q->where('zl.validation_status', 'validated')->where('g.data_classification', 'public'))
->orderBy('g.location_type')->orderBy('g.name')->get(['g.id', 'g.name', 'g.location_type', 'zl.scope_type']));
if (! $management) { $zone->makeHidden('primary_territory_id'); }
return $management ? $zone : [...app(InvestmentZoneOperationalProfile::class)->publicDetails($zone->toArray()),'operational_details_public'=>app(\App\Services\InvestmentZoneOperationalDetails::class)->publicData($zone)];
});
$props = ['detailsInstallation'=>$management ? app(\App\Services\InvestmentZoneOperationalDetailsInstallation::class)->preview($request->user()) : null, 'installation' => $management ? app(InvestmentZoneProfileInstallation::class)->preview($request->user()) : null, 'records' => $rows, 'filters' => $filters, 'management' => $management, 'territorySelection' => $path,
'zoneExtensionsAvailable' => Schema::hasColumn('investment_zones', 'source_evidence')];
return $request->is('api/*') ? response()->json($props) : Inertia::render('investment/zones', $props);
}
public function export(Request $request, InvestmentZones $zones): StreamedResponse
{
$filters = $request->validate(['category' => ['nullable', 'in:zee,zfi,industrial_park'], 'physical_category' => ['nullable', 'in:economic_zone,industrial_park,agro_industrial_park,industrial_logistics_park,industrial_free_zone,integrated_tourism_area,other,unknown'],
'legal_regime' => ['nullable', 'in:zee,zfi,general,pending,not_applicable'], 'development_status' => ['nullable', 'in:announced,planned,in_development,partially_operational,operational,suspended,unknown'],
'availability_status' => ['nullable', 'in:confirmed,unavailable,reported_unverified,not_informed'], 'activity' => ['nullable', 'string', 'max:120'], 'q' => ['nullable', 'string', 'max:120'],
'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid']]);
abort_if((! empty($filters['development_status']) || ! empty($filters['availability_status'])) && ! Schema::hasColumn('investment_zones', 'source_evidence'), 503,
'Os filtros de desenvolvimento e disponibilidade estarão disponíveis após a migração do catálogo de zonas.');
$query = $zones->search($filters)->orderBy('name')->orderBy('id');
return response()->streamDownload(function () use ($query): void {
$geography = app(InvestmentGeography::class);
$stream = fopen('php://output', 'wb');
if ($stream === false) {
throw new \RuntimeException('CSV output stream unavailable.');
}
fwrite($stream, "\xEF\xBB\xBF");
fputcsv($stream, ['Código', 'Nome', 'Categoria física', 'Regime', 'Desenvolvimento', 'Disponibilidade', 'Província', 'Distrito', 'Área declarada', 'Unidade', 'Fontes'], ';');
$query->chunk(200, function ($zones) use ($stream, $geography): void {
foreach ($zones as $zone) {
$territoryPath = $geography->path($zone->primary_territory_id);
$province = collect($territoryPath)->firstWhere('location_type', 'province')->name ?? $zone->province;
$district = collect($territoryPath)->firstWhere('location_type', 'district')->name ?? $zone->district;
$sources = collect($zone->source_evidence ?? [])->map(fn ($source) => ($source['title'] ?? '').' — '.($source['url'] ?? '').' — '.($source['source_date'] ?? 'data não informada'))->implode(' | ');
$values = [$zone->code, $zone->name, $zone->physical_category, $zone->legal_regime, $zone->development_status,
$zone->availability_status, $province, $district, $zone->area_value, $zone->area_unit, $sources];
$values = array_map(fn ($value) => is_string($value) && preg_match('/^[=+@\-\t\r]/u', $value) ? "'".$value : $value, $values);
fputcsv($stream, $values, ';');
}
});
fclose($stream);
}, 'zonas-e-parques.csv', ['Content-Type' => 'text/csv; charset=UTF-8']);
}
public function save(Request $request, InvestmentZones $zones, ?string $id = null): JsonResponse
{
if ($request->has('aliases_text')) {
$request->merge(['aliases' => array_values(array_unique(array_filter(preg_split('/[\r\n,;]+/u', (string) $request->input('aliases_text')) ?: [], fn ($value) => trim($value) !== '')))]);
}
if ($request->filled('historical_name')) {
$request->merge(['historical_territory' => [
'name' => $request->input('historical_name'), 'district' => $request->input('historical_district'),
'province' => $request->input('historical_province'), 'source_reference' => $request->input('historical_source_reference'),
]]);
}
if ($request->filled('activity_name')) {
$request->merge(['activities' => [['name' => $request->input('activity_name'), 'status' => $request->input('activity_status', 'potential')]]]);
}
$data = $request->validate(['version_no' => [$id ? 'required' : 'nullable', 'integer', 'min:1'],
'code' => ['required', 'string', 'max:80', Rule::unique('investment_zones')->ignore($id)],
'category' => ['required', 'in:zee,zfi,industrial_park,pending'], 'name' => ['required', 'string', 'max:255'],
'name_en' => ['nullable', 'string', 'max:255'], 'description' => ['nullable', 'string', 'max:15000'],
'description_en' => ['nullable', 'string', 'max:15000'], 'province' => ['nullable', 'string', 'max:100'], 'district' => ['nullable', 'string', 'max:100'],
'operator_id' => ['nullable', 'uuid', Rule::exists('organizations', 'id')->whereNull('deleted_at')],
'legal_source_id' => ['nullable', 'uuid', 'exists:investment_legal_sources,id']]);
'legal_source_id' => ['nullable', 'uuid', 'exists:investment_legal_sources,id'],
'physical_category' => ['nullable', 'in:economic_zone,industrial_park,agro_industrial_park,industrial_logistics_park,industrial_free_zone,integrated_tourism_area,other,unknown'],
'legal_regime' => ['nullable', 'in:zee,zfi,general,pending,not_applicable'],
'operational_status' => ['nullable', 'in:operational,implementation,planned,suspended,unknown'],
'development_status' => ['nullable', 'in:announced,planned,in_development,partially_operational,operational,suspended,unknown'],
'availability_status' => ['nullable', 'in:confirmed,unavailable,reported_unverified,not_informed'],
'area_components' => ['sometimes', 'array', 'max:30'], 'area_components.*' => ['array:type,value,unit,source_reference,source_date,status'],
'area_components.*.type' => ['required', 'in:total,legal,developed,pilot,available'], 'area_components.*.value' => ['nullable', 'numeric', 'min:0'],
'area_components.*.unit' => ['required', 'in:ha,km2,m2'], 'area_components.*.source_reference' => ['required', 'string', 'min:5', 'max:2000'],
'area_components.*.source_date' => ['nullable', 'date_format:Y-m-d'], 'area_components.*.status' => ['required', 'in:reported,confirmed,disputed'],
'source_evidence' => ['sometimes', 'array', 'max:30'], 'source_evidence.*' => ['array:title,url,source_type,supports,source_date'],
'source_evidence.*.title' => ['required', 'string', 'max:255'], 'source_evidence.*.url' => ['required', 'url:https', 'max:2000'],
'source_evidence.*.source_type' => ['required', 'in:government,institutional,promoter,other'], 'source_evidence.*.supports' => ['required', 'string', 'max:500'],
'source_evidence.*.source_date' => ['nullable', 'date_format:Y-m-d'],
'area_value' => ['nullable', 'numeric', 'min:0'], 'area_unit' => ['nullable', 'string', 'max:24'],
'aliases' => ['sometimes', 'array', 'max:20'], 'aliases.*' => ['string', 'max:120'],
'operational_details' => ['sometimes','array'],
'activities' => ['sometimes', 'array', 'max:50'], 'activities.*' => ['array:id,name,status,source_reference,source_date,kind,scope,capacity_type,capacity_value,capacity_unit,focus,economic_activity_id,support_document_ids,private_note'],
'activities.*.id' => ['nullable','uuid'],
'activities.*.source_date' => ['nullable','date_format:Y-m-d'],
'activities.*.kind' => ['nullable','in:infrastructure,service,access,other'],
'activities.*.scope' => ['nullable','in:inside,regional,unknown'],
'activities.*.capacity_type' => ['nullable','string','max:120'],
'activities.*.capacity_value' => ['nullable','numeric','min:0'],
'activities.*.capacity_unit' => ['nullable','string','max:40'],
'activities.*.focus' => ['nullable','in:promotional,potential,installed,unknown'],
'activities.*.economic_activity_id' => ['nullable','uuid','exists:economic_activities,id'],
'activities.*.support_document_ids' => ['sometimes','array','max:10'],
'activities.*.support_document_ids.*' => ['uuid'],
'activities.*.private_note' => ['nullable','string','max:2000'],
'activities.*.name' => ['required', 'string', 'max:300'], 'activities.*.status' => ['required', 'in:proposed,authorized,conditional,excluded,potential'],
'activities.*.source_reference' => ['nullable', 'string', 'max:2000'],
'resources' => ['sometimes', 'array', 'max:50'], 'resources.*' => ['array:id,name,status,source_reference,source_date,kind,scope,capacity_type,capacity_value,capacity_unit,focus,economic_activity_id,support_document_ids,private_note'],
'resources.*.id' => ['nullable','uuid'],
'resources.*.source_date' => ['nullable','date_format:Y-m-d'],
'resources.*.kind' => ['nullable','in:infrastructure,service,access,other'],
'resources.*.scope' => ['nullable','in:inside,regional,unknown'],
'resources.*.capacity_type' => ['nullable','string','max:120'],
'resources.*.capacity_value' => ['nullable','numeric','min:0'],
'resources.*.capacity_unit' => ['nullable','string','max:40'],
'resources.*.focus' => ['nullable','in:promotional,potential,installed,unknown'],
'resources.*.economic_activity_id' => ['nullable','uuid','exists:economic_activities,id'],
'resources.*.support_document_ids' => ['sometimes','array','max:10'],
'resources.*.support_document_ids.*' => ['uuid'],
'resources.*.private_note' => ['nullable','string','max:2000'],
'resources.*.name' => ['required', 'string', 'max:120'], 'resources.*.status' => ['required', 'in:existing,planned,unconfirmed'],
'resources.*.source_reference' => ['nullable', 'string', 'max:2000'], 'data_as_of' => ['nullable', 'date_format:Y-m-d'],
'sources_consulted_at' => ['nullable', 'date_format:Y-m-d'], 'parent_zone_id' => ['nullable', 'uuid', 'exists:investment_zones,id'],
'historical_territory' => ['nullable', 'array:name,district,province,source_reference'],
'historical_territory.name' => ['required_with:historical_territory', 'string', 'max:255'],
'historical_territory.district' => ['nullable', 'string', 'max:120'], 'historical_territory.province' => ['nullable', 'string', 'max:120'],
'historical_territory.source_reference' => ['nullable', 'string', 'max:2000'],
'locations' => ['sometimes', 'array', 'max:20'], 'locations.*.geo_location_id' => ['required', 'uuid', 'exists:geo_locations,id'],
'locations.*.scope_type' => ['required', 'in:legal_scope,administrative,site,influence'],
'locations.*.source_reference' => ['required', 'string', 'min:10', 'max:2000'],
'locations.*.legal_source_id' => ['nullable', 'uuid', 'exists:investment_legal_sources,id']]);
foreach (['development_status', 'availability_status', 'area_components', 'source_evidence'] as $column) {
if (! Schema::hasColumn('investment_zones', $column)) {
unset($data[$column]);
}
}
foreach ($data['activities'] ?? [] as $index => $activity) {
if (! in_array($activity['status'], ['potential', 'proposed'], true) && trim((string) ($activity['source_reference'] ?? '')) === '') {
throw ValidationException::withMessages([
'activities.'.$index.'.source_reference' => 'Indique a fonte da actividade documentada antes de a classificar como autorizada, condicionada ou excluída.',
]);
}
}
if (! $id && $request->has('province_id')) {
$geo = $request->validate(['province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid']]);
@@ -85,18 +257,18 @@ class InvestmentZoneController extends Controller
$data['district'] = $path[1]->name ?? null;
}
return response()->json($zones->save($id, $data, $request->user()->id), $id ? 200 : 201);
$saved=$zones->save($id, $data, $request->user()->id);
return response()->json(app(InvestmentZoneOperationalProfile::class)->management($saved), $id ? 200 : 201);
}
public function transition(Request $request, InvestmentZones $zones, string $id, string $action): JsonResponse
{
if ($action === 'publish') {
InvestmentZoneAccess::requireRole($request->user(), 'publishers');
}
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1']]);
return response()->json($action === 'publish' ? $zones->publish($id, $data['version_no'], $request->user()->id)
: $zones->suspend($id, $data['version_no'], $request->user()->id));
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1'], 'base_public_version'=>['required_if:action,reconcile','nullable','integer','min:1']]);
if($action==='reconcile'){
$request->validate(['base_public_version'=>'required|integer|min:1']);
$saved=DB::transaction(function()use($id,$data,$request){$zone=InvestmentZone::whereKey($id)->lockForUpdate()->firstOrFail();abort_unless($zone->category==='industrial_park',422);return app(InvestmentZoneOperationalProfile::class)->reconcile($zone,(int)$data['version_no'],(int)$data['base_public_version'],$request->user()->id);});
}else{$saved=$action === 'publish' ? $zones->publish($id,$data['version_no'],$request->user()->id) : $zones->suspend($id,$data['version_no'],$request->user()->id);}
return response()->json(app(InvestmentZoneOperationalProfile::class)->management($saved));
}
public function revisions(string $id): JsonResponse
@@ -105,4 +277,12 @@ class InvestmentZoneController extends Controller
return response()->json(DB::table('investment_zone_revisions')->where('zone_id', $id)->orderByDesc('version_no')->paginate(20, ['id', 'version_no', 'actor_id', 'action', 'sha256', 'created_at']));
}
public function validateLocation(Request $request, InvestmentZones $zones, string $id): JsonResponse
{
InvestmentZoneAccess::requireRole($request->user(), 'territory_reviewers');
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1']]);
return response()->json($zones->validateLocation($id, $data['version_no'], $request->user()->id));
}
}
@@ -9,6 +9,7 @@ use Illuminate\Database\QueryException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
@@ -98,9 +99,15 @@ class InvestmentZoneGeometryController extends Controller
public function map(Request $request, InvestmentZones $zones): JsonResponse
{
$data = $request->validate(['zone_id' => ['nullable', 'uuid'], 'bbox' => ['nullable', 'array', 'size:4'], 'bbox.*' => ['required', 'numeric']]);
$data = $request->validate(['zone_id' => ['nullable', 'uuid'], 'bbox' => ['nullable', 'array', 'size:4'], 'bbox.*' => ['required', 'numeric'],
'category' => ['nullable', 'in:zee,zfi,industrial_park'], 'physical_category' => ['nullable', 'string', 'max:40'], 'legal_regime' => ['nullable', 'string', 'max:30'],
'development_status' => ['nullable', 'string', 'max:32'], 'availability_status' => ['nullable', 'string', 'max:32'], 'activity' => ['nullable', 'string', 'max:120'], 'q' => ['nullable', 'string', 'max:120'],
'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid']]);
abort_if((! empty($data['development_status']) || ! empty($data['availability_status'])) && ! Schema::hasColumn('investment_zones', 'source_evidence'), 503,
'Os filtros de desenvolvimento e disponibilidade estarão disponíveis após a migração do catálogo de zonas.');
$eligibleZones = $zones->search($data)->select('investment_zones.id');
$query = DB::table('investment_zone_geometries as l')->join('geo_geometries as g', 'g.id', '=', 'l.geometry_id')
->whereIn('l.zone_id', $zones->visible()->select('investment_zones.id'))->where('l.publication_authorized', true)->where('l.validation_status', 'validated')
->whereIn('l.zone_id', $eligibleZones)->where('l.publication_authorized', true)->where('l.validation_status', 'validated')
->whereNull('g.deleted_at')->where('g.data_classification', 'public')->when($data['zone_id'] ?? null, fn ($q, $v) => $q->where('l.zone_id', $v))
->where(fn ($q) => $q->whereNull('l.legal_source_id')->orWhereExists(fn ($s) => $s->selectRaw('1')->from('investment_legal_sources as ls')
->whereColumn('ls.id', 'l.legal_source_id')->where('ls.validation_status', 'validated')->whereDate('ls.valid_from', '<=', today())
@@ -0,0 +1,156 @@
<?php
namespace App\Http\Controllers;
use App\Services\InvestmentGeography;
use App\Services\InvestorContext;
use App\Services\InvestorContextInstallation;
use App\Services\InvestorPath;
use Illuminate\Http\Request;
use Inertia\Inertia;
final class InvestorContextController extends Controller
{
private function entryComplements(Request $request): array
{
$data = $request->validate(['complement_origin' => 'nullable|string|max:100', 'complement_code' => 'required_with:complement_origin|string|max:40',
'complement_target' => 'nullable|string|max:100', 'complement_territory' => 'nullable|uuid']);
if (empty($data['complement_origin'])) return [];
return app(\App\Services\InvestmentComplements::class)->resolve([['origin' => $data['complement_origin'], 'code' => $data['complement_code'],
'target' => $data['complement_target'] ?? null, 'territory_id' => $data['complement_territory'] ?? null]]);
}
private function entryChoices(Request $r, InvestorContext $context): array
{
$data = $r->validate(['source' => 'nullable|string|max:100', 'from_dossier' => 'nullable|uuid', 'opportunities' => 'sometimes|array|max:100', 'opportunities.*' => 'integer|distinct|min:1',
'province_id' => 'nullable|uuid', 'district_id' => 'nullable|uuid', 'admin_post_id' => 'nullable|uuid', 'locality_id' => 'nullable|uuid']);
$source = !empty($data['source']) ? $context->reference($data['source']) : null;
$ids = $data['opportunities'] ?? (($source['kind'] ?? '') === 'opportunity' ? [$source['id']] : []);
abort_if(!empty($data['from_dossier']) && !$ids, 422, 'Escolha a oportunidade associada à ficha da carteira.');
$path = app(InvestmentGeography::class)->selection($data);
$input = array_map(fn($id) => ['source' => 'opportunity:'.$id, ...($path ? ['territory' => 'territory:'.$path[array_key_last($path)]->id] : [])], $ids);
if (!empty($data['from_dossier'])) $input[0]['portfolio_origins'] = [$data['from_dossier']];
foreach ($this->entryComplements($r) as $complement) {
$keys = array_filter([$complement['origin']['key'], $complement['target']['key'] ?? null], fn($key) => str_starts_with($key, 'opportunity:'));
foreach ($keys as $key) {
if (in_array($key, array_column($input, 'source'), true)) continue;
$input[] = ['source' => $key, ...($complement['territory_id'] ? ['territory' => 'territory:'.$complement['territory_id']] : []),
...(str_starts_with($complement['origin']['key'], 'portfolio:') ? ['portfolio_origins' => [substr($complement['origin']['key'], 10)]] : [])];
}
}
return app(\App\Services\InvestmentOpportunityChoices::class)->resolve($input, [], true);
}
public function current(Request $r, InvestorContext $context)
{
return response()->json($context->current($r->user()));
}
public function focus(Request $r, InvestorContext $context)
{
return response()->json($context->focus($r->user(), $r->all()));
}
public function show(Request $r, string $id, InvestorContext $context)
{
$data = $r->validate(['opportunity' => 'nullable|string|max:100']);
return response()->json($context->view($r->user(), $id, $data['opportunity'] ?? null));
}
public function options(Request $r, InvestorContext $context)
{
$d = $r->validate(['q' => 'nullable|string|max:120']);
return response()->json($context->options($d['q'] ?? ''));
}
public function corridorOptions(Request $r)
{
$data = $r->validate(['corridor' => ['nullable', \Illuminate\Validation\Rule::in(array_keys(config('investment_corridors')))], 'territory' => 'nullable|uuid']);
$service = app(\App\Services\InvestmentCorridorContext::class);
return response()->json(['corridors' => collect(config('investment_corridors'))->map(fn($entry,$key)=>['key'=>$key,'name'=>$entry['name'],'provinces'=>$service->provinces($key)])->values(),
'parks' => $service->parks($data['corridor'] ?? null, $data['territory'] ?? null)]);
}
public function saveDraft(Request $r, string $id, InvestorContext $context)
{
return response()->json($context->saveDraft($r->user(), $id, $r->all()));
}
public function edit(Request $r, string $id, InvestorContext $context)
{
$data = $context->view($r->user(), $id);
abort_unless($data['can_write'], 403);
$entries = $this->entryChoices($r, $context);
$choices = $entries ?: null;
$source = $r->query('source') ? $context->reference($r->validate(['source' => 'required|string|max:100'])['source']) : null;
if ($choices === null && ($source['kind'] ?? '') === 'opportunity') $choices = app(\App\Services\InvestmentOpportunityChoices::class)->resolve([['source' => $source['key']]]);
$explicitChoices=$choices;
$selectionKey=$r->validate(['selection_key'=>'nullable|uuid'])['selection_key']??null;
$entryKey=fn($c)=>$c['opportunity']['key'].($selectionKey?'@'.$selectionKey:'');
$entrySources=array_map($entryKey,$explicitChoices??[]);
if ($choices !== null) $choices = app(\App\Services\InvestmentOpportunityChoices::class)->mergeEntries($data['context']['opportunity_choices'] ?? [], $choices);
$draft = $context->draft($r->user(), $id);
if ($draft && isset($draft['opportunity_choices'])) {
$recovered = app(\App\Services\InvestmentOpportunityChoices::class)->resolve($draft['opportunity_choices'], $data['context']['opportunity_choices'] ?? [], true);
$new=array_filter($explicitChoices??[],fn($c)=>!in_array($entryKey($c),$draft['entry_sources']??[],true));
$choices = app(\App\Services\InvestmentOpportunityChoices::class)->mergeEntries($recovered, array_values($new));
}
$differences = [];
foreach ($explicitChoices ?? [] as $entry) {
$kept = collect($choices ?? [])->firstWhere('opportunity.key', $entry['opportunity']['key']);
if ($kept && ($kept['territory']['key'] ?? null) !== ($entry['territory']['key'] ?? null)) $differences[] = ['title'=>$entry['opportunity']['title'],'saved'=>$kept['territory']['title']??'Por definir','requested'=>$entry['territory']['title']??'Por definir'];
}
$complements = isset($draft['complements']) ? app(\App\Services\InvestmentComplements::class)->resolve($draft['complements'], $data['context']['complements'] ?? []) : ($data['context']['complements'] ?? []);
$entryTerritory = null;
if (($source['kind'] ?? null) === 'portfolio') {
$path = app(InvestmentGeography::class)->selection($r->validate(['province_id' => 'nullable|uuid', 'district_id' => 'nullable|uuid', 'admin_post_id' => 'nullable|uuid', 'locality_id' => 'nullable|uuid']));
if ($path) $entryTerritory = $context->reference('territory:'.$path[array_key_last($path)]->id);
}
foreach ($this->entryComplements($r) as $entry) {
$tag = 'complement:'.hash('sha256', $entry['key'].'@'.($selectionKey ?? ''));
$entrySources[] = $tag;
if (!in_array($tag, $draft['entry_sources'] ?? [], true)) $complements = collect([...$complements, $entry])->unique('key')->values()->all();
}
return Inertia::render('investor-path/context', ['entryTerritory' => $entryTerritory, 'entryComplements' => $complements, 'entryLocationDifferences'=>$differences,'entrySources'=>$entrySources,'draft' => $draft, 'requestedChoices' => $choices, 'initial' => $data, 'options' => $context->options(), 'source' => $source, 'territorySelection' => isset($data['context']['selection']['territory']) ? app(InvestmentGeography::class)->path($data['context']['selection']['territory']['id']) : ($entryTerritory ? app(InvestmentGeography::class)->path($entryTerritory['id']) : [])]);
}
public function preview(Request $r, string $id, InvestorContext $context)
{
return response()->json($context->preview($r->user(), $id, $r->all()));
}
public function commit(Request $r, string $id, InvestorContext $context)
{
return response()->json($context->commit($r->user(), $id, $r->all()));
}
public function start(Request $r, InvestorPath $path, InvestorContext $context)
{
$d = $r->validate(['source' => 'nullable|string|max:100', 'step' => ['nullable', 'regex:/^S(0[1-9]|1[0-9])$/']]);
$ref = isset($d['source']) ? $context->reference($d['source']) : null;
$submissionId = $r->validate(['submission' => 'nullable|uuid'])['submission'] ?? null;
$entrySubmission = null;
if ($submissionId) {
$submission = \App\Models\InvestmentSubmission::where('id', $submissionId)->where('user_id', $r->user()->id)->where('status', 'draft')->firstOrFail();
abort_unless(($ref['kind'] ?? null) === 'portfolio' && $submission->dossier_id === $ref['id'], 422, 'Conserve a ficha de origem da manifestação.');
$entrySubmission = ['id' => $submission->id, 'version' => $submission->version];
}
$choices = $this->entryChoices($r, $context);
return Inertia::render('investor-path/context-start', ['entrySubmission' => $entrySubmission, 'entryComplements' => $this->entryComplements($r), 'entryQuery' => $r->only(['complement_origin', 'complement_code', 'complement_target', 'complement_territory', 'from_dossier', 'province_id', 'district_id', 'admin_post_id', 'locality_id']), 'opportunityChoices' => $choices, 'contexts' => $path->contexts($r->user()), 'source' => $ref, 'step' => $d['step'] ?? null]);
}
public function installation(Request $r, InvestorContextInstallation $install)
{
$plan = $install->preview($r->user());
return $r->expectsJson() ? response()->json($plan) : Inertia::render('investor-path/context-installation', ['plan' => $plan]);
}
public function install(Request $r, InvestorContextInstallation $install)
{
return response()->json($install->apply($r->user(),$r->all()));
}
}
@@ -0,0 +1,78 @@
<?php
namespace App\Http\Controllers;
use App\Services\Ai\EvidenceRepository;
use App\Services\Ai\InvestorGuidance;
use App\Services\InvestorPath;
use App\Services\VirtualDesk;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class InvestorGuidanceController extends Controller
{
public function contexts(Request $request, VirtualDesk $desk): JsonResponse
{
$actor = $request->user();
$organizations = DB::table('memberships')->where('user_id', $actor->id)->whereNull('deleted_at')->pluck('organization_id');
$cases = DB::table('desk_cases')->whereNull('closed_at')->where(fn ($q) => $q->where(fn ($p) => $p->where('user_id', $actor->id)->whereNull('organization_id'))->orWhereIn('organization_id', $organizations))
->orderByDesc('updated_at')->get(['id', 'title', 'journey_id'])->map(function ($case) {
return ['id' => $case->id, 'title' => $case->title, 'path_supported' => app(InvestorPath::class)->available()
&& $case->journey_id && DB::table('investor_journeys')->where('id', $case->journey_id)->whereNotNull('path_definition_id')->exists()];
});
return response()->json(['cases' => $cases]);
}
public function show(Request $request, string $id, InvestorGuidance $guidance): JsonResponse
{
return response()->json($guidance->view($request->user(), $id));
}
public function assumptions(Request $request, string $id, InvestorGuidance $guidance): JsonResponse|RedirectResponse
{
$guidance->assumptions($request->user(), $id, $request->all());
return $this->completed($request, 'Pressupostos corrigidos; os dados dos módulos de origem foram preservados.');
}
public function feedback(Request $request, string $id, InvestorGuidance $guidance): JsonResponse|RedirectResponse
{
$guidance->feedback($request->user(), $id, $request->all());
return $this->completed($request, 'Revisão registada. Assumir uma tarefa continua a ser uma acção separada.');
}
public function task(Request $request, string $id, InvestorGuidance $guidance): JsonResponse|RedirectResponse
{
$task = $guidance->acceptTask($request->user(), $id, $request->all());
return $request->expectsJson() ? response()->json(['id' => $task->id], 201) : back()->with('success', 'Tarefa assumida no CRM interno. Sincronização externa pendente de finalidade e autorização.');
}
public function updateTask(Request $request, string $id, string $task, InvestorGuidance $guidance): JsonResponse|RedirectResponse
{
$guidance->updateTask($request->user(), $id, $task, $request->all());
return $this->completed($request, 'Resultado da tarefa registado no mesmo histórico do dossier.');
}
public function search(Request $request, string $id, VirtualDesk $desk, EvidenceRepository $evidence): JsonResponse
{
$desk->case($request->user(), $id);
$data = $request->validate(['query' => 'required|string|min:3|max:300']);
$enabled = config('investmoz_ai.enabled') && Schema::hasTable('ai_controls') && DB::table('ai_controls')->where('capability', 'search')->where('suspended', false)->exists();
$items = $enabled ? $evidence->search($data['query'], 'pt', ['public_only' => true]) : [];
return response()->json(['mode' => 'lexical_evidence', 'model' => null, 'status' => ! $enabled ? 'unavailable' : ($items ? 'evidence_found' : 'no_evidence'),
'consulted' => $items, 'limitations' => ['Apenas excertos das fontes públicas autorizadas indexadas; não é uma consulta exaustiva nem validação jurídica.']]);
}
private function completed(Request $request, string $message): JsonResponse|RedirectResponse
{
return $request->expectsJson() ? response()->json(['message' => $message]) : back()->with('success', $message);
}
}
@@ -0,0 +1,140 @@
<?php
namespace App\Http\Controllers;
use App\Services\FintechInvestment;
use App\Services\InvestorPath;
use App\Services\InvestmentZonePathContext;
use App\Services\LegacyContent;
use App\Services\SectorInvestment;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response;
class InvestorPathController extends Controller
{
public function index(Request $request, InvestorPath $path): JsonResponse|Response
{
abort_unless($path->available(), 404);
$data = ['contexts' => $path->contexts($request->user())];
return $request->expectsJson() ? response()->json($data) : Inertia::render('investor-path/index', $data);
}
public function sectorStart(Request $request, string $sectorSlug, InvestorPath $path): JsonResponse|Response
{
abort_unless($path->available(), 404);
$data = ['contexts' => $path->contexts($request->user()), 'sectorSource' => app(SectorInvestment::class)->context($sectorSlug)];
return $request->expectsJson() ? response()->json($data) : Inertia::render('investor-path/index', $data);
}
public function sectorAttach(Request $request, string $id): JsonResponse|RedirectResponse
{
app(SectorInvestment::class)->attach($request->user(), $id, $request->all());
return $request->expectsJson() ? response()->json(['ok' => true]) : redirect('/investimentos/'.$id)->with('success', 'Setor associado ao mesmo dossier.');
}
public function zoneStart(Request $request,string $zoneId,InvestorPath $path): JsonResponse|Response
{
abort_unless($path->available(),404);
$data=$request->validate(['consulted_public_version'=>'required|integer|min:1']);
$data=['contexts'=>$path->contexts($request->user()),'zoneSource'=>app(InvestmentZonePathContext::class)->context($zoneId,(int)$data['consulted_public_version'])];
return $request->expectsJson()?response()->json($data):Inertia::render('investor-path/index',$data);
}
public function zoneAttach(Request $request,string $id): JsonResponse|RedirectResponse
{
app(InvestmentZonePathContext::class)->attach($request->user(),$id,$request->all());
return $request->expectsJson()?response()->json(['ok'=>true]):redirect('/investimentos/'.$id)->with('success','Referência do parque guardada; não constitui reserva ou autorização.');
}
public function create(Request $request, InvestorPath $path): JsonResponse|RedirectResponse
{
$zone=$request->has('zone_id') ? app(InvestmentZonePathContext::class)->input($request->all()) : null;
if($zone){ $request->validate(['confirm'=>'required|accepted']); app(InvestmentZonePathContext::class)->context($zone['zone_id'],(int)$zone['consulted_public_version']); }
$sector = $request->validate(['sector_slug' => 'nullable|string|max:150'])['sector_slug'] ?? null;
if ($sector !== null) {
app(SectorInvestment::class)->context($sector);
}
$source = $request->input('content_resource_id');
if ($source !== null) {
$resourceId = filter_var($source, FILTER_VALIDATE_INT);
abort_unless(is_int($resourceId) && $resourceId !== 0, 422);
app(LegacyContent::class)->articleForResource($resourceId);
}
$id = DB::transaction(function () use ($path, $request, $source, $sector, $zone): string {
$id = $path->create($request->user(), $request->all());
if ($source !== null) {
$case = $path->view($request->user(), $id)['case'];
app(LegacyContent::class)->attach($request->user(), $id, ['version' => $case['version'], 'resource_id' => (int) $source]);
}
if ($sector !== null) {
$case = $path->view($request->user(), $id)['case'];
app(SectorInvestment::class)->attach($request->user(), $id, ['version' => $case['version'], 'sector_slug' => $sector]);
}
if($zone){$case=$path->view($request->user(),$id)['case']; app(InvestmentZonePathContext::class)->attach($request->user(),$id,[...$zone,'version'=>$case['version'],'confirm'=>true]);}
return $id;
});
return $request->expectsJson() ? response()->json(['id' => $id], 201) : redirect('/investimentos/'.$id)->with('success', 'Investimento guardado no seu dossier.');
}
public function show(Request $request, string $id, InvestorPath $path): JsonResponse|Response
{
$choice = $request->validate(['opportunity' => 'nullable|string|max:100'])['opportunity'] ?? null;
$data = $path->view($request->user(), $id, $choice);
return $request->expectsJson() ? response()->json($data) : Inertia::render('investor-path/show', $data);
}
public function facts(Request $request, string $id, InvestorPath $path): JsonResponse|RedirectResponse
{
abort_unless($path->available(), 404);
$path->facts($request->user(), $id, $request->all());
return $this->saved($request, 'Aplicabilidade registada; o histórico foi preservado.');
}
public function stage(Request $request, string $id, string $code, InvestorPath $path): JsonResponse|RedirectResponse
{
abort_unless($path->available(), 404);
$path->stage($request->user(), $id, $code, $request->all());
return $this->saved($request, 'Etapa e fundamento registados.');
}
public function tool(Request $request, string $id, InvestorPath $path): JsonResponse
{
abort_unless($path->available(), 404);
return response()->json(['result' => $path->returnTool($request, $id, $request->all())]);
}
public function task(Request $request, string $id, InvestorPath $path): JsonResponse|RedirectResponse
{
abort_unless($path->available(), 404);
$task = $path->task($request->user(), $id, $request->all());
return $request->expectsJson() ? response()->json(['id' => $task], 201) : back()->with('success', 'Tarefa assumida no acompanhamento interno.');
}
public function fintech(Request $request, string $id, InvestorPath $path): JsonResponse|RedirectResponse
{
abort_unless($path->available(), 404);
app(FintechInvestment::class)->save($request->user(), $id, $request->all());
return $this->saved($request, 'Caracterização Fintech guardada no mesmo dossier; pendências e histórico preservados.');
}
private function saved(Request $request, string $message): JsonResponse|RedirectResponse
{
return $request->expectsJson() ? response()->json(['message' => $message]) : back()->with('success', $message);
}
}
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use App\Models\InterestRequest;
use App\Models\Investor;
use App\Models\InvestorJourney;
use App\Services\CountryCatalogue;
use App\Services\InterestSubmission;
use App\Services\PortalAudit;
use Illuminate\Http\RedirectResponse;
@@ -23,10 +24,12 @@ class InvestorWorkspaceController extends Controller
$interest = $journey->interest_request_id ? InterestRequest::find($journey->interest_request_id) : null;
return ['id' => $journey->id, 'dossier_id' => $dossier?->id, 'title' => $dossier->title ?? 'Ficha temporariamente indisponível',
'status' => $journey->status, 'reference' => $interest?->reference, 'request_status' => $interest?->status];
'status' => $journey->status, 'reference' => $interest?->reference, 'request_status' => $interest?->status,
'country' => $interest?->country,
'declared_origin' => app(CountryCatalogue::class)->describe($interest?->metadata['declared_origin_country_code'] ?? null)];
});
return Inertia::render('portfolio/workspace', ['journeys' => $journeys]);
return Inertia::render('portfolio/workspace', ['journeys' => $journeys, 'countries' => app(CountryCatalogue::class)->all()]);
}
public function start(Request $request, string $id): RedirectResponse
@@ -57,7 +60,8 @@ class InvestorWorkspaceController extends Controller
public function submit(Request $request, string $id, InterestSubmission $submission): RedirectResponse
{
$data = $request->validate(['message' => ['required', 'string', 'min:20', 'max:5000'], 'country' => ['required', 'string', 'max:100'],
$data = $request->validate(['message' => ['required', 'string', 'min:20', 'max:5000'], 'country' => ['required', 'string', 'max:100', app(CountryCatalogue::class)->legacyCountryRule()],
'declared_origin_country_code' => ['nullable', app(CountryCatalogue::class)->rule()],
'privacy_consent' => ['accepted'], 'role' => ['required', 'in:investir,financiar,fornecer,operar,parceria'],
'horizon' => ['required', 'string', 'max:100']]);
DB::transaction(function () use ($request, $id, $data, $submission) {
@@ -74,6 +78,7 @@ class InvestorWorkspaceController extends Controller
'sector_interest' => Str::limit($dossier->content['sector'] ?? $dossier->title, 150, ''), 'message' => $data['message'],
'privacy_consent' => true, 'consented_at' => now(), 'status' => 'received', 'source_page' => '/carteira/'.$dossier->id,
'metadata' => ['journey_id' => $journey->id, 'dossier_id' => $dossier->id, 'origin_dossier_id' => $journey->origin_dossier_id,
'declared_origin_country_code' => $data['declared_origin_country_code'] ?? null,
'project_id' => $dossier->project_id ?? $origin?->project_id,
'cluster_id' => $dossier->cluster_id ?? $origin?->cluster_id, 'corridor_id' => $dossier->corridor_id ?? $origin?->corridor_id,
'role' => $data['role'], 'horizon' => $data['horizon'], 'purpose' => 'Acompanhamento da manifestação de interesse'],
@@ -0,0 +1,496 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Http\Requests\StoreLandAssistanceCaseRequest;
use App\Jobs\ScanDocumentForMalware;
use App\Models\InterestRequest;
use App\Models\LandAssistanceCase;
use App\Services\CountryCatalogue;
use App\Services\InterestSubmission;
use App\Services\InvestmentGeography;
use App\Services\InvestorContext;
use App\Services\InvestorContextConsumers;
use App\Services\VerifiedDocumentBinary;
use App\Services\VirtualDesk;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class LandAssistanceController extends Controller
{
private const DISCLAIMER = 'Este documento apoia a preparação e o acompanhamento. Não constitui requerimento oficialmente recebido, título de DUAT, confirmação de disponibilidade da terra ou decisão da autoridade competente.';
public function guide(): Response
{
return Inertia::render('land-assistance/guide', ['disclaimer' => self::DISCLAIMER]);
}
public function blank(): Response
{
return Inertia::render('land-assistance/blank', ['disclaimer' => self::DISCLAIMER]);
}
public function index(Request $request): Response
{
$cases = LandAssistanceCase::where('user_id', $request->user()->id)
->latest('updated_at')->paginate(20)->through(fn (LandAssistanceCase $case): array => [
'id' => $case->id,
'reference' => $case->reference,
'status' => $case->status,
'version' => $case->version,
'updated_at' => $case->updated_at,
]);
return Inertia::render('land-assistance/workspace', ['cases' => $cases]);
}
public function create(Request $request): Response
{
$context = $request->validate(['context' => ['nullable', 'uuid']])['context'] ?? null;
$dossier = null;
if ($context !== null) {
$case = app(VirtualDesk::class)->case($request->user(), $context);
abort_unless(config('investor-path.enabled') && (int) $case->user_id === $request->user()->id && ! $case->closed_at && $case->journey_id
&& DB::table('investor_journeys')->where('id', $case->journey_id)->whereNotNull('path_definition_id')->whereNull('deleted_at')->exists(), 403);
$dossier = ['id' => $case->id, 'title' => $case->title, 'url' => '/investimentos/'.$case->id];
}
return Inertia::render('land-assistance/form', [
'case' => null, 'boundContextRevision' => null,
'returnDossier' => $dossier,
'user' => ['name' => $request->user()->name, 'email' => $request->user()->email],
'disclaimer' => self::DISCLAIMER,
'modelVersion' => 'land-assistance-v1',
'countries' => app(CountryCatalogue::class)->all(),
]);
}
public function edit(Request $request, string $id): Response
{
$case = $this->owned($request, $id);
abort_unless(in_array($case->status, ['draft', 'waiting_applicant'], true), 409, 'Este pedido não está aberto para edição.');
return Inertia::render('land-assistance/form', [
'case' => $case->only(['id', 'reference', 'version', 'status', 'payload']), 'boundContextRevision' => app(InvestorContextConsumers::class)->landRevision($case->id),
'returnDossier' => $this->dossierContext($case),
'user' => ['name' => $request->user()->name, 'email' => $request->user()->email],
'disclaimer' => self::DISCLAIMER,
'modelVersion' => 'land-assistance-v1',
'countries' => app(CountryCatalogue::class)->all(),
]);
}
public function store(StoreLandAssistanceCaseRequest $request): RedirectResponse|JsonResponse
{
$payload = $request->payload();
$payload['full_name'] = $payload['full_name'] ?? $request->user()->name;
$payload['email'] = $payload['email'] ?? $request->user()->email;
$case = DB::transaction(function () use ($request, $payload): LandAssistanceCase {
$case = LandAssistanceCase::create([
'user_id' => $request->user()->id,
'reference' => 'TERRA-'.now()->format('Y').'-'.Str::upper(Str::random(8)),
'status' => 'draft',
'version' => 1,
'payload' => $payload,
'crm_submission_key' => (string) Str::uuid(),
]);
$this->revision($case);
$this->event($case, $request, 'draft_created');
return $case;
});
return $request->expectsJson()
? response()->json(['id' => $case->id, 'reference' => $case->reference, 'version' => $case->version], 201)
: redirect()->route('land-assistance.show', $case->id);
}
public function show(Request $request, string $id): Response|JsonResponse
{
$case = $this->owned($request, $id);
$data = [
...$this->presentation($case),
'case' => $case->only(['id', 'reference', 'status', 'version', 'payload', 'updated_at']),
'documents' => DB::table('land_assistance_documents as link')->join('documents as document', 'document.id', '=', 'link.document_id')
->where('link.case_id', $id)->whereIn('document.status', ['active', 'quarantined'])->whereNull('document.deleted_at')
->get(['link.id', 'link.category', 'document.file_name', 'document.status']),
'events' => DB::table('land_assistance_events')->where('case_id', $id)->orderBy('created_at')
->get(['action', 'version', 'metadata', 'created_at']),
'supportTicket' => $case->interest_request_id
? InterestRequest::query()->whereKey($case->interest_request_id)->first(['reference', 'status', 'created_at'])
: null,
'disclaimer' => self::DISCLAIMER,
'management' => false,
];
return $request->expectsJson() ? response()->json($data) : Inertia::render('land-assistance/case', $data);
}
public function save(StoreLandAssistanceCaseRequest $request, string $id): RedirectResponse|JsonResponse
{
$data = $request->validated();
$payload = $request->payload();
$case = DB::transaction(function () use ($request, $id, $data, $payload): LandAssistanceCase {
$case = $this->owned($request, $id, true);
abort_unless(in_array($case->status, ['draft', 'waiting_applicant'], true), 409, 'Este pedido está bloqueado para edição pelo requerente.');
$this->checkVersion($case, (int) $data['version']);
$payload['full_name'] = $payload['full_name'] ?? $case->payload['full_name'] ?? $request->user()->name;
$payload['email'] = $payload['email'] ?? $case->payload['email'] ?? $request->user()->email;
$case->forceFill(['payload' => $payload, 'version' => $case->version + 1])->save();
$this->revision($case);
$this->event($case, $request, 'draft_updated');
return $case;
});
return $request->expectsJson()
? response()->json(['id' => $case->id, 'reference' => $case->reference, 'version' => $case->version])
: redirect()->route('land-assistance.show', $case->id)->with('status', 'Rascunho guardado.');
}
public function requestSupport(StoreLandAssistanceCaseRequest $request, string $id, InterestSubmission $submission): RedirectResponse|JsonResponse
{
$data = $request->validated();
$submittedPayload = $request->payload();
$case = DB::transaction(function () use ($request, $id, $data, $submission, $submittedPayload): LandAssistanceCase {
if ($boundId = $submittedPayload['investor_path_case_id'] ?? null) {
app(InvestorContextConsumers::class)->assertReviewed($request->user(), $boundId, $request->all(), app(InvestorContextConsumers::class)->landRevision($id));
}
$case = $this->owned($request, $id, true);
$replaying = $case->status === 'support_requested';
abort_unless(in_array($case->status, ['draft', 'waiting_applicant', 'support_requested'], true), 409, 'Este pedido já não pode ser submetido neste estado.');
if (! $replaying) {
$this->checkVersion($case, (int) $data['version']);
}
$payload = [...$case->payload, ...$submittedPayload];
$payload['full_name'] = $payload['full_name'] ?? $request->user()->name;
if (! array_key_exists('email', $payload)) {
$payload['email'] = $request->user()->email;
}
$payload['email'] = is_string($payload['email']) && trim($payload['email']) !== '' ? strtolower(trim($payload['email'])) : null;
abort_if(empty($payload['email']) && empty($payload['phone']), 422, 'Indique pelo menos um contacto valido.');
if ($replaying) {
// A retry of the same accepted attempt recovers its original receipt.
// Changed data, another version or a missing receipt must not reopen the case.
abort_unless((int) $case->version === (int) $data['version'] + 1 && $case->interest_request_id
&& hash_equals(hash('sha256', json_encode(Arr::sortRecursive($case->payload), JSON_THROW_ON_ERROR)),
hash('sha256', json_encode(Arr::sortRecursive($payload), JSON_THROW_ON_ERROR))),
409, 'O pedido mudou; recupere o estado atual antes de voltar a enviar.');
}
$message = trim(($payload['request_description'] ?? 'Pedido de apoio sobre Terra e DUAT.')."\nReferência interna: {$case->reference}. Tipo: ".($payload['support_kind'] ?? 'não indicado').'.');
$interest = $submission->receive([
'kind' => 'support', 'fullName' => $payload['full_name'], 'email' => $payload['email'],
'phone' => $payload['phone'] ?? null, 'country' => $payload['country'] ?? 'Não indicado',
'sectorInterest' => 'Terra e DUAT', 'message' => mb_substr($message, 0, 2000),
'privacyConsent' => true, 'portal_user_id' => $request->user()->id,
'land_case_id' => $case->id, 'land_case_reference' => $case->reference,
'declared_origin_country_code' => $payload['declared_origin_country_code'] ?? null,
], $case->crm_submission_key, fn () => InterestRequest::create([
'reference' => 'AP-'.now()->year.'-'.Str::ulid(), 'full_name' => $payload['full_name'],
'email' => $payload['email'], 'phone' => $payload['phone'] ?? null,
'country' => $payload['country'] ?? 'Não indicado',
// Use the existing intake vocabulary; retain the more specific declared category.
// This factory only runs for a new receipt. Older submissions/replay hashes stay intact.
'investor_type' => match ($payload['applicant_category'] ?? null) {
'individual' => 'individual', 'entity' => 'empresa', default => 'outro',
},
'sector_interest' => 'Terra e DUAT', 'message' => mb_substr($message, 0, 2000),
'privacy_consent' => true, 'consented_at' => now(), 'status' => 'received', 'source_page' => '/terra-duat',
'metadata' => ['kind' => 'support', 'portal_user_id' => $request->user()->id,
'land_case_id' => $case->id, 'land_case_reference' => $case->reference,
'applicant_category' => $payload['applicant_category'] ?? null,
'declared_origin_country_code' => $payload['declared_origin_country_code'] ?? null],
]));
if ($replaying) {
abort_unless((int) $interest->getKey() === (int) $case->interest_request_id && ! $interest->trashed(),
409, 'O recibo original não está disponível; recupere o estado do pedido.');
return $case;
}
$case->forceFill([
'payload' => $payload,
'status' => 'support_requested',
'support_requested_at' => now(),
'version' => $case->version + 1,
'interest_request_id' => $interest->id,
])->save();
$this->revision($case);
$this->event($case, $request, 'support_requested');
return $case;
});
return $request->expectsJson()
? response()->json(['id' => $case->id, 'reference' => $case->reference, 'status' => $case->status, 'version' => $case->version], 202)
: redirect()->route('land-assistance.show', $case->id)->with('status', 'Pedido de apoio registado no InvestMoz. Isto não é submissão oficial de DUAT.');
}
public function reportUpdate(Request $request, string $id): RedirectResponse|JsonResponse
{
$data = $request->validate([
'version' => ['required', 'integer', 'min:1'],
'reported_status' => ['required', 'string', 'max:1000'],
'official_reference' => ['nullable', 'string', 'max:160'],
'receiving_entity' => ['nullable', 'string', 'max:200'],
'reported_at' => ['nullable', 'date_format:Y-m-d'],
]);
$case = DB::transaction(function () use ($request, $id, $data): LandAssistanceCase {
$case = $this->owned($request, $id, true);
abort_unless(in_array($case->status, ['support_requested', 'internal_review', 'waiting_applicant', 'referral_prepared'], true), 409);
$this->checkVersion($case, (int) $data['version']);
$payload = $case->payload;
$updates = $payload['reported_updates'] ?? [];
$updates[] = ['reported_status' => $data['reported_status'], 'official_reference' => $data['official_reference'] ?? null,
'receiving_entity' => $data['receiving_entity'] ?? null, 'reported_at' => $data['reported_at'] ?? null, 'recorded_at' => now()->toIso8601String()];
$payload['reported_updates'] = array_slice($updates, -20);
$case->forceFill(['payload' => $payload, 'version' => $case->version + 1])->save();
$this->revision($case);
$this->event($case, $request, 'applicant_update_recorded');
return $case;
});
return $request->expectsJson()
? response()->json(['version' => $case->version, 'reported_by_applicant' => true])
: redirect()->route('land-assistance.show', $case->id)->with('status', 'Actualização registada como informação comunicada por si.');
}
public function attach(Request $request, string $id): RedirectResponse|JsonResponse
{
$data = $request->validate([
'version' => ['required', 'integer', 'min:1'],
'category' => ['required', Rule::in(['sketch', 'prior_right', 'exploration_plan', 'official_receipt', 'consultation_minutes', 'photo', 'other'])],
'file' => ['required', 'file', 'mimes:pdf,jpg,jpeg,png', 'max:10240'],
]);
$case = $this->owned($request, $id, true);
$this->checkVersion($case, (int) $data['version']);
abort_unless(! in_array($case->status, ['closed'], true), 409);
abort_if(DB::table('land_assistance_documents')->where('case_id', $id)->count() >= 20, 422, 'Máximo de vinte documentos por pedido.');
$file = $request->file('file');
$documentId = (string) Str::uuid();
$key = 'land-assistance/'.$id.'/'.$documentId;
$stored = $file->storeAs('land-assistance/'.$id, $documentId, 'local');
if (! is_string($stored) || $stored === '') {
throw ValidationException::withMessages(['file' => 'Não foi possível guardar o ficheiro em armazenamento privado.']);
}
try {
DB::transaction(function () use ($request, $case, $data, $file, $documentId, $stored): void {
$locked = $this->owned($request, $case->id, true);
$this->checkVersion($locked, (int) $data['version']);
DB::table('documents')->insert([
'id' => $documentId,
'source_system' => 'land-assistance',
'data_classification' => 'restricted',
'file_name' => basename($file->getClientOriginalName()),
'media_type' => (string) $file->getMimeType(),
'storage_key' => $stored,
'sha256' => hash_file('sha256', Storage::disk('local')->path($stored)),
'size_bytes' => $file->getSize(),
'uploaded_by' => $request->user()->id,
// The configured scanner releases only the exact original hash after a clean result.
'status' => 'quarantined',
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('land_assistance_documents')->insert([
'id' => (string) Str::uuid(), 'case_id' => $locked->id, 'document_id' => $documentId,
'category' => $data['category'], 'created_at' => now(),
]);
$locked->forceFill(['version' => $locked->version + 1])->save();
$this->revision($locked);
$this->event($locked, $request, 'attachment_added', ['document_id' => $documentId, 'category' => $data['category']]);
});
} catch (\Throwable $exception) {
Storage::disk('local')->delete($stored);
throw $exception;
}
ScanDocumentForMalware::dispatch($documentId)->afterCommit();
return $request->expectsJson()
? response()->json(['uploaded' => true], 201)
: back()->with('status', 'Documento privado anexado; a revisão ainda está pendente.');
}
public function download(Request $request, string $id, string $attachment): \Illuminate\Http\Response|BinaryFileResponse
{
// Management uses its existing explicit permission; the owner URL stays owner-only.
if ($request->routeIs('land-assistance.manage.download')) {
abort_unless($request->user()->can('manage-portal'), 403);
$case = LandAssistanceCase::whereKey($id)->firstOrFail();
} else {
$case = $this->owned($request, $id);
}
$document = DB::table('land_assistance_documents as link')->join('documents as document', 'document.id', '=', 'link.document_id')
->where('link.case_id', $id)->where('link.id', $attachment)->where('document.status', 'active')->whereNull('document.deleted_at')
->where('document.malware_scan_status', 'clean')->whereColumn('document.malware_scanned_sha256', 'document.sha256')
->first(['document.*']);
abort_if($document === null, 404);
$response = app(VerifiedDocumentBinary::class)->respond($document);
DB::table('audit_events')->insert(['source_system' => 'land-assistance', 'entity_type' => 'documents', 'entity_id' => $document->id,
'actor_user_id' => $request->user()->id, 'action' => 'document_downloaded', 'data_classification' => 'restricted',
'after_data' => json_encode(['case_id' => $case->id, 'attachment_id' => $attachment], JSON_THROW_ON_ERROR)]);
return $response;
}
public function print(Request $request, string $id): Response
{
$case = $this->owned($request, $id);
return Inertia::render('land-assistance/print', [
'case' => $case->only(['reference', 'version', 'payload', 'updated_at']),
'disclaimer' => self::DISCLAIMER,
'modelVersion' => 'land-assistance-v1',
'country' => app(CountryCatalogue::class)->describe($case->payload['country'] ?? null),
'declaredOrigin' => app(CountryCatalogue::class)->describe($case->payload['declared_origin_country_code'] ?? null),
]);
}
/** @return array<string,mixed> */
private function presentation(LandAssistanceCase $case, bool $management = false): array
{
$payload = $case->payload;
$countries = app(CountryCatalogue::class);
$country = $countries->describe($payload['country'] ?? null);
$paths = [];
foreach ($payload['parcels'] ?? [] as $parcel) {
$id = $parcel['locality_id'] ?? $parcel['admin_post_id'] ?? $parcel['district_id'] ?? $parcel['province_id'] ?? null;
$paths[] = app(InvestmentGeography::class)->path($id ?: null, $management);
}
return ['countryName' => $country['code'] !== null ? $country['name'] : ($payload['country'] ?? 'Não informado'),
'declaredOrigin' => $countries->describe($payload['declared_origin_country_code'] ?? null), 'territoryPaths' => $paths, 'returnDossier' => $this->dossierContext($case)];
}
private function dossierContext(LandAssistanceCase $case): ?array
{
$id = $case->payload['investor_path_case_id'] ?? null;
$actor = request()->user();
if (! $actor || (int) $case->user_id !== $actor->id || ! is_string($id) || ! Str::isUuid($id) || ! config('investor-path.enabled')) {
return null;
}
$dossier = DB::table('desk_cases')->where('id', $id)->where('user_id', $actor->id)->first();
return $dossier ? ['id' => $dossier->id, 'title' => $dossier->title, 'url' => '/investimentos/'.$dossier->id] : null;
}
public function manage(Request $request): Response
{
$cases = LandAssistanceCase::query()->latest('updated_at')->paginate(30)->through(fn (LandAssistanceCase $case): array => [
'id' => $case->id, 'reference' => $case->reference, 'status' => $case->status,
'version' => $case->version, 'updated_at' => $case->updated_at,
'applicant' => $case->payload['full_name'] ?? null,
]);
return Inertia::render('land-assistance/management', ['cases' => $cases]);
}
public function manageShow(Request $request, string $id): Response
{
$case = LandAssistanceCase::findOrFail($id);
$events = DB::table('land_assistance_events')->where('case_id', $id)->orderBy('created_at')->get(['action', 'version', 'metadata', 'created_at']);
return Inertia::render('land-assistance/case', [
...$this->presentation($case, true),
'case' => $case->only(['id', 'reference', 'status', 'version', 'payload', 'updated_at']),
'documents' => DB::table('land_assistance_documents as link')->join('documents as document', 'document.id', '=', 'link.document_id')
->where('link.case_id', $id)->whereIn('document.status', ['active', 'quarantined'])->whereNull('document.deleted_at')->get(['link.id', 'link.category', 'document.file_name', 'document.status']),
'events' => $events,
'supportTicket' => $case->interest_request_id
? InterestRequest::query()->whereKey($case->interest_request_id)->first(['reference', 'status', 'created_at'])
: null,
'disclaimer' => self::DISCLAIMER,
'management' => true,
]);
}
public function changeStatus(Request $request, string $id): RedirectResponse|JsonResponse
{
$data = $request->validate([
'version' => ['required', 'integer', 'min:1'],
'status' => ['required', Rule::in(['internal_review', 'waiting_applicant', 'referral_prepared', 'closed'])],
'evidence_reference' => ['nullable', 'string', 'max:300'],
]);
$case = DB::transaction(function () use ($request, $id, $data): LandAssistanceCase {
$case = LandAssistanceCase::whereKey($id)->lockForUpdate()->firstOrFail();
$this->checkVersion($case, (int) $data['version']);
$allowed = [
'support_requested' => ['internal_review', 'waiting_applicant', 'referral_prepared', 'closed'],
'internal_review' => ['waiting_applicant', 'referral_prepared', 'closed'],
'waiting_applicant' => ['internal_review', 'referral_prepared', 'closed'],
'referral_prepared' => ['internal_review', 'waiting_applicant', 'closed'],
];
abort_unless(in_array($data['status'], $allowed[$case->status] ?? [], true), 409, 'Transição de estado não permitida.');
if ($data['status'] === 'referral_prepared' && empty($data['evidence_reference'])) {
throw ValidationException::withMessages(['evidence_reference' => 'Registe a referência interna do material preparado para encaminhamento.']);
}
$previousStatus = $case->status;
$case->forceFill(['status' => $data['status'], 'version' => $case->version + 1])->save();
$this->revision($case);
$this->event($case, $request, 'status_changed', [
'from' => $previousStatus, 'to' => $data['status'],
'evidence_reference' => $data['evidence_reference'] ?? null,
'external_submission_confirmed' => false,
]);
return $case;
});
return $request->expectsJson()
? response()->json(['status' => $case->status, 'version' => $case->version])
: redirect()->route('land-assistance.manage.show', $case->id)->with('status', 'Estado interno actualizado. Nenhuma recepção pública foi presumida.');
}
private function owned(Request $request, string $id, bool $lock = false): LandAssistanceCase
{
$query = LandAssistanceCase::whereKey($id)->where('user_id', $request->user()->id);
return ($lock ? $query->lockForUpdate() : $query)->firstOrFail();
}
private function checkVersion(LandAssistanceCase $case, int $expected): void
{
abort_unless($case->version === $expected, 412, 'Este rascunho foi alterado. Recarregue antes de continuar.');
}
/** @param array<string,mixed> $metadata */
private function event(LandAssistanceCase $case, Request $request, string $action, array $metadata = []): void
{
$contextId = $case->payload['investor_path_case_id'] ?? null;
if ($contextId) {
$binding = app(InvestorContextConsumers::class)->landRevision($case->id);
if ($action === 'draft_created' || $request->boolean('context_reviewed')) {
$binding = app(InvestorContextConsumers::class)->assertReviewed($request->user(), $contextId, $action === 'draft_created' ? ['context_reviewed' => true, 'context_revision' => app(InvestorContext::class)->state(app(VirtualDesk::class)->case($request->user(), $contextId))['revision']] : $request->all(), $binding);
}
$metadata['investment_context_revision'] = $binding;
}
DB::table('land_assistance_events')->insert([
'case_id' => $case->id, 'actor_id' => $request->user()->id,
'action' => $action, 'version' => $case->version, 'metadata' => json_encode($metadata, JSON_THROW_ON_ERROR), 'created_at' => now(),
]);
}
private function revision(LandAssistanceCase $case): void
{
DB::table('land_assistance_revisions')->insert([
'case_id' => $case->id, 'version' => $case->version,
'snapshot' => Crypt::encryptString(json_encode(['status' => $case->status, 'payload' => $case->payload], JSON_THROW_ON_ERROR)),
'created_at' => now(),
]);
}
}
@@ -0,0 +1,70 @@
<?php
namespace App\Http\Controllers;
use App\Services\InvestorPath;
use App\Services\LegacyContent;
use App\Services\VirtualDesk;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpKernel\Exception\HttpException;
class LegacyContentController extends Controller
{
public function landing(Request $request, LegacyContent $legacy, string $sectorSlug = 'agronegocio'): Response
{
$filters = $request->validate(['q' => 'nullable|string|max:120']);
$articles = $legacy->articlesForGroup($sectorSlug);
$root = config('legacy_content.groups.'.$sectorSlug.'.root');
$article = $legacy->article($root);
$q = $filters['q'] ?? '';
$articles = array_values(array_filter($articles, fn ($item) => $q === ''
|| str_contains(mb_strtolower(strip_tags($item['html'].' '.$item['title'])), mb_strtolower($q))));
$match = $articles !== [];
return Inertia::render('portal/legacy', ['article' => $article, 'articles' => $articles, 'view' => 'landing', 'query' => $q, 'match' => $match]);
}
public function article(Request $request, LegacyContent $legacy, string $legacySlug = 'agronegocio'): Response
{
return Inertia::render('portal/legacy', ['article' => $legacy->article($legacySlug), 'view' => 'article', 'contentContext' => $this->context($request)]);
}
public function start(Request $request, LegacyContent $legacy, InvestorPath $path, string $legacySlug = 'agronegocio'): Response
{
abort_unless($path->available(), 404);
$article = $legacy->article($legacySlug);
return Inertia::render('investor-path/index', ['contexts' => $path->contexts($request->user()), 'contentSource' => $article]);
}
public function attach(Request $request, string $id, LegacyContent $legacy): JsonResponse|RedirectResponse
{
$legacy->attach($request->user(), $id, $request->all());
return $request->expectsJson() ? response()->json(['saved' => true]) : redirect('/investimentos/'.$id)->with('success', 'Referência guardada no dossier.');
}
/** @return array{id: string, title: string}|null */
private function context(Request $request): ?array
{
$data = $request->validate(['context' => 'nullable|uuid']);
if (! $request->user() || empty($data['context'])) {
return null;
}
try {
$case = app(VirtualDesk::class)->case($request->user(), $data['context'], true);
return $case->closed_at ? null : ['id' => $case->id, 'title' => $case->title];
} catch (HttpException $error) {
if (! in_array($error->getStatusCode(), [403, 404], true)) {
throw $error;
}
return null; // Consultation stays public; an unauthorized private context is never returned.
}
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Http\Controllers;
use App\Models\Opportunity;
use App\Services\OpportunityClassification;
use App\Services\OpportunityManagement;
use App\Services\PortalProfileAccess;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class OpportunityClassificationController extends Controller
{
public function index(Request $request, int $id, OpportunityClassification $service): JsonResponse
{
$record = Opportunity::findOrFail($id);
app(PortalProfileAccess::class)->authorize($request->user(), 'view', $record);
return response()->json(['version' => $service::VERSION, 'etag' => app(OpportunityManagement::class)->etag($record), 'rows' => $service->listing($request->user(), $record), 'duplicates' => $service->duplicates($request->user(), $record)])->header('Cache-Control', 'private, no-store');
}
public function review(Request $request, int $id, OpportunityClassification $service): JsonResponse
{
$record = $service->review($request->user(), Opportunity::findOrFail($id), $request->all());
return response()->json(['etag' => app(OpportunityManagement::class)->etag($record), 'rows' => $service->listing($request->user(), $record)])->header('Cache-Control', 'private, no-store');
}
}
@@ -0,0 +1,179 @@
<?php
namespace App\Http\Controllers;
use App\Domain\Opportunities\Enums\OpportunityStatus;
use App\Models\Opportunity;
use App\Models\Sector;
use App\Services\InvestmentGeography;
use App\Services\OpportunityCanonicalContract;
use App\Services\OpportunityCanonicalResponse;
use App\Services\OpportunityManagement;
use App\Services\OpportunityManagementInstallation;
use App\Services\OpportunityMaturity;
use App\Services\OpportunityProfileWorkflow;
use App\Services\OpportunityResources;
use App\Services\OpportunityWorkflowTasks;
use App\Services\PortalProfileAccess;
use Illuminate\Database\Eloquent\SoftDeletingScope;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
class OpportunityManagementController extends Controller
{
public function installation(Request $request, OpportunityManagementInstallation $service): JsonResponse|Response
{
return $request->expectsJson() ? response()->json($service->preview()) : Inertia::render('management/opportunity-install', $service->preview());
}
public function applyInstallation(Request $request, OpportunityManagementInstallation $service): RedirectResponse
{
$data = $request->validate(['hash' => 'required|string|size:64', 'confirm' => 'required|accepted']);
$service->apply($request->user(), $data['hash']);
return back()->with('success', 'Gestão instalada; nenhuma oportunidade foi submetida ou publicada.');
}
public function index(Request $request, OpportunityManagement $service): Response
{
if (! $service->installed()) {
return app(PortalManagementController::class)->catalog($request, 'opportunities');
}
$filters = $request->validate(['queue' => 'nullable|in:review,publication,appeal,assigned', 'q' => 'nullable|string|max:150', 'status' => ['nullable', Rule::in([...array_column(OpportunityStatus::cases(), 'value'), 'returned', 'auto_validation_failed', 'technical_review', 'legal_territorial_review', 'approval_pending', 'publication_pending'])], 'sector_id' => ['nullable', 'integer', Rule::exists('sectors', 'id')->whereNull('deleted_at')], 'geography_scope' => ['nullable', Rule::in(['unspecified', 'specific', 'multiple', 'national', 'digital'])], 'category' => ['nullable', Rule::in(OpportunityManagement::CATEGORIES)], 'deleted' => 'nullable|boolean', 'page' => 'nullable|integer|min:1']);
$query = Opportunity::query();
$access = app(PortalProfileAccess::class);
if (Schema::hasColumn('opportunities', 'owner_user_id')) {
$query = $access->scope($query, $request->user());
if (str_starts_with($request->path(), 'minhas-oportunidades')) {
$query->whereNotNull('owner_user_id');
}
}
if ($filters['deleted'] ?? false) {
$query->withoutGlobalScope(SoftDeletingScope::class)->whereNotNull('opportunities.deleted_at');
}
if ($filters['q'] ?? null) {
$query->where(function ($q) use ($filters) {
$q->whereLike('title', '%'.$filters['q'].'%')->orWhereLike('reference', '%'.$filters['q'].'%');
if (Schema::hasColumn('opportunities', 'owner_user_id')) {
$q->orWhere(fn ($draft) => $draft->whereNotNull('owner_user_id')->whereRaw("management_draft::jsonb->'canonical'->>'official_title' ILIKE ?", ['%'.$filters['q'].'%']));
}
});
}
if ($filters['status'] ?? null) {
if (Schema::hasColumn('opportunities', 'workflow_context')) {
$query->whereRaw("COALESCE(workflow_context->>'working_state',status)=?", [$filters['status']]);
} else {
$query->where('status', $filters['status']);
}
}
if ($filters['category'] ?? null) {
$query->where('opportunity_category', $filters['category']);
}
if ($filters['sector_id'] ?? null) {
$sector = Sector::find($filters['sector_id']);
$query->whereIn('sector_id', $sector ? Sector::whereIn('slug', \App\Services\SectorOrganization::slugs($sector->slug))->pluck('id') : []);
}
if ($filters['geography_scope'] ?? null) {
$query->where('geography_scope', $filters['geography_scope']);
}
if ($filters['queue'] ?? null) {
$queue = $filters['queue'];
if ($queue === 'review') {
$query->whereRaw("COALESCE(workflow_context->>'working_state',status) IN ('submitted','in_review','information_pending')");
} elseif ($queue === 'publication') {
$query->whereRaw("COALESCE(workflow_context->>'working_state',status)='approved'");
} elseif ($queue === 'appeal') {
$query->whereRaw("workflow_context->'appeal'->>'pending'='true'");
} else {
$query->whereExists(fn ($q) => $q->selectRaw('1')->from('tasks')->where('tasks.source_system', 'opportunity-workflow')->where('tasks.assignee_user_id', $request->user()->id)->whereIn('tasks.status', ['open', 'in_progress'])->whereRaw("tasks.external_id LIKE 'portal-opportunity:' || opportunities.id::text || ':%'"));
}
}
$base = str_starts_with($request->path(), 'minhas-oportunidades') ? '/minhas-oportunidades' : '/management/opportunities';
$stateSql = Schema::hasColumn('opportunities', 'workflow_context') ? "COALESCE(workflow_context->>'working_state',status)" : 'status';
$counts = (clone $query)->selectRaw($stateSql.' AS working_status,count(*) AS total')->groupByRaw($stateSql)->pluck('total', 'working_status');
$geographyReviewCount = (clone $query)->where('geography_scope', 'unspecified')->count();
$records = $query->orderByDesc('id')->paginate(20)->withQueryString();
$records->getCollection()->each(function (Opportunity $r) use ($access, $service, $request) {
$r->setAttribute('can_edit', $access->canEdit($request->user(), $r));
$r->setAttribute('can_promote', app(\App\Services\OpportunityPromotion::class)->allowed($request->user(), $r));
$r->setAttribute('working_status', $access->state($r));
$r->setAttribute('publication', $r->publicationPresentation());
$r->setAttribute('draft_title', $service->values($r)['canonical']['official_title'] ?? $r->title);
});
return Inertia::render('management/opportunities', ['basePath' => $base, 'canCreate' => $base === '/management/opportunities' ? $access->legacyWriteAllowed($request->user()) : (bool) ($access->navigation($request->user())['contexts'] ?? []), 'records' => $records, 'filters' => $filters, 'counts' => $counts, 'categories' => OpportunityManagement::CATEGORIES, 'geographyReviewCount' => $geographyReviewCount, 'sectors' => Sector::choices()->orderBy('name')->get(['id', 'name', 'slug'])]);
}
public function edit(Request $request, OpportunityManagement $service, ?int $id = null): Response
{
if (! $service->installed()) {
return app(PortalManagementController::class)->edit('opportunities', $id);
}
$record = $id ? Opportunity::withTrashed()->findOrFail($id) : null;
$access = app(PortalProfileAccess::class);
if (! $record && ! str_starts_with($request->path(), 'minhas-oportunidades')) {
abort_unless($access->legacyWriteAllowed($request->user()), 403);
}
if ($record) {
$access->authorize($request->user(), 'view', $record);
}
Inertia::share(['basePath' => str_starts_with($request->path(), 'minhas-oportunidades') ? '/minhas-oportunidades' : '/management/opportunities',
'canonicalOptions' => ($record?->owner_user_id !== null || str_starts_with($request->path(), 'minhas-oportunidades')) ? app(OpportunityCanonicalContract::class)->options($request->user(), $record) : null,
'workflowState' => $record ? $access->state($record) : null,
'profileReauthenticationRequired' => $record && $record->owner_user_id !== null
? app(OpportunityProfileWorkflow::class)->reauthenticationRequired($request->user(), $record) : false,
'publicationState' => $record?->publicationPresentation(),
'promotionHref' => $record && app(\App\Services\OpportunityPromotion::class)->allowed($request->user(), $record) ? '/minhas-oportunidades/'.$record->id.'/divulgacao' : null,
'canonicalResponse' => $record?->owner_user_id !== null ? app(OpportunityCanonicalResponse::class)->read($request->user(), $record) : null,
'canEditDraft' => $record ? $access->canEdit($request->user(), $record) : false,
'workflowParticipants' => $record && $record->owner_user_id !== null ? app(OpportunityProfileWorkflow::class)->participants($request->user(), $record) : ['validators' => [], 'publishers' => []],
'workflowTasks' => $record && $record->owner_user_id !== null ? app(OpportunityWorkflowTasks::class)->listing($record) : [],
'profileMode' => $record ? $record->owner_user_id !== null : str_starts_with($request->path(), 'minhas-oportunidades'),
'profileActions' => $record && $record->owner_user_id !== null ? app(OpportunityProfileWorkflow::class)->actions($request->user(), $record) : [], 'profileContext' => $record?->workflow_context, 'profileOrganization' => $record?->organization_id]);
$values = $record ? $service->values($record) : [];
if (! $record && $request->filled('sector_id')) {
$selection = $request->validate(['sector_id' => ['required', 'integer', Rule::exists('sectors', 'id')->where('is_active', true)->whereNull('deleted_at')]]);
$values['sector_id'] = (int) $selection['sector_id'];
$values['canonical']['sector_id'] = (int) $selection['sector_id'];
}
Inertia::share('publicationPreview', app(OpportunityCanonicalContract::class)->publicProjection($values['canonical'] ?? []));
$classification = $id ? DB::table('audit_events')->where('source_system', 'opportunity-origin-review')->where('after_data->opportunity_id', $id)->orderByDesc('occurred_at')->orderByDesc('id')->value('after_data') : null;
return Inertia::render('management/opportunity-draft', ['proactiveHistory' => app(\App\Services\ProactiveInvestment::class)->history($record), 'proactiveOptions' => app(\App\Services\ProactiveInvestment::class)->options($record), 'resourcesInstalled' => app(OpportunityResources::class)->installed(), 'record' => $record, 'values' => $values, 'etag' => $record ? $service->etag($record) : null, 'readOnly' => str_ends_with($request->path(), '/view') || ($record && ! $access->canEdit($request->user(), $record)), 'closedVersion' => $record ? $service->closed($record) : false, 'classification' => $classification ? json_decode($classification, true)['classification'] : 'undetermined', 'sectors' => Sector::choices()->orderBy('name')->get(['id', 'name', 'slug']), 'categories' => OpportunityManagement::CATEGORIES,
'maturityCatalogue' => ['version' => OpportunityMaturity::VERSION, 'stages' => OpportunityMaturity::STAGES, 'legacyVersion' => OpportunityMaturity::LEGACY, 'legacyLabels' => OpportunityMaturity::LEGACY_LABELS],
'territoryPath' => app(InvestmentGeography::class)->path($values['primary_territory_id'] ?? null, true),
'history' => $id ? DB::table('audit_events')->where('source_system', 'opportunity-management')->where('after_data->record_id', $id)->orderByDesc('occurred_at')->get(['id', 'action', 'actor_user_id', 'occurred_at']) : []]);
}
public function save(Request $request, OpportunityManagement $service, ?int $id = null): JsonResponse
{
$record = $service->save($request->user(), $request->all(), $id);
if (! $id) {
$request->session()->flash('success', 'Rascunho guardado no servidor. Pode continuar o preenchimento; não foi submetido.');
}
return response()->json(['id' => $record->id, 'etag' => $service->etag($record), 'message' => 'Rascunho guardado no servidor. Não foi submetido, aprovado ou publicado.'], $id ? 200 : 201)->header('ETag', '"'.$service->etag($record).'"');
}
public function canonicalResponse(Request $request, int $id, OpportunityCanonicalResponse $response): JsonResponse
{
return response()->json($response->read($request->user(), Opportunity::withTrashed()->findOrFail($id)))
->header('Cache-Control', 'private, no-store');
}
public function lifecycle(Request $request, int $id, OpportunityManagement $service): JsonResponse
{
abort_unless($service->installed(), 503);
$record = $service->lifecycle($request->user(), $id, $request->all());
return response()->json(['id' => $record->id, 'message' => $request->input('action') === 'restore' ? 'Restaurado como rascunho; nova revisão necessária antes de publicar.' : 'Exclusão lógica/arquivo registado; histórico e relações preservados.']);
}
}
@@ -0,0 +1,32 @@
<?php
namespace App\Http\Controllers;
use App\Models\Opportunity;
use App\Services\OpportunityOriginReview;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class OpportunityOriginReviewController extends Controller
{
public function index(OpportunityOriginReview $review): Response
{
return Inertia::render('management/opportunity-origin', ['records' => Opportunity::whereIn('reference', ['INV-MZ-AGR-001', 'INV-MZ-ENE-002', 'INV-MZ-TUR-003', 'INV-MZ-INF-004', 'INV-MZ-BLUE-005', 'INV-MZ-FIN-006'])->orderBy('id')->pluck('id')->map(fn ($id) => $review->state($id))->all(), 'ready' => $review->ready()]);
}
public function preview(Request $request, OpportunityOriginReview $review): JsonResponse
{
$data = $request->validate(['id' => ['required', 'integer', 'exists:opportunities,id'], 'classification' => ['required', 'in:real,example,undetermined'], 'reason' => ['required', 'string', 'max:2000']]);
return response()->json($review->preview($data['id'], $data['classification'], $data['reason']));
}
public function apply(Request $request, OpportunityOriginReview $review): JsonResponse
{
$data = $request->validate(['id' => ['required', 'integer', 'exists:opportunities,id'], 'classification' => ['required', 'in:real,example,undetermined'], 'reason' => ['required', 'string', 'max:2000'], 'snapshot_hash' => ['required', 'string', 'size:64'], 'confirm' => ['required', 'accepted']]);
return response()->json($review->apply($data['id'], $data['classification'], $data['reason'], $data['snapshot_hash'], $request->user()->id));
}
}
@@ -0,0 +1,26 @@
<?php
namespace App\Http\Controllers;
use App\Models\Opportunity;
use App\Services\OpportunityPromotion;
use Illuminate\Http\Request;
use Inertia\Inertia;
final class OpportunityPromotionController extends Controller
{
public function show(Request $request, int $id, OpportunityPromotion $service): \Inertia\Response
{
return Inertia::render('management/opportunity-promotion', $service->editor($request->user(), Opportunity::findOrFail($id)));
}
public function preview(Request $request, int $id, OpportunityPromotion $service): \Illuminate\Http\JsonResponse
{
return response()->json($service->preview($request->user(), Opportunity::findOrFail($id), $request->all()));
}
public function confirm(Request $request, int $id, OpportunityPromotion $service): \Illuminate\Http\JsonResponse
{
return response()->json($service->confirm($request->user(), $id, $request->all()));
}
}
@@ -0,0 +1,261 @@
<?php
namespace App\Http\Controllers;
use App\Models\Opportunity;
use App\Services\CountryCatalogue;
use App\Services\InvestmentGeography;
use App\Services\OpportunityImageProcessor;
use App\Services\OpportunityManagement;
use App\Services\OpportunityResourceInstallation;
use App\Services\OpportunityResources;
use App\Services\OrganizationCountryInstallation;
use App\Services\PortalProfileAccess;
use App\Services\VerifiedDocumentBinary;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class OpportunityResourceController extends Controller
{
public function installation(Request $request, OpportunityResourceInstallation $service): JsonResponse|Response
{
$preview = [...$service->preview(), 'imageProcessingAvailable' => app(OpportunityImageProcessor::class)->available(), 'actionUrl' => '/management/opportunity-resources-install'];
$preview['changes'] = [...$preview['changes'], 'Código reconciliado: catálogo ISO comum; residência e constituição distintas; origem declarada guardada no payload existente e estatísticas internas autorizadas. Não converte países históricos nem usa IP.', 'Seletores territoriais reutilizados com pesquisa; filtros contextuais do catálogo e fichas territoriais consultáveis no percurso do mapa. Zero geometrias não permite desenhar estruturas sem dados.', 'Não reinstala a gestão básica nem acrescenta associações territoriais. Revisão transversal, cadastro completo em todos os campos e percursos educativos continuam parciais; consultar a matriz por módulo.'];
return $request->expectsJson() ? response()->json($preview) : Inertia::render('management/opportunity-install', $preview);
}
public function applyInstallation(Request $request, OpportunityResourceInstallation $service): RedirectResponse
{
$data = $request->validate(['hash' => 'required|string|size:64', 'confirm' => 'required|accepted']);
$service->apply($request->user(), $data['hash']);
return back()->with('success', 'Recursos instalados; nenhum ficheiro publicado ou oportunidade submetida.');
}
public function organizationCountryPreview(Request $request, OrganizationCountryInstallation $service): JsonResponse|Response
{
$plan = $service->preview($request->user());
$scope = $this->countryOperationScope($request, $plan);
$operation = $request->session()->get('portal.country_installation_operation');
if (! is_array($operation) || ($operation['scope'] ?? null) !== $scope) {
$operation = ['scope' => $scope, 'key' => (string) Str::uuid()];
$request->session()->put('portal.country_installation_operation', $operation);
}
$plan = [...$plan, 'operationKey' => $operation['key'], 'actionUrl' => '/management/opportunity-resources-install/organization-country', 'extension' => true];
return $request->expectsJson() ? response()->json($plan) : Inertia::render('management/opportunity-install', $plan);
}
public function organizationCountryApply(Request $request, OrganizationCountryInstallation $service): JsonResponse|RedirectResponse
{
// Preview authorizes the actor; the UUID remains stable until this scope changes or logout.
$plan = $service->preview($request->user());
$operation = $request->session()->get('portal.country_installation_operation');
$data = $request->validate(['idempotency_key' => 'required|uuid']);
abort_unless(is_array($operation) && ($operation['scope'] ?? null) === $this->countryOperationScope($request, $plan)
&& hash_equals($operation['key'], $data['idempotency_key']), 409, 'A operação não corresponde a esta prévia. Atualize a página antes de tentar novamente.');
$result = $service->apply($request->user(), $request->all());
return $request->expectsJson() ? response()->json($result) : back()->with('success', 'País mestre instalado; nenhuma submissão anterior alterada.');
}
/** @param array<string, mixed> $plan */
private function countryOperationScope(Request $request, array $plan): string
{
return hash('sha256', json_encode([$request->user()->getAuthIdentifier(), $plan['target'], $plan['migration'], $plan['code_hash']], JSON_THROW_ON_ERROR));
}
public function index(int $id, OpportunityResources $service): Response
{
$opportunity = Opportunity::withTrashed()->findOrFail($id);
app(PortalProfileAccess::class)->authorize(request()->user(), 'view', $opportunity);
Inertia::share(['basePath' => str_starts_with(request()->path(), 'minhas-oportunidades') ? '/minhas-oportunidades' : '/management/opportunities', 'readOnly' => ! app(PortalProfileAccess::class)->canEdit(request()->user(), $opportunity)]);
abort_unless($service->installed(), 503, 'Recursos ainda não instalados; consulte o plano autenticado.');
$resources = [];
Inertia::share('imageProcessingAvailable', app(OpportunityImageProcessor::class)->available());
Inertia::share('countries', app(CountryCatalogue::class)->all());
foreach (OpportunityResources::KINDS as $kind) {
$resources[$kind] = $service->listing($id, $kind);
}
foreach ($resources['locations'] as $location) {
$p = $location->payload;
$location->territoryPath = app(InvestmentGeography::class)->path($p['locality_id'] ?? $p['admin_post_id'] ?? $p['district_id'] ?? $p['province_id'] ?? null, true);
}
foreach ($resources['images'] as $image) {
$image->derivativePreviews = [];
foreach ($image->payload['derivatives'] ?? [] as $derived) {
if (DB::table('documents')->where('id', $derived['document_id'])->where('status', 'active')->where('malware_scan_status', 'clean')->whereColumn('sha256', 'malware_scanned_sha256')->exists()) {
$image->derivativePreviews[] = [...$derived, 'url' => URL::temporarySignedRoute(str_starts_with(request()->path(), 'minhas-oportunidades') ? 'promoter.opportunity-image-preview' : 'management.opportunity-image-preview', now()->addMinutes(10), ['id' => $id, 'resourceId' => $image->id, 'documentId' => $derived['document_id']])];
}
}
}
return Inertia::render('management/opportunity-resources', ['opportunity' => $opportunity, 'etag' => app(OpportunityManagement::class)->etag($opportunity), 'resources' => $resources, 'librarySources' => !app(\App\Services\PortalProfileAccess::class)->canEdit(request()->user(), $opportunity) ? [] : app(\App\Services\OpportunityLibrarySupport::class)->choices(request()->user()), 'existingLocations' => DB::table('opportunity_locations as l')->join('geo_locations as g', 'g.id', '=', 'l.geo_location_id')->where('opportunity_id', $id)->get(['g.id', 'g.name', 'g.location_type']), 'locationReference' => app(\App\Services\OpportunityTerritorialPresentation::class)->describe($opportunity, $resources['locations']), 'assessment' => $this->assessment($resources)]);
}
public function reuseLibrary(Request $request, int $id): JsonResponse
{
$row = app(\App\Services\OpportunityLibrarySupport::class)->attach($request->user(), Opportunity::findOrFail($id), $request->all());
return response()->json(['id' => $row->id, 'message' => 'Original associado como suporte privado, sem copiar ficheiros ou publicar.']);
}
public function save(Request $request, int $id, string $kind, OpportunityResources $service, ?string $resourceId = null): JsonResponse
{
$input = $request->all();
if (is_string($input['payload'] ?? null)) {
$input['payload'] = json_decode($input['payload'], true);
}
$record = $service->save($request->user(), $id, $kind, $input, $request->file('file'), $resourceId);
return response()->json(['id' => $record->id, 'message' => 'Recurso guardado no servidor. Não publicado; validação final e scan são independentes.', 'etag' => app(OpportunityManagement::class)->etag(Opportunity::findOrFail($id))], $resourceId ? 200 : 201);
}
public function lifecycle(Request $request, int $id, string $kind, string $resourceId, OpportunityResources $service): JsonResponse
{
$service->lifecycle($request->user(), $id, $kind, $resourceId, $request->all());
return response()->json(['message' => 'Exclusão lógica/restauração registada; original e versões preservados.']);
}
public function prepareImage(Request $request, int $id, string $resourceId, OpportunityImageProcessor $service): JsonResponse
{
$service->prepare($request->user(), $id, $resourceId, $request->all());
return response()->json(['message' => 'Derivadas WebP geradas sem EXIF original; scan independente e revisão de divulgação pendentes.']);
}
public function previewImage(int $id, string $resourceId, string $documentId): \Illuminate\Http\Response|BinaryFileResponse
{
$o = Opportunity::findOrFail($id);
abort_if($o->status === 'archived', 404);
$image = DB::table('opportunity_images')->where('opportunity_id', $id)->where('id', $resourceId)->whereNull('deleted_at')->first();
abort_unless($image !== null, 404);
$metadata = json_decode($image->payload, true);
abort_unless(in_array($documentId, array_column($metadata['derivatives'] ?? [], 'document_id'), true), 404);
$doc = DB::table('documents')->where('id', $documentId)->whereNull('deleted_at')->first();
abort_unless($doc && $doc->status === 'active' && $doc->malware_scan_status === 'clean' && hash_equals(trim($doc->sha256), (string) $doc->malware_scanned_sha256), 423, 'Scan da derivada pendente.');
return app(VerifiedDocumentBinary::class)->respond($doc, true);
}
public function download(int $id, string $kind, string $resourceId, OpportunityResources $service): \Illuminate\Http\Response|BinaryFileResponse
{
abort_unless(in_array($kind, ['documents', 'images'], true), 404);
$opportunity = Opportunity::findOrFail($id);
app(\App\Services\PortalProfileAccess::class)->authorize(request()->user(), 'view', $opportunity);
abort_if($opportunity->status === 'archived', 404);
$record = DB::table($service->table($kind).' as r')->join('documents as d', 'd.id', '=', 'r.document_id')->where('r.id', $resourceId)->where('r.opportunity_id', $id)->whereNull('r.deleted_at')->whereNull('d.deleted_at')->first(['d.*']);
abort_unless($record !== null, 404, 'Recurso indisponível ou excluído logicamente.');
abort_unless($record->status === 'active' && $record->malware_scan_status === 'clean' && hash_equals(trim($record->sha256), (string) $record->malware_scanned_sha256), 423, 'Scan pendente, inconclusivo ou inseguro. O ficheiro permanece em quarentena.');
$support = DB::table($service->table($kind))->where('id', $resourceId)->first();
app(\App\Services\OpportunityLibrarySupport::class)->assertAvailable($support, $record);
if (request()->filled('source_sha256')) abort_unless(hash_equals(trim($record->sha256), (string)request('source_sha256')), 409, 'O original já não corresponde à versão consultada.');
return app(VerifiedDocumentBinary::class)->respond($record);
}
/** @param array<string,list<\stdClass>> $resources
* @return list<string>
*/
private function assessment(array $resources): array
{
$issues = [];
$locations = array_values(array_filter($resources['locations'], fn ($r) => ! $r->deleted_at));
if (count($locations) < 1) {
$issues[] = 'Localização operacional ainda não preparada; ligações históricas não são convertidas automaticamente.';
}
if (count(array_filter($locations, fn ($r) => $r->payload['is_primary'] ?? false)) !== 1) {
$issues[] = 'Escolha exatamente uma localização principal.';
}
$allocated = array_filter($locations, fn ($r) => isset($r->payload['allocation_percentage']));
if ($allocated && (count($allocated) !== count($locations) || abs(array_sum(array_map(fn ($r) => (float) $r->payload['allocation_percentage'], $allocated)) - 100) > 0.01)) {
$issues[] = 'A distribuição deve abranger todas as localizações e somar 100% (tolerância 0,01).';
}
$provinces = [];
$districts = [];
foreach ($locations as $location) {
$p = $location->payload;
$mozambican = empty($p['country_code']) || $p['country_code'] === 'MZ';
if ($mozambican && (empty($p['province_id']) || empty($p['district_id']))) {
$issues[] = 'Uma localização em Moçambique tem província ou distrito pendente.';
}
if (! $mozambican && array_filter(array_intersect_key($p, array_flip(['province_id', 'district_id', 'admin_post_id', 'locality_id'])))) {
$issues[] = 'Uma localização noutro país conserva uma seleção territorial de Moçambique incompatível; corrigir explicitamente sem converter o histórico.';
}
if (empty($p['role'])) {
$issues[] = 'Uma localização tem papel pendente.';
}
if ($mozambican && ! empty($p['district_id'])) {
$districts[$p['district_id']] = true;
}
if ($mozambican && ! empty($p['province_id'])) {
$provinces[$p['province_id']] = true;
}
if (($p['role'] ?? null) === 'other' && empty($p['role_description'])) {
$issues[] = 'Papel outro exige descrição.';
}
if (empty($p['source_reference'])) {
$issues[] = 'Localização com rastreabilidade por recuperar; aceitação oficial anterior preservada.';
}
if (! empty($p['geometry'])) {
$issues[] = 'Compatibilidade espacial depende de limite oficial correspondente; geometria não confirma disponibilidade.';
}
if (isset($p['area_value']) !== isset($p['area_unit'])) {
$issues[] = 'Área exige valor e unidade juntos.';
}
}
if (count($districts) > 1 && ! array_filter($locations, fn ($r) => mb_strlen($r->payload['multi_district_rationale'] ?? '') >= 50)) {
$issues[] = 'Multidistrito/interprovincial exige fundamento operacional de 50–1000 caracteres.';
}
if ($locations) {
$issues[] = 'Modo preparado: '.(count($locations) > 1 ? 'multiple' : 'single').'; âmbito administrativo moçambicano derivado: '.(count($provinces) > 1 ? 'interprovincial' : (count($districts) > 1 ? 'multi_district' : ($districts ? 'district' : 'sem seleção moçambicana'))).'. Sem conversão de contexto em implantação.';
}
foreach (['images', 'documents'] as $kind) {
foreach ($resources[$kind] as $r) {
if ($r->deleted_at) {
continue;
}
if ($r->file->malware_scan_status !== 'clean') {
$issues[] = 'Anexo '.$r->file->file_name.': scan '.$r->file->malware_scan_status.'; não é evidência de segurança.';
}
$required = $kind === 'images' ? ['title', 'caption', 'alt_text', 'author_source', 'rights_status'] : ['category', 'issuer', 'edition', 'classification'];
foreach ($required as $field) {
if (empty($r->payload[$field])) {
$issues[] = 'Anexo '.$r->file->file_name.': '.$field.' pendente.';
}
}
if ($kind === 'images') {
if (empty($r->payload['derivatives'])) {
$issues[] = 'Imagem '.$r->file->file_name.': derivadas sem EXIF ainda por preparar (GD/WebP e scan necessários).';
}
$issues[] = 'Imagem '.$r->file->file_name.': revisão de dados pessoais/direitos e aprovação de divulgação ainda pendentes.';
foreach (['title' => 5, 'caption' => 10, 'alt_text' => 10, 'author_source' => 2] as $field => $minimum) {
if (mb_strlen($r->payload[$field] ?? '') < $minimum) {
$issues[] = 'Imagem '.$r->file->file_name.': '.$field.' abaixo do mínimo para validação final.';
}
}
if (($r->payload['role'] ?? null) === 'location' && empty($r->payload['location_id'])) {
$issues[] = 'Imagem de localização exige ligação a um local ativo da mesma oportunidade.';
}
if (in_array($r->payload['role'] ?? null, ['asset', 'product', 'evidence'], true)) {
$issues[] = 'Imagem de ativo/produto/evidência: módulo e associação específica ainda pendentes.';
}
if (in_array($r->payload['rights_status'] ?? null, ['licensed', 'authorized'], true) && empty($r->payload['rights_document_id'])) {
$issues[] = 'Imagem licenciada/autorizada exige comprovativo.';
}
}
}
}
$issues[] = 'Esta conferência não submete nem publica; workflow final, revisão humana e derivados seguros permanecem separados.';
return array_values(array_unique($issues));
}
}
@@ -0,0 +1,401 @@
<?php
namespace App\Http\Controllers;
use App\Jobs\ScanDocumentForMalware;
use App\Models\ExistingBusiness;
use App\Models\User;
use App\Services\InvestmentCatalogue;
use App\Services\InvestmentGeography;
use App\Services\InvestmentRelations;
use App\Services\PartnershipExchange;
use App\Services\PartnershipMatching;
use App\Services\PartnershipSources;
use App\Services\VerifiedDocumentBinary;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class PartnershipExchangeController extends Controller
{
public function source(Request $request, string $type, string $id): JsonResponse
{
if (! Schema::hasTable('partnership_origins')) {
return response()->json(['available' => false, 'items' => []]);
}
app(PartnershipSources::class)->resolve($type, $id, $request->user());
return response()->json(['available' => true, 'items' => DB::table('portal_partnerships as p')->join('partnership_origins as origin', 'origin.partnership_id', '=', 'p.id')
->where('origin.type', $type)->where('origin.target_id', $id)->where('p.status', 'published')->orderByDesc('p.id')->distinct()->get(['p.id', 'p.title'])])->header('Cache-Control', 'private, no-store');
}
public function index(Request $request): Response|JsonResponse
{
$filters = $request->validate(['q' => 'nullable|string|max:120', 'sector_id' => 'nullable|integer', 'partnership_type' => 'nullable|string|max:40',
'kind' => 'nullable|in:offering,seeking', 'territory_id' => 'nullable|uuid', 'origin_type' => 'nullable|string|max:40', 'origin_id' => 'nullable|string|max:100']);
$query = DB::table('portal_partnerships as p')->where('p.status', 'published');
foreach (['sector_id', 'partnership_type', 'kind'] as $field) {
if (! empty($filters[$field])) {
if ($field === 'sector_id') {
$sector = \App\Models\Sector::find($filters[$field]);
$query->whereIn('p.sector_id', $sector ? \App\Models\Sector::whereIn('slug', \App\Services\SectorOrganization::slugs($sector->slug))->pluck('id') : []);
} else { $query->where('p.'.$field, $filters[$field]); }
}
}
if (! empty($filters['q'])) {
$term = '%'.$filters['q'].'%';
$query->where(fn ($q) => $q->where('p.title', 'ilike', $term)->orWhere('p.sector', 'ilike', $term)
->orWhereRaw("p.payload->>'summary' ILIKE ?", [$term])->orWhere(fn ($details) => $details->whereRaw("COALESCE(p.payload->>'confidentiality','public')='public'")->where(fn ($body) => $body->where('p.offer', 'ilike', $term)->orWhere('p.seeking', 'ilike', $term))));
}
if (! empty($filters['origin_type']) && ! empty($filters['origin_id'])) {
// Authenticate access to a private master before exposing a backlink query.
app(PartnershipSources::class)->resolve($filters['origin_type'], $filters['origin_id'], $request->user());
$query->whereExists(fn ($q) => $q->selectRaw('1')->from('partnership_origins')->whereColumn('partnership_id', 'p.id')->where('type', $filters['origin_type'])->where('target_id', $filters['origin_id']));
}
if (! empty($filters['territory_id'])) {
abort_unless(app(InvestmentGeography::class)->publicReferences()->where('id', $filters['territory_id'])->exists(), 422);
$ids = app(InvestmentGeography::class)->descendants($filters['territory_id']);
$investmentIds = app(InvestmentCatalogue::class)->query(['geo_location_id' => $filters['territory_id']])->pluck('id')->map(fn ($id) => (string) $id)->all();
$query->where(fn ($q) => $q->whereExists(fn ($places) => $places->selectRaw('1')->from('partnership_locations')->whereColumn('partnership_id', 'p.id')->whereIn('geo_location_id', $ids))
->orWhereExists(fn ($origins) => $origins->selectRaw('1')->from('partnership_origins as origin')->join('project_locations as pl', DB::raw('pl.project_id::text'), '=', 'origin.target_id')
->whereColumn('origin.partnership_id', 'p.id')->where('origin.type', 'project')->whereNull('pl.deleted_at')->where('pl.data_classification', 'public')->whereIn('pl.geo_location_id', $ids))
->orWhereExists(fn ($origins) => $origins->selectRaw('1')->from('partnership_origins')->whereColumn('partnership_id', 'p.id')->where('type', 'investment')->whereIn('target_id', $investmentIds))
->orWhereExists(fn ($members) => $members->selectRaw('1')->from('partnership_origins as origin')
->joinSub(app(InvestmentRelations::class)->memberships()->select(['cp.cluster_id', 'cp.project_id']), 'member', DB::raw('member.cluster_id::text'), '=', 'origin.target_id')
->join('project_locations as pl', 'pl.project_id', '=', 'member.project_id')->whereColumn('origin.partnership_id', 'p.id')->where('origin.type', 'cluster')
->whereNull('pl.deleted_at')->where('pl.data_classification', 'public')->whereIn('pl.geo_location_id', $ids))
->orWhereExists(fn ($zones) => $zones->selectRaw('1')->from('partnership_origins as origin')->join('investment_zone_locations as zl', DB::raw('zl.zone_id::text'), '=', 'origin.target_id')
->join('investment_zones as zone', 'zone.id', '=', 'zl.zone_id')->whereColumn('origin.partnership_id', 'p.id')->where('origin.type', 'zone')->where('zone.publication_status', 'published')
->whereNull('zone.deleted_at')->where('zl.validation_status', 'validated')->whereIn('zl.geo_location_id', $ids)));
}
$results = $query->orderByDesc('p.id')->select(['p.id', 'p.title', 'p.sector', 'p.province', 'p.kind', 'p.partnership_type', 'p.sector_id', 'p.deadline', 'p.published_at'])
->selectRaw("p.payload->>'summary' as summary")->paginate(12)->withQueryString();
if ($request->expectsJson()) {
return response()->json($results);
}
$mapPoints = [];
foreach ($results as $partnership) {
$locations = app(InvestmentRelations::class)->locations(array_values(DB::table('partnership_locations')->where('partnership_id', $partnership->id)->get()
->map(fn ($location) => ['id' => $location->geo_location_id, 'role' => $location->role, 'source_reference' => $location->source_reference])->all()));
foreach ($locations as $location) {
if ($location['location_type'] !== 'site') {
continue;
}
foreach ($location['coordinates'] as $coordinate) {
$mapPoints[] = ['geometry' => $coordinate['geometry'], 'properties' => ['id' => count($mapPoints), 'title' => $partnership->title,
'location' => $location['name'].' · '.$location['role'].' · '.$location['source_reference'], 'url' => '/parcerias/'.$partnership->id]];
}
}
foreach (app(PartnershipSources::class)->forPartnership($partnership->id) as $source) {
$context = $source['geography']['locations'] ?? [];
foreach ($source['geography']['members'] ?? [] as $member) {
$context = [...$context, ...$member['locations']];
}
foreach ($context as $location) {
foreach ($location['coordinates'] as $coordinate) {
$mapPoints[] = ['geometry' => $coordinate['geometry'], 'properties' => ['id' => count($mapPoints), 'title' => $partnership->title,
'location' => 'Contexto da origem: '.$location['name'].'; implantação da parceria não confirmada', 'url' => '/parcerias/'.$partnership->id]];
}
}
}
}
return Inertia::render('partnerships/catalogue', ['results' => $results, 'filters' => $filters, 'mapPoints' => $mapPoints, ...$this->catalogues($request)]);
}
public function dashboard(Request $request): Response
{
$actor = $request->user();
$organizations = DB::table('memberships')->where('user_id', $actor->id)->whereNull('deleted_at')->pluck('organization_id');
$mine = DB::table('portal_partnerships')->where(fn ($q) => $q->where(fn ($own) => $own->where('user_id', $actor->id)->whereNull('organization_id'))->orWhereIn('organization_id', $organizations))->latest()->limit(100)->get();
$interests = DB::table('partnership_interests as i')->join('portal_partnerships as p', 'p.id', '=', 'i.partnership_id')
->leftJoin('partnership_rooms as room', 'room.interest_id', '=', 'i.id')
->where(fn ($q) => $q->where(fn ($own) => $own->where('i.user_id', $actor->id)->whereNull('i.organization_id'))->orWhereIn('i.organization_id', $organizations))
->select(['i.id', 'i.partnership_id', 'i.status', 'i.decision_reason', 'p.title', 'room.id as room_id'])->orderByDesc('i.id')->limit(100)->get();
$moderation = Gate::forUser($actor)->allows('manage-portal') ? DB::table('portal_partnerships')->whereIn('status', ['submitted', 'pending'])->orderBy('updated_at')->get(['id', 'title', 'status', 'version']) : [];
return Inertia::render('partnerships/dashboard', ['mine' => $mine->map(fn ($p) => ['id' => $p->id, 'title' => $p->title, 'status' => $p->status, 'version' => $p->version]), 'interests' => $interests, 'moderation' => $moderation,
'notifications' => DB::table('partnership_notifications')->where('user_id', $actor->id)->orderByDesc('id')->limit(50)->get()]);
}
public function edit(Request $request, ?int $id = null): Response
{
$record = $id ? app(PartnershipExchange::class)->find($id) : null;
abort_if($record && ! app(PartnershipExchange::class)->owns($request->user(), $record), 403);
$origins = $record ? DB::table('partnership_origins')->where('partnership_id', $id)->get() : [];
if (! $record && $request->filled('origin_type') && $request->filled('origin_id')) {
$source = app(PartnershipSources::class)->resolve($request->string('origin_type')->toString(), $request->string('origin_id')->toString(), $request->user());
$origins = [['type' => $source['type'], 'target_id' => $source['target_id']]];
}
return Inertia::render('partnerships/editor', ['record' => $record ? [...(array) $record, 'payload' => json_decode($record->payload, true)] : null, 'origins' => $origins,
'locations' => $record ? DB::table('partnership_locations')->where('partnership_id', $id)->get() : [], ...$this->catalogues($request)]);
}
public function save(Request $request, ?int $id = null): RedirectResponse|JsonResponse
{
$record = app(PartnershipExchange::class)->save($request->user(), $request->all(), $id);
return $request->expectsJson() ? response()->json(['id' => $record->id, 'version' => $record->version, 'status' => $record->status]) : redirect('/parcerias/'.$record->id.'/editar')->with('success', 'Rascunho guardado.');
}
public function show(Request $request, int $id): Response|JsonResponse
{
$record = app(PartnershipExchange::class)->find($id);
$owner = $request->user() && app(PartnershipExchange::class)->owns($request->user(), $record, false);
$moderator = $request->user() && Gate::forUser($request->user())->allows('manage-portal');
abort_unless($record->status === 'published' || $owner || $moderator, 404);
$payload = json_decode($record->payload, true);
unset($payload['_creation_hash']);
$privateDetails = ($payload['confidentiality'] ?? 'public') === 'private' && ! $owner && ! $moderator;
$mine = $request->user() ? DB::table('partnership_interests')->where('partnership_id', $id)->get()->filter(fn ($i) => app(PartnershipExchange::class)->owns($request->user(), $i, false))->values() : collect();
$interests = $owner ? DB::table('partnership_interests')->where('partnership_id', $id)->orderBy('id')->get() : $mine;
$interestData = $interests->map(function ($interest) use ($record) {
return [...(array) $interest, 'payload' => json_decode($interest->payload, true), 'assessment' => json_decode($interest->assessment, true),
'match' => app(PartnershipMatching::class)->explain($record, $interest), 'room_id' => DB::table('partnership_rooms')->where('interest_id', $interest->id)->value('id')];
});
$props = ['record' => ['id' => $record->id, 'title' => $record->title, 'status' => $record->status, 'version' => $record->version, 'kind' => $record->kind,
'partnership_type' => $record->partnership_type, 'sector' => $record->sector, 'province' => $record->province, 'sector_id' => $record->sector_id, 'deadline' => $record->deadline,
'offer' => $privateDetails ? null : $record->offer, 'seeking' => $privateDetails ? null : $record->seeking, 'payload' => $privateDetails ? ['summary' => $payload['summary'] ?? '', 'confidentiality' => 'private'] : $payload],
'owner' => (bool) $owner, 'canEdit' => $request->user() && app(PartnershipExchange::class)->owns($request->user(), $record), 'moderator' => (bool) $moderator,
'origins' => app(PartnershipSources::class)->forPartnership($id, $owner ? $request->user() : null), 'interests' => $interestData,
'locations' => app(InvestmentRelations::class)->locations(array_values(DB::table('partnership_locations')->where('partnership_id', $id)->get()->map(fn ($l) => ['id' => $l->geo_location_id, 'role' => $l->role, 'source_reference' => $l->source_reference])->all())),
'history' => ($owner || $moderator) ? DB::table('partnership_events')->where('partnership_id', $id)->where('event_type', 'like', 'partnership_opportunity.%')->orderBy('occurred_at')->get(['event_type', 'actor_id', 'occurred_at', 'payload']) : [],
...$this->catalogues($request)];
if ($request->user()) {
Inertia::encryptHistory();
}
return $request->expectsJson() ? response()->json($props)->header('Cache-Control', 'private, no-store') : Inertia::render('partnerships/detail', $props);
}
public function transition(Request $request, int $id): RedirectResponse|JsonResponse
{
$data = $request->validate(['version' => 'required|integer|min:1', 'action' => 'required|string', 'reason' => 'required|string|max:2000']);
$record = app(PartnershipExchange::class)->transition($request->user(), $id, $data['version'], $data['action'], $data['reason']);
return $request->expectsJson() ? response()->json(['status' => $record->status, 'version' => $record->version]) : back()->with('success', 'Decisão registada.');
}
public function interest(Request $request, int $id): RedirectResponse|JsonResponse
{
$interest = app(PartnershipExchange::class)->interest($request->user(), $id, $request->all());
return $request->expectsJson() ? response()->json(['id' => $interest->id, 'version' => $interest->version]) : back()->with('success', 'Interesse guardado.');
}
public function decide(Request $request, int $id): RedirectResponse|JsonResponse
{
$data = $request->validate(['version' => 'required|integer|min:1', 'action' => 'required|string', 'reason' => 'required|string|max:2000', 'assessment' => 'array|max:7']);
$interest = app(PartnershipExchange::class)->decideInterest($request->user(), $id, $data['version'], $data['action'], $data['reason'], $data['assessment'] ?? []);
return $request->expectsJson() ? response()->json(['status' => $interest->status, 'version' => $interest->version]) : back()->with('success', 'Candidatura atualizada.');
}
public function room(Request $request, int $id): Response|JsonResponse
{
$context = app(PartnershipExchange::class)->room($request->user(), $id);
$props = [...$context, 'room' => [...(array) $context['room'], 'agreement' => $context['room']->agreement ? json_decode($context['room']->agreement, true) : null],
'messages' => DB::table('partnership_messages as m')->join('users as u', 'u.id', '=', 'm.author_id')->where('room_id', $id)->orderBy('m.id')->get(['m.id', 'm.body', 'm.created_at', 'm.author_id', 'u.name']),
'documents' => DB::table('partnership_documents as link')->join('documents as d', 'd.id', '=', 'link.document_id')->where('room_id', $id)
->where(fn ($q) => $q->where('link.shared', true)->orWhere('link.owner_id', $request->user()->id))
->get(['link.id', 'link.shared', 'link.owner_id', 'd.file_name', 'd.status', 'd.malware_scan_status', 'd.id as document_id']),
'milestones' => DB::table('partnership_milestones')->where('room_id', $id)->orderBy('id')->get(),
'canWrite' => app(PartnershipExchange::class)->owns($request->user(), $context['partnership']) || app(PartnershipExchange::class)->owns($request->user(), $context['interest'])];
return $request->expectsJson() ? response()->json($props) : Inertia::render('partnerships/room', $props);
}
public function message(Request $request, int $id): RedirectResponse|JsonResponse
{
$data = $request->validate(['client_key' => 'required|uuid', 'body' => 'required|string|max:15000']);
DB::transaction(function () use ($request, $id, $data) {
$context = app(PartnershipExchange::class)->room($request->user(), $id, true);
$locked = DB::table('partnership_rooms')->where('id', $id)->lockForUpdate()->first();
abort_unless($locked !== null && ! in_array($locked->status, ['closed', 'cancelled'], true), 409, 'Sala encerrada.');
$key = ['room_id' => $id, 'author_id' => $request->user()->id, 'client_key' => $data['client_key']];
$existing = DB::table('partnership_messages')->where($key)->first();
if ($existing) {
abort_unless($existing->body === $data['body'], 409, 'Chave já utilizada com outro conteúdo.');
return;
}
DB::table('partnership_messages')->insert([...$key, 'body' => $data['body']]);
app(PartnershipExchange::class)->event($context['partnership'], $request->user(), 'partnership.message_created.v1', ['room_id' => $id], 'Nova mensagem privada', [$context['partnership']->user_id, $context['interest']->user_id]);
});
return $request->expectsJson() ? response()->json(['sent' => true]) : back()->with('success', 'Mensagem registada.');
}
public function agreement(Request $request, int $id): RedirectResponse|JsonResponse
{
$data = $request->validate(['version' => 'required|integer|min:1', 'action' => 'required|in:record,confirm,start,complete,cancel,renew', 'title' => 'required_if:action,record|nullable|string|max:200',
'terms' => 'required_if:action,record|nullable|string|max:20000', 'reason' => 'required|string|max:2000']);
DB::transaction(function () use ($request, $id, $data) {
$context = app(PartnershipExchange::class)->room($request->user(), $id, true);
$room = DB::table('partnership_rooms')->where('id', $id)->lockForUpdate()->first();
abort_unless((int) $room->version === (int) $data['version'], 409, 'Acordo alterado.');
$agreement = $room->agreement ? json_decode($room->agreement, true) : null;
$status = $room->status;
if ($data['action'] === 'record') {
abort_unless(in_array($status, ['negotiation', 'agreement_recorded'], true), 409);
$previous = $agreement['previous_versions'] ?? [];
if ($agreement) {
$previous[] = ['title' => $agreement['title'], 'terms' => $agreement['terms'], 'confirmations' => $agreement['confirmations'], 'recorded_at' => $agreement['recorded_at']];
}
$agreement = ['title' => $data['title'], 'terms' => $data['terms'], 'recorded_by' => $request->user()->id, 'recorded_at' => now()->toIso8601String(), 'confirmations' => [],
'parties' => [$context['partnership']->user_id, $context['interest']->user_id], 'legal_signature_status' => 'not_verified', 'previous_versions' => $previous];
$status = 'agreement_recorded';
} elseif ($data['action'] === 'confirm') {
abort_unless($status === 'agreement_recorded' && $agreement, 409);
$party = app(PartnershipExchange::class)->owns($request->user(), $context['partnership']) ? 'proponent' : 'candidate';
$agreement['confirmations'][$party] = ['actor_id' => $request->user()->id, 'date' => now()->toIso8601String(), 'reason' => $data['reason']];
if (count($agreement['confirmations']) === 2) {
$status = 'agreed';
}
} else {
$allowed = ['start' => ['agreed'], 'complete' => ['execution'], 'cancel' => ['negotiation', 'agreement_recorded', 'agreed', 'execution'], 'renew' => ['agreed', 'execution']];
abort_unless(in_array($status, $allowed[$data['action']] ?? [], true), 409);
if ($data['action'] === 'complete') {
abort_if(DB::table('partnership_milestones')->where('room_id', $id)->where('status', '!=', 'completed')->exists(), 409, 'Existem marcos pendentes.');
}
$status = ['start' => 'execution', 'complete' => 'closed', 'cancel' => 'cancelled', 'renew' => 'negotiation'][$data['action']];
if ($data['action'] === 'renew') {
$agreement['previous_versions'][] = ['title' => $agreement['title'], 'terms' => $agreement['terms'], 'confirmations' => $agreement['confirmations']];
$agreement['confirmations'] = [];
}
}
DB::table('partnership_rooms')->where('id', $id)->update(['agreement' => $agreement ? json_encode($agreement) : null, 'status' => $status, 'version' => $room->version + 1, 'updated_at' => now()]);
app(PartnershipExchange::class)->event($context['partnership'], $request->user(), 'partnership_agreement.changed.v1', ['room_id' => $id, 'status' => $status, 'version' => $room->version + 1, 'reason' => $data['reason']], 'Acordo: '.$status, [$context['partnership']->user_id, $context['interest']->user_id]);
});
return $request->expectsJson() ? response()->json(['recorded' => true]) : back()->with('success', 'Acordo atualizado; assinatura jurídica não verificada.');
}
public function milestone(Request $request, int $id, ?int $milestone = null): RedirectResponse|JsonResponse
{
$data = $request->validate(['title' => 'required|string|max:200', 'responsible_id' => 'required|integer', 'due_date' => 'nullable|date_format:Y-m-d', 'status' => 'required|in:pending,progress,completed',
'evidence' => 'required_if:status,completed|nullable|string|max:10000', 'version' => ($milestone ? 'required' : 'nullable').'|integer|min:1']);
DB::transaction(function () use ($request, $id, $milestone, $data) {
$context = app(PartnershipExchange::class)->room($request->user(), $id, true);
$locked = DB::table('partnership_rooms')->where('id', $id)->lockForUpdate()->first();
abort_unless($locked !== null && in_array($locked->status, ['agreement_recorded', 'agreed', 'execution'], true), 409);
$responsible = User::query()->whereKey($data['responsible_id'])->first();
abort_unless($responsible !== null && (app(PartnershipExchange::class)->owns($responsible, $context['partnership']) || app(PartnershipExchange::class)->owns($responsible, $context['interest'])), 422, 'Responsável deve ser participante identificado.');
if ($milestone) {
$existing = DB::table('partnership_milestones')->where('room_id', $id)->where('id', $milestone)->lockForUpdate()->first();
abort_unless($existing !== null, 404);
abort_unless((int) $existing->version === (int) $data['version'], 409);
DB::table('partnership_milestones')->where('id', $milestone)->update([...$data, 'version' => $existing->version + 1, 'updated_at' => now()]);
} else {
unset($data['version']);
$milestone = DB::table('partnership_milestones')->insertGetId([...$data, 'room_id' => $id, 'created_at' => now(), 'updated_at' => now()]);
}
app(PartnershipExchange::class)->event($context['partnership'], $request->user(), 'partnership_milestone.changed.v1', ['milestone_id' => $milestone, 'status' => $data['status']], 'Marco atualizado', [$context['partnership']->user_id, $context['interest']->user_id]);
});
return $request->expectsJson() ? response()->json(['saved' => true]) : back()->with('success', 'Marco guardado.');
}
public function upload(Request $request, int $id): RedirectResponse|JsonResponse
{
$data = $request->validate(['file' => 'required|file|mimes:pdf,jpg,jpeg,png|max:10240', 'shared' => 'required|boolean']);
$context = app(PartnershipExchange::class)->room($request->user(), $id, true);
$file = $request->file('file');
$documentId = (string) Str::uuid();
$stored = $file->storeAs('partnerships/'.$id, $documentId, 'local');
abort_unless(is_string($stored) && $stored !== '', 503);
try {
DB::transaction(function () use ($request, $id, $data, $file, $documentId, $stored, $context) {
app(PartnershipExchange::class)->room($request->user(), $id, true);
$locked = DB::table('partnership_rooms')->where('id', $id)->lockForUpdate()->first();
abort_unless($locked !== null && ! in_array($locked->status, ['closed', 'cancelled'], true), 409);
DB::table('documents')->insert(['id' => $documentId, 'source_system' => 'partnership-exchange', 'data_classification' => 'restricted',
'file_name' => basename($file->getClientOriginalName()), 'media_type' => $file->getMimeType(), 'storage_key' => $stored,
'sha256' => hash_file('sha256', Storage::disk('local')->path($stored)), 'status' => 'quarantined', 'size_bytes' => $file->getSize(), 'uploaded_by' => $request->user()->id]);
DB::table('partnership_documents')->insert(['room_id' => $id, 'document_id' => $documentId, 'owner_id' => $request->user()->id, 'shared' => $data['shared']]);
app(PartnershipExchange::class)->event($context['partnership'], $request->user(), 'partnership.document_added.v1', ['room_id' => $id, 'document_id' => $documentId, 'shared' => $data['shared']], 'Documento privado anexado');
});
} catch (\Throwable $e) {
Storage::disk('local')->delete($stored);
throw $e;
}
ScanDocumentForMalware::dispatch($documentId)->afterCommit();
return $request->expectsJson() ? response()->json(['uploaded' => true]) : back()->with('success', 'Documento privado guardado; análise antimalware pendente.');
}
public function download(Request $request, int $id, int $attachment): \Illuminate\Http\Response|BinaryFileResponse
{
$context = app(PartnershipExchange::class)->room($request->user(), $id);
$document = DB::table('partnership_documents as link')->join('documents as d', 'd.id', '=', 'link.document_id')->where('link.room_id', $id)->where('link.id', $attachment)
->where(fn ($q) => $q->where('link.shared', true)->orWhere('link.owner_id', $request->user()->id))->where('d.status', 'active')->whereNull('d.deleted_at')
->where('d.malware_scan_status', 'clean')->whereColumn('d.malware_scanned_sha256', 'd.sha256')->first(['d.*']);
abort_unless($document !== null, 404);
$response = app(VerifiedDocumentBinary::class)->respond($document);
app(PartnershipExchange::class)->event($context['partnership'], $request->user(), 'partnership.document_accessed.v1', ['room_id' => $id, 'attachment_id' => $attachment], 'Documento consultado');
return $response;
}
public function share(Request $request, int $id, int $attachment): RedirectResponse|JsonResponse
{
$data = $request->validate(['shared' => 'required|boolean']);
DB::transaction(function () use ($request, $id, $attachment, $data) {
$context = app(PartnershipExchange::class)->room($request->user(), $id, (bool) $data['shared']);
$link = DB::table('partnership_documents')->where('room_id', $id)->where('id', $attachment)->lockForUpdate()->first();
abort_unless($link !== null && (int) $link->owner_id === $request->user()->id, 403, 'Só o autor pode autorizar ou retirar a partilha.');
DB::table('partnership_documents')->where('id', $attachment)->update(['shared' => $data['shared']]);
app(PartnershipExchange::class)->event($context['partnership'], $request->user(), 'partnership.document_sharing_changed.v1', ['room_id' => $id, 'attachment_id' => $attachment, 'shared' => $data['shared']], 'Autorização documental atualizada');
});
return $request->expectsJson() ? response()->json(['saved' => true]) : back()->with('success', 'Autorização de partilha atualizada.');
}
public function notification(Request $request, int $id): RedirectResponse
{
abort_unless(DB::table('partnership_notifications')->where('id', $id)->where('user_id', $request->user()->id)->update(['read_at' => now()]) > 0, 404);
return back();
}
/** @return array<string,mixed> */
private function catalogues(Request $request): array
{
$sources = [];
foreach (InvestmentPortfolioController::visible()->where(fn ($q) => $q->whereNotNull('project_id')->orWhereNotNull('cluster_id'))->get(['id', 'title', 'project_id', 'cluster_id']) as $dossier) {
$sources[] = ['type' => $dossier->project_id ? 'project' : 'cluster', 'target_id' => $dossier->project_id ?? $dossier->cluster_id, 'title' => $dossier->title, 'href' => '/carteira/'.$dossier->id];
}
foreach (DB::table('opportunities')->where('status', 'published')->whereNotNull('published_at')->get(['id', 'title', 'slug']) as $investment) {
$sources[] = ['type' => 'investment', 'target_id' => (string) $investment->id, 'title' => $investment->title, 'href' => '/oportunidades/'.$investment->slug];
}
foreach (DB::table('investment_zones')->where('publication_status', 'published')->whereNull('deleted_at')->get(['id', 'name']) as $zone) {
$sources[] = ['type' => 'zone', 'target_id' => $zone->id, 'title' => $zone->name, 'href' => '/zonas/'.$zone->id];
}
foreach (ExistingBusiness::query()->published()->get(['id', 'public_title', 'public_slug']) as $business) {
$sources[] = ['type' => 'business', 'target_id' => $business->id, 'title' => $business->public_title, 'href' => '/negocios/'.$business->public_slug];
}
if ($request->user()) {
foreach (DB::table('investment_company_declarations')->where('user_id', $request->user()->id)->get(['id']) as $company) {
$sources[] = ['type' => 'company', 'target_id' => $company->id, 'title' => 'Declaração empresarial existente', 'href' => '/investidor/profile'];
}
foreach (DB::table('simulation_form_drafts')->where('owner_user_id', $request->user()->id)->whereNull('deleted_at')->get(['id', 'created_at']) as $draft) {
$sources[] = ['type' => 'simulation', 'target_id' => $draft->id, 'title' => 'Ficha de projeto · '.$draft->created_at, 'href' => '/simulador-fiscal?draft='.$draft->id];
}
}
return ['types' => config('partnerships.types'), 'criteria' => config('partnerships.criteria'), 'sources' => $sources, 'sectors' => \App\Models\Sector::choices()->orderBy('name')->get(['id', 'name', 'slug']),
'territories' => app(InvestmentGeography::class)->publicReferences()->orderBy('name')->get(['id', 'name', 'location_type', 'parent_id']),
'organizations' => $request->user() ? DB::table('memberships as m')->join('organizations as o', 'o.id', '=', 'm.organization_id')->where('m.user_id', $request->user()->id)
->whereNull('m.deleted_at')->whereNull('o.deleted_at')->whereIn('m.role', ['admin', 'manager', 'officer'])->get(['o.id', 'o.name']) : []];
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Http\Controllers;
use App\Services\PartnershipInstallation;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class PartnershipInstallationController extends Controller
{
public function preview(PartnershipInstallation $installation): Response
{
return Inertia::render('partnerships/installation', $installation->preview());
}
public function apply(Request $request, PartnershipInstallation $installation): RedirectResponse
{
$data = $request->validate(['hash' => 'required|string|size:64']);
$installation->apply($data['hash'], $request->user());
return back()->with('success', 'Bolsa de Parcerias instalada. Registos antigos preservados.');
}
}
@@ -0,0 +1,40 @@
<?php
namespace App\Http\Controllers;
use App\Mail\ContactMessageReceivedMail;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Throwable;
class PortalContactController extends Controller
{
// Adapter for investidor's contact form: preserve its fields and validation,
// using the configured mail transport without adding a database dependency.
public function store(Request $request): JsonResponse
{
$data = $request->validate([
'name' => ['required', 'string', 'min:2', 'max:150'],
'email' => ['required', 'email', 'max:255'],
'phone' => ['nullable', 'string', 'max:30'],
'subject' => ['required', 'string', 'min:3', 'max:100'],
'message' => ['required', 'string', 'min:10', 'max:2000'],
]);
try {
Mail::to(config('contact_messages.recipient'))
->send(new ContactMessageReceivedMail([
'name' => $data['name'], 'email' => $data['email'], 'phone' => $data['phone'] ?? null,
'subject' => $data['subject'], 'message' => $data['message'], 'platform' => 'InvesteMoz',
]));
} catch (Throwable $exception) {
Log::warning('Falha no envio de contacto do portal.', ['exception' => $exception::class]);
return response()->json(['success' => false, 'message' => 'Ocorreu um erro ao enviar a mensagem.'], 500);
}
return response()->json(['success' => true, 'message' => 'Mensagem enviada com sucesso.'], 201);
}
}
@@ -7,10 +7,14 @@ use App\Models\InvestmentSubmission;
use App\Models\Opportunity;
use App\Models\Sector;
use App\Models\User;
use App\Services\CountryCatalogue;
use App\Services\DeclaredOriginStatistics;
use App\Services\InterestWorkflow;
use App\Services\InvestmentGeography;
use App\Services\InvestmentWorkspace;
use App\Services\OpportunityManagement;
use App\Services\PortalAudit;
use App\Services\PortalProfileAccess;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
@@ -49,16 +53,60 @@ class PortalManagementController extends Controller
return Inertia::render('management/history', ['events' => $events]);
}
private function interestAssignmentEtag(InterestRequest $interest): string
{
return hash('sha256',json_encode([$interest->id,$interest->status,$interest->assigned_to,$interest->updated_at?->format('Y-m-d H:i:s.u')],JSON_THROW_ON_ERROR));
}
public function interestAssignments(Request $request): Response|JsonResponse
{
abort_unless(app(\App\Services\ScopedPortalAdministration::class)->administrative($request->user()),403);
$rows=InterestRequest::whereNotIn('status',['cancelled','closed','refused','converted'])->orderBy('id')->get(['id','reference','status','assigned_to','updated_at'])
->map(fn($row)=>['id'=>$row->id,'protocol'=>$row->reference,'status'=>$row->status,'analyst_id'=>$row->assigned_to,'version'=>0,'etag'=>$this->interestAssignmentEtag($row),'due_at'=>null]);
$data=['kind'=>'interest','requests'=>$rows,'analysts'=>$this->interestAssignees()->get(['id','name'])];
return $request->expectsJson()?response()->json($data):Inertia::render('management/desk-assignments',$data);
}
public function assignInterest(Request $request, InterestRequest $interest): RedirectResponse|JsonResponse
{
abort_unless(app(\App\Services\ScopedPortalAdministration::class)->administrative($request->user()),403);
$data=$request->validate(['etag'=>'required|string|size:64','analyst_id'=>['required','integer',Rule::in($this->interestAssignees()->pluck('id'))],'reason'=>'required|string|min:10|max:2000'],['reason.required'=>'Explique o motivo da atribuição.','reason.min'=>'Explique o motivo da atribuição com pelo menos 10 caracteres.','analyst_id.required'=>'Seleccione o responsável.','analyst_id.in'=>'Seleccione um responsável autorizado.']);
DB::transaction(function()use($request,$interest,$data){
$row=InterestRequest::whereKey($interest->id)->lockForUpdate()->firstOrFail();
if (!hash_equals($this->interestAssignmentEtag($row),$data['etag'])) throw ValidationException::withMessages(['etag'=>'A atribuição mudou. Recupere a lista actual e tente novamente.']);
if (in_array($row->status,['cancelled','closed','refused','converted'],true)) throw ValidationException::withMessages(['status'=>'Este pedido já não permite uma nova atribuição. Recupere a lista actual.']);
$owner=(int)($row->metadata['portal_user_id']??0);
abort_if($owner===(int)$data['analyst_id'] || InvestmentSubmission::where('interest_request_id',$row->id)->where('user_id',$data['analyst_id'])->exists(),422,'O interessado não pode acompanhar o seu próprio pedido como equipa.');
PortalAudit::save($row,['assigned_to'=>(int)$data['analyst_id']],$request->user()->id);
app(\App\Services\PortalProfileAccess::class)->audit($request->user(),'interest_assigned',['interest_id'=>$row->id,'assigned_to'=>(int)$data['analyst_id'],'reason'=>$data['reason']]);
});
return $request->expectsJson()?response()->json(['ok'=>true]):back()->with('success','Responsável atribuído.');
}
public function interests(Request $request): Response
{
$filters = $request->validate([
'q' => ['nullable', 'string', 'max:150'],
'status' => ['nullable', Rule::in(self::STATES)],
'follow_up' => ['nullable', Rule::in(['overdue', 'upcoming', 'unassigned'])],
'country' => ['nullable', app(CountryCatalogue::class)->rule()],
'origin' => ['nullable', Rule::in([...array_column(app(CountryCatalogue::class)->all(), 'code'), 'unknown'])],
]);
$query = InterestRequest::query()->with('assignee:id,name')->select([
'id', 'reference', 'full_name', 'email', 'sector_interest', 'existing_business_id', 'modality', 'status', 'assigned_to', 'next_action', 'next_action_due_at', 'created_at',
'id', 'reference', 'full_name', 'email', 'country', 'metadata', 'sector_interest', 'existing_business_id', 'modality', 'status', 'assigned_to', 'next_action', 'next_action_due_at', 'created_at',
]);
app(\App\Services\ScopedPortalAdministration::class)->interestQuery($query, $request->user());
if ($country = $filters['country'] ?? null) {
$values = app(CountryCatalogue::class)->filterValues($country);
$query->whereIn(DB::raw('LOWER(BTRIM(country))'), $values);
}
if ($origin = $filters['origin'] ?? null) {
if ($origin === 'unknown') {
$query->where(fn ($q) => $q->whereNull('metadata->declared_origin_country_code')->orWhereNotIn('metadata->declared_origin_country_code', array_column(app(CountryCatalogue::class)->all(), 'code')));
} else {
$query->where('metadata->declared_origin_country_code', $origin);
}
}
if ($term = $filters['q'] ?? null) {
$query->where(fn ($q) => $q->whereLike('reference', "%{$term}%")
->orWhereLike('full_name', "%{$term}%")->orWhereLike('email', "%{$term}%"));
@@ -77,13 +125,24 @@ class PortalManagementController extends Controller
}
return Inertia::render('management/interests', [
'requests' => $query->orderByDesc('id')->paginate(20)->withQueryString(),
'requests' => $query->orderByDesc('id')->paginate(20)->withQueryString()->through(function ($row) {
$result = $row->toArray();
$result['declaredOrigin'] = app(CountryCatalogue::class)->describe($row->metadata['declared_origin_country_code'] ?? null);
$country = app(CountryCatalogue::class)->describe($row->country);
$result['countryName'] = $country['code'] !== null ? $country['name'] : $row->country;
unset($result['metadata']);
return $result;
}),
'filters' => $filters,
'countries' => app(CountryCatalogue::class)->all(),
'originStatistics' => app(DeclaredOriginStatistics::class)->forManager($request->user(), $filters['origin'] ?? null),
]);
}
public function interest(InterestRequest $interest): Response
{
abort_unless(app(\App\Services\ScopedPortalAdministration::class)->interest(request()->user(), $interest),403);
$history = DB::table('business_workflow_transitions')->where('interest_request_id', $interest->id)
->orderByDesc('created_at')->limit(50)->get(['from_status', 'to_status', 'actor_id', 'reason', 'created_at'])
->map(function ($row) {
@@ -96,11 +155,12 @@ class PortalManagementController extends Controller
->orderBy('created_at')->get(['purpose', 'policy_version', 'status', 'granted_at', 'withdrawn_at']);
return Inertia::render('management/interest', [
'declaredOrigin' => app(CountryCatalogue::class)->describe($interest->metadata['declared_origin_country_code'] ?? null),
'interest' => $interest->only([
'id', 'reference', 'full_name', 'email', 'phone', 'investor_type', 'country',
'sector_interest', 'opportunity_reference', 'investment_range', 'message',
'privacy_consent', 'consented_at', 'status', 'created_at', 'reviewed_at', 'reviewed_by', 'metadata',
'existing_business_id', 'modality', 'assigned_to', 'next_action', 'next_action_due_at',
'existing_business_id', 'modality', 'assigned_to', 'next_action', 'next_action_due_at', 'source_page',
]),
'history' => $history,
'consents' => $consents,
@@ -110,12 +170,18 @@ class PortalManagementController extends Controller
public function review(Request $request, InterestRequest $interest, InterestWorkflow $workflow): RedirectResponse|JsonResponse
{
abort_unless(app(\App\Services\ScopedPortalAdministration::class)->interest(request()->user(), $interest),403);
$validated = $request->validate([
'status' => ['required', Rule::in(self::STATES)],
'note' => ['nullable', 'string', 'min:10', 'max:2000'],
'next_action' => ['nullable', 'required_with:next_action_due_at', 'string', 'max:180'],
'next_action_due_at' => ['nullable', 'required_with:next_action', 'date', 'after_or_equal:today'],
'assigned_to' => ['nullable', 'integer', Rule::in($this->interestAssignees()->pluck('id'))],
], [
'next_action_due_at.required_with' => 'Indique o prazo da próxima acção.',
'next_action_due_at.date' => 'Indique uma data válida para a próxima acção.',
'next_action_due_at.after_or_equal' => 'O prazo da próxima acção deve ser hoje ou uma data futura.',
'next_action.required_with' => 'Descreva a próxima acção correspondente ao prazo indicado.',
]);
$before = $interest->status;
$updated = $workflow->transition($request->user(), $interest, $validated['status'], $validated['note'] ?? null,
@@ -132,22 +198,44 @@ class PortalManagementController extends Controller
public function withdrawProponentSharingConsent(Request $request, InterestRequest $interest): RedirectResponse|JsonResponse
{
abort_unless(app(\App\Services\ScopedPortalAdministration::class)->interest(request()->user(), $interest),403);
$data = $request->validate(['reason' => ['required', 'string', 'min:10', 'max:2000']]);
DB::transaction(function () use ($request, $interest, $data) {
$consent = DB::table('consents')->where('interest_request_id', $interest->id)
->where('purpose', 'share_contact_with_proponent')->where('status', 'granted')->lockForUpdate()->first();
$this->withdrawConsent($request, $interest, $data['reason'], 'share_contact_with_proponent');
return $request->expectsJson() ? response()->json(['reference' => $interest->reference, 'sharing_consent' => 'withdrawn'])
: back()->with('success', 'Consentimento de partilha revogado.');
}
public function withdrawContactConsent(Request $request, InterestRequest $interest): RedirectResponse|JsonResponse
{
abort_unless(app(\App\Services\ScopedPortalAdministration::class)->interest(request()->user(), $interest),403);
$data = $request->validate(['reason' => ['required', 'string', 'min:10', 'max:2000']]);
$this->withdrawConsent($request, $interest, $data['reason'], 'respond_to_interest');
return $request->expectsJson() ? response()->json(['reference' => $interest->reference, 'contact_consent' => 'withdrawn'])
: back()->with('success', 'Consentimento de contacto revogado.');
}
private function withdrawConsent(Request $request, InterestRequest $interest, string $reason, string $purpose): void
{
DB::transaction(function () use ($request, $interest, $reason, $purpose) {
$lockedInterest = InterestRequest::query()->lockForUpdate()->findOrFail($interest->id);
$consent = DB::table('consents')->where('interest_request_id', $lockedInterest->id)
->where('purpose', $purpose)->where('status', 'granted')->whereNull('deleted_at')
->lockForUpdate()->first();
if (! $consent) {
return;
}
DB::table('consents')->where('id', $consent->id)->update(['status' => 'withdrawn', 'withdrawn_at' => now(), 'updated_at' => now()]);
$correlationId = (string) Str::uuid();
DB::table('business_workflow_transitions')->insert([
'id' => (string) Str::uuid(), 'interest_request_id' => $interest->id,
'from_status' => $interest->status, 'to_status' => $interest->status,
'actor_id' => $request->user()->id, 'reason' => Crypt::encryptString($data['reason']),
'id' => (string) Str::uuid(), 'interest_request_id' => $lockedInterest->id,
'from_status' => $lockedInterest->status, 'to_status' => $lockedInterest->status,
'actor_id' => $request->user()->id, 'reason' => Crypt::encryptString($reason),
'correlation_id' => $correlationId,
'metadata' => json_encode(['source' => 'management-interest', 'action' => 'consent_withdrawn',
'purpose' => 'share_contact_with_proponent'], JSON_THROW_ON_ERROR),
'purpose' => $purpose], JSON_THROW_ON_ERROR),
'created_at' => now(),
]);
DB::table('outbox_events')->insertOrIgnore([
@@ -156,14 +244,12 @@ class PortalManagementController extends Controller
'event_type' => 'InterestConsentWithdrawn', 'data_classification' => 'confidential',
'correlation_id' => $correlationId, 'status' => 'pending',
'payload' => json_encode(['schema_version' => 1, 'consent_id' => $consent->id,
'interest_id' => $interest->integration_id, 'purpose' => 'share_contact_with_proponent'], JSON_THROW_ON_ERROR),
'interest_id' => $lockedInterest->integration_id, 'purpose' => $purpose], JSON_THROW_ON_ERROR),
]);
});
return $request->expectsJson() ? response()->json(['reference' => $interest->reference, 'sharing_consent' => 'withdrawn'])
: back()->with('success', 'Consentimento de partilha revogado.');
}
/** @return Builder<User> */
private function interestAssignees(): Builder
{
$emails = array_unique(array_merge(config('portal.managers', []), ...array_map(
@@ -171,7 +257,9 @@ class PortalManagementController extends Controller
['triagers', 'reviewers', 'approvers_l1', 'approvers_l2', 'publishers'],
)));
return User::query()->whereNotNull('email_verified_at')->whereIn(DB::raw('lower(email)'), $emails)->orderBy('name');
return User::query()->whereNotNull('email_verified_at')->where(function ($q) use ($emails) {
$q->whereIn(DB::raw('lower(email)'), $emails)->orWhereIn('id', DB::table('portal_superuser_pair')->select('first_user_id'))->orWhereIn('id', DB::table('portal_superuser_pair')->select('second_user_id'));
})->orderBy('name');
}
public function catalog(Request $request, string $resource): Response
@@ -181,9 +269,19 @@ class PortalManagementController extends Controller
'q' => ['nullable', 'string', 'max:150'],
'status' => ['nullable', Rule::in($resource === 'sectors' ? ['active', 'inactive'] : ['draft', 'published', 'archived'])],
'sector_id' => ['nullable', 'integer', Rule::exists('sectors', 'id')->whereNull('deleted_at')],
'geography_scope' => ['nullable', Rule::in(['unspecified', 'specific', 'multiple', 'national', 'digital'])],
'page' => ['nullable', 'integer', 'min:1'],
]);
$query = $model::query();
if ($resource === 'opportunities') {
if (Schema::hasColumn('opportunities', 'owner_user_id')) {
$query = app(PortalProfileAccess::class)->scope(Opportunity::query(), $request->user());
}
$query->with('primaryTerritory:id,name,location_type,source_system,source_code');
if (isset($filters['geography_scope'])) {
$query->where('geography_scope', $filters['geography_scope']);
}
}
if ($term = trim($filters['q'] ?? '')) {
$query->where(function ($query) use ($term, $resource) {
$query->whereLike($resource === 'sectors' ? 'name' : 'title', "%{$term}%")
@@ -203,14 +301,18 @@ class PortalManagementController extends Controller
}
}
if ($resource === 'opportunities' && ($filters['sector_id'] ?? null)) {
$query->where('sector_id', $filters['sector_id']);
$sector = Sector::find($filters['sector_id']);
$query->whereIn('sector_id', $sector ? Sector::whereIn('slug', \App\Services\SectorOrganization::slugs($sector->slug))->pluck('id') : []);
}
return Inertia::render('management/catalog', [
'resource' => $resource,
'records' => $query->orderByDesc('id')->paginate(20)->withQueryString(),
'filters' => $filters,
'sectors' => $resource === 'opportunities' ? Sector::orderBy('name')->get(['id', 'name']) : [],
'sectors' => $resource === 'opportunities' ? Sector::choices()->orderBy('name')->get(['id', 'name', 'slug']) : [],
'geographyReviewCount' => $resource === 'opportunities'
? (clone $query)->where('geography_scope', 'unspecified')->count()
: 0,
]);
}
@@ -223,12 +325,15 @@ class PortalManagementController extends Controller
'resource' => $resource,
'record' => $record,
'territoryPath' => $record instanceof Opportunity ? app(InvestmentGeography::class)->path($record->primary_territory_id, true) : [],
'sectors' => Sector::orderBy('name')->get(['id', 'name']),
'sectors' => Sector::choices()->orderBy('name')->get(['id', 'name', 'slug']),
]);
}
public function save(Request $request, string $resource, ?int $id = null): RedirectResponse
{
if ($resource === 'opportunities' && app(OpportunityManagement::class)->installed()) {
throw ValidationException::withMessages(['status' => 'Use Gestão de Oportunidades para guardar uma versão de rascunho; publicação não é uma edição.']);
}
$model = $resource === 'sectors' ? Sector::class : Opportunity::class;
$record = $id ? $model::findOrFail($id) : new $model;
$rules = $resource === 'sectors' ? [
@@ -238,6 +343,7 @@ class PortalManagementController extends Controller
'description' => ['nullable', 'string', 'max:20000'],
'sort_order' => ['required', 'integer', 'min:0', 'max:2147483647'],
'is_active' => ['required', 'boolean'],
'editorial_published' => ['required', 'boolean'],
] : [
'sector_id' => ['required', 'integer', Rule::exists('sectors', 'id')->whereNull('deleted_at')],
'reference' => ['required', 'string', 'max:80', Rule::unique('opportunities')->ignore($record->id)],
@@ -258,12 +364,17 @@ class PortalManagementController extends Controller
'is_featured' => ['required', 'boolean'],
];
if ($resource === 'opportunities') {
$rules['geography_scope'] = ['nullable', Rule::in(['unspecified', 'specific', 'multiple', 'national', 'digital'])];
foreach (InvestmentGeography::FIELDS as $field => $type) {
$rules[$field] = ['nullable', 'uuid'];
}
$rules['territory_version'] = [$id && $request->hasAny(array_keys(InvestmentGeography::FIELDS)) ? 'required' : 'nullable', 'integer', 'min:0'];
}
$data = $request->validate($rules);
if ($resource === 'sectors') {
$data['published_at'] = $data['editorial_published'] ? ($record->published_at ?? now()) : null;
unset($data['editorial_published']);
}
if ($resource === 'opportunities' && $data['status'] === 'published' && $record->exists
&& Schema::hasTable('education_investment_proposals')
&& DB::table('education_investment_proposals')->where('opportunity_id', $record->id)->exists()) {
@@ -285,8 +396,14 @@ class PortalManagementController extends Controller
abort_unless((int) $current->territory_version === (int) $data['territory_version'], 409, 'Localização alterada. Recarregue a ficha.');
}
$path = app(InvestmentGeography::class)->selection($data, true);
if (($data['geography_scope'] ?? null) === 'national' && $path !== []) {
throw ValidationException::withMessages(['geography_scope' => 'O âmbito nacional não pode ter uma localização territorial específica.']);
}
$data = array_diff_key($data, InvestmentGeography::FIELDS);
$data['primary_territory_id'] = $path === [] ? null : $path[array_key_last($path)]->id;
if ($path !== [] && empty($data['geography_scope'])) {
$data['geography_scope'] = 'specific';
}
$data['territory_version'] = (int) ($data['territory_version'] ?? 0) + 1;
$data['province'] = $path[0]->name ?? null;
$data['district'] = $path[1]->name ?? null;
@@ -0,0 +1,185 @@
<?php
namespace App\Http\Controllers;
use App\Models\Opportunity;
use App\Services\OpportunityCanonicalContract;
use App\Services\OpportunityContacts;
use App\Services\OpportunityManagement;
use App\Services\OpportunityProfileWorkflow;
use App\Services\PortalInternalDesignation;
use App\Services\PortalProfileAccess;
use App\Services\PortalProfileAdministration;
use App\Services\PortalSuperusers;
use App\Services\VerifiedDocumentBinary;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class PortalProfileController extends Controller
{
public function requests(Request $request, PortalProfileAccess $access): Response
{
return Inertia::render('profiles/request', ['requests' => DB::table('portal_profile_requests')->where('user_id', $request->user()->id)->latest()->get(),
'types' => OpportunityManagement::CATEGORIES,
'organizations' => DB::table('memberships as m')->join('organizations as o', 'o.id', '=', 'm.organization_id')->where('m.user_id', $request->user()->id)
->whereNull('m.deleted_at')->whereNull('o.deleted_at')->whereIn('m.role', ['admin', 'manager', 'officer'])->get(['o.id', 'o.name']),
'documents' => DB::table('documents')->where('uploaded_by', $request->user()->id)->whereNull('deleted_at')->where('status', 'active')->where('malware_scan_status', 'clean')
->whereColumn('sha256', 'malware_scanned_sha256')->get(['id', 'file_name']), 'grants' => $access->grants($request->user(), 'promoter')]);
}
public function requestProfile(Request $request, PortalProfileAdministration $service): JsonResponse
{
return response()->json(['id' => $service->request($request->user(), $request->all()), 'message' => 'Pedido recebido; perfil ainda não ativado.'], 201);
}
public function requestStatus(Request $request, string $id): JsonResponse
{
$row = DB::table('portal_profile_requests')->where('id', $id)->where('user_id', $request->user()->id)->first();
abort_unless($row !== null, 403);
return response()->json($row);
}
public function administration(Request $request, PortalProfileAdministration $service): Response
{
$service->administrative($request->user());
return Inertia::render('profiles/administration', ['requests' => DB::table('portal_profile_requests')->latest()->get(),
'assignments' => DB::table('portal_role_assignments')->get(), 'pair' => DB::table('portal_superuser_pair')->first(),
'operatorOrganizations' => app(PortalInternalDesignation::class)->organizations(),
'internalTypes' => OpportunityCanonicalContract::BASE_CATEGORIES,
'actorId' => $request->user()->id, 'types' => OpportunityManagement::CATEGORIES,
'organizations' => DB::table('organizations')->whereNull('deleted_at')->get(['id', 'name']),
'securityRequests' => DB::table('portal_security_approvals')->latest()->limit(30)->get(),
'securityAlerts' => DB::table('audit_events')->where('source_system', 'portal-profiles-v1')->whereIn('action', ['superuser_pair_initialized', 'superuser_replaced', 'role_revoked'])->latest('occurred_at')->limit(20)->get(['id', 'action', 'actor_user_id', 'occurred_at'])]);
}
public function decide(Request $request, string $id, PortalProfileAdministration $service): JsonResponse
{
$service->decide($request->user(), $id, $request->all());
return response()->json(['message' => 'Decisão registada; somente os tipos aprovados ficam autorizados.']);
}
public function audit(Request $request, PortalProfileAccess $access): Response|JsonResponse
{
abort_unless($access->has($request->user(), 'auditor') || ($access->superuser($request->user()) && $access->mfa($request->user())), 403);
$filters = $request->validate(['actor_id' => 'nullable|integer|min:1', 'action' => 'nullable|string|max:100', 'record_id' => 'nullable|integer|min:1', 'from' => 'nullable|date', 'until' => 'nullable|date|after_or_equal:from', 'format' => 'nullable|in:json']);
$query = DB::table('audit_events')->when($filters['actor_id'] ?? null, fn ($q, $v) => $q->where('actor_user_id', $v))
->when($filters['action'] ?? null, fn ($q, $v) => $q->where('action', $v))
->when($filters['record_id'] ?? null, fn ($q, $v) => $q->where('after_data->record_id', $v))
->when($filters['from'] ?? null, fn ($q, $v) => $q->where('occurred_at', '>=', $v))
->when($filters['until'] ?? null, fn ($q, $v) => $q->where('occurred_at', '<=', $v));
// Minimized metadata: never decrypt private snapshots or export authentication bodies.
$query->select(['id', 'source_system', 'action', 'actor_user_id', 'entity_type', 'occurred_at', 'correlation_id']);
if (($filters['format'] ?? null) === 'json') {
$records = $query->orderByDesc('occurred_at')->limit(1000)->get();
$access->audit($request->user(), 'audit_metadata_exported', ['filters' => $filters, 'count' => $records->count()]);
return response()->json(['filters' => $filters, 'records' => $records, 'limit' => 1000])->header('Content-Disposition', 'attachment; filename="audit-metadata.json"')->header('Cache-Control', 'private, no-store');
}
$records = $query->orderByDesc('occurred_at')->paginate(30)->withQueryString();
$access->audit($request->user(), 'audit_metadata_read', ['filters' => $filters, 'count' => $records->count()]);
return Inertia::render('profiles/audit', ['records' => $records, 'filters' => $filters]);
}
public function registerOperatorOrganization(Request $request, PortalInternalDesignation $service): JsonResponse
{
return response()->json($service->registerOrganization($request->user(), $request->all()), 201);
}
public function grant(Request $request, PortalProfileAdministration $service): JsonResponse
{
return response()->json(['id' => $service->grant($request->user(), $request->all())], 201);
}
public function requestDocument(Request $request, string $id, string $documentId, PortalProfileAdministration $service): \Illuminate\Http\Response|BinaryFileResponse
{
$service->administrative($request->user());
$row = DB::table('portal_profile_requests')->where('id', $id)->first();
abort_unless($row && in_array($documentId, json_decode($row->document_ids, true), true), 404);
$document = DB::table('documents')->where('id', $documentId)->first();
abort_unless($document !== null, 404);
return app(VerifiedDocumentBinary::class)->respond($document, true);
}
public function revoke(Request $request, string $id, PortalProfileAdministration $service): JsonResponse
{
$service->revoke($request->user(), $id, (string) $request->input('reason'));
return response()->json(['message' => 'Papel revogado; sessões e tokens anteriores invalidados.']);
}
public function opportunities(Request $request, PortalProfileAccess $access): Response
{
abort_unless($access->grants($request->user()) !== [] || $access->superuser($request->user()), 403);
return app(OpportunityManagementController::class)->index($request, app(OpportunityManagement::class));
}
public function edit(Request $request, PortalProfileAccess $access, ?int $id = null): Response|\Symfony\Component\HttpFoundation\Response
{
if ($id) {
$access->authorize($request->user(), str_ends_with($request->path(), '/view') ? 'view' : 'edit', Opportunity::withTrashed()->findOrFail($id));
} else {
if (! ($access->navigation($request->user())['contexts'] ?? [])) {
$reason = $access->has($request->user(), 'auditor')
? 'O papel de Auditor permite consulta e impede a criação de oportunidades.'
: 'Esta conta não dispõe de um contexto ativo de Promotor para uma organização e modalidade autorizadas. Confirme a vigência, o âmbito e os tipos atribuídos na gestão de perfis. A elevação excecional de Superutilizador não substitui a autorização de Promotor.';
if ($request->expectsJson()) {
abort(403, $reason);
}
return response()->view('profiles.creation-unavailable', ['reason' => $reason], 403);
}
}
return app(OpportunityManagementController::class)->edit($request, app(OpportunityManagement::class), $id);
}
public function workflow(Request $request, int $id, OpportunityProfileWorkflow $service): JsonResponse
{
$record = $service->transition($request->user(), $id, $request->all());
return response()->json(['id' => $id, 'status' => app(PortalProfileAccess::class)->state($record), 'publication_status' => $record->status, 'etag' => app(OpportunityManagement::class)->etag($record), 'message' => 'Ação interna registada; não representa decisão externa.']);
}
public function contact(Request $request, int $id, OpportunityContacts $service): JsonResponse
{
return response()->json($service->save($request->user(), $id, $request->all()));
}
public function replacement(Request $request, PortalSuperusers $service): JsonResponse
{
$data = $request->validate(['old_user_id' => 'required|integer', 'new_user_id' => 'required|integer', 'reason' => 'required|string|min:20|max:1000']);
return response()->json(['id' => $service->requestReplacement($request->user(), $data['old_user_id'], $data['new_user_id'], $data['reason'])], 201);
}
public function approveSecurity(Request $request, string $id, PortalSuperusers $service): JsonResponse
{
$service->approve($request->user(), $id);
return response()->json(['message' => 'Aprovação independente registada.']);
}
public function replace(Request $request, string $id, PortalSuperusers $service): JsonResponse
{
$service->applyReplacement($request->user(), $id);
return response()->json(['message' => 'Titular substituído atomicamente; continuam dois titulares.']);
}
public function elevation(Request $request, PortalSuperusers $service): JsonResponse
{
$data = $request->validate(['action' => 'required|string', 'record_id' => 'nullable|integer|exists:opportunities,id', 'reason' => 'required|string|min:20|max:1000']);
return response()->json(['id' => $service->requestElevation($request->user(), $data['action'], $data['record_id'] ?? null, $data['reason'])], 201);
}
}
@@ -0,0 +1,40 @@
<?php
namespace App\Http\Controllers;
use App\Services\PortalProfileInstallation;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class PortalProfileInstallationController extends Controller
{
public function preview(Request $request, PortalProfileInstallation $installation): JsonResponse|Response
{
$plan = $installation->preview($request->user());
return $request->expectsJson() ? response()->json($plan) : Inertia::render('profiles/installation', $plan);
}
public function apply(Request $request, PortalProfileInstallation $installation): JsonResponse
{
return response()->json($installation->apply($request->user(), $request->all()));
}
public function bootstrap(Request $request, PortalProfileInstallation $installation): JsonResponse
{
return response()->json($installation->bootstrap($request->user(), $request->all()));
}
public function bootstrapPreview(Request $request, string $id, PortalProfileInstallation $installation): JsonResponse|Response
{
$plan = $installation->bootstrapPreview($request->user(), $id);
return $request->expectsJson() ? response()->json($plan) : Inertia::render('profiles/bootstrap-confirmation', $plan);
}
public function confirmBootstrap(Request $request, string $id, PortalProfileInstallation $installation): JsonResponse
{
return response()->json($installation->confirmBootstrap($request->user(), $id, $request->all()));
}
}
+726 -45
View File
@@ -2,16 +2,27 @@
namespace App\Http\Controllers;
use App\Jobs\ScanDocumentForMalware;
use App\Models\InterestRequest;
use App\Models\Sector;
use App\Services\CountryCatalogue;
use App\Services\IdempotencyConflict;
use App\Services\InterestSubmission;
use App\Services\LegacyContent;
use App\Services\PortalProfileAccess;
use App\Services\Malware\MalwareScanner;
use App\Services\VerifiedDocumentBinary;
use App\Services\VirtualDesk;
use Illuminate\Database\Query\Builder;
use Illuminate\Database\QueryException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
@@ -20,24 +31,46 @@ class PortalServicesController extends Controller
{
public function home(): Response
{
return Inertia::render('PortalInvestidor', ['essentialResources' => $this->resources()->where('r.essential', true)
->orderBy('r.title')->limit(6)->get(['r.id', 'r.title'])]);
try {
$essentialResources = $this->resources()->where('r.essential', true)
->orderBy('r.title')->limit(6)->get(['r.id', 'r.title']);
} catch (QueryException $exception) {
// A catalogue schema mismatch must not take down unrelated home content.
// Keep the document list empty and retain the full exception in internal logs.
report($exception);
$essentialResources = collect();
}
return Inertia::render('PortalInvestidor', ['essentialResources' => $essentialResources, 'countries' => app(CountryCatalogue::class)->all()]);
}
private function resources(): Builder
{
return DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')
->where('r.published', true)->where('d.status', 'active')->where('d.data_classification', 'public')->whereNull('d.deleted_at');
->where('r.published', true)->where('r.rights_status', '!=', 'restricted')->where('d.rights_status', '!=', 'restricted')
->where('d.status', 'active')->where('d.data_classification', 'public')->whereNull('d.deleted_at')
->where('d.malware_scan_status', 'clean')->whereColumn('d.malware_scanned_sha256', 'd.sha256');
}
public function page(Request $request): Response
public function page(Request $request): Response|JsonResponse|RedirectResponse
{
$page = $request->route()->defaults['page'];
$filters = $request->validate(['q' => ['nullable', 'string', 'max:120'], 'category' => ['nullable', 'string', 'max:80'],
'topic' => ['nullable', 'string', 'max:80'], 'sector' => ['nullable', 'string', 'max:150'], 'province' => ['nullable', 'string', 'max:100']]);
$props = ['section' => $page, 'filters' => $filters];
if ($page === 'partnerships' && Schema::hasColumn('portal_partnerships', 'partnership_type')) {
return app(PartnershipExchangeController::class)->index($request);
}
$filters = $request->validate([
'q' => ['nullable', 'string', 'max:120'], 'category' => ['nullable', 'string', 'max:80'],
'topic' => ['nullable', 'string', 'max:80'], 'sector' => ['nullable', 'string', 'max:150'], 'province' => ['nullable', 'string', 'max:100'],
'document_type' => ['nullable', 'string', 'max:40'], 'language' => ['nullable', 'string', 'max:12'],
'issuer' => ['nullable', 'string', 'max:180'], 'legal_status' => ['nullable', 'string', 'max:24'],
'source_confirmation' => ['nullable', 'string', 'max:24'], 'territory_id' => ['nullable', 'uuid'],
'activity' => ['nullable', 'array', 'max:10'], 'activity.*' => ['string', 'max:180'],
'stage' => ['nullable', 'array', 'max:10'], 'stage.*' => ['string', 'max:80'],
'module' => ['nullable', 'array', 'max:10'], 'module.*' => ['string', 'max:80'],
]);
$props = ['section' => $page, 'filters' => $filters, 'countries' => app(CountryCatalogue::class)->all()];
if ($page === 'sectors') {
$props['sectors'] = Sector::where('is_active', true)->withCount(['opportunities' => fn ($q) => $q->published()])->orderBy('sort_order')->get(['id', 'name', 'slug', 'short_description']);
$props['sectors'] = \App\Services\SectorOrganization::catalog();
}
if (in_array($page, ['documents', 'legislation'], true)) {
$query = $this->resources();
@@ -46,15 +79,59 @@ class PortalServicesController extends Controller
}
$props['categories'] = (clone $query)->distinct()->orderBy('r.category')->pluck('r.category');
$props['topics'] = (clone $query)->distinct()->orderBy('r.topic')->pluck('r.topic');
foreach (['category', 'topic'] as $field) {
foreach (['category', 'topic', 'document_type', 'language', 'legal_status', 'source_confirmation'] as $field) {
if (! empty($filters[$field])) {
$query->where('r.'.$field, $filters[$field]);
}
}
if (! empty($filters['q'])) {
$query->where('r.title', 'ilike', '%'.$filters['q'].'%');
if (! empty($filters['issuer'])) {
$query->where('d.issuer', 'ilike', '%'.$filters['issuer'].'%');
}
if (! empty($filters['territory_id'])) {
$territoryIds = $this->territoryScopeIds($filters['territory_id']);
$query->where(function (Builder $territorial) use ($territoryIds): void {
$territorial->where('r.territorial_scope', 'national')->orWhereIn('r.territory_id', $territoryIds)
->orWhereExists(fn ($link) => $link->selectRaw('1')->from('portal_resource_links as l')
->whereColumn('l.resource_id', 'r.id')->where('l.target_type', 'territory')->whereIn('l.target_key', $territoryIds));
});
}
foreach (['activity', 'stage', 'module'] as $dimension) {
if (! empty($filters[$dimension])) {
$query->whereExists(fn ($link) => $link->selectRaw('1')->from('portal_resource_links as l')
->whereColumn('l.resource_id', 'r.id')->where('l.target_type', $dimension)
->whereIn('l.target_key', array_values($filters[$dimension])));
}
}
if (! empty($filters['q'])) {
$term = '%'.$filters['q'].'%';
$query->where(fn (Builder $search) => $search->where('r.title', 'ilike', $term)->orWhere('r.description', 'ilike', $term)
->orWhere('r.document_number', 'ilike', $term)->orWhere('d.issuer', 'ilike', $term)
->orWhere('r.source_reference', 'ilike', $term)->orWhere('r.category', 'ilike', $term)->orWhere('r.topic', 'ilike', $term));
}
$pageResult = $query->orderByRaw("CASE WHEN r.document_type IN ('official_form','digital_adaptation','guidance_template') THEN 1 ELSE 0 END")
->orderByDesc('r.essential')->orderBy('r.title')->select([
'r.id', 'r.document_id', 'r.title', 'r.description', 'r.category', 'r.topic', 'r.document_type', 'r.document_number', 'r.document_date',
'r.applicable_from', 'r.applicable_until',
'r.source_url', 'r.language', 'r.version_label', 'r.document_group_id', 'r.territorial_scope', 'r.territory_id',
'r.source_confirmation', 'r.legal_status', 'r.rights_status', 'r.last_reviewed_at', 'r.last_reviewed_by', 'r.independent_review', 'r.metadata_version', 'r.source_reference', 'r.essential',
'd.file_name', 'd.media_type', 'd.size_bytes', 'd.issuer', 'd.issued_on', 'd.valid_until', 'd.version_no',
])->paginate(20)->withQueryString();
$pageResult->getCollection()->transform(fn ($resource) => app(\App\Services\PublicDocumentPresentation::class)->resource($resource));
$props['resources'] = $pageResult;
$props['documentTypes'] = (clone $this->resources())->distinct()->orderBy('r.document_type')->pluck('r.document_type');
$props['languages'] = (clone $this->resources())->distinct()->orderBy('r.language')->pluck('r.language');
$props['issuers'] = (clone $this->resources())->whereNotNull('d.issuer')->distinct()->orderBy('d.issuer')->pluck('d.issuer');
$props['legalStatuses'] = (clone $this->resources())->distinct()->orderBy('r.legal_status')->pluck('r.legal_status');
$props['territories'] = DB::table('geo_locations')->where('data_classification', 'public')->whereNull('deleted_at')
->whereIn('location_type', ['province', 'district', 'municipality', 'site'])->orderBy('name')->get(['id', 'name', 'location_type', 'parent_id']);
foreach (['activities' => 'activity', 'stages' => 'stage', 'modules' => 'module'] as $key => $type) {
$props[$key] = DB::table('portal_resource_links as l')->join('portal_resources as r', 'r.id', '=', 'l.resource_id')
->join('documents as d', 'd.id', '=', 'r.document_id')->where('l.target_type', $type)->where('r.published', true)
->where('r.rights_status', '!=', 'restricted')->where('d.rights_status', '!=', 'restricted')
->where('d.status', 'active')->where('d.data_classification', 'public')->whereNull('d.deleted_at')
->where('d.malware_scan_status', 'clean')->whereColumn('d.malware_scanned_sha256', 'd.sha256')
->distinct()->orderBy('l.target_key')->pluck('l.target_key');
}
$props['resources'] = $query->orderByDesc('r.essential')->orderBy('r.title')->select(['r.id', 'r.title', 'r.category', 'r.topic', 'r.source_reference', 'r.essential'])->paginate(20)->withQueryString();
}
if ($page === 'partnerships') {
$query = DB::table('portal_partnerships')->where('status', 'published');
@@ -69,6 +146,9 @@ class PortalServicesController extends Controller
$props['partnerships'] = $query->orderByDesc('id')->select(['id', 'title', 'sector', 'province', 'offer', 'seeking'])->paginate(20)->withQueryString();
}
if ($page === 'desk') {
if (app(VirtualDesk::class)->installed()) {
return redirect('/balcao-virtual/painel');
}
$props['requests'] = InterestRequest::where('metadata->portal_user_id', $request->user()->id)->latest()->limit(100)->get(['reference', 'status', 'created_at', 'metadata']);
$props['myPartnerships'] = DB::table('portal_partnerships')->where('user_id', $request->user()->id)->latest()->limit(100)->get(['id', 'title', 'status']);
}
@@ -77,12 +157,49 @@ class PortalServicesController extends Controller
return Inertia::render('portal/services', $props);
}
public function recommendations(Request $request): JsonResponse
{
$data = $request->validate([
'activity_id' => ['nullable', 'string', 'max:180'], 'province_id' => ['nullable', 'uuid'],
'stage' => ['nullable', 'string', 'max:80'], 'module' => ['nullable', 'string', 'max:80'],
]);
$module = $data['module'] ?? 'simulator';
$query = $this->resources()->whereExists(fn ($link) => $link->selectRaw('1')->from('portal_resource_links as l')
->whereColumn('l.resource_id', 'r.id')->where('l.target_type', 'module')->where('l.target_key', $module));
$matches = [['type' => 'module', 'key' => $module]];
foreach (['activity_id' => 'activity', 'stage' => 'stage'] as $input => $type) {
if (! empty($data[$input])) {
$key = $data[$input];
$query->where(function (Builder $contextual) use ($type, $key): void {
$contextual->whereNotExists(fn ($link) => $link->selectRaw('1')->from('portal_resource_links as general')
->whereColumn('general.resource_id', 'r.id')->where('general.target_type', $type))
->orWhereExists(fn ($link) => $link->selectRaw('1')->from('portal_resource_links as matching')
->whereColumn('matching.resource_id', 'r.id')->where('matching.target_type', $type)->where('matching.target_key', $key));
});
$matches[] = ['type' => $type, 'key' => $data[$input]];
}
}
if (! empty($data['province_id'])) {
$ids = $this->territoryScopeIds($data['province_id']);
$query->where(fn (Builder $territorial) => $territorial->where('r.territorial_scope', 'national')->orWhereIn('r.territory_id', $ids)
->orWhereExists(fn ($link) => $link->selectRaw('1')->from('portal_resource_links as l')->whereColumn('l.resource_id', 'r.id')
->where('l.target_type', 'territory')->whereIn('l.target_key', $ids)));
$matches = [...$matches, ...array_map(fn ($id) => ['type' => 'territory', 'key' => $id], $ids)];
}
$resources = $query->orderByDesc('r.essential')->orderBy('r.title')->limit(10)
->get(['r.id', 'r.title', 'r.description', 'r.document_type', 'r.document_date', 'r.document_number', 'r.applicable_from', 'r.applicable_until', 'r.language', 'r.territorial_scope', 'r.legal_status', 'r.source_confirmation', 'r.source_url', 'd.issuer', 'd.media_type', 'd.size_bytes']);
$resources->transform(fn ($resource) => app(\App\Services\PublicDocumentPresentation::class)->resource($resource));
return response()->json(['data' => $resources]);
}
public function submit(Request $request, InterestSubmission $submission): RedirectResponse
{
$data = $request->validate([
'kind' => ['required', 'in:support,investor,company,partnership'],
'fullName' => ['required', 'string', 'min:3', 'max:150'], 'email' => ['required', 'email:rfc', 'max:150'],
'phone' => ['nullable', 'string', 'max:30'], 'country' => ['required', 'string', 'max:100'],
'phone' => ['nullable', 'string', 'max:30'], 'country' => ['required', 'string', 'max:100', app(CountryCatalogue::class)->legacyCountryRule()],
'declared_origin_country_code' => ['nullable', app(CountryCatalogue::class)->rule()],
'company' => ['nullable', 'required_if:kind,company', 'string', 'max:150'],
'sectorInterest' => ['required', 'string', 'max:150'], 'message' => ['required', 'string', 'min:20', 'max:2000'],
'privacyConsent' => ['required', 'accepted'], 'submission_key' => ['required', 'uuid'],
@@ -92,15 +209,25 @@ class PortalServicesController extends Controller
abort_unless(DB::table('portal_partnerships')->where('id', $data['partnership_id'])->where('status', 'published')->exists(), 422);
}
$data['portal_user_id'] = $request->user()?->id;
// Preserve the actual entry form, not only the shared POST endpoint. Never accept an external or administrative referrer as the entry page.
$entry = parse_url($request->headers->get('referer', ''));
$sourcePage = '/apoio';
if (is_array($entry) && strtolower($entry['scheme'] ?? '') === $request->getScheme()
&& strtolower($entry['host'] ?? '') === strtolower($request->getHost())
&& (int) ($entry['port'] ?? (($entry['scheme'] ?? '') === 'https' ? 443 : 80)) === $request->getPort()
&& in_array($entry['path'] ?? '', ['/apoio', '/formularios/investidor', '/formularios/empresa', '/parcerias', '/parcerias/painel'], true)) {
$sourcePage = $entry['path'];
}
try {
$interest = $submission->receive($data, $data['submission_key'], fn () => InterestRequest::create([
'reference' => 'AP-'.now()->year.'-'.Str::ulid(), 'full_name' => $data['fullName'], 'email' => $data['email'],
'phone' => $data['phone'] ?? null, 'country' => $data['country'],
'investor_type' => $data['kind'] === 'company' ? 'empresa' : 'individual',
'sector_interest' => $data['sectorInterest'], 'message' => $data['message'],
'privacy_consent' => true, 'consented_at' => now(), 'status' => 'received', 'source_page' => '/apoio',
'privacy_consent' => true, 'consented_at' => now(), 'status' => 'received', 'source_page' => $sourcePage,
'metadata' => ['kind' => $data['kind'], 'company' => $data['company'] ?? null,
'portal_user_id' => $data['portal_user_id'], 'partnership_id' => $data['partnership_id'] ?? null],
'portal_user_id' => $data['portal_user_id'], 'partnership_id' => $data['partnership_id'] ?? null,
'declared_origin_country_code' => $data['declared_origin_country_code'] ?? null],
]));
} catch (IdempotencyConflict) {
abort(409, 'Este envio já foi usado com dados diferentes. Recarregue o formulário.');
@@ -120,82 +247,636 @@ class PortalServicesController extends Controller
return back()->with('success', 'Proposta recebida para publicação. Acompanhe no Balcão Virtual.');
}
public function download(int $id): BinaryFileResponse
public function download(int $id): BinaryFileResponse|\Illuminate\Http\Response
{
$resource = $this->resources()->where('r.id', $id)->first(['d.storage_key', 'd.file_name', 'd.sha256']);
$resource = $this->resources()->where('r.id', $id)->first(['d.*']);
abort_unless($resource !== null, 404);
$root = realpath(Storage::disk('local')->path(''));
$path = realpath(Storage::disk('local')->path($resource->storage_key));
abort_unless($root && $path && str_starts_with(strtolower($path), strtolower($root.DIRECTORY_SEPARATOR)) && is_file($path), 404);
$checksum = hash_file('sha256', $path);
abort_unless($checksum !== false && hash_equals($resource->sha256, $checksum), 404);
return response()->download($path, basename($resource->file_name), ['X-Content-Type-Options' => 'nosniff', 'Cache-Control' => 'private, no-store']);
return app(VerifiedDocumentBinary::class)->respond($resource);
}
public function manage(): Response
public function showDocument(Request $request, int $id): Response
{
return Inertia::render('portal/manage', ['resources' => DB::table('portal_resources')->latest()->limit(100)->get(),
$resource = $this->resources()->where('r.id', $id)->first([
'r.id', 'r.document_id', 'r.title', 'r.description', 'r.category', 'r.topic', 'r.document_type',
'r.document_number', 'r.document_date', 'r.applicable_from', 'r.applicable_until', 'r.source_url', 'r.language', 'r.version_label',
'r.territorial_scope', 'r.territory_id', 'r.source_confirmation', 'r.legal_status', 'r.source_reference',
'r.essential', 'd.file_name', 'd.media_type', 'd.size_bytes', 'd.issuer', 'd.issued_on', 'd.valid_until', 'd.version_no',
]);
abort_unless($resource !== null, 404);
$resource->territory_name = $resource->territory_id
? DB::table('geo_locations')->where('id', $resource->territory_id)->value('name') : null;
$resource = app(\App\Services\PublicDocumentPresentation::class)->resource($resource);
$back = $request->query('back');
$backUrl = is_string($back) && (str_starts_with($back, '/documentos') || str_starts_with($back, '/legislacao')) && ! str_starts_with($back, '//')
? $back : '/documentos';
return Inertia::render('portal/document', [
'resource' => $resource,
'backUrl' => $backUrl,
'downloadUrl' => route('services.download', ['id' => $id]),
'articleUrl' => $this->articleUrl($id),
]);
}
public function viewDocument(int $id): BinaryFileResponse|\Illuminate\Http\Response
{
$document = $this->resources()->where('r.id', $id)->first(['d.*']);
abort_unless($document && in_array($document->media_type, ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'], true), 404);
return app(VerifiedDocumentBinary::class)->respond($document, true);
}
public function downloadForManager(Request $request, string $id): BinaryFileResponse|\Illuminate\Http\Response
{
$document = app(\App\Services\DocumentManagementAccess::class)->documents($request->user())->where('id', $id)->first();
abort_unless($document && $document->status === 'active' && $document->malware_scan_status === 'clean'
&& hash_equals(trim($document->sha256), trim((string) $document->malware_scanned_sha256)), 404);
return app(VerifiedDocumentBinary::class)->respond($document);
}
public function manage(Request $request, MalwareScanner $scanner): Response
{
$managementFilters = $request->validate(['q' => 'nullable|string|max:150', 'state' => 'nullable|in:draft,published,withdrawn', 'intent' => 'nullable|in:document,legislation']);
$access = app(\App\Services\DocumentManagementAccess::class);
if (! $access->full($request->user())) {
$documents = $access->documents($request->user())->where('status', '!=', 'deleted');
$health = $scanner->health();
$health['signatures_at'] = $health['signatures_at']?->format(DATE_ATOM);
$editorial = $access->editorial($request->user());
$resources = $editorial ? $this->filterManagedResources($access->editorialResources($request->user()), $managementFilters)->orderByDesc('r.updated_at')
->get(['r.*', 'd.file_name', 'd.media_type', 'd.size_bytes', 'd.issuer', 'd.status as file_status', 'd.data_classification', 'd.version_no as file_version', 'd.rights_status as file_rights_status', 'd.malware_scan_status', 'd.malware_failure_reason']) : collect();
$links = DB::table('portal_resource_links')->whereIn('resource_id', $resources->pluck('id'))->orderBy('id')->get()->groupBy('resource_id');
$revisions = DB::table('portal_resource_revisions')->whereIn('resource_id', $resources->pluck('id'))->orderByDesc('revision_no')->get(['resource_id', 'revision_no', 'change_type', 'actor_user_id', 'created_at'])->groupBy('resource_id');
$resources->each(function ($resource) use ($links, $revisions) {
$resource->links = $links->get($resource->id, collect())->values();
$resource->revisions = $revisions->get($resource->id, collect())->values();
});
return Inertia::render('portal/manage', [
'managementFilters' => $managementFilters, 'associationOptions' => $this->contentAssociations($request),
'documentAccessOnly' => ! $editorial, 'editorialScopeOnly' => $editorial, 'resources' => $resources, 'partnerships' => [], 'territories' => [],
'requirementCatalogue' => [], 'requirementAssociationsEnabled' => false,
'disclosureAuthorization' => null, 'incompleteCount' => $resources->filter(fn ($r) => ! $r->description || ! $r->issuer || ! $r->source_url || $r->source_confirmation === 'unconfirmed' || $r->legal_status === 'unknown' || $r->rights_status === 'unconfirmed')->count(), 'possibleDuplicates' => $resources->whereNotNull('duplicate_of_resource_id')->count(),
'documents' => (clone $documents)->latest()->limit(250)->get([
'id', 'file_name', 'media_type', 'size_bytes', 'issuer', 'issued_on', 'valid_until',
'uploaded_by', 'created_at', 'status', 'data_classification', 'version_no', 'sha256',
'rights_status', 'malware_scan_status', 'malware_scanned_at', 'malware_failure_reason',
'malware_engine_version', 'malware_signatures_at', 'malware_scan_attempts',
]),
'malwareCounts' => (clone $documents)->select('malware_scan_status', DB::raw('count(*) as total'))
->groupBy('malware_scan_status')->pluck('total', 'malware_scan_status'),
'malwareHealth' => $health,
]);
}
$disclosureAuthorization = DB::table('audit_events')->where('source_system', 'user-instruction')
->where('external_id', 'document-repository-disclosure-authorization-2026-10-06-v1')
->where('action', 'public_disclosure_authorized')->first(['external_id', 'occurred_at', 'after_data']);
$disclosureAuthorizationData = $disclosureAuthorization ? json_decode($disclosureAuthorization->after_data, true) : null;
$resources = $this->filterManagedResources(DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')->whereNull('d.deleted_at'), $managementFilters)->orderByDesc('r.updated_at')->limit(200)
->get(['r.*', 'd.file_name', 'd.media_type', 'd.size_bytes', 'd.issuer', 'd.status as file_status', 'd.data_classification', 'd.version_no as file_version', 'd.rights_status as file_rights_status', 'd.malware_scan_status', 'd.malware_failure_reason']);
$links = DB::table('portal_resource_links')->whereIn('resource_id', $resources->pluck('id'))->orderBy('id')->get()->groupBy('resource_id');
$resources->each(fn ($resource) => $resource->links = $links->get($resource->id, collect())->values());
$revisions = DB::table('portal_resource_revisions')->whereIn('resource_id', $resources->pluck('id'))->orderByDesc('revision_no')->get(['resource_id', 'revision_no', 'change_type', 'actor_user_id', 'created_at'])->groupBy('resource_id');
$resources->each(fn ($resource) => $resource->revisions = $revisions->get($resource->id, collect())->values());
$malwareHealth = $scanner->health();
$malwareHealth['signatures_at'] = $malwareHealth['signatures_at']?->format(DATE_ATOM);
return Inertia::render('portal/manage', ['resources' => $resources,
'managementFilters' => $managementFilters, 'associationOptions' => $this->contentAssociations($request),
'requirementCatalogue' => $this->requirementEvidenceEnabled()
? collect(is_array($catalogue = config('document-requirements.catalogue', [])) ? $catalogue : [])->map(fn ($item, $key) => ['key' => $key, ...$item])->values()
: collect(),
'requirementAssociationsEnabled' => $this->requirementEvidenceEnabled(),
'partnerships' => DB::table('portal_partnerships')->latest()->limit(100)->get(),
'documents' => DB::table('documents')->where('status', 'active')->where('data_classification', 'public')->whereNull('deleted_at')->get(['id', 'file_name'])]);
'documents' => DB::table('documents')->where('status', '!=', 'deleted')->whereNull('deleted_at')->latest()->limit(250)
->get(['id', 'file_name', 'media_type', 'size_bytes', 'issuer', 'issued_on', 'valid_until', 'uploaded_by', 'created_at', 'status', 'data_classification', 'version_no', 'sha256', 'rights_status', 'malware_scan_status', 'malware_scanned_at', 'malware_failure_reason', 'malware_engine_version', 'malware_signatures_at', 'malware_scan_attempts']),
'malwareHealth' => $malwareHealth,
'disclosureAuthorization' => $disclosureAuthorizationData ? [
'external_id' => $disclosureAuthorization->external_id,
'occurred_at' => $disclosureAuthorization->occurred_at,
'resources' => DB::table('portal_resources')->whereIn('id', $disclosureAuthorizationData['resource_ids'] ?? [])
->orderBy('id')->get(['id', 'title', 'published', 'rights_status']),
] : null,
'malwareCounts' => DB::table('documents')->whereNull('deleted_at')->where('status', '!=', 'deleted')
->select('malware_scan_status', DB::raw('count(*) as total'))->groupBy('malware_scan_status')->pluck('total', 'malware_scan_status'),
'territories' => DB::table('geo_locations')->where('data_classification', 'public')->whereNull('deleted_at')
->whereIn('location_type', ['country', 'province', 'district', 'municipality', 'site'])->orderBy('name')->get(['id', 'name', 'location_type']),
'incompleteCount' => DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')
->where(fn ($q) => $q->whereNull('r.description')->orWhereNull('d.issuer')->orWhereNull('r.source_url')
->orWhere('r.source_confirmation', 'unconfirmed')->orWhere('r.legal_status', 'unknown')->orWhere('r.rights_status', 'unconfirmed'))
->count(),
'possibleDuplicates' => DB::table('portal_resources')->whereNotNull('duplicate_of_resource_id')->count()]);
}
private function filterManagedResources(Builder $query, array $filters): Builder
{
if (! empty($filters['q'])) {
$term = '%'.addcslashes($filters['q'], '%_\\').'%';
$query->where(fn ($q) => $q->where('r.title', 'ilike', $term)->orWhere('r.document_number', 'ilike', $term)->orWhere('d.issuer', 'ilike', $term)->orWhere('d.file_name', 'ilike', $term));
}
if (! empty($filters['state'])) {
$query->where('r.published', $filters['state'] === 'published');
if ($filters['state'] !== 'published') {
$method = $filters['state'] === 'withdrawn' ? 'whereExists' : 'whereNotExists';
$query->$method(fn ($q) => $q->selectRaw('1')->from('portal_resource_revisions as history')->whereColumn('history.resource_id', 'r.id')->whereIn('history.change_type', ['unpublished', 'withdrawn']));
}
}
return $query;
}
private function contentAssociations(Request $request): array
{
$access = app(\App\Services\DocumentManagementAccess::class);
$resources = $access->full($request->user()) ? DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')->whereNull('d.deleted_at')
: ($access->editorial($request->user()) ? $access->editorialResources($request->user()) : null);
$documents = $resources ? $resources->orderBy('r.title')->get(['r.id as value', 'r.title as label', 'r.document_type']) : collect();
return [
'sector' => \App\Models\Sector::choices()->orderBy('name')->get(['id as value', 'name as label']),
'territory' => app(\App\Services\InvestmentGeography::class)->publicReferences()->orderBy('name')->get(['id as value', 'name as label']),
'opportunity' => app(PortalProfileAccess::class)->scope(\App\Models\Opportunity::query(), $request->user())->orderBy('title')->get(['id as value', 'title as label']),
'document' => $documents,
'procedure' => $documents->where('document_type', 'procedure')->values(),
'stage' => collect(config('virtual-desk.steps', []))->map(fn ($step, $key) => ['value' => $key, 'label' => $step['title']])->values(),
'activity' => app(\App\Services\EconomicReferenceCatalog::class)->activities()->map(fn ($row) => ['value' => $row->id, 'label' => $row->display_label])->values(),
];
}
public function upload(Request $request): RedirectResponse
{
$request->validate(['file' => ['required', 'file', 'mimetypes:application/pdf,text/plain', 'max:10240'], 'publication_consent' => ['accepted']]);
$this->validateManagement($request, ['file' => ['required', 'file', 'mimetypes:application/pdf,text/plain,text/csv,image/jpeg,image/png,image/webp,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.oasis.opendocument.text'.(app(LegacyContent::class)->available() ? ',application/json' : ''), 'max:10240'],
'original_source' => ['nullable', 'url:https', 'max:2000'], 'issuer' => ['nullable', 'string', 'max:180']]);
$file = $request->file('file');
$checksum = hash_file('sha256', $file->getRealPath());
if (DB::table('documents')->where('sha256', $checksum)->whereNull('deleted_at')->exists()) {
return to_route('services.manage')->withErrors(['file' => 'Já existe um ficheiro com o mesmo conteúdo. Verifique se corresponde a uma nova versão ou a um duplicado.']);
}
$id = (string) Str::uuid();
$key = $file->storeAs('portal-documents', $id, 'local');
abort_unless($key !== false, 500);
try {
DB::transaction(function () use ($file, $id, $key, $request) {
DB::transaction(function () use ($file, $id, $key, $request, $checksum) {
DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', ['investmoz-document-content:'.$checksum]);
abort_unless(! DB::table('documents')->where('sha256', $checksum)->whereNull('deleted_at')->exists(), 409,
'Já existe um ficheiro com o mesmo conteúdo. Verifique se corresponde a uma nova versão ou a um duplicado.');
DB::table('documents')->insert(['id' => $id, 'source_system' => 'portal-services',
'file_name' => basename($file->getClientOriginalName()), 'media_type' => $file->getMimeType(), 'storage_key' => $key,
'sha256' => hash_file('sha256', Storage::disk('local')->path($key)), 'status' => 'active', 'data_classification' => 'public']);
'sha256' => $checksum, 'size_bytes' => $file->getSize(), 'issuer' => $request->input('issuer'),
'original_source' => $request->input('original_source'), 'uploaded_by' => $request->user()->id,
'status' => 'quarantined', 'data_classification' => 'internal', 'rights_status' => 'unconfirmed']);
DB::table('audit_events')->insert(['source_system' => 'portal-services', 'entity_type' => 'documents', 'entity_id' => $id,
'actor_user_id' => $request->user()->id, 'action' => 'public_document_uploaded']);
'actor_user_id' => $request->user()->id, 'action' => 'document_uploaded_to_quarantine']);
});
} catch (\Throwable $exception) {
Storage::disk('local')->delete($key);
throw $exception;
}
return back()->with('success', 'Documento carregado. Seleccione-o no catálogo e registe a fonte para publicar.');
ScanDocumentForMalware::dispatch($id)->afterCommit();
return to_route('services.manage', array_filter(['intent' => in_array($request->input('intent'), ['document', 'legislation'], true) ? $request->input('intent') : null]))->with('uploaded_document_id', $id)->with('success', 'Documento recebido em armazenamento privado e quarentena. Não aparece na biblioteca pública até validação e publicação explícitas.');
}
public function resource(Request $request): RedirectResponse
{
$data = $request->validate(['document_id' => ['required', 'uuid'], 'title' => ['required', 'string', 'max:200'],
'category' => ['required', 'string', 'max:80'], 'topic' => ['required', 'string', 'max:80'],
'source_reference' => ['required', 'string', 'min:10', 'max:2000'],
'legislation' => ['required', 'boolean'], 'essential' => ['required', 'boolean'], 'published' => ['required', 'boolean']]);
DB::transaction(function () use ($data, $request) {
abort_unless(DB::table('documents')->where('id', $data['document_id'])->where('status', 'active')->where('data_classification', 'public')->whereNull('deleted_at')->lockForUpdate()->exists(), 422, 'Seleccione um documento público activo.');
$id = DB::table('portal_resources')->insertGetId([...$data, 'approved_by' => $request->user()->id, 'created_at' => now(), 'updated_at' => now()]);
$this->audit($request, 'resource_registered', 'portal_resources', $id);
$data = $this->validateResource($request);
$id = DB::transaction(function () use ($data, $request): int {
$document = DB::table('documents')->where('id', $data['document_id'])->where('status', '!=', 'deleted')->whereNull('deleted_at')->lockForUpdate()->first();
abort_unless($document !== null, 422, 'Seleccione um documento existente e não eliminado.');
abort_unless(app(\App\Services\DocumentManagementAccess::class)->documents($request->user())->where('id', $document->id)->exists(), 403);
$identity = $this->identityKey($data);
if ($identity) {
DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', ['portal-resource-identity:'.$identity]);
}
$duplicate = $identity ? DB::table('portal_resources')->where('document_identity_key', $identity)->value('id') : null;
$fields = $this->resourceFields($data, $request->user()->id, $identity, $duplicate, true);
$this->assertPublicClassificationEligible($data, $document);
$id = DB::table('portal_resources')->insertGetId([...$fields, 'approved_by' => null, 'published' => false, 'created_at' => now(), 'updated_at' => now()]);
$this->syncResourceLinks($id, $data['associations'] ?? []);
$this->recordResourceRevision($id, 'created', $request->user()->id, 1);
DB::table('documents')->where('id', $document->id)->update(['issuer' => ($data['issuer'] ?? null) ?: $document->issuer,
'issued_on' => ($data['document_date'] ?? null) ?: $document->issued_on, 'rights_status' => $data['rights_status'],
'data_classification' => $data['data_classification'] ?? $document->data_classification, 'updated_at' => now()]);
$this->audit($request, $duplicate ? 'resource_registered_possible_duplicate' : 'resource_registered', 'portal_resources', $id);
return $id;
});
return back()->with('success', 'Documento catalogado.');
return to_route('services.manage')->with('success', 'Documento registado como rascunho privado da biblioteca. A publicação requer uma acção separada.');
}
public function updateResource(Request $request, int $id): RedirectResponse
{
$data = $this->validateResource($request);
$expectedVersion = (int) $request->validate(['metadata_version' => ['required', 'integer', 'min:1']])['metadata_version'];
DB::transaction(function () use ($data, $request, $id, $expectedVersion): void {
$resource = DB::table('portal_resources')->where('id', $id)->lockForUpdate()->first();
abort_unless($resource !== null, 404);
app(\App\Services\DocumentManagementAccess::class)->assertEditableResource($request->user(), $id);
abort_if((int) $resource->metadata_version !== $expectedVersion, 409, 'O documento foi alterado por outra sessão. Recarregue a revisão antes de editar.');
$document = DB::table('documents')->where('id', $data['document_id'])->where('status', '!=', 'deleted')->whereNull('deleted_at')->lockForUpdate()->first();
abort_unless($document !== null, 422, 'Seleccione um documento existente e não eliminado.');
abort_unless(app(\App\Services\DocumentManagementAccess::class)->documents($request->user())->where('id', $document->id)->exists(), 403);
$identity = $this->identityKey($data);
if ($identity) {
DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', ['portal-resource-identity:'.$identity]);
}
$duplicate = $identity ? DB::table('portal_resources')->where('document_identity_key', $identity)->where('id', '!=', $id)->value('id') : null;
$fields = $this->resourceFields($data, $request->user()->id, $identity, $duplicate, false);
$this->assertPublicClassificationEligible($data, $document);
$retainPublication = (bool) $resource->published && $data['rights_status'] === 'authorized'
&& $data['source_confirmation'] !== 'unconfirmed' && ! empty($data['source_url'])
&& (! ($data['legislation'] || $data['document_type'] === 'legislation') || $data['source_confirmation'] === 'official_confirmed')
&& $document->status === 'active' && ($data['data_classification'] ?? $document->data_classification) === 'public' && $document->rights_status === 'authorized'
&& $document->malware_scan_status === 'clean' && hash_equals(trim($document->sha256), trim((string) $document->malware_scanned_sha256));
DB::table('portal_resources')->where('id', $id)->update([...$fields, 'approved_by' => $retainPublication ? $resource->approved_by : null, 'published' => $retainPublication,
'metadata_version' => $expectedVersion + 1, 'updated_at' => now()]);
DB::table('portal_resource_links')->where('resource_id', $id)->delete();
$this->syncResourceLinks($id, $data['associations'] ?? []);
$this->recordResourceRevision($id, 'metadata_updated', $request->user()->id, $expectedVersion + 1);
DB::table('documents')->where('id', $document->id)->update(['issuer' => ($data['issuer'] ?? null) ?: $document->issuer,
'issued_on' => ($data['document_date'] ?? null) ?: $document->issued_on, 'rights_status' => $data['rights_status'],
'data_classification' => $data['data_classification'] ?? $document->data_classification, 'updated_at' => now()]);
$this->audit($request, 'resource_metadata_updated', 'portal_resources', $id);
});
return to_route('services.manage')->with('success', 'Metadados e relações actualizados; o estado de publicação foi preservado.');
}
public function moderate(Request $request, string $type, int $id): RedirectResponse
{
$data = $request->validate(['published' => ['required', 'boolean']]);
$access = app(\App\Services\DocumentManagementAccess::class);
if (! $access->full($request->user())) {
abort_unless($type === 'resources', 403);
$access->assertEditableResource($request->user(), $id);
}
if ($type === 'partnerships' && Schema::hasColumn('portal_partnerships', 'partnership_type')) {
return redirect('/parcerias/'.$id)->with('success', 'Reveja a proposta e registe uma decisão fundamentada na ficha da Bolsa.');
}
$data = $this->validateManagement($request, ['published' => ['required', 'boolean']]);
$table = $type === 'resources' ? 'portal_resources' : 'portal_partnerships';
DB::transaction(function () use ($request, $data, $table, $type, $id) {
abort_unless(DB::table($table)->where('id', $id)->lockForUpdate()->first() !== null, 404);
if ($type === 'resources') app(\App\Services\DocumentManagementAccess::class)->assertEditableResource($request->user(), $id);
if ($type === 'resources' && $data['published']) {
$resource = DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')
->where('r.id', $id)->first(['r.document_type', 'r.legislation', 'r.source_confirmation', 'r.legal_status', 'r.rights_status',
'r.source_url', 'd.status', 'd.data_classification', 'd.deleted_at', 'd.rights_status as file_rights_status',
'd.malware_scan_status', 'd.malware_scanned_sha256', 'd.sha256']);
abort_unless($resource !== null && $resource->deleted_at === null, 404);
if ($resource->status !== 'active') {
throw $this->managementValidationException(['published' => 'A análise técnica ainda não libertou o ficheiro da quarentena. Aguarde a análise antimalware antes de publicar.']);
}
if ($resource->data_classification !== 'public') {
throw $this->managementValidationException(['published' => 'O ficheiro já saiu da quarentena técnica, mas continua classificado como interno. Reveja os direitos e a possibilidade de divulgação; só então altere explicitamente a classificação para pública.']);
}
if ($resource->rights_status !== 'authorized' || $resource->file_rights_status !== 'authorized') {
throw $this->managementValidationException(['published' => 'Confirme e registe os direitos de utilização antes de publicar.']);
}
if ($resource->source_confirmation === 'unconfirmed' || ! $resource->source_url) {
throw $this->managementValidationException(['published' => 'Identifique e confirme a fonte documental antes de publicar.']);
}
if ($resource->malware_scan_status !== 'clean'
|| ! hash_equals(trim($resource->sha256), trim((string) $resource->malware_scanned_sha256))) {
throw $this->managementValidationException(['published' => 'A análise antimalware tem de terminar antes da publicação.']);
}
}
DB::table($table)->where('id', $id)->update($type === 'resources'
? ['published' => $data['published'], 'approved_by' => $request->user()->id, 'updated_at' => now()]
? ['published' => $data['published'], 'approved_by' => $data['published'] ? $request->user()->id : null,
'last_reviewed_by' => $request->user()->id, 'last_reviewed_at' => now(), 'metadata_version' => DB::raw('metadata_version + 1'), 'updated_at' => now()]
: ['status' => $data['published'] ? 'published' : 'withdrawn', 'reviewed_by' => $request->user()->id, 'updated_at' => now()]);
if ($type === 'resources') {
$this->recordResourceRevision($id, $data['published'] ? 'published' : 'unpublished', $request->user()->id,
(int) DB::table('portal_resources')->where('id', $id)->value('metadata_version'));
}
$this->audit($request, $data['published'] ? 'published' : 'withdrawn', $table, $id);
});
return back()->with('success', 'Publicação actualizada.');
return to_route('services.manage')->with('success', 'Publicação actualizada.');
}
private function audit(Request $request, string $action, string $table, int $id): void
public function applyDocumentDisclosureAuthorization(Request $request): RedirectResponse
{
$event = DB::table('audit_events')->where('source_system', 'user-instruction')
->where('external_id', 'document-repository-disclosure-authorization-2026-10-06-v1')
->where('action', 'public_disclosure_authorized')->first();
abort_unless($event !== null, 404);
$authorization = json_decode($event->after_data, true);
$resourceIds = array_values(array_unique(array_map('intval', $authorization['resource_ids'] ?? [])));
abort_unless(count($resourceIds) === 4, 409, 'O âmbito da autorização registada foi alterado; interrompida a publicação.');
$published = [];
$skipped = [];
DB::transaction(function () use ($request, $event, $resourceIds, &$published, &$skipped): void {
foreach ($resourceIds as $resourceId) {
$resource = DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')
->where('r.id', $resourceId)->lockForUpdate()->first([
'r.id', 'r.title', 'r.document_type', 'r.legislation', 'r.source_url', 'r.source_confirmation',
'r.rights_status as resource_rights', 'r.published', 'r.approved_by', 'r.metadata_version',
'd.id as document_id', 'd.storage_key', 'd.sha256', 'd.status', 'd.deleted_at', 'd.rights_status as file_rights',
'd.malware_scan_status', 'd.malware_scanned_sha256', 'd.data_classification',
]);
if ($resource === null) {
$skipped[$resourceId] = 'Registo ou ficheiro inexistente.';
continue;
}
if ($resource->published && $resource->data_classification === 'public' && $resource->resource_rights === 'authorized' && $resource->file_rights === 'authorized') {
$published[$resourceId] = 'Já publicado.';
continue;
}
$path = Storage::disk('local')->path($resource->storage_key);
$root = realpath(Storage::disk('local')->path(''));
$realPath = realpath($path);
$actualHash = $realPath && is_file($realPath) && $root
&& str_starts_with(strtolower($realPath), strtolower($root.DIRECTORY_SEPARATOR))
? hash_file('sha256', $realPath) : false;
if ($resource->deleted_at !== null || $resource->status !== 'active'
|| $resource->malware_scan_status !== 'clean' || ! is_string($actualHash)
|| ! hash_equals((string) $resource->sha256, $actualHash)
|| ! hash_equals((string) $resource->sha256, (string) $resource->malware_scanned_sha256)) {
$skipped[$resourceId] = 'Ficheiro indisponível, não limpo ou diferente dos bytes analisados.';
continue;
}
if (in_array($resource->resource_rights, ['restricted'], true) || in_array($resource->file_rights, ['restricted'], true)
|| $resource->source_confirmation === 'unconfirmed' || ! filter_var($resource->source_url, FILTER_VALIDATE_URL)
|| parse_url($resource->source_url, PHP_URL_SCHEME) !== 'https') {
$skipped[$resourceId] = 'Restrição de divulgação ou origem identificada em falta.';
continue;
}
$now = now();
$nextVersion = (int) $resource->metadata_version + 1;
DB::table('documents')->where('id', $resource->document_id)->update([
'rights_status' => 'authorized', 'data_classification' => 'public', 'updated_at' => $now,
]);
DB::table('portal_resources')->where('id', $resourceId)->update([
'rights_status' => 'authorized', 'published' => true, 'approved_by' => $request->user()->id,
'last_reviewed_by' => $request->user()->id, 'last_reviewed_at' => $now,
'metadata_version' => $nextVersion, 'updated_at' => $now,
]);
$this->attachAuthorizedDocumentaryEvidence($resourceId, $resource->title);
$this->recordResourceRevision($resourceId, 'published', $request->user()->id, $nextVersion);
$this->audit($request, 'published_under_user_authorization', 'portal_resources', $resourceId, [
'authorization_source_system' => $event->source_system,
'authorization_external_id' => $event->external_id,
'source_confirmation' => $resource->source_confirmation,
'legal_status_preserved' => true,
]);
$published[$resourceId] = 'Publicado com a situação de fonte e vigência já registada.';
}
});
return to_route('services.manage')->with('success', count($published).' documento(s) disponibilizado(s); '.count($skipped).' pendência(s) individual(is).')
->with('disclosureResults', ['published' => $published, 'skipped' => $skipped]);
}
/** @param array<string,mixed> $additional */
private function audit(Request $request, string $action, string $table, int $id, array $additional = []): void
{
DB::table('audit_events')->insert(['source_system' => 'portal-services', 'entity_type' => $table,
'actor_user_id' => $request->user()->id, 'action' => $action, 'after_data' => json_encode(['record_id' => $id], JSON_THROW_ON_ERROR)]);
'actor_user_id' => $request->user()->id, 'action' => $action,
'after_data' => json_encode(['record_id' => $id, ...$additional], JSON_THROW_ON_ERROR)]);
}
private function attachAuthorizedDocumentaryEvidence(int $resourceId, string $title): void
{
if (! $this->requirementEvidenceEnabled()) {
return;
}
$evidence = match (true) {
str_contains($title, 'Lei n.º 22/2022') => [['fiscal.iva.lei_22_2022', 'confirmed', 'Lei n.º 22/2022, art. 17(1); citação conferida no exemplar cujo SHA-256 coincide com o ficheiro oficial. Isto é evidência documental, não valida vigência ou cálculo.']],
str_contains($title, 'BR 248/2025') || str_contains($title, 'n.º 248/2025') => array_map(fn (array $law) => ['fiscal.'.$law[0].'.'.$law[1], 'pending', 'BR n.º 248/2025, Suplemento n.º 1, '.$law[2].'; artigo/dispositivo específico não conferido nesta associação. Evidência identificada, não fundamento confirmado.'], [
['ice', 'lei_7_2025', 'Lei n.º 7/2025'], ['customs', 'lei_8_2025', 'Lei n.º 8/2025'],
['ispc', 'lei_9_2025', 'Lei n.º 9/2025'], ['iva', 'lei_10_2025', 'Lei n.º 10/2025'],
['irps', 'lei_11_2025', 'Lei n.º 11/2025'], ['irpc', 'lei_12_2025', 'Lei n.º 12/2025'],
]),
str_contains($title, 'Lei n.º 13/2016') || str_contains($title, 'BR n.º 89') => [['fiscal.iva.lei_13_2016_rectification_2017', 'pending', 'BR n.º 89/2017, I Série, p. 569, aviso relativo à Lei n.º 13/2016; a correspondência do exemplar local e os dispositivos rectificados aguardam conferência. Evidência identificada, não fundamento confirmado.']],
default => [],
};
foreach ($evidence as [$key, $reviewStatus, $basis]) {
if (! array_key_exists($key, config('document-requirements.catalogue', []))) {
continue;
}
$exists = DB::table('portal_resource_links')->where('resource_id', $resourceId)
->where('target_type', 'requirement')->where('target_key', $key)->exists();
if ($exists) {
continue;
}
DB::table('portal_resource_links')->insert([
'resource_id' => $resourceId, 'target_type' => 'requirement', 'target_key' => $key,
'relationship' => 'cites', 'applicability' => 'evidence', 'basis_reference' => $basis,
'review_status' => $reviewStatus, 'created_at' => now(), 'updated_at' => now(),
]);
}
}
/** @return array<string,mixed> */
private function validateResource(Request $request): array
{
$data = $this->validateManagement($request, [
'document_id' => ['required', 'uuid'], 'title' => ['required', 'string', 'max:200'],
'description' => ['nullable', 'string', 'max:4000'], 'category' => ['required', 'string', 'max:80'],
// QA legacy originals may have no theme (e.g. a shared image). Do not invent a sector to disclose an authorized original.
'topic' => [app(LegacyContent::class)->available() ? 'nullable' : 'required', 'string', 'max:80'],
'document_type' => ['required', 'in:legislation,procedure,official_form,digital_adaptation,guidance_template,guide,study,reference_data,unknown'],
'issuer' => ['nullable', 'string', 'max:180'], 'document_number' => ['nullable', 'string', 'max:120'],
'document_date' => ['nullable', 'date_format:Y-m-d'], 'source_url' => ['nullable', 'url:https', 'max:2000'],
'applicable_from' => ['nullable', 'date_format:Y-m-d'],
'applicable_until' => ['nullable', 'date_format:Y-m-d'],
'language' => ['required', 'string', 'max:12'], 'version_label' => ['nullable', 'string', 'max:120'],
'document_group_id' => ['nullable', 'uuid'], 'territorial_scope' => ['required', 'in:national,provincial,district,locality,multiple,unknown'],
'territory_id' => ['nullable', 'uuid', 'exists:geo_locations,id'],
'source_confirmation' => ['required', 'in:unconfirmed,identified,official_confirmed'],
'legal_status' => ['required', 'in:not_applicable,in_force,historical,proposed,repealed,amended,unknown'],
'rights_status' => ['required', 'in:unconfirmed,authorized,restricted'], 'independent_review' => ['required', 'boolean'],
'data_classification' => ['nullable', 'in:internal,public'],
'source_reference' => ['required', 'string', 'min:10', 'max:2000'],
'legislation' => ['required', 'boolean'], 'essential' => ['required', 'boolean'],
'associations' => ['nullable', 'array', 'max:40'], 'associations.*.target_type' => ['required', 'in:theme,module,activity,stage,procedure,entity,territory,document,investment_type,legal_source,sector,opportunity'.($this->requirementEvidenceEnabled() ? ',requirement' : '')],
'associations.*.target_key' => ['required', 'string', 'max:180'],
'associations.*.review_status' => ['nullable', 'in:pending,confirmed'],
'associations.*.relationship' => ['required', 'in:applies_to,supports,version_of,translation_of,amends,repeals,implements,cites,related_to'],
'associations.*.applicability' => ['required', 'in:required,conditional,support,optional_template,evidence'],
'associations.*.condition_text' => ['nullable', 'string', 'max:2000'], 'associations.*.basis_reference' => ['nullable', 'string', 'max:2000'],
]);
$data['topic'] = $data['topic'] ?? '';
if (! empty($data['applicable_from']) && ! empty($data['applicable_until']) && $data['applicable_until'] < $data['applicable_from']) {
throw $this->managementValidationException(['applicable_until' => 'A data final deve ser igual ou posterior à data inicial.']);
}
foreach ($data['associations'] ?? [] as $index => $association) {
if (($association['target_type'] ?? null) === 'requirement' && ! $this->requirementEvidenceEnabled()) {
throw $this->managementValidationException(["associations.$index.target_type" => 'As associações documentais a requisitos ainda aguardam a migração do catálogo.']);
}
if (($association['target_type'] ?? null) === 'requirement'
&& ! array_key_exists($association['target_key'], config('document-requirements.catalogue', []))) {
throw $this->managementValidationException(["associations.$index.target_key" => 'Seleccione um identificador técnico de requisito existente.']);
}
if (mb_strlen(trim($association['basis_reference'] ?? '')) < 5) {
throw $this->managementValidationException(["associations.$index.basis_reference" => 'Indique o artigo, secção, página ou outro trecho que esta associação sustenta.']);
}
if ($association['applicability'] === 'conditional' && mb_strlen(trim($association['condition_text'] ?? '')) < 5) {
throw $this->managementValidationException(["associations.$index.condition_text" => 'Descreva a condição de aplicação.']);
}
}
return $data;
}
/** Keep validation redirects on the management GET page even if the request
* arrived with a stale Referer pointing at a write-only resource endpoint.
*
* @param array<string,mixed> $rules
* @return array<string,mixed>
*/
private function validateManagement(Request $request, array $rules): array
{
try {
return $request->validate($rules);
} catch (ValidationException $exception) {
throw $this->managementValidationException($exception->errors());
}
}
/** @param array<string,string|list<string>> $errors */
private function managementValidationException(array $errors): ValidationException
{
$exception = ValidationException::withMessages($errors);
$exception->redirectTo = route('services.manage');
return $exception;
}
/** @param array<string,mixed> $data
* @return array<string,mixed> */
private function resourceFields(array $data, int $actor, ?string $identity, ?int $duplicate, bool $creating): array
{
unset($data['associations'], $data['issuer'], $data['data_classification']);
$data['document_identity_key'] = $identity;
$data['duplicate_of_resource_id'] = $duplicate;
$data['last_reviewed_at'] = $data['source_confirmation'] !== 'unconfirmed' || $data['rights_status'] === 'authorized' ? now() : null;
$data['last_reviewed_by'] = $actor;
if ($creating) {
$data['created_by'] = $actor;
} else {
unset($data['metadata_version']);
}
return $data;
}
/** @param list<array<string,mixed>> $links */
private function syncResourceLinks(int $resourceId, array $links): void
{
foreach ($links as $link) {
if ($link['target_type'] === 'document') {
abort_unless(DB::table('portal_resources')->where('id', $link['target_key'])->exists(), 422, 'A relação documental seleccionada não existe.');
}
DB::table('portal_resource_links')->insert([
'resource_id' => $resourceId, 'target_type' => $link['target_type'], 'target_key' => $link['target_key'],
'relationship' => $link['relationship'], 'applicability' => $link['applicability'],
'condition_text' => $link['condition_text'] ?? null, 'basis_reference' => $link['basis_reference'] ?? null,
...($this->requirementEvidenceEnabled() ? ['review_status' => $link['review_status'] ?? 'pending'] : []),
'created_at' => now(), 'updated_at' => now(),
]);
}
}
/** @param array<string,mixed> $data */
private function assertPublicClassificationEligible(array $data, \stdClass $document): void
{
if (($data['data_classification'] ?? $document->data_classification) !== 'public') {
return;
}
abort_unless($document->status === 'active' && $document->malware_scan_status === 'clean'
&& hash_equals(trim($document->sha256), trim((string) $document->malware_scanned_sha256)), 422,
'A análise antimalware e a integridade do ficheiro têm de estar confirmadas antes de o classificar como público.');
abort_unless($data['rights_status'] === 'authorized', 422,
'Registe a autorização de utilização antes de classificar o ficheiro como público.');
abort_unless($data['source_confirmation'] !== 'unconfirmed' && ! empty($data['source_url']), 422,
'Identifique e confirme a fonte antes de classificar o ficheiro como público.');
}
/** @param array<string,mixed> $data */
private function identityKey(array $data): ?string
{
if (empty($data['issuer']) || empty($data['document_number'])) {
return null;
}
return hash('sha256', mb_strtolower(implode('|', [trim($data['issuer']), trim($data['document_number']),
$data['document_date'] ?? '', $data['language'], $data['version_label'] ?? ''])));
}
private function recordResourceRevision(int $id, string $changeType, int $actor, int $revision): void
{
$resource = DB::table('portal_resources')->where('id', $id)->first();
$links = DB::table('portal_resource_links')->where('resource_id', $id)->orderBy('id')->get();
DB::table('portal_resource_revisions')->insert(['resource_id' => $id, 'revision_no' => $revision, 'change_type' => $changeType,
'snapshot' => json_encode(['resource' => $resource, 'associations' => $links], JSON_THROW_ON_ERROR),
'actor_user_id' => $actor, 'created_at' => now()]);
}
private function requirementEvidenceEnabled(): bool
{
return Schema::hasColumn('portal_resource_links', 'review_status')
&& Schema::hasTable('migrations')
&& DB::table('migrations')->where('migration', '2026_10_06_200000_add_document_requirement_evidence_links')->exists();
}
/** @return list<string> */
private function resourceLinkColumns(): array
{
$columns = ['target_type', 'target_key', 'relationship', 'applicability', 'condition_text', 'basis_reference'];
if (Schema::hasColumn('portal_resource_links', 'review_status')) {
$columns[] = 'review_status';
}
return $columns;
}
/** @return list<string> */
private function territoryScopeIds(string $id): array
{
$rows = DB::select(<<<'SQL'
WITH RECURSIVE territory_tree AS (
SELECT id FROM geo_locations WHERE id = ? AND deleted_at IS NULL AND data_classification = 'public'
UNION ALL
SELECT child.id FROM geo_locations child
INNER JOIN territory_tree parent ON child.parent_id = parent.id
WHERE child.deleted_at IS NULL AND child.data_classification = 'public'
)
SELECT id FROM territory_tree
SQL, [$id]);
$ids = [];
foreach ($rows as $row) {
if (! is_string($row->id)) {
throw new \RuntimeException('Territory identifier must be a string.');
}
$ids[] = $row->id;
}
return $ids ?: [$id];
}
private function articleUrl(int $id): ?string
{
if (!Schema::hasColumn('portal_resources', 'editorial_content')) return null;
$resource = DB::table('portal_resources')->find($id);
if (!$resource) return null;
if ($resource->editorial_content !== null) return '/artigos/'.$id;
try {
return app(\App\Services\EditorialReading::class)->original($resource) ? '/artigos/'.$resource->id : null;
} catch (\Symfony\Component\HttpKernel\Exception\HttpException) {
return null;
}
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Http\Controllers;
use App\Models\Opportunity;
use App\Services\ProactiveSupply;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
final class ProactiveSupplyController extends Controller
{
public function index(Request $request,ProactiveSupply $service): JsonResponse
{
$v=$request->validate(['record_id'=>'nullable|integer|min:1']);
$record=isset($v['record_id'])?Opportunity::findOrFail($v['record_id']):null;
return response()->json($service->search($request->user(),$request->all(),$record))->header('Cache-Control','private, no-store');
}
}
@@ -0,0 +1,138 @@
<?php
namespace App\Http\Controllers;
use App\Services\Procurement;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
class ProcurementController extends Controller
{
private const STATE_SQL = "CASE WHEN v.payload->>'procedural_state' IN ('cancelled','results') THEN v.payload->>'procedural_state' WHEN ((v.payload->>'deadline')::timestamp AT TIME ZONE (v.payload->>'timezone'))<=CURRENT_TIMESTAMP THEN 'deadline_ended' WHEN v.payload->>'extended'='true' THEN 'extended' ELSE 'open' END";
private function query(Procurement $service)
{
abort_unless($service->available(), 404);
return DB::table('portal_tenders as t')->join('portal_tender_versions as v', fn ($q) => $q->on('v.id', '=', 't.published_version_id')->on('v.tender_id', '=', 't.id'))->whereNotNull('v.published_at');
}
public function index(Request $request, Procurement $service)
{
$filters = $request->validate(['q' => 'nullable|string|max:150', 'entity' => 'nullable|uuid', 'sector' => 'nullable|integer', 'territory' => 'nullable|uuid', 'modality' => 'nullable|string|max:200', 'financier' => 'nullable|string|max:200', 'state' => 'nullable|in:open,extended,deadline_ended,cancelled,results', 'deadline_before' => 'nullable|date_format:Y-m-d', 'regime' => 'nullable|in:state,external,private,other']);
$filters = array_filter($filters, fn ($value) => $value !== null && $value !== '');
$query = $this->query($service);
if (! empty($filters['q'])) {
$query->where(fn ($q) => $q->whereRaw("v.payload->>'title' ILIKE ?", ['%'.$filters['q'].'%'])->orWhereRaw("v.payload->>'object' ILIKE ?", ['%'.$filters['q'].'%'])->orWhereRaw("v.payload->>'reference' ILIKE ?", ['%'.$filters['q'].'%']));
}
foreach (['entity' => 'organization_id', 'sector' => 'sector_id', 'modality' => 'modality', 'financier' => 'financier', 'regime' => 'regime'] as $filter => $field) {
if (! empty($filters[$filter])) {
$query->whereRaw('v.payload->>? = ?', [$field, (string) $filters[$filter]]);
}
}
if (! empty($filters['territory'])) {
$query->whereRaw("v.payload->>'territory_id' IN (WITH RECURSIVE scope(id,depth) AS (SELECT id,0 FROM geo_locations WHERE id=? AND deleted_at IS NULL UNION ALL SELECT c.id,s.depth+1 FROM geo_locations c JOIN scope s ON c.parent_id=s.id WHERE c.deleted_at IS NULL AND s.depth<4) SELECT id::text FROM scope)", [$filters['territory']]);
}
if (! empty($filters['state'])) {
$query->whereRaw(self::STATE_SQL.' = ?', [$filters['state']]);
}
if (! empty($filters['deadline_before'])) {
$query->whereRaw("left(v.payload->>'deadline',10)<=?", [$filters['deadline_before']]);
}
$rows = $query->select('t.id', 'v.id as vid')->orderByDesc('v.published_at')->paginate(20)->withQueryString();
$rows->through(fn ($r) => ['id' => $r->id, ...$this->summary($service, $r->vid)]);
$facets = $this->query($service)->get(['v.payload'])->map(fn ($r) => json_decode($r->payload, true));
$options = [];
foreach (['entity' => ['organization_id', 'organization_name'], 'sector' => ['sector_id', 'sector_name'], 'territory' => ['territory_id', 'territory_name'], 'modality' => ['modality', 'modality'], 'financier' => ['financier', 'financier']] as $name => [$value,$label]) {
$options[$name] = $facets->filter(fn ($p) => ! empty($p[$value]))->map(fn ($p) => ['id' => (string) $p[$value], 'name' => $p[$label]])->unique('id')->values();
}
return Inertia::render('procurement/index', ['items' => $rows, 'filters' => $filters, 'options' => $options, 'regimes' => config('procurement.regimes'), 'management' => $service->access($request->user())])->toResponse($request)->header('Cache-Control', 'private, no-store');
}
private function summary(Procurement $service, string $vid): array
{
$v = $service->publishedVersion($vid);
return ['publication' => $v, 'state' => $service->state($v)];
}
public function show(Request $request, string $id, Procurement $service)
{
return $this->detail($request, $id, $service, false);
}
public function print(Request $request, string $id, Procurement $service)
{
return $this->detail($request, $id, $service, true);
}
private function detail(Request $request, string $id, Procurement $service, bool $print)
{
$row = $this->query($service)->where('t.id', $id)->firstOrFail(['t.id', 'v.id as vid']);
$filters = $request->only(['q', 'entity', 'sector', 'territory', 'modality', 'financier', 'state', 'deadline_before', 'regime', 'page']);
return Inertia::render('procurement/show', ['id' => $id, ...$this->summary($service, $row->vid), 'printMode' => $print, 'backUrl' => '/concursos'.($filters ? '?'.http_build_query($filters) : ''),
'history' => DB::table('portal_tender_versions')->where('tender_id', $id)->whereNotNull('published_at')->orderBy('version')->pluck('id')->map(fn ($vid) => $service->publishedVersion($vid)),
'watching' => $request->user() && DB::table('portal_tender_watches')->where('tender_id', $id)->where('user_id', $request->user()->id)->exists()])->toResponse($request)->header('Cache-Control', 'private, no-store');
}
public function manage(Request $request, Procurement $service)
{
$service->guard($request->user());
return Inertia::render('procurement/manage', ['items' => DB::table('portal_tenders')->orderByDesc('updated_at')->get(['id', 'draft', 'version', 'published_version_id'])->map(fn ($r) => ['id' => $r->id, 'title' => json_decode($r->draft, true)['title'] ?? 'Rascunho sem título', 'version' => $r->version, 'published' => (bool) $r->published_version_id])])->toResponse($request)->header('Cache-Control', 'private, no-store');
}
public function edit(Request $request, Procurement $service, ?string $id = null)
{
$service->guard($request->user());
$r = $id ? $service->record($id) : null;
return Inertia::render('procurement/edit', ['record' => $r ? ['id' => $id, 'version' => $r->version, 'draft' => json_decode($r->draft, true), 'published' => (bool) $r->published_version_id] : null,
'references' => $service->references($request->user()), 'history' => $id ? DB::table('portal_tender_versions')->where('tender_id', $id)->orderByDesc('version')->get(['version', 'act', 'reason', 'actor_id', 'published_at', 'created_at']) : []])->toResponse($request)->header('Cache-Control', 'private, no-store');
}
public function save(Request $request, Procurement $service, ?string $id = null)
{
$id = $service->save($request->user(), $id, $request->all());
return redirect('/management/concursos/'.$id)->with('success', 'Rascunho guardado. A publicação anterior permanece intacta.');
}
public function preview(Request $request, string $id, Procurement $service)
{
return response()->json($service->preview($request->user(), $id, (string) $request->input('act')))->header('Cache-Control', 'private, no-store');
}
public function publish(Request $request, string $id, Procurement $service)
{
$service->publish($request->user(), $id, $request->all());
return redirect('/management/concursos/'.$id)->with('success', 'Acto publicado, com versão e auditoria. Nenhuma proposta recebida pelo portal.');
}
public function watch(Request $request, string $id, Procurement $service)
{
$row = $this->query($service)->where('t.id', $id)->firstOrFail(['v.id']);
DB::table('portal_tender_watches')->updateOrInsert(['tender_id' => $id, 'user_id' => $request->user()->id], ['seen_version_id' => $row->id, 'created_at' => now(), 'updated_at' => now()]);
return back()->with('success', 'Concurso guardado para acompanhamento. Isto não apresenta uma proposta.');
}
public function unwatch(Request $request, string $id, Procurement $service)
{
abort_unless($service->available(), 404);
DB::table('portal_tender_watches')->where('tender_id', $id)->where('user_id', $request->user()->id)->delete();
return back();
}
public function watched(Request $request, Procurement $service)
{
$rows = $this->query($service)->join('portal_tender_watches as w', 'w.tender_id', '=', 't.id')->where('w.user_id', $request->user()->id)->get(['t.id', 'v.id as vid', 'w.seen_version_id'])->map(fn ($r) => ['id' => $r->id, 'changed' => $r->seen_version_id !== $r->vid, ...$this->summary($service, $r->vid)]);
return Inertia::render('procurement/manage', ['watched' => true, 'items' => $rows])->toResponse($request)->header('Cache-Control', 'private, no-store');
}
}
@@ -4,11 +4,16 @@ namespace App\Http\Controllers;
use App\Http\Resources\PublicBusinessOpportunityResource;
use App\Models\ExistingBusiness;
use App\Services\CountryCatalogue;
use App\Services\ExistingBusinessDrafts;
use App\Services\ExistingBusinessClassification;
use App\Services\OpportunityClassification;
use App\Services\InvestmentGeography;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
@@ -16,13 +21,21 @@ class PublicBusinessOpportunityController extends Controller
{
public function index(Request $request): Response|JsonResponse
{
$filters = $request->validate([
'q' => ['nullable', 'string', 'max:120'],
'sector' => ['nullable', 'integer', 'min:1'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:50'],
]);
$filters = $this->filters($request);
$query = $this->publishedQuery();
app(ExistingBusinessClassification::class)->constrain($query, $filters);
$path = app(InvestmentGeography::class)->selection($filters);
if ($path !== []) {
$territoryIds = app(InvestmentGeography::class)->descendants($path[array_key_last($path)]->id);
$query->whereExists(fn ($locations) => $locations->selectRaw('1')->from('existing_business_locations as matching_location')
->whereColumn('matching_location.existing_business_id', 'existing_businesses.id')->where('matching_location.is_public', true)
->where(function ($match) use ($territoryIds): void {
foreach (['province_id', 'district_id', 'admin_post_id', 'locality_id'] as $field) {
$match->orWhereIn('matching_location.'.$field, $territoryIds);
}
}));
}
if (! empty($filters['q'])) {
$term = trim($filters['q']);
$query->where(fn (Builder $builder) => $builder->whereLike('existing_businesses.public_title', '%'.$term.'%')
@@ -30,15 +43,34 @@ class PublicBusinessOpportunityController extends Controller
->orWhereLike('existing_businesses.objective', '%'.$term.'%'));
}
if (! empty($filters['sector'])) {
$query->where('existing_businesses.sector_id', $filters['sector']);
$sector = \App\Models\Sector::find($filters['sector']);
$query->whereIn('existing_businesses.sector_id', $sector ? \App\Models\Sector::whereIn('slug', \App\Services\SectorOrganization::slugs($sector->slug))->pluck('id') : []);
}
if (! empty($filters['modality'])) {
$query->where('existing_businesses.modality', $filters['modality']);
}
$results = PublicBusinessOpportunityResource::collection($query->paginate($filters['per_page'] ?? 12)->withQueryString());
$results = PublicBusinessOpportunityResource::collection($query->paginate($filters['per_page'] ?? 12)->appends($filters));
if ($request->expectsJson()) {
return $results->response();
}
return Inertia::render('investment/business-marketplace', ['results' => $results, 'filters' => $filters]);
return Inertia::render('investment/business-marketplace', ['results' => $results, 'filters' => $filters, 'territorySelection' => $path,
'classificationOptions' => app(OpportunityClassification::class)->options(),
'authorities' => DB::table('authorities')->where('status', 'published')->orderBy('label_pt_mz')->get(['id', 'label_pt_mz'])]);
}
private function filters(Request $request): array
{
return $request->validate([
'q' => ['nullable', 'string', 'max:120'],
'modality' => ['nullable', Rule::in(ExistingBusinessDrafts::MODALITIES)],
'sector' => ['nullable', 'integer', 'min:1'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:50'],
'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'],
'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid'],
...app(ExistingBusinessClassification::class)->filterRules(),
]);
}
public function show(Request $request, string $slug): Response|JsonResponse
@@ -49,16 +81,24 @@ class PublicBusinessOpportunityController extends Controller
return $resource->response();
}
return Inertia::render('investment/business-opportunity', ['record' => $resource]);
$returnFilters = $this->filters($request);
return Inertia::render('investment/business-opportunity', ['record' => $resource->resolve($request),
'returnUrl' => $returnFilters === [] ? '/negocios' : '/negocios?'.http_build_query($returnFilters),
'interestUrl' => '/negocios/'.rawurlencode($slug).'/manifestar-interesse'.($returnFilters === [] ? '' : '?'.http_build_query($returnFilters))]);
}
public function interest(Request $request, string $slug): Response
{
$record = $this->publishedQuery()->where('existing_businesses.public_slug', $slug)->firstOrFail();
$returnFilters = $this->filters($request);
return Inertia::render('investment/business-interest', [
'record' => (new PublicBusinessOpportunityResource($record))->resolve($request),
'modalities' => ExistingBusinessDrafts::MODALITIES,
'countries' => app(CountryCatalogue::class)->all(),
'returnUrl' => $returnFilters === [] ? '/negocios' : '/negocios?'.http_build_query($returnFilters),
]);
}
@@ -77,10 +117,11 @@ class PublicBusinessOpportunityController extends Controller
return ExistingBusiness::query()->published()->with('sector:id,name,slug')
->leftJoinSub($publicLocations, 'public_location', 'public_location.existing_business_id', '=', 'existing_businesses.id')
->select(['existing_businesses.id', 'existing_businesses.public_slug', 'existing_businesses.public_title',
->select(['existing_businesses.id', 'existing_businesses.public_slug', 'existing_businesses.public_title', 'existing_businesses.modality',
'existing_businesses.activity_code', 'existing_businesses.stage', 'existing_businesses.objective',
'existing_businesses.currency', 'existing_businesses.amount_min', 'existing_businesses.amount_max',
'existing_businesses.available_percentage', 'existing_businesses.published_at', 'existing_businesses.sector_id',
'existing_businesses.owner_user_id', 'existing_businesses.organization_id', 'existing_businesses.version_no', 'existing_businesses.expires_at', 'existing_businesses.status', 'existing_businesses.modality',
'public_location.province', 'public_location.district', 'public_location.locality']);
}
}
@@ -5,23 +5,35 @@ namespace App\Http\Controllers;
use App\Services\InvestmentGeography;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class PublicGeographyController extends Controller
{
public function options(Request $request, InvestmentGeography $geography): JsonResponse
{
$data = $request->validate(['type' => ['required', 'in:province,district,admin_post,locality'], 'parent_id' => ['nullable', 'uuid'], 'q' => ['nullable', 'string', 'max:100'], 'page' => ['nullable', 'integer', 'min:1']]);
$data = $request->validate(['corridor' => ['nullable', \Illuminate\Validation\Rule::in(array_keys(config('investment_corridors')))], 'type' => ['required', 'in:province,district,admin_post,locality'], 'id' => ['nullable', 'uuid'], 'parent_id' => ['nullable', 'uuid'], 'q' => ['nullable', 'string', 'max:100'], 'page' => ['nullable', 'integer', 'min:1']]);
$query = $geography->publicReferences()->where('location_type', $data['type']);
if (!empty($data['corridor'])) {
$corridors = app(\App\Services\InvestmentCorridorContext::class);
if ($data['type'] === 'province') $query->whereIn('id', array_column($corridors->provinces($data['corridor']), 'id'));
elseif (!$corridors->compatible($data['corridor'], $data['parent_id'] ?? null)) abort(422, 'O território não pertence ao corredor escolhido.');
}
if (! empty($data['id'])) {
$query->where('id', $data['id']);
}
if ($data['type'] !== 'province') {
$parentType = ['district' => 'province', 'admin_post' => 'district', 'locality' => 'admin_post'][$data['type']];
$parent = $geography->publicReferences()->where('id', $data['parent_id'] ?? '00000000-0000-0000-0000-000000000000')->where('location_type', $parentType)->value('id');
$query->where('parent_id', $parent ?? '00000000-0000-0000-0000-000000000000');
}
if (! empty($data['q'])) {
$term = '%'.addcslashes($data['q'], '%_\\').'%';
$query->where(fn ($q) => $q->whereRaw('name ILIKE ?', [$term])->orWhereRaw('code ILIKE ?', [$term])->orWhereRaw('source_code ILIKE ?', [$term]));
$term = '%'.addcslashes(Str::ascii($data['q']), '%_\\').'%';
$query->where(fn ($q) => $q
->whereRaw("translate(lower(name), 'áàâãäåéèêëíìîïóòôõöúùûüçýÿ', 'aaaaaaeeeeiiiiooooouuuucyy') ILIKE ?", [$term])
->orWhereRaw('code ILIKE ?', [$term])
->orWhereRaw('source_code ILIKE ?', [$term]));
}
return response()->json($query->orderBy('name')->orderBy('id')->paginate(100, ['id', 'name', 'parent_id', 'location_type', 'source_code']));
return response()->json($query->orderBy('name')->orderBy('id')->paginate(100, ['id', 'name', 'parent_id', 'location_type', 'source_code'])->withQueryString());
}
}
@@ -4,6 +4,8 @@ namespace App\Http\Controllers;
use App\Models\Opportunity;
use App\Models\Sector;
use App\Services\CountryCatalogue;
use App\Services\SectorInvestment;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
@@ -17,12 +19,13 @@ class PublicPortalController extends Controller
return Inertia::render('PortalInvestidor', ['initialView' => 'opportunities', 'initialQuery' => $filters['q'] ?? '', 'initialSector' => $filters['sector'] ?? '']);
}
public function sector(string $slug): Response
public function sector(string $slug): Response|\Illuminate\Http\RedirectResponse
{
$sector = Sector::where('slug', $slug)->where('is_active', true)->firstOrFail();
if (in_array($slug, ['energia', 'fintech'], true)) return redirect(\App\Services\SectorOrganization::href($slug), 301);
if ($slug === 'mercado-financeiro/fintech') $slug = 'fintech';
$sector = Sector::where('slug', $slug)->where('is_active', true)->whereNotNull('published_at')->firstOrFail();
return Inertia::render('portal/services', ['section' => 'sector', 'sector' => $sector->only(['name', 'slug', 'description', 'short_description']),
'opportunities' => $sector->opportunities()->published()->latest('published_at')->limit(12)->get(['title', 'slug', 'province', 'summary'])]);
return Inertia::render('portal/services', ['section' => 'sector', ...app(SectorInvestment::class)->page($sector)]);
}
public function opportunity(string $slug): Response
@@ -32,13 +35,13 @@ class PublicPortalController extends Controller
public function interest(?string $slug = null): Response
{
return $slug ? $this->detail($slug, 'interest') : Inertia::render('PortalInvestidor', ['initialView' => 'interest']);
return $slug ? $this->detail($slug, 'interest') : Inertia::render('PortalInvestidor', ['initialView' => 'interest', 'countries' => app(CountryCatalogue::class)->all()]);
}
private function detail(string $slug, string $view): Response
{
$opportunity = Opportunity::with('sector:id,name,slug')->published()->where('slug', $slug)->firstOrFail();
return Inertia::render('PortalInvestidor', ['initialView' => $view, 'initialOpportunity' => $opportunity]);
return Inertia::render('PortalInvestidor', ['initialView' => $view, 'initialOpportunity' => $opportunity, 'countries' => app(CountryCatalogue::class)->all()]);
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Http\Controllers;
use App\Services\SectorReconciliationExecution;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
final class SectorReconciliationController extends Controller
{
public function preview(Request $request, SectorReconciliationExecution $execution)
{
$plan = $execution->preview($request);
return $request->expectsJson() ? response()->json($plan)->header('Cache-Control', 'private, no-store')
: response()->view('management.sector-reconciliation', ['plan' => $plan, 'operationKey' => (string)Str::uuid()])->header('Cache-Control', 'private, no-store');
}
public function apply(Request $request, SectorReconciliationExecution $execution)
{
return response()->json($execution->apply($request))->header('Cache-Control', 'private, no-store');
}
}
@@ -0,0 +1,46 @@
<?php
namespace App\Http\Controllers;
use App\Models\InvestmentLegalSource;
use App\Services\SimulationTaxGovernanceAccess;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/** Explicit fiscal curation of an identified official source; not an independent legal opinion. */
final class SimulationFiscalSourceController extends Controller
{
public function store(Request $request): JsonResponse
{
SimulationTaxGovernanceAccess::requireRole($request->user(), 'editors');
$data = $request->validate([
'title' => ['required', 'string', 'max:255'], 'url' => ['required', 'url:https', 'max:2000'],
'diploma' => ['required', 'string', 'max:255'], 'article' => ['required', 'string', 'max:255'],
'competent_entity' => ['required', 'string', 'max:255'], 'valid_from' => ['required', 'date_format:Y-m-d'],
'valid_until' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:valid_from'],
'sha256' => ['required', 'regex:/^[a-f0-9]{64}$/D'],
'citation' => ['required', 'string', 'min:10', 'max:2000'],
'official_origin' => ['required', 'string', 'min:10', 'max:2000'],
]);
return DB::transaction(function () use ($request, $data): JsonResponse {
$actor = $request->user();
$acceptance = ['scope' => 'fiscal_parameters', 'basis' => 'Tempo-official-sources-20261008',
'responsible_user_id' => $actor->id, 'sha256' => $data['sha256'], 'citation' => $data['citation'],
'official_origin' => $data['official_origin'], 'accepted_at' => now()->toISOString(),
'legal_opinion' => false, 'independent_review' => false];
unset($data['sha256'], $data['citation'], $data['official_origin']);
$source = InvestmentLegalSource::create([...$data, 'owner_id' => $actor->id, 'validator_id' => null,
'validation_status' => 'pending', 'validated_at' => null, 'version_no' => 1, 'fiscal_acceptance' => $acceptance]);
DB::table('audit_events')->insert(['source_system' => 'simulation-fiscal-maintenance',
'entity_type' => 'investment_legal_sources', 'entity_id' => $source->id, 'actor_user_id' => $actor->id,
'action' => 'official_source_acceptance', 'after_data' => json_encode([
'source' => $source->attributesToArray(), 'basis' => $acceptance,
'sha256' => hash('sha256', $source->toJson()),
], JSON_THROW_ON_ERROR)]);
return response()->json($source->fresh(), 201);
});
}
}
@@ -0,0 +1,129 @@
<?php
namespace App\Http\Controllers;
use App\Services\SimulationFiscalQaPreview;
use App\Services\SimulationTaxParameterResolver;
use App\Services\SimulationTaxRuleExpression;
use App\Services\SimulationTaxRuleGovernance;
use App\Support\IsolatedQaDatabase;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
use InvalidArgumentException;
class SimulationTaxRuleGovernanceController extends Controller
{
public function qaReview(Request $request, string $id, SimulationTaxRuleGovernance $governance): Response
{
abort_unless(IsolatedQaDatabase::allowsSchemaChanges(), 404);
$rule = $governance->show($request->user(), $id);
abort_unless($rule->status === 'draft' && $rule->rule_code === 'IVA-GERAL-BASE-LIQUIDA', 422,
'Seleccione o rascunho do primeiro lote fiscal.');
return Inertia::render('management/fiscal-qa-review', ['rule' => [
'id' => $rule->id, 'code' => $rule->rule_code, 'version' => $rule->rule_version,
]]);
}
public function qaPreview(Request $request, string $id, SimulationFiscalQaPreview $preview): JsonResponse
{
abort_unless(IsolatedQaDatabase::allowsSchemaChanges(), 404);
$key = $request->header('Idempotency-Key');
abort_unless(is_string($key) && strlen($key) >= 16 && strlen($key) <= 128, 428, 'Idempotency-Key é obrigatório.');
$input = $request->validate([
'base' => ['required', 'string', 'max:40', 'regex:/^(?:0|[1-9][0-9]*)(?:\.[0-9]+)?$/D'],
'reference_date' => ['required', 'date_format:Y-m-d'],
'currency' => ['required', 'in:MZN'],
'operation' => ['required', 'in:internal_general_net'],
]);
$result = $preview->create($request->user(), $id, $input, $key);
$simulation = $result['simulation'];
return response()->json(['data' => $simulation, 'replayed' => $result['replayed']], $result['replayed'] ? 200 : 202)
->header('Location', url('/api/v1/simulations/'.$simulation->id));
}
public function index(Request $request, SimulationTaxRuleGovernance $governance): JsonResponse
{
$filters = $request->validate([
'rule_code' => ['nullable', 'string', 'max:64'],
'component' => ['nullable', 'in:IRPC,IVA,ISPC,IRPS,INSS,RETENCOES,DIREITOS_ADUANEIROS,ICE'],
'scope_key' => ['nullable', 'string', 'max:160'],
'status' => ['nullable', 'in:draft,submitted,reviewed,rejected,approved,published,suspended,archived'],
'per_page' => ['nullable', 'integer', 'between:1,100'],
]);
return response()->json($governance->listing($request->user(), $filters));
}
public function store(Request $request, SimulationTaxRuleGovernance $governance): JsonResponse
{
$rule = $governance->create($request->user(), $this->validatedRule($request));
return response()->json($rule, 201)->header('Location', url('/api/v2/investment/admin/tax-rules/'.$rule->id));
}
public function show(Request $request, SimulationTaxRuleGovernance $governance, string $id): JsonResponse
{
return response()->json($governance->show($request->user(), $id));
}
public function update(Request $request, SimulationTaxRuleGovernance $governance, string $id): JsonResponse
{
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1']]);
return response()->json($governance->update($request->user(), $id, (int) $data['version_no'], $this->validatedRule($request)));
}
public function transition(Request $request, SimulationTaxRuleGovernance $governance, string $id, string $action): JsonResponse
{
$data = $request->validate([
'version_no' => ['required', 'integer', 'min:1'],
'evidence' => ['required', 'string', 'min:20', 'max:2000'],
]);
return response()->json($governance->transition($request->user(), $id, (int) $data['version_no'], $action, $data['evidence']));
}
/** @return array<string,mixed> */
private function validatedRule(Request $request): array
{
$data = $request->validate([
'rule_code' => ['required', 'string', 'regex:/^[A-Z0-9][A-Z0-9._-]{2,63}$/'],
'rule_version' => ['required', 'integer', 'min:1'],
'component' => ['required', 'in:IRPC,IVA,ISPC,IRPS,INSS,RETENCOES,DIREITOS_ADUANEIROS,ICE'],
'scope_key' => ['required', 'string', 'min:2', 'max:160'],
'valid_from' => ['required', 'date_format:Y-m-d'],
'valid_until' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:valid_from'],
'legal_source_id' => ['required', 'uuid', 'exists:investment_legal_sources,id'],
'source_sha256' => ['required', 'regex:/^[a-f0-9]{64}$/'],
'rule_definition' => ['required', 'array'],
'parameters' => ['required', 'array'],
'rounding_policy' => ['required', 'array'],
]);
foreach (['rule_definition', 'parameters', 'rounding_policy'] as $key) {
$json = json_encode($data[$key], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
Validator::make(['json' => $json], ['json' => ['max:50000']])->validate();
}
if (($data['rule_definition']['language'] ?? null) === 'investmoz.tax.v1') {
try {
app(SimulationTaxRuleExpression::class)->validate($data['rule_definition']);
} catch (InvalidArgumentException $exception) {
throw ValidationException::withMessages(['rule_definition' => $exception->getMessage()]);
}
try {
app(SimulationTaxParameterResolver::class)->validateParameters($data['parameters']);
} catch (InvalidArgumentException $exception) {
throw ValidationException::withMessages(['parameters' => $exception->getMessage()]);
}
}
return $data;
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Http\Controllers;
use App\Services\TerritoryGeometryImport;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class TerritoryGeometryImportController extends Controller
{
public function preview(Request $request, TerritoryGeometryImport $service): JsonResponse|Response
{
$data = $request->validate(['level' => 'nullable|integer|in:1,2,3']);
$plan = $service->preview($request->user(), (int) ($data['level'] ?? 1));
return $request->expectsJson() ? response()->json($plan) : Inertia::render('management/territory-geometry-import', ['plan' => $plan]);
}
public function apply(Request $request, TerritoryGeometryImport $service): JsonResponse
{
return response()->json($service->apply($request->user(), $request->all()));
}
public function reverse(Request $request, string $batch, TerritoryGeometryImport $service): JsonResponse
{
return response()->json($service->reverse($request->user(), $batch, $request->all()));
}
}
@@ -0,0 +1,350 @@
<?php
namespace App\Http\Controllers;
use App\Jobs\ScanDocumentForMalware;
use App\Models\InvestmentSubmission;
use App\Models\User;
use App\Services\Ai\InvestorGuidance;
use App\Services\CountryCatalogue;
use App\Services\InstitutionalLinkMonitor;
use App\Services\InvestmentGeography;
use App\Services\InvestorContextConsumers;
use App\Services\VerifiedDocumentBinary;
use App\Services\VirtualDesk;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\StreamedResponse;
class VirtualDeskController extends Controller
{
public function catalogue(Request $request, VirtualDesk $desk): JsonResponse|Response
{
$filters = $request->validate(['q' => 'nullable|string|max:120', 'theme' => 'nullable|string|max:80', 'profile' => 'nullable|string|max:100']);
$installed = $desk->installed();
$services = $installed ? DB::table('desk_service_versions')->where('status', 'published')->orderBy('code')->orderByDesc('version')->get()->unique('code')->map(fn ($s) => ['id' => $s->id, 'version' => $s->version, 'definition' => json_decode($s->definition, true), 'source' => json_decode($s->source, true)])->values() : collect();
$services = $services->filter(fn ($s) => (! ($filters['q'] ?? '') || str_contains(mb_strtolower(json_encode($s['definition'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)), mb_strtolower($filters['q'])))
&& (! ($filters['theme'] ?? '') || $s['definition']['theme'] === $filters['theme']) && (! ($filters['profile'] ?? '') || $s['definition']['profile'] === $filters['profile']))->values();
$page = max(1, (int) $request->query('page', 1));
$data = ['installed' => $installed, 'services' => $services->slice(($page - 1) * 12, 12)->values(), 'total' => $services->count(), 'page' => $page, 'filters' => $filters,
'prepared' => $installed ? [] : config('virtual-desk.services'), 'source' => config('virtual-desk.source'), 'authenticated' => (bool) $request->user(), 'support' => config('portal.support')];
return $request->expectsJson() ? response()->json($data) : Inertia::render('desk/catalogue', $data);
}
public function panel(Request $request, VirtualDesk $desk): JsonResponse|Response
{
$actor = $request->user();
$organizations = DB::table('memberships')->where('user_id', $actor->id)->whereNull('deleted_at')->pluck('organization_id');
$cases = DB::table('desk_cases')->where(fn ($q) => $q->where(fn ($p) => $p->where('user_id', $actor->id)->whereNull('organization_id'))->orWhereIn('organization_id', $organizations))->orderByDesc('updated_at')->get(['id', 'title', 'version', 'updated_at']);
$queue = DB::table('desk_requests as r')->join('desk_cases as c', 'c.id', '=', 'r.case_id')->whereNotIn('r.status', ['draft', 'cancelled'])->when(! $actor->can('manage-portal'), fn ($q) => $q->where('r.analyst_id', $actor->id))
->orderByRaw('r.due_at ASC NULLS LAST')->get(['r.id', 'r.protocol', 'r.status', 'r.version', 'r.analyst_id', 'r.due_at', 'c.title']);
$data = ['cases' => $cases, 'queue' => $queue, 'notifications' => DB::table('desk_notifications')->where('user_id', $actor->id)->orderByDesc('id')->limit(100)->get(),
'legacyRequests' => DB::table('interest_requests')->where('metadata->portal_user_id', $actor->id)->whereNull('deleted_at')->orderByDesc('created_at')->get(['id', 'reference', 'status', 'created_at']),
'companies' => DB::table('investment_company_declarations')->where('user_id', $actor->id)->get(['id', 'payload'])->map(fn ($c) => ['id' => $c->id, 'name' => $desk->decode($c->payload)['name'] ?? 'Declaração empresarial']),
'submissions' => InvestmentSubmission::where('user_id', $actor->id)->get()->map(fn ($s) => ['id' => $s->id, 'status' => $s->status, 'name' => ($s->opportunity_id ? DB::table('opportunities')->where('id', $s->opportunity_id)->value('title') : null) ?? ($s->dossier_id ? DB::table('investment_dossiers')->where('id', $s->dossier_id)->value('title') : null) ?? ($s->payload['organisation'] ?? null)]),
'journeys' => DB::table('investor_journeys as j')->leftJoin('investment_dossiers as d', 'd.id', '=', 'j.dossier_id')->where('j.user_id', $actor->id)->whereNull('j.deleted_at')->get(['j.id', 'j.dossier_id', 'j.status', 'd.title as name']),
'simulationDrafts' => DB::table('simulation_form_drafts')->where('owner_user_id', $actor->id)->whereNull('deleted_at')->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()))->get(['id', 'version_no']),
'guidanceTasks' => app(InvestorGuidance::class)->installed() ? DB::table('tasks as t')->join('desk_cases as c', 'c.id', '=', 't.desk_case_id')
->whereNull('t.deleted_at')->when(! $actor->can('manage-portal'), fn ($q) => $q->where(fn ($p) => $p->where('t.assignee_user_id', $actor->id)->orWhere(fn ($s) => $s->where('c.user_id', $actor->id)->whereNull('c.organization_id'))->orWhereIn('c.organization_id', $organizations)))
->orderByRaw('t.due_at ASC NULLS LAST')->get(['t.id', 't.title', 't.status', 't.due_at', 'c.id as case_id', 'c.title as case_title']) : [],
'organizations' => DB::table('organizations')->whereIn('id', $organizations)->get(['id', 'name']), 'manager' => $actor->can('manage-portal'), 'states' => config('virtual-desk.request_states')];
return $request->expectsJson() ? response()->json($data) : Inertia::render('desk/panel', $data);
}
public function createCase(Request $request, VirtualDesk $desk): JsonResponse|RedirectResponse
{
$case = $desk->createCase($request->user(), $request->all());
return $request->expectsJson() ? response()->json(['id' => $case->id, 'version' => $case->version], 201) : redirect('/balcao-virtual/dossiers/'.$case->id)
->with('success', 'Dossier guardado e disponível para continuar. Próxima ação: escolha um serviço publicado e prepare o rascunho de apoio. Nenhum pedido foi submetido.');
}
public function showCase(Request $request, string $id, VirtualDesk $desk): JsonResponse|Response
{
$case = $desk->case($request->user(), $id);
$owner = $desk->owns($request->user(), $case, false);
$this->auditRead($request, 'desk_cases', $id);
$hidden = $owner ? ['_creation_hash'] : ['_creation_hash', 'profile', 'company', 'investment', 'name', 'email'];
if ((int) $case->user_id !== $request->user()->id) {
$hidden[] = 'investor_path';
}
$data = ['case' => [...(array) $case, 'payload' => Arr::except($desk->decode($case->payload), $hidden), 'workflow' => json_decode($case->workflow, true)],
'legacyRequest' => ($case->interest_request_id ?? null) ? DB::table('interest_requests as r')->leftJoin('users as u', 'u.id', '=', 'r.assigned_to')->where('r.id', $case->interest_request_id)->whereNull('r.deleted_at')
->when(! $request->user()->can('manage-portal'), fn ($q) => $q->where('r.metadata->portal_user_id', $request->user()->id))
->first(['r.id', 'r.reference', 'r.status', 'r.next_action', 'r.next_action_due_at', 'u.name as responsible_name']) : null,
'owner' => $owner, 'canWrite' => $desk->owns($request->user(), $case), 'manager' => $request->user()->can('manage-portal'),
'requests' => DB::table('desk_requests')->where('case_id', $id)->when(! $owner && ! $request->user()->can('manage-portal'), fn ($q) => $q->where('analyst_id', $request->user()->id))->orderByDesc('updated_at')->get(['id', 'status', 'protocol', 'service_version_id', 'version']),
'services' => DB::table('desk_service_versions')->where('status', 'published')->orderByDesc('version')->get(['id', 'code', 'version', 'definition'])->unique('code')->values()->map(fn ($s) => ['id' => $s->id, 'title' => json_decode($s->definition, true)['title'], 'version' => $s->version]),
'documents' => $this->documents($id, $request->user()), 'events' => $this->events($id, $desk, $request->user()), 'stepStates' => config('virtual-desk.step_states'), 'states' => config('virtual-desk.request_states'),
'links' => DB::table('desk_institutional_links')->get()->map(fn ($link) => [...(array) $link, 'monitor' => app(InstitutionalLinkMonitor::class)->status($link)]), 'source' => config('virtual-desk.source'), 'support' => config('portal.support'),
'guidance' => app(InvestorGuidance::class)->installed() ? app(InvestorGuidance::class)->view($request->user(), $id) : null];
return $request->expectsJson() ? response()->json($data) : Inertia::render('desk/case', $data);
}
/** @return Collection<int,\stdClass> */
private function documents(string $case, User $actor): Collection
{
return DB::table('desk_documents as l')->join('documents as d', 'd.id', '=', 'l.document_id')->where('l.case_id', $case)->whereNull('d.deleted_at')
->when(! app(VirtualDesk::class)->owns($actor, app(VirtualDesk::class)->case($actor, $case), false) && ! $actor->can('manage-portal'), fn ($q) => $q->where(fn ($p) => $p->whereNull('l.request_id')->orWhereIn('l.request_id', DB::table('desk_requests')->where('analyst_id', $actor->id)->select('id'))))
->get(['l.id', 'l.document_id', 'l.request_id', 'l.category', 'l.stage', 'l.source_reference', 'l.valid_until', 'd.file_name', 'd.status', 'd.malware_scan_status', 'd.sha256']);
}
/** @return Collection<int,array{id:mixed,request_id:mixed,action:mixed,version:mixed,created_at:mixed,name:mixed,payload:array<string,mixed>}> */
private function events(string $case, VirtualDesk $desk, User $actor): Collection
{
return DB::table('desk_events as e')->join('users as u', 'u.id', '=', 'e.actor_id')->where('e.case_id', $case)
->when(! $desk->owns($actor, $desk->case($actor, $case), false) && ! $actor->can('manage-portal'), fn ($q) => $q->where(fn ($p) => $p->whereNull('e.request_id')->orWhereIn('e.request_id', DB::table('desk_requests')->where('analyst_id', $actor->id)->select('id'))))
->orderBy('e.event_order')->get(['e.id', 'e.request_id', 'e.action', 'e.payload', 'e.version', 'e.created_at', 'u.name'])->map(fn ($e) => ['id' => $e->id, 'request_id' => $e->request_id, 'action' => $e->action, 'version' => $e->version, 'created_at' => $e->created_at, 'name' => $e->name, 'payload' => $desk->decode($e->payload)]);
}
public function stage(Request $request, string $id, VirtualDesk $desk): JsonResponse|RedirectResponse
{
$case = $desk->stage($request->user(), $id, $request->all());
return $request->expectsJson() ? response()->json(['version' => $case->version]) : back()->with('success', 'Etapa e evidência registadas.');
}
public function createRequest(Request $request, string $id, VirtualDesk $desk): JsonResponse|RedirectResponse
{
$data = $request->validate(['service_version_id' => 'required|uuid', 'client_key' => 'required|uuid', 'opportunity' => 'nullable|string|max:100']);
$row = $desk->createRequest($request->user(), $id, $data['service_version_id'], $data['client_key'], $data['opportunity'] ?? null);
return $request->expectsJson() ? response()->json(['id' => $row->id, 'version' => $row->version], 201) : redirect('/balcao-virtual/pedidos/'.$row->id);
}
public function showRequest(Request $request, string $id, VirtualDesk $desk): JsonResponse|Response
{
$row = $desk->request($request->user(), $id);
$case = $desk->case($request->user(), $row->case_id);
$payload = $desk->decode($row->payload);
$definition = $desk->definition($row);
$territoryPaths = [];
foreach ($definition['fields'] as $key => $field) {
if ($field['type'] === 'territory') {
$territoryPaths[$key] = app(InvestmentGeography::class)->path($payload[$key] ?? null);
}
}
$messageKinds = [];
foreach (DB::table('desk_events')->where('request_id', $id)->where('action', 'message_added')->get() as $event) {
$original = $desk->decode($event->payload);
if (isset($original['message_id'], $original['kind'])) {
$messageKinds[$original['message_id']] = $original['kind'];
}
}
$this->auditRead($request, 'desk_requests', $id);
$data = ['boundContextRevision' => app(InvestorContextConsumers::class)->requestRevision($row), 'countries' => app(CountryCatalogue::class)->all(), 'toolPrefill' => $desk->requestToolPrefill($request->user(), $row), 'request' => [...(array) $row, 'payload' => $payload, 'submitted_snapshot' => $desk->decode($row->submitted_snapshot), 'editable_fields' => json_decode($row->editable_fields ?? '[]', true)],
'case' => ['id' => $case->id, 'title' => $case->title], 'caseVersion' => $case->version, 'definition' => $definition, 'territoryPaths' => $territoryPaths,
'serviceSource' => json_decode(DB::table('desk_service_versions')->where('id', $row->service_version_id)->value('source'), true),
'owner' => $desk->owns($request->user(), $case), 'manager' => app(\App\Services\ScopedPortalAdministration::class)->deskDecision($request->user(),$row), 'analyst' => (int) $row->analyst_id === $request->user()->id,
'fieldsEditable' => in_array($row->status, ['draft', 'returned'], true), 'states' => config('virtual-desk.request_states'), 'transitions' => config('virtual-desk.transitions.'.$row->status, []),
'findings' => $desk->findings($payload), 'ai' => config('virtual-desk.ai'), 'documents' => $this->documents($case->id, $request->user())->filter(fn ($d) => ! $d->request_id || $d->request_id === $id)->values(),
'messages' => DB::table('desk_messages as m')->join('users as u', 'u.id', '=', 'm.actor_id')->where('request_id', $id)->orderBy('m.created_at')->get(['m.id', 'm.actor_id', 'm.body', 'm.created_at', 'u.name'])->map(fn ($m) => [...(array) $m, 'body' => Crypt::decryptString($m->body),
'kind' => $messageKinds[$m->id] ?? 'historical_unclassified']),
'events' => $this->events($case->id, $desk, $request->user())->filter(fn ($e) => $e['request_id'] === $id)->values(),
'revisions' => DB::table('desk_revisions')->where('request_id', $id)->orderByDesc('version')->get(['version', 'snapshot', 'created_at'])->map(fn ($r) => [...(array) $r, 'snapshot' => $desk->decode($r->snapshot)]),
'payments' => DB::table('desk_payments')->where('request_id', $id)->get(),
'analysts' => $request->user()->can('manage-portal') ? User::orderBy('name')->get(['id', 'name']) : [],
'territories' => app(InvestmentGeography::class)->publicReferences()->orderBy('name')->get(['id', 'name', 'location_type'])];
return $request->expectsJson() ? response()->json($data) : Inertia::render('desk/request', $data);
}
public function save(Request $request, string $id, VirtualDesk $desk): JsonResponse|RedirectResponse
{
$row = $desk->save($request->user(), $id, $request->all(), $request->boolean('submit'));
return $request->expectsJson() ? response()->json(['id' => $row->id, 'version' => $row->version, 'status' => $row->status, 'protocol' => $row->protocol]) : back()->with('success', $request->boolean('submit') ? 'Pedido submetido; protocolo disponível.' : 'Rascunho guardado.');
}
public function transition(Request $request, string $id, VirtualDesk $desk): JsonResponse|RedirectResponse
{
$row = $desk->transition($request->user(), $id, $request->all());
return $request->expectsJson() ? response()->json(['version' => $row->version, 'status' => $row->status]) : back()->with('success', 'Decisão interna registada.');
}
public function assignments(Request $request): JsonResponse|Response
{
abort_unless(app(\App\Services\ScopedPortalAdministration::class)->administrative($request->user()),403);
// Triage projection deliberately excludes payloads, documents, investor identity and case titles.
$data=['requests'=>DB::table('desk_requests')->whereNotIn('status',['draft','cancelled','closed'])->orderBy('created_at')->get(['id','protocol','status','version','analyst_id','due_at']),
'analysts'=>User::whereNotNull('email_verified_at')->orderBy('name')->get(['id','name'])];
return $request->expectsJson()?response()->json($data):Inertia::render('management/desk-assignments',$data);
}
public function assign(Request $request, string $id, VirtualDesk $desk): JsonResponse|RedirectResponse
{
abort_unless(app(\App\Services\ScopedPortalAdministration::class)->administrative($request->user()), 403);
$data = $request->validate(['version' => 'required|integer', 'analyst_id' => 'required|integer|exists:users,id', 'reason' => 'required|string|min:3|max:3000', 'due_at' => 'nullable|date'], ['reason.required'=>'Explique o motivo da atribuição.','reason.min'=>'Explique o motivo da atribuição com pelo menos 3 caracteres.','analyst_id.required'=>'Seleccione o responsável.','analyst_id.exists'=>'Seleccione um responsável com conta disponível.']);
DB::transaction(function () use ($request, $id, $desk, $data) {
$row = DB::table('desk_requests')->where('id',$id)->lockForUpdate()->first();
abort_unless($row,404);
$case = DB::table('desk_cases')->where('id',$row->case_id)->first();
abort_unless($case,404);
if ((int)$row->version !== (int)$data['version']) throw \Illuminate\Validation\ValidationException::withMessages(['version'=>'Este pedido foi actualizado. Recupere a lista e tente novamente.']);
if (in_array($row->status,['draft','cancelled','closed'],true)) throw \Illuminate\Validation\ValidationException::withMessages(['status'=>'Este pedido já não permite uma nova atribuição. Recupere a lista actual.']);
$analyst = User::where('id', $data['analyst_id'])->firstOrFail();
abort_unless($analyst->hasVerifiedEmail(), 422, 'O responsável deve ter conta verificada.');
abort_if($desk->owns($analyst, $case, false), 422, 'O proponente não pode ser o analista.');
DB::table('desk_requests')->where('id', $id)->update(['analyst_id' => $analyst->id, 'due_at' => $data['due_at'] ?? null, 'version' => $row->version + 1, 'updated_at' => now()]);
$desk->event($case, $request->user(), 'analyst_assigned', [...$data, 'deadline_basis' => 'internal_target_not_legal_deadline'], DB::table('desk_requests')->where('id', $id)->first());
});
return $request->expectsJson() ? response()->json(['ok' => true]) : back()->with('success', 'Analista e meta interna atribuídos.');
}
public function message(Request $request, string $id, VirtualDesk $desk): JsonResponse|RedirectResponse
{
$data = $request->validate(['body' => 'required|string|min:1|max:10000', 'client_key' => 'required|uuid']);
DB::transaction(function () use ($request, $id, $desk, $data) {
$row = $desk->request($request->user(), $id, false, true);
$case = $desk->case($request->user(), $row->case_id);
abort_if(in_array($row->status, ['closed', 'cancelled'], true), 409, 'Pedido encerrado.');
abort_unless($desk->owns($request->user(), $case) || $request->user()->can('manage-portal') || (int) $row->analyst_id === $request->user()->id, 403);
$existing = DB::table('desk_messages')->where('request_id', $id)->where('actor_id', $request->user()->id)->where('client_key', $data['client_key'])->first();
if ($existing) {
abort_unless(Crypt::decryptString($existing->body) === $data['body'], 409);
return;
}
$messageId = (string) Str::uuid();
DB::table('desk_messages')->insert(['id' => $messageId, 'request_id' => $id, 'actor_id' => $request->user()->id, 'client_key' => $data['client_key'], 'body' => Crypt::encryptString($data['body'])]);
$desk->event($case, $request->user(), 'message_added', ['message_id' => $messageId,
'kind' => $desk->owns($request->user(), $case) ? 'investor_message' : 'responsible_response'], $row);
});
return $request->expectsJson() ? response()->json(['ok' => true]) : back()->with('success', 'Mensagem privada guardada.');
}
public function receipt(Request $request, string $id, VirtualDesk $desk): StreamedResponse|\Illuminate\Http\Response
{
$row = $desk->request($request->user(), $id);
abort_unless($row->protocol && $row->submitted_snapshot, 404);
$snapshot = $desk->decode($row->submitted_snapshot);
$format = $request->validate(['format' => 'nullable|in:html'])['format'] ?? null;
$this->auditRead($request, 'desk_requests', $id, 'receipt_downloaded');
if ($format === 'html') {
return response()->view('desk.receipt', ['record' => $row, 'snapshot' => $snapshot, 'definition' => $desk->definition($row)], 200, ['Cache-Control' => 'private, no-store', 'X-Content-Type-Options' => 'nosniff']);
}
return response()->streamDownload(function () use ($row, $snapshot) {
echo 'Balcão Virtual — recibo de submissão interna'.PHP_EOL.$row->protocol.PHP_EOL.'Data: '.$row->submitted_at.PHP_EOL.'Versão: '.$row->version.PHP_EOL;
echo json_encode($snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
}, $row->protocol.'.txt', ['Content-Type' => 'text/plain; charset=utf-8', 'Cache-Control' => 'private, no-store']);
}
public function attach(Request $request, string $id, VirtualDesk $desk): JsonResponse|RedirectResponse
{
$data = $request->validate(['version' => 'required|integer', 'file' => 'nullable|required_without:document_id|file|mimetypes:application/pdf,image/png,image/jpeg,text/plain|max:10240',
'document_id' => 'nullable|required_without:file|uuid', 'category' => 'required|string|max:80', 'stage' => 'nullable|string|max:3', 'request_id' => 'nullable|uuid', 'valid_until' => 'nullable|date_format:Y-m-d', 'source_reference' => 'required|string|max:3000', 'consent' => 'accepted']);
$key = null;
$newId = null;
try {
DB::transaction(function () use ($request, $id, $desk, $data, &$key, &$newId) {
$case = $desk->case($request->user(), $id, true, true);
abort_if($case->closed_at, 409, 'Dossier encerrado.');
abort_unless((int) $case->version === (int) $data['version'], 409);
if ($data['stage'] ?? null) {
abort_unless(array_key_exists($data['stage'], config('virtual-desk.steps')), 422);
}
if ($data['request_id'] ?? null) {
$row = $desk->request($request->user(), $data['request_id'], true, true);
abort_unless($row->case_id === $id && in_array($row->status, ['draft', 'returned'], true), 409, 'Anexos de pedidos submetidos são imutáveis; use a devolução para corrigir.');
}
abort_if(DB::table('desk_documents')->where('case_id', $id)->count() >= 100, 422, 'Limite de anexos do dossier atingido.');
if ($request->hasFile('file')) {
$file = $request->file('file');
$hash = hash_file('sha256', $file->getRealPath());
$duplicate = DB::table('desk_documents as l')->join('documents as d', 'd.id', '=', 'l.document_id')->where('l.case_id', $id)->where('d.sha256', $hash)->first();
if ($duplicate) {
return;
}
$newId = (string) Str::uuid();
$key = $file->storeAs('virtual-desk/'.$id, $newId, 'local');
abort_unless(is_string($key) && $key !== '', 500);
DB::table('documents')->insert(['id' => $newId, 'source_system' => 'virtual-desk', 'file_name' => basename($file->getClientOriginalName()), 'media_type' => $file->getMimeType(), 'storage_key' => $key,
'sha256' => $hash, 'size_bytes' => $file->getSize(), 'uploaded_by' => $request->user()->id, 'status' => 'quarantined', 'data_classification' => 'confidential']);
$documentId = $newId;
} else {
$documentId = $data['document_id'];
$document = DB::table('documents')->where('id', $documentId)->whereNull('deleted_at')->first();
abort_unless($document !== null, 404);
$owned = (int) $document->uploaded_by === $request->user()->id || DB::table('desk_documents as l')->join('desk_cases as c', 'c.id', '=', 'l.case_id')->where('l.document_id', $documentId)->where('c.user_id', $request->user()->id)->whereNull('c.organization_id')->exists();
abort_unless($owned, 403, 'Não há autorização para reutilizar este documento.');
if (DB::table('desk_documents')->where('case_id', $id)->where('document_id', $documentId)->exists()) {
return;
}
}
DB::table('desk_documents')->insert(['id' => (string) Str::uuid(), 'case_id' => $id, 'document_id' => $documentId, 'request_id' => $data['request_id'] ?? null,
'category' => $data['category'], 'stage' => $data['stage'] ?? null, 'valid_until' => $data['valid_until'] ?? null, 'source_reference' => $data['source_reference'], 'linked_by' => $request->user()->id]);
DB::table('desk_cases')->where('id', $id)->update(['version' => $case->version + 1, 'updated_at' => now()]);
$desk->event(DB::table('desk_cases')->where('id', $id)->first(), $request->user(), 'document_linked', ['document_id' => $documentId, 'source' => $data['source_reference'], 'consent' => true]);
});
} catch (\Throwable $e) {
if ($key) {
Storage::disk('local')->delete($key);
} throw $e;
}
if ($newId) {
ScanDocumentForMalware::dispatch($newId)->afterCommit();
}
return $request->expectsJson() ? response()->json(['ok' => true]) : back()->with('success', 'Documento privado associado; consulte o estado da análise.');
}
public function download(Request $request, string $id, string $document, VirtualDesk $desk): BinaryFileResponse|\Illuminate\Http\Response
{
$desk->case($request->user(), $id);
$doc = DB::table('desk_documents as l')->join('documents as d', 'd.id', '=', 'l.document_id')->where('l.case_id', $id)->where('l.id', $document)->whereNull('d.deleted_at')->first(['d.*', 'l.request_id']);
if ($doc && $doc->request_id) {
$desk->request($request->user(), $doc->request_id);
}
abort_unless($doc && $doc->status === 'active' && $doc->malware_scan_status === 'clean' && trim($doc->sha256) === $doc->malware_scanned_sha256, 404);
$response = app(VerifiedDocumentBinary::class)->respond($doc);
$this->auditRead($request, 'documents', $doc->id, 'document_downloaded');
return $response;
}
public function notification(Request $request, int $id): RedirectResponse
{
DB::table('desk_notifications')->where('id', $id)->where('user_id', $request->user()->id)->update(['read_at' => now()]);
return back();
}
public function redirectLink(Request $request, string $id, VirtualDesk $desk): RedirectResponse
{
$case = $desk->case($request->user(), $request->validate(['case_id' => 'required|uuid'])['case_id']);
$link = DB::table('desk_institutional_links')->where('id', $id)->first();
if (! $link || ! app(InstitutionalLinkMonitor::class)->status($link)['available'] || $request->boolean('unavailable')) {
$desk->event($case, $request->user(), 'institutional_link_unavailable', ['link_id' => $id, 'contact' => $link?->contact, 'scope' => 'internal_services_remain_available']);
return redirect('/balcao-virtual/dossiers/'.$case->id)->with('warning', 'Ligação indisponível ou por verificar. Contacto alternativo: '.($link?->contact ?: (config('portal.support.email') ?: 'utilize o pedido de apoio neste dossier')).'. Pode continuar a preparação e os serviços internos.');
}
abort_unless(filter_var($link->url, FILTER_VALIDATE_URL) && parse_url($link->url, PHP_URL_SCHEME) === 'https', 422);
$desk->event($case, $request->user(), 'institutional_referral', ['link_id' => $id, 'version' => $link->version, 'purpose' => $link->purpose]);
// No personal data, case ID or protocol appended to the external URL.
return redirect()->away($link->url)->withHeaders(['Referrer-Policy' => 'no-referrer']);
}
private function auditRead(Request $request, string $type, string $id, string $action = 'consulted'): void
{
DB::table('audit_events')->insert(['source_system' => 'virtual-desk', 'entity_type' => $type, 'entity_id' => $id, 'actor_user_id' => $request->user()->id, 'action' => $action]);
}
}
@@ -0,0 +1,97 @@
<?php
namespace App\Http\Controllers;
use App\Services\VirtualDeskInstallation;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
class VirtualDeskInstallationController extends Controller
{
public function reviewLink(Request $request, string $id): JsonResponse|RedirectResponse
{
$data = $request->validate(['version' => 'required|integer|min:1', 'available' => 'required|boolean',
'evidence' => 'required|string|min:10|max:3000', 'contact' => 'nullable|string|max:255']);
DB::transaction(function () use ($request, $id, $data) {
$link = DB::table('desk_institutional_links')->where('id', $id)->lockForUpdate()->first();
abort_unless($link !== null, 404);
abort_unless((int) $link->version === (int) $data['version'], 409);
abort_unless(filter_var($link->url, FILTER_VALIDATE_URL) && parse_url($link->url, PHP_URL_SCHEME) === 'https', 422);
DB::table('desk_institutional_links')->where('id', $id)->update(['status' => $data['available'] ? 'active' : 'unavailable',
'contact' => $data['contact'] ?? $link->contact, 'verified_at' => now(), 'verified_by' => $request->user()->id,
'version' => $link->version + 1, 'updated_at' => now()]);
DB::table('audit_events')->insert(['source_system' => 'virtual-desk', 'entity_type' => 'desk_institutional_links', 'entity_id' => $id,
'actor_user_id' => $request->user()->id, 'action' => 'institutional_link_checked',
'after_data' => json_encode(['url' => $link->url, 'prior_version' => $link->version, ...$data, 'scope' => 'availability_only_not_legal_approval'])]);
});
return $request->expectsJson() ? response()->json(['checked' => true]) : back()->with('success', 'Conferência de disponibilidade registada por sete dias.');
}
public function sources(Request $request): JsonResponse|Response
{
$read = function (string $name): array {
$file = storage_path('app/private/virtual-desk/'.$name.'.json');
if (! is_file($file)) {
return [];
}
$bytes = file_get_contents($file);
abort_unless(is_string($bytes), 503);
return json_decode($bytes, true, 512, JSON_THROW_ON_ERROR);
};
$inventory = $read('source-inventory');
$forms = $read('form-register');
$data = ['total' => count($inventory), 'attachmentCount' => array_sum(array_map(fn ($item) => count($item['attachments'] ?? []), $inventory)),
'forms' => array_map(fn ($item) => ['identifier' => $item['identifier'], 'sha256' => $item['sha256'], 'size' => $item['size'], 'edition' => $item['edition'],
'provenance' => str_contains($item['path'], DIRECTORY_SEPARATOR.'Scan'.DIRECTORY_SEPARATOR) ? 'Acervo disponibilizado — digitalização' : 'Acervo disponibilizado — fonte de desenho ou template',
'consultation' => $item['consultation'], 'legal_documentary_status' => $item['legal_documentary_status'], 'rule_use' => $item['rule_use'], 'official_confirmation' => $item['official_confirmation'], 'fields_status' => $item['fields_status']], $forms),
'source' => config('virtual-desk.source')];
return $request->expectsJson() ? response()->json($data) : Inertia::render('desk/sources', $data);
}
public function preview(Request $request, VirtualDeskInstallation $installation): JsonResponse|Response
{
$data = $installation->preview();
return $request->expectsJson() ? response()->json($data) : Inertia::render('desk/install', $data);
}
public function apply(Request $request, VirtualDeskInstallation $installation): JsonResponse|RedirectResponse
{
$data = $request->validate(['hash' => 'required|string|size:64', 'confirm' => 'accepted']);
$installation->apply($data['hash'], $request->user());
return $request->expectsJson() ? response()->json(['installed' => true]) : back()->with('success', 'Instalação concluída; serviços ainda exigem publicação explícita.');
}
public function publishService(Request $request): JsonResponse|RedirectResponse
{
$data = $request->validate(['code' => ['required', Rule::in(array_keys(config('virtual-desk.services')))], 'confirm' => 'accepted']);
$row = DB::transaction(function () use ($request, $data) {
DB::select('select pg_advisory_xact_lock(261008, 2)');
$definition = config('virtual-desk.services.'.$data['code']);
$existing = DB::table('desk_service_versions')->where('code', $data['code'])->orderByDesc('version')->first();
if ($existing && $existing->status === 'published' && json_decode($existing->definition, true) === $definition) {
return $existing;
}
$id = (string) Str::uuid();
DB::table('desk_service_versions')->insert(['id' => $id, 'code' => $data['code'], 'version' => ($existing->version ?? 0) + 1, 'status' => 'published',
'definition' => json_encode($definition), 'source' => json_encode(config('virtual-desk.source')), 'author_id' => $request->user()->id,
'published_by' => $request->user()->id, 'published_at' => now(), 'created_at' => now(), 'updated_at' => now()]);
DB::table('audit_events')->insert(['source_system' => 'virtual-desk', 'entity_type' => 'desk_service_versions', 'entity_id' => $id, 'actor_user_id' => $request->user()->id,
'action' => 'internal_service_published', 'after_data' => json_encode(['code' => $data['code'], 'nature' => 'digital_preparation_not_official_form'])]);
return DB::table('desk_service_versions')->where('id', $id)->first();
});
return $request->expectsJson() ? response()->json(['id' => $row->id], 201) : back()->with('success', 'Serviço de preparação interna publicado.');
}
}
@@ -0,0 +1,136 @@
<?php
namespace App\Http\Controllers;
use App\Services\Ai\InvestorGuidanceEvidence;
use App\Services\VirtualDesk;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
/** Manual operations use the same requests, documents and encrypted audit history. */
class VirtualDeskOperationsController extends Controller
{
private function result(Request $request): JsonResponse|RedirectResponse
{
return $request->expectsJson() ? response()->json(['ok' => true]) : back()->with('success', 'Registo interno guardado; não constitui confirmação de uma instituição externa.');
}
private function reviewer(Request $request, VirtualDesk $desk, \stdClass $case, ?\stdClass $row): void
{
abort_unless(! $desk->owns($request->user(), $case, false) && ($request->user()->can('manage-portal') || ($row && (int) $row->analyst_id === $request->user()->id)), 403);
}
private function cleanDocument(string $caseId, string $documentId, ?string $requestId): \stdClass
{
$doc = DB::table('desk_documents as l')->join('documents as d', 'd.id', '=', 'l.document_id')
->where('l.case_id', $caseId)->where('d.id', $documentId)->whereNull('d.deleted_at')
->where(fn ($q) => $q->whereNull('l.request_id')->when($requestId, fn ($p) => $p->orWhere('l.request_id', $requestId)))
->first(['d.*', 'l.id as link_id']);
abort_unless($doc !== null && $doc->status === 'active' && $doc->malware_scan_status === 'clean' && trim($doc->sha256) === $doc->malware_scanned_sha256, 422, 'Documento indisponível para conferência: verifique autorização, integridade e análise antimalware.');
$path = realpath(Storage::disk('local')->path($doc->storage_key));
$root = realpath(Storage::disk('local')->path(''));
abort_unless($path && $root && str_starts_with($path, $root.DIRECTORY_SEPARATOR) && is_file($path) && is_string($hash = hash_file('sha256', $path)) && hash_equals(trim($doc->sha256), $hash), 422, 'O ficheiro original ou a sua integridade não pôde ser confirmado.');
return $doc;
}
public function documentReview(Request $request, string $id, string $document, VirtualDesk $desk): JsonResponse|RedirectResponse
{
$data = $request->validate(['version' => 'required|integer|min:1', 'request_id' => 'nullable|uuid', 'outcome' => 'required|in:internal_verified,internal_issue,contradiction_identified', 'what_checked' => 'required|string|min:3|max:3000', 'evidence' => 'required|string|min:3|max:5000', 'supports' => 'nullable|array|max:4', 'supports.*' => ['string', Rule::in(InvestorGuidanceEvidence::FIELDS)]]);
DB::transaction(function () use ($request, $id, $document, $desk, $data) {
$case = $desk->case($request->user(), $id, false, true);
abort_unless(! $case->closed_at && (int) $case->version === (int) $data['version'], 409);
$link = DB::table('desk_documents')->where('case_id', $id)->where('id', $document)->first();
abort_unless($link !== null, 404);
$requestId = $link->request_id ?: ($data['request_id'] ?? null);
$row = $requestId ? $desk->request($request->user(), $requestId, false, true) : null;
abort_if($row && $row->case_id !== $id, 422);
$this->reviewer($request, $desk, $case, $row);
$doc = $this->cleanDocument($id, $link->document_id, $requestId);
DB::table('desk_cases')->where('id', $id)->update(['version' => $case->version + 1, 'updated_at' => now()]);
$desk->event(DB::table('desk_cases')->where('id', $id)->firstOrFail(), $request->user(), 'document_reviewed', [
...$data, 'document_id' => $doc->id, 'link_id' => $link->id, 'sha256' => trim($doc->sha256), 'document_version' => (int) $doc->version_no, 'reviewer_id' => $request->user()->id,
'scope' => 'internal_documentary_check_not_official_approval_registration_or_license',
], $row);
});
return $this->result($request);
}
public function payment(Request $request, string $id, VirtualDesk $desk): JsonResponse|RedirectResponse
{
$data = $request->validate(['reference' => 'required|string|max:255', 'amount' => ['required', 'regex:/^\d{1,12}(\.\d{1,2})?$/', 'numeric', 'gt:0'], 'currency' => 'required|in:MZN,USD,EUR', 'source_reference' => 'required|string|min:3|max:3000', 'document_id' => 'nullable|uuid', 'consent' => 'accepted']);
DB::transaction(function () use ($request, $id, $desk, $data) {
$row = $desk->request($request->user(), $id, true, true);
$case = $desk->case($request->user(), $row->case_id);
abort_if(in_array($row->status, ['closed', 'cancelled'], true), 409);
if ($data['document_id'] ?? null) {
abort_unless(DB::table('desk_documents')->where('case_id', $case->id)->where('document_id', $data['document_id'])->where(fn ($q) => $q->whereNull('request_id')->orWhere('request_id', $id))->exists(), 422);
}
// References are supplied by the investor; no bank reference or fee is generated.
$existing = DB::table('desk_payments')->where('reference', $data['reference'])->first();
if ($existing) {
$parts = explode('.', (string) $data['amount']);
$amount = (ltrim($parts[0], '0') ?: '0').'.'.str_pad($parts[1] ?? '', 2, '0');
abort_unless($existing->request_id === $id && (string) $existing->amount === $amount && $existing->currency === $data['currency'] && $existing->source_reference === $data['source_reference'] && $existing->document_id === ($data['document_id'] ?? null), 409);
return;
}
$payment = (string) Str::uuid();
DB::table('desk_payments')->insert(['id' => $payment, 'request_id' => $id, 'reference' => $data['reference'], 'amount' => $data['amount'], 'currency' => $data['currency'], 'source_reference' => $data['source_reference'], 'document_id' => $data['document_id'] ?? null, 'status' => 'pending', 'created_at' => now(), 'updated_at' => now()]);
$desk->event($case, $request->user(), 'payment_declared', ['payment_id' => $payment, 'consent' => true, 'scope' => 'manual_declaration_no_bank_confirmation'], $row);
});
return $this->result($request);
}
public function paymentReview(Request $request, string $id, string $payment, VirtualDesk $desk): JsonResponse|RedirectResponse
{
$data = $request->validate(['version' => 'required|integer|min:1', 'status' => 'required|in:internal_verified,internal_issue', 'reason' => 'required|string|min:3|max:3000', 'evidence' => 'required|string|min:3|max:5000']);
DB::transaction(function () use ($request, $id, $payment, $desk, $data) {
$row = $desk->request($request->user(), $id, false, true);
$case = $desk->case($request->user(), $row->case_id);
$this->reviewer($request, $desk, $case, $row);
abort_if(in_array($row->status, ['closed', 'cancelled'], true), 409);
$record = DB::table('desk_payments')->where('request_id', $id)->where('id', $payment)->lockForUpdate()->first();
abort_unless($record !== null, 404);
abort_unless((int) $record->version === (int) $data['version'], 409);
$doc = null;
if ($data['status'] === 'internal_verified') {
abort_unless($record->document_id, 422, 'A conferência exige um comprovativo autorizado.');
$doc = $this->cleanDocument($case->id, $record->document_id, $id);
}
DB::table('desk_payments')->where('id', $payment)->update(['status' => $data['status'], 'reason' => $data['reason'], 'verified_by' => $request->user()->id, 'version' => $record->version + 1, 'updated_at' => now()]);
$desk->event($case, $request->user(), 'payment_reviewed', [...$data, 'payment_id' => $payment, 'document_id' => $doc?->id, 'sha256' => $doc ? trim($doc->sha256) : null, 'reviewer_id' => $request->user()->id, 'scope' => 'internal_proof_check_no_bank_confirmation'], $row);
});
return $this->result($request);
}
public function communication(Request $request, string $id, VirtualDesk $desk): JsonResponse|RedirectResponse
{
$data = $request->validate(['channel' => 'required|in:phone,email,sms,whatsapp,in_person', 'direction' => 'required|in:received,sent', 'occurred_at' => 'required|date|before_or_equal:now', 'summary' => 'required|string|min:3|max:5000', 'evidence' => 'required|string|min:3|max:3000', 'client_key' => 'required|uuid']);
DB::transaction(function () use ($request, $id, $desk, $data) {
$row = $desk->request($request->user(), $id, false, true);
$case = $desk->case($request->user(), $row->case_id);
abort_unless($desk->owns($request->user(), $case) || $request->user()->can('manage-portal') || (int) $row->analyst_id === $request->user()->id, 403);
abort_if(in_array($row->status, ['closed', 'cancelled'], true), 409);
foreach (DB::table('desk_events')->where('request_id', $id)->where('actor_id', $request->user()->id)->where('action', 'manual_communication_recorded')->get(['payload']) as $event) {
$saved = $desk->decode($event->payload);
if (($saved['client_key'] ?? null) === $data['client_key']) {
abort_unless(array_intersect_key($saved, $data) === $data, 409);
return;
}
}
$desk->event($case, $request->user(), 'manual_communication_recorded', [...$data, 'scope' => 'manual_record_not_portal_delivery_confirmation'], $row);
});
return $this->result($request);
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\Http\Middleware;
use App\Services\PortalProfileAccess;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
class AuditProfileDecision
{
public function handle(Request $request, Closure $next): Response
{
$access = app(PortalProfileAccess::class);
if (! $request->user() || ! $access->governanceAvailable() || ! preg_match('#^(perfil/|perfis/|minhas-oportunidades)#', $request->path())) {
return $next($request);
}
try {
$response = $next($request);
} catch (\Throwable $error) {
$status = $error instanceof HttpExceptionInterface ? $error->getStatusCode() : ($error instanceof ValidationException ? 422 : 500);
$this->record($request, $access, $status);
throw $error;
}
if ($response->getStatusCode() >= 400) {
$this->record($request, $access, $response->getStatusCode());
}
return $response;
}
private function record(Request $request, PortalProfileAccess $access, int $status): void
{
$access->audit($request->user(), 'request_denied', ['method' => $request->method(), 'path' => $request->path(), 'status' => $status]);
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Http\Middleware;
use App\Services\DocumentManagementAccess;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureDocumentManagement
{
public function handle(Request $request, Closure $next): Response
{
$actor = $request->user();
abort_unless($actor, 403);
$access = app(DocumentManagementAccess::class);
if ($access->full($actor)) {
return $next($request); // Preserve existing management controls.
}
$allowed = $access->limited($actor) || $access->editorial($actor);
$access->audit($actor, $allowed ? 'document_access_allowed' : 'document_access_denied', [
'scope' => $access->editorial($actor) ? 'approved-editorial-catalogue-and-own-files' : 'own-portal-services-files', 'method' => $request->method(), 'path' => $request->path(),
]);
abort_unless($allowed, 403, 'O acesso documental exige autorização, email confirmado e autenticação MFA nesta sessão.');
return $next($request);
}
}
@@ -0,0 +1,37 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/** Environment reservation is not a portal identity or a business permission. */
class EnsureHomologationAccess
{
public function handle(Request $request, Closure $next): Response
{
if (!config('portal.homologation_only', false)) return $next($request);
$mode=config('portal.homologation_access_mode', 'reviewers');
if ($mode==='perimeter') {
// Set only by the trusted server after its environment-access check.
// A client HTTP_INVESTMOZ_* header is deliberately not accepted.
abort_unless($request->server('INVESTMOZ_HOMOLOGATION_ACCESS')==='granted',403,
'Este ambiente de homologação tem acesso reservado à Tempo.');
$response=$next($request);
$response->headers->set('Cache-Control','private, no-store');
$response->headers->set('X-Robots-Tag','noindex, nofollow, noarchive');
return $response;
}
abort_unless($mode==='reviewers',503,'A reserva da homologação não está configurada.');
// Compatibility until an independent perimeter is explicitly configured.
if ($request->is('login','logout','register','forgot-password','reset-password','reset-password/*',
'email/verify','email/verify/*','email/verification-notification','two-factor-challenge',
'user/confirm-password','user/confirmed-password-status','user/two-factor-*',
'settings/security','settings/password','user/password','passkeys/*','user/passkeys/*','up')) return $next($request);
$actor=$request->user();
if (!$actor) return $request->expectsJson()
? response()->json(['message'=>'Entre na conta autorizada para homologação.'],401)
: redirect()->guest(route('login'));
abort_unless($actor->hasVerifiedEmail() && in_array(strtolower(trim($actor->email)),config('portal.homologation_reviewers',[]),true),403,
'Esta homologação está reservada às contas indicadas pela Tempo.');
$response=$next($request);$response->headers->set('Cache-Control','private, no-store');return $response;
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Http\Middleware;
use App\Services\PortalProfileAccess;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsurePortalProfileAdministration
{
public function handle(Request $request, Closure $next): Response
{
abort_unless(config('portal.profile_administration_enabled') || config('portal.profile_access_enabled'), 404);
abort_unless(app(PortalProfileAccess::class)->governanceAvailable(), 503, 'Autorização por perfis ainda não instalada.');
return $next($request);
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Http\Middleware;
use App\Services\PortalProfileAccess;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsurePortalProfiles
{
public function handle(Request $request, Closure $next): Response
{
abort_unless(config('portal.profile_access_enabled'), 404);
abort_unless(app(PortalProfileAccess::class)->available(), 503, 'Autorização por perfis ainda não instalada.');
return $next($request);
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureScopedEditorial
{
public function handle(Request $request, Closure $next): Response
{
$actor = $request->user();
abort_unless($actor, 403);
$access = app(\App\Services\DocumentManagementAccess::class);
if ($access->full($actor)) {
return $next($request);
}
abort_unless($access->editorial($actor), 403);
$allowed = in_array($request->route()->getName(), ['editorial.edit', 'editorial.save', 'editorial.preview', 'editorial.preview-content', 'services.resource', 'services.resource.update'], true)
|| ($request->route()->getName() === 'services.moderate' && $request->route('type') === 'resources');
abort_unless($allowed, 403);
if ($request->route('id') !== null) {
$access->assertEditableResource($actor, (int) $request->route('id'));
}
return $next($request);
}
}
@@ -0,0 +1,41 @@
<?php
namespace App\Http\Middleware;
use App\Services\PortalProfileAccess;
use App\Services\ScopedPortalAdministration;
use App\Services\SimulationTaxGovernanceAccess;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
final class EnsureScopedPortalAdministration
{
public function handle(Request $request, Closure $next): Response
{
$actor = $request->user();
abort_unless($actor !== null, 403);
$scope = app(ScopedPortalAdministration::class);
$path = $request->path();
$interests = $request->is('management/interests', 'management/interests/*', 'api/v1/investment/admin/interests/*');
// Fiscal maintenance reuses authorised administration; unrelated gates remain unchanged.
$geometryRead = $request->isMethod('GET') && $path === 'management/territory-geometries';
$catalogue = $geometryRead || $request->is('management/territory-geometry-import', 'management/territory-geometry-import/*',
'management/economic-references/lot-01', 'management/economic-references/lot-01/*',
'management/education', 'management/education/*', 'management/investment/references', 'management/investment/references/*', 'api/v1/investment/admin/references', 'api/v1/investment/admin/references/*',
'management/investment/zones', 'management/investment/zones/*', 'api/v2/investment/admin/zones', 'api/v2/investment/admin/zones/*',
'management/balcao/atribuicoes', 'management/balcao/atribuicoes/*', 'management/balcao/servicos',
'management/balcao/fontes') || ($request->isMethod('GET') && $path === 'management/balcao-install');
$fiscal = $request->is('api/v2/investment/admin/tax-rules', 'api/v2/investment/admin/tax-rules/*',
'api/v2/investment/admin/tax-rule-sources', 'management/investment/tax-rules/*');
$allowed = ($fiscal && SimulationTaxGovernanceAccess::maintainer($actor)) || ($interests ? $scope->interestOperator($actor) : ($catalogue && $scope->administrative($actor)));
abort_unless($allowed || ($actor->can('manage-portal') && ! app(PortalProfileAccess::class)->has($actor, 'auditor')), 403, 'Acesso reservado ao âmbito já designado.');
$response = $next($request);
if (! $request->isMethod('GET') && $response->getStatusCode() < 400) {
app(PortalProfileAccess::class)->audit($actor, 'scoped_administration', [
'method' => $request->method(), 'route' => $request->route()?->uri(), 'scope' => $fiscal ? 'fiscal-maintenance' : ($interests ? 'assigned-interest' : 'catalogue-administration')]);
}
return $response;
}
}
@@ -0,0 +1,94 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
final class EnsureSimulationRecoveryCookie
{
public const NAME = 'investmoz_simulation_recovery';
public const DAYS = 30;
public function handle(Request $request, Closure $next): Response
{
$token = $request->cookie(self::NAME);
$issued = ! is_string($token) || strlen($token) < 32 || strlen($token) > 256;
if (! is_string($token) || strlen($token) < 32 || strlen($token) > 256) {
$provided = $request->header('X-Simulation-Token');
$token = is_string($provided) && strlen($provided) >= 32 && strlen($provided) <= 256
? $provided
: Str::random(96);
}
$request->headers->set('X-Simulation-Token', $token);
$response = $next($request);
$forget = $request->isMethod('DELETE')
&& $request->path() === 'api/v1/simulation-drafts/current'
&& $response->getStatusCode() === 204;
$activity = $response->isSuccessful() && $this->isExplicitDraftActivity($request, $response);
$replay = $response->isSuccessful() && $this->isDraftActivityReplay($request, $response);
if (! $issued && ! $activity && ! $replay && ! $forget) {
return $response;
}
// Successful draft writes reset the server deadline to now + DAYS, so
// refresh the cookie by the same duration. Routine reads never refresh it.
$minutes = $forget ? 0 : self::DAYS * 24 * 60;
if ($replay) {
$payload = json_decode((string) $response->getContent(), true);
$expiry = is_array($payload) ? ($payload['expires_at'] ?? null) : null;
if (is_string($expiry)) {
try {
$secondsLeft = Carbon::parse($expiry)->getTimestamp() - now()->getTimestamp();
$minutes = max(1, (int) ceil($secondsLeft / 60));
} catch (\Throwable) {
// Preserve the standard cookie lifetime if the response has no valid deadline.
}
}
}
$response->headers->setCookie(cookie(
self::NAME,
$forget ? '' : $token,
$minutes,
'/api/v1',
null,
$request->isSecure(),
true,
false,
'lax',
));
return $response;
}
private function isExplicitDraftActivity(Request $request, Response $response): bool
{
if ($request->isMethod('POST') && $request->path() === 'api/v1/simulation-drafts') {
return $response->getStatusCode() === 201;
}
return ($request->isMethod('PUT') && preg_match('#^api/v1/simulation-drafts/[0-9a-f-]{36}$#i', $request->path()) === 1)
|| ($request->isMethod('POST') && preg_match('#^api/v1/simulation-drafts/[0-9a-f-]{36}/(?:conversions|touch)$#i', $request->path()) === 1);
}
private function isDraftActivityReplay(Request $request, Response $response): bool
{
if (! $request->isMethod('POST') || $response->getStatusCode() !== 200) {
return false;
}
if ($request->path() !== 'api/v1/simulation-drafts'
&& preg_match('#^api/v1/simulation-drafts/[0-9a-f-]{36}/conversions$#i', $request->path()) !== 1) {
return false;
}
$payload = json_decode((string) $response->getContent(), true);
return is_array($payload) && ($payload['data']['replayed'] ?? $payload['replayed'] ?? false) === true;
}
}
+42 -1
View File
@@ -2,6 +2,11 @@
namespace App\Http\Middleware;
use App\Models\ExistingBusiness;
use App\Services\InvestorPath;
use App\Services\LegacyContent;
use App\Services\OpportunityManagement;
use App\Services\PortalProfileAccess;
use Illuminate\Http\Request;
use Inertia\Middleware;
@@ -23,6 +28,14 @@ class HandleInertiaRequests extends Middleware
*/
public function version(Request $request): ?string
{
if (app()->environment('testing') && config('portal.qa_build_directory')) {
$manifest = public_path(config('portal.qa_build_directory').'/manifest.json');
$version = is_file($manifest) ? hash_file('xxh128', $manifest) : false;
return $version === false ? null : $version;
}
return parent::version($request);
}
@@ -35,16 +48,44 @@ class HandleInertiaRequests extends Middleware
*/
public function share(Request $request): array
{
$contentManagementAccess=app(\App\Services\ContentManagement::class)->access($request->user());
return [
...parent::share($request),
'name' => config('app.name'),
'procurementEnabled' => app(\App\Services\Procurement::class)->available(),
'homologationReserved' => (bool) config('portal.homologation_only', false),
'profileAccess' => app(PortalProfileAccess::class)->navigation($request->user()),
'contentManagementHref' => $contentManagementAccess !== [] ? '/gestao-conteudos' : null,
'contentManagementAccess'=>$contentManagementAccess,
'portalOperator' => config('portal.operator_name'),
'portalInstitutionalContactEmail' => filter_var(config('portal.institutional_contact_email'), FILTER_VALIDATE_EMAIL) ?: null,
'legacyContentEnabled' => app(LegacyContent::class)->available(),
'educationEnabled' => (bool) config('education.enabled', false),
'aiEnabled' => (bool) config('investmoz_ai.enabled', false),
'existingBusinessEnabled' => (bool) config('existing-business.enabled', false),
'flash' => ['success' => fn () => $request->session()->get('success')],
'businessReviewUrl' => config('existing-business.enabled') && $request->user()?->can('reviewQueue', ExistingBusiness::class)
? '/management/negocios' : null,
'investorPathEnabled' => app(InvestorPath::class)->available(),
'opportunityManagement' => config('portal.opportunity_dashboard_access_enabled') && $request->user() && app(PortalProfileAccess::class)->legacyWriteAllowed($request->user()) ? [
'installed' => app(OpportunityManagement::class)->installed(),
'manage' => ['label' => 'Gestão de Oportunidades', 'href' => '/management/opportunities'],
'create' => ['label' => 'Nova oportunidade', 'href' => '/management/opportunities/create'],
'installationHref' => '/management/opportunity-management-install',
] : null,
'flash' => ['success' => fn () => $request->session()->get('success'), 'warning' => fn () => $request->session()->get('warning'), 'uploaded_document_id' => fn () => $request->session()->get('uploaded_document_id')],
'scopedAdministration' => $request->user() && app(\App\Services\ScopedPortalAdministration::class)->administrative($request->user()) ? [
['label'=>'Zonas e parques','href'=>'/management/investment/zones'],
['label'=>'Referências económicas','href'=>'/management/economic-references/lot-01'],
['label'=>'Referências e formulários','href'=>'/management/investment/references'],
['label'=>'Educação e competências','href'=>'/management/education'],
['label'=>'Interesses atribuídos','href'=>'/management/interests'],
['label'=>'Atribuições de interesses','href'=>'/management/interests/atribuicoes'],
['label'=>'Atribuições do Balcão','href'=>'/management/balcao/atribuicoes'],
] : [],
'auth' => [
'user' => $request->user(),
'canManagePortal' => $request->user()?->can('manage-portal') ?? false,
'legacyManagementAccess' => $request->user()?->hasVerifiedEmail() && app(PortalProfileAccess::class)->legacyWriteAllowed($request->user()),
],
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
];
@@ -0,0 +1,25 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Schema;
use Symfony\Component\HttpFoundation\Response;
class PartnershipInstalled
{
/** @param Closure(Request):Response $next */
public function handle(Request $request, Closure $next): Response
{
if (! Schema::hasTable('partnership_interests')) {
if ($request->expectsJson()) {
return response()->json(['code' => 'partnership_installation_pending', 'message' => 'A extensão da Bolsa aguarda aplicação autenticada; o catálogo existente continua disponível.'], 503);
}
return redirect('/parcerias')->with('success', 'A extensão da Bolsa aguarda aplicação pela gestão. As propostas existentes continuam preservadas.');
}
return $next($request);
}
}
@@ -0,0 +1,40 @@
<?php
namespace App\Http\Middleware;
use App\Services\PortalProfileAccess;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
class PortalProfileSession
{
public function handle(Request $request, Closure $next): Response
{
$access = app(PortalProfileAccess::class);
$available = $access->governanceAvailable();
if ($request->user() && (config('portal.profile_access_enabled') || config('portal.profile_administration_enabled')) && ! $available) {
$recovery = $request->is('management/profile-install', 'management/profile-install/bootstrap')
|| ($request->is('logout') && $request->isMethod('POST'));
abort_if(! in_array($request->method(), ['GET', 'HEAD', 'OPTIONS'], true) && ! $recovery, 503,
'Autorizações indisponíveis: recuperar a consistência dos perfis antes de gravar. Pode terminar a sessão ou consultar o instalador.');
}
if ($request->user() && $available) {
$revision = (int) DB::table('users')->where('id', $request->user()->id)->value('portal_access_revision');
$bound = $request->session()->get('portal.access_revision');
if (($bound === null && $revision > 0) || ($bound !== null && (int) $bound !== $revision)) {
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return $request->expectsJson() ? response()->json(['message' => 'A autorização foi alterada. Inicie uma nova sessão.'], 401)
: redirect('/login')->withErrors(['email' => 'A autorização foi alterada. Inicie uma nova sessão.']);
}
$request->session()->put('portal.access_revision', $revision);
}
return $next($request);
}
}
@@ -0,0 +1,41 @@
<?php
namespace App\Http\Middleware;
use App\Services\PortalProfileAccess;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
class ProtectPrivilegedIdentity
{
public function handle(Request $request, Closure $next): Response
{
$actor = $request->user();
$access = app(PortalProfileAccess::class);
if (! $actor || ! $access->governanceAvailable()) {
return $next($request);
}
if (! in_array($request->method(), ['GET', 'HEAD', 'OPTIONS'], true) && $access->has($actor, 'auditor')
&& $request->is('management/*', 'api/v1/investment/admin/*', 'api/v2/investment/admin/*')) {
$access->audit($actor, 'auditor_administrative_write_denied', ['method' => $request->method(), 'path' => $request->path()]);
abort(403, 'Auditor só permite leitura administrativa, mesmo com outro papel ou permissão legada.');
}
$mandatoryMfa = $access->superuser($actor) || $access->has($actor, 'validator') || $access->has($actor, 'publisher');
$denied = $request->isMethod('DELETE') && $request->is('user/two-factor-authentication') && $mandatoryMfa;
$denied = $denied || ($access->superuser($actor) && $request->is('settings/profile') && $request->isMethod('PATCH')
&& $request->has('email') && $request->input('email') !== $actor->email);
if ($request->isMethod('DELETE') && $request->is('settings/profile')) {
$denied = $denied || $access->superuser($actor)
|| DB::table('portal_role_assignments')->where('user_id', $actor->id)->orWhere('approved_by', $actor->id)->exists()
|| DB::table('portal_profile_requests')->where('user_id', $actor->id)->orWhere('decided_by', $actor->id)->exists();
}
if ($denied) {
$access->audit($actor, 'privileged_identity_change_denied', ['method' => $request->method(), 'path' => $request->path()]);
abort(403, 'Esta alteração removeria MFA obrigatório, um titular ou histórico de autorização. Use revogação ou substituição controlada; não desative MFA para recuperar acesso.');
}
return $next($request);
}
}
@@ -0,0 +1,42 @@
<?php
namespace App\Http\Middleware;
use App\Models\Opportunity;
use App\Services\PortalProfileAccess;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Schema;
use Symfony\Component\HttpFoundation\Response;
class ProtectProfileOpportunity
{
public function handle(Request $request, Closure $next): Response
{
$id = null;
if (preg_match('#^(?:(?:management|api/v1)/opportunities|api/v1/investment/admin/opportunities|minhas-oportunidades|management/territories/opportunities)/(\d+)(?:/|$)#', $request->path(), $match)) {
$id = (int) $match[1];
} elseif ($request->is('management/opportunity-origin/preview', 'management/opportunity-origin/apply')) {
$id = filter_var($request->input('id'), FILTER_VALIDATE_INT) ?: null;
} elseif ($request->is('api/v2/investment/admin/zones/*/links')) {
$id = filter_var($request->input('opportunity_id'), FILTER_VALIDATE_INT) ?: null;
}
if ($request->user() && $id !== null && Schema::hasColumn('opportunities', 'owner_user_id')) {
$record = Opportunity::withTrashed()->find($id);
if ($record && $record->owner_user_id === null && ! $request->isMethod('GET')) {
abort_unless(app(PortalProfileAccess::class)->legacyWriteAllowed($request->user()), 403, 'O papel v1 não contorna os controlos através de um registo legado.');
}
if ($record && ($record->owner_user_id !== null || str_starts_with($request->path(), 'minhas-oportunidades'))) {
abort_if($record->owner_user_id === null && str_starts_with($request->path(), 'minhas-oportunidades'), 403, 'Registo legado não convertido para a política v1.');
$access = app(PortalProfileAccess::class);
// The promotional controller checks its own scoped disclosure capability,
// like workflow decisions; publication does not grant draft editing.
$access->authorize($request->user(), $request->isMethod('GET') || str_ends_with($request->path(), '/workflow') || str_ends_with($request->path(), '/preview') || $request->is('minhas-oportunidades/*/classification/review', 'minhas-oportunidades/*/divulgacao/previa', 'minhas-oportunidades/*/divulgacao/confirmar') ? 'view' : 'edit', $record);
abort_if($request->is('management/opportunity-origin/apply'), 403, 'O cadastro privado v1 não usa a classificação editorial dos exemplos legados.');
abort_if($request->isMethod('POST') && str_ends_with($request->path(), '/lifecycle') && ! str_contains($request->path(), '/resources/'), 403, 'Use o workflow com decisões segregadas; o perfil não concede exclusão.');
}
}
return $next($request);
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Http\Middleware;
use App\Models\InvestmentSubmission;
use Closure;
use Illuminate\Auth\Middleware\EnsureEmailIsVerified;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/** Tempo 19/10/2026: exception for simple participation only; formal operations retain verification. */
class VerifiedOrSimpleParticipation
{
public function handle(Request $request, Closure $next): Response
{
if ($request->user()->hasVerifiedEmail()) return $next($request);
if ($request->isMethod('GET') && $request->is('investidor/manifestacoes', 'api/v1/investment/submissions')) return $next($request);
$id = $request->route('id');
$show = $request->isMethod('GET') && $request->is('investidor/manifestacoes/'.$id, 'api/v1/investment/submissions/'.$id);
$message = $request->isMethod('POST') && $request->is('investidor/manifestacoes/'.$id.'/messages', 'api/v1/investment/submissions/'.$id.'/messages');
if ($id && ($show || $message)) {
$row = InvestmentSubmission::whereKey($id)->where('user_id', $request->user()->id)->firstOrFail();
if ($row->status === 'submitted' && ($row->payload['simple_participation'] ?? false) === true) return $next($request);
}
return app(EnsureEmailIsVerified::class)->handle($request, $next);
}
}

Some files were not shown because too many files have changed in this diff Show More