From b983b044deef7813a4b76b6c04b87908737f7882 Mon Sep 17 00:00:00 2001 From: Rob Ristroph Date: Tue, 22 Sep 2026 22:10:01 -0500 Subject: [PATCH 01/24] WS-472: Added PersonnelAndCost section, stubbed in. --- .../custom/foia_raw_data_to_report/README.md | 7 ++++ .../src/XmlReportBuilder.php | 33 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/docroot/modules/custom/foia_raw_data_to_report/README.md b/docroot/modules/custom/foia_raw_data_to_report/README.md index ee25a933e..52bcf6617 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/README.md +++ b/docroot/modules/custom/foia_raw_data_to_report/README.md @@ -624,3 +624,10 @@ For appeal rows (Column X populated), CSV validation requires E–P and T–W to be blank. Q, R, and S may contain expedited-processing data and retain their date and dependency checks. Appeal rows are exempt from the rule that S = G requires M = E, since M must remain blank on these rows. + +## Personnel and cost placeholders + +`PersonnelAndCostSection` follows fee waivers. Every uploaded component and +the agency receive all six personnel/staffing/cost fields with literal `N/A`, +since the CSV has no source data for these values. `PC1`, `PC2`, etc. link to +component organizations; `PC0` links to the agency. No CSV pass is needed. diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/XmlReportBuilder.php b/docroot/modules/custom/foia_raw_data_to_report/src/XmlReportBuilder.php index 73268515d..150cd1aad 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/src/XmlReportBuilder.php +++ b/docroot/modules/custom/foia_raw_data_to_report/src/XmlReportBuilder.php @@ -193,6 +193,8 @@ public function build(TermInterface $agency, array $components, int $fiscal_year $this->addFeeWaivers($document, $root, $fee_waivers, $component_map); } + $this->addPersonnelAndCost($document, $root, $component_map); + $xml = $document->saveXML(); if ($xml === FALSE) { throw new \RuntimeException('Unable to serialize the raw data report XML.'); @@ -652,6 +654,37 @@ private function addFeeWaivers(\DOMDocument $document, \DOMElement $root, array } } + /** + * Adds personnel and cost placeholders because CSVs lack these values. + */ + private function addPersonnelAndCost(\DOMDocument $document, \DOMElement $root, array $component_map): void { + $section = $this->addTextElement($document, $root, 'foia', 'PersonnelAndCostSection'); + $organizations = array_values($component_map); + $organizations[] = 'ORG0'; + $fields = [ + 'FullTimeEmployeeQuantity', + 'EquivalentFullTimeEmployeeQuantity', + 'TotalFullTimeStaffQuantity', + 'ProcessingCostAmount', + 'LitigationCostAmount', + 'TotalCostAmount', + ]; + foreach ($organizations as $organization_id) { + $entry = $this->addTextElement($document, $section, 'foia', 'PersonnelAndCost'); + $entry->setAttributeNS(self::NAMESPACES['s'], 's:id', 'PC' . substr($organization_id, 3)); + foreach ($fields as $name) { + $this->addTextElement($document, $entry, 'foia', $name, 'N/A'); + } + } + foreach ($organizations as $organization_id) { + $association = $this->addTextElement($document, $section, 'foia', 'PersonnelAndCostOrganizationAssociation'); + $reference = $this->addTextElement($document, $association, 'foia', 'ComponentDataReference'); + $reference->setAttributeNS(self::NAMESPACES['s'], 's:ref', 'PC' . substr($organization_id, 3)); + $organization = $this->addTextElement($document, $association, 'nc', 'OrganizationReference'); + $organization->setAttributeNS(self::NAMESPACES['s'], 's:ref', $organization_id); + } + } + /** * Appends a namespaced element and safely escapes optional text. */ From 66918de2f7b14ae9713c5cad2f7a090bb57649ac Mon Sep 17 00:00:00 2001 From: Rob Ristroph Date: Tue, 22 Sep 2026 22:29:25 -0500 Subject: [PATCH 02/24] WS-472: Added FeesCollected. --- .../custom/foia_raw_data_to_report/README.md | 13 +++ .../src/FeesCollectedAggregator.php | 93 +++++++++++++++++++ .../QueueWorker/RawDataToReportProcessing.php | 4 +- .../src/XmlReportBuilder.php | 36 ++++++- 4 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 docroot/modules/custom/foia_raw_data_to_report/src/FeesCollectedAggregator.php diff --git a/docroot/modules/custom/foia_raw_data_to_report/README.md b/docroot/modules/custom/foia_raw_data_to_report/README.md index 52bcf6617..4a99a7cce 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/README.md +++ b/docroot/modules/custom/foia_raw_data_to_report/README.md @@ -631,3 +631,16 @@ S = G requires M = E, since M must remain blank on these rows. the agency receive all six personnel/staffing/cost fields with literal `N/A`, since the CSV has no source data for these values. `PC1`, `PC2`, etc. link to component organizations; `PC0` links to the agency. No CSV pass is needed. + +## Fees collected + +`FeesCollectedAggregator` sums Column W across all rows without track, outcome, +or date filters. Blank cells contribute zero. Plain decimal amounts with up +to two decimal places (and optional sign) are summed as integer cents to avoid +floating-point drift. Unsupported formats and overflow produce contextual +exceptions rather than silently dropping or rounding data. + +`FeesCollectedSection` follows personnel and cost. Each component and the agency +receive a two-decimal `FeesCollectedAmount`, including zero, and a fixed +`FeesCollectedCostPercent` of `0.0000`. `FC1`, `FC2`, etc. and agency `FC0` +use the usual Organization associations. diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/FeesCollectedAggregator.php b/docroot/modules/custom/foia_raw_data_to_report/src/FeesCollectedAggregator.php new file mode 100644 index 000000000..c124a2120 --- /dev/null +++ b/docroot/modules/custom/foia_raw_data_to_report/src/FeesCollectedAggregator.php @@ -0,0 +1,93 @@ +parseCents($value, $context); + $components[$id] = $this->addCents($components[$id], $cents, $context); + } + if (!feof($stream)) { + throw new \RuntimeException('Unable to finish reading a CSV for fees collected.'); + } + } + finally { + fclose($stream); + } + } + $overall = 0; + foreach ($components as $amount) { + $overall = $this->addCents($overall, $amount, 'Agency fees collected'); + } + return ['components' => $components, 'overall' => $overall]; + } + + /** + * Parses plain decimal amounts exactly, allowing signed adjustments. + */ + private function parseCents(string $value, string $context): int { + if (!preg_match('/^([+-]?)([0-9]+)(?:\.([0-9]{1,2}))?$/', $value, $parts)) { + throw new \RuntimeException($context . ': Expected a decimal fee amount with at most two decimal places; received "' . $value . '".'); + } + $digits = ltrim($parts[2] . str_pad($parts[3] ?? '', 2, '0'), '0'); + $limit = (string) PHP_INT_MAX; + if (strlen($digits) > strlen($limit) || (strlen($digits) === strlen($limit) && strcmp($digits, $limit) > 0)) { + throw new \RuntimeException($context . ': Fee amount exceeds the supported range.'); + } + $cents = (int) $digits; + return $parts[1] === '-' ? -$cents : $cents; + } + + /** + * Prevents an overflowing integer sum from silently becoming a float. + */ + private function addCents(int $total, int $amount, string $context): int { + $sum = $total + $amount; + if (!is_int($sum) || $sum === PHP_INT_MIN) { + throw new \RuntimeException($context . ': Fee total exceeds the supported range.'); + } + return $sum; + } + +} diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/Plugin/QueueWorker/RawDataToReportProcessing.php b/docroot/modules/custom/foia_raw_data_to_report/src/Plugin/QueueWorker/RawDataToReportProcessing.php index 0e7f13486..b95fb7a42 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/src/Plugin/QueueWorker/RawDataToReportProcessing.php +++ b/docroot/modules/custom/foia_raw_data_to_report/src/Plugin/QueueWorker/RawDataToReportProcessing.php @@ -25,6 +25,7 @@ use Drupal\foia_raw_data_to_report\OldestPendingRequestAggregator; use Drupal\foia_raw_data_to_report\ExpeditedProcessingAggregator; use Drupal\foia_raw_data_to_report\FeeWaiverAggregator; +use Drupal\foia_raw_data_to_report\FeesCollectedAggregator; use Drupal\foia_raw_data_to_report\ProcessedResponseTimeAggregator; use Drupal\foia_raw_data_to_report\PendingPerfectedRequestsAggregator; use Drupal\foia_raw_data_to_report\AppealDispositionAggregator; @@ -203,7 +204,8 @@ protected function generateXmlReport(NodeInterface $node): void { $oldest_pending_requests = (new OldestPendingRequestAggregator())->aggregate($sources, $fiscal_year); $expedited_processing = (new ExpeditedProcessingAggregator())->aggregate($sources); $fee_waivers = (new FeeWaiverAggregator())->aggregate($sources); - $xml = (new XmlReportBuilder())->build($node->get('field_agency')->entity, $components, $fiscal_year, $statutes, $request_statistics, $dispositions, $other_reasons, $applied_exemptions, $appeal_statistics, $appeal_dispositions, $appeal_exemptions, $appeal_denials, $appeal_other_reasons, $appeal_response_times, $oldest_pending_appeals, $processed_response_times, $information_granted_response_times, $simple_response_increments, $complex_response_increments, $expedited_response_increments, $pending_perfected_requests, $oldest_pending_requests, $expedited_processing, $fee_waivers); + $fees_collected = (new FeesCollectedAggregator())->aggregate($sources); + $xml = (new XmlReportBuilder())->build($node->get('field_agency')->entity, $components, $fiscal_year, $statutes, $request_statistics, $dispositions, $other_reasons, $applied_exemptions, $appeal_statistics, $appeal_dispositions, $appeal_exemptions, $appeal_denials, $appeal_other_reasons, $appeal_response_times, $oldest_pending_appeals, $processed_response_times, $information_granted_response_times, $simple_response_increments, $complex_response_increments, $expedited_response_increments, $pending_perfected_requests, $oldest_pending_requests, $expedited_processing, $fee_waivers, $fees_collected); $field = $node->get('field_request_data_xml'); $previous_file = $field->entity; $item = $field->first() ?? $field->appendItem(); diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/XmlReportBuilder.php b/docroot/modules/custom/foia_raw_data_to_report/src/XmlReportBuilder.php index 150cd1aad..53346fba1 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/src/XmlReportBuilder.php +++ b/docroot/modules/custom/foia_raw_data_to_report/src/XmlReportBuilder.php @@ -73,11 +73,13 @@ final class XmlReportBuilder { * Component and agency counters from ExpeditedProcessingAggregator. * @param array $fee_waivers * Component and agency counters from FeeWaiverAggregator. + * @param array $fees_collected + * Component and agency totals in cents from FeesCollectedAggregator. * * @return string * The serialized report XML. */ - public function build(TermInterface $agency, array $components, int $fiscal_year, array $statutes = [], array $request_statistics = [], array $dispositions = [], array $other_reasons = [], array $applied_exemptions = [], array $appeal_statistics = [], array $appeal_dispositions = [], array $appeal_exemptions = [], array $appeal_denials = [], array $appeal_other_reasons = [], array $appeal_response_times = [], array $oldest_pending_appeals = [], array $processed_response_times = [], array $information_granted_response_times = [], array $simple_response_increments = [], array $complex_response_increments = [], array $expedited_response_increments = [], array $pending_perfected_requests = [], array $oldest_pending_requests = [], array $expedited_processing = [], array $fee_waivers = []): string { + public function build(TermInterface $agency, array $components, int $fiscal_year, array $statutes = [], array $request_statistics = [], array $dispositions = [], array $other_reasons = [], array $applied_exemptions = [], array $appeal_statistics = [], array $appeal_dispositions = [], array $appeal_exemptions = [], array $appeal_denials = [], array $appeal_other_reasons = [], array $appeal_response_times = [], array $oldest_pending_appeals = [], array $processed_response_times = [], array $information_granted_response_times = [], array $simple_response_increments = [], array $complex_response_increments = [], array $expedited_response_increments = [], array $pending_perfected_requests = [], array $oldest_pending_requests = [], array $expedited_processing = [], array $fee_waivers = [], array $fees_collected = []): string { $document = new \DOMDocument('1.0', 'UTF-8'); $document->formatOutput = TRUE; $root = $document->createElementNS(self::NAMESPACES['iepd'], 'iepd:FoiaAnnualReport'); @@ -195,6 +197,10 @@ public function build(TermInterface $agency, array $components, int $fiscal_year $this->addPersonnelAndCost($document, $root, $component_map); + if ($fees_collected !== []) { + $this->addFeesCollected($document, $root, $fees_collected, $component_map); + } + $xml = $document->saveXML(); if ($xml === FALSE) { throw new \RuntimeException('Unable to serialize the raw data report XML.'); @@ -685,6 +691,34 @@ private function addPersonnelAndCost(\DOMDocument $document, \DOMElement $root, } } + /** + * Adds exact fee totals and the unavailable cost percentage placeholder. + */ + private function addFeesCollected(\DOMDocument $document, \DOMElement $root, array $fees, array $component_map): void { + $section = $this->addTextElement($document, $root, 'foia', 'FeesCollectedSection'); + $organizations = []; + foreach ($component_map as $component_id => $organization_id) { + $organizations[$organization_id] = $fees['components'][$component_id]; + } + $organizations['ORG0'] = $fees['overall']; + foreach ($organizations as $organization_id => $cents) { + $entry = $this->addTextElement($document, $section, 'foia', 'FeesCollected'); + $entry->setAttributeNS(self::NAMESPACES['s'], 's:id', 'FC' . substr($organization_id, 3)); + // Format integer cents without converting the total to floating point. + $absolute = abs($cents); + $amount = ($cents < 0 ? '-' : '') . intdiv($absolute, 100) . '.' . str_pad((string) ($absolute % 100), 2, '0', STR_PAD_LEFT); + $this->addTextElement($document, $entry, 'foia', 'FeesCollectedAmount', $amount); + $this->addTextElement($document, $entry, 'foia', 'FeesCollectedCostPercent', '0.0000'); + } + foreach ($organizations as $organization_id => $cents) { + $association = $this->addTextElement($document, $section, 'foia', 'FeesCollectedOrganizationAssociation'); + $reference = $this->addTextElement($document, $association, 'foia', 'ComponentDataReference'); + $reference->setAttributeNS(self::NAMESPACES['s'], 's:ref', 'FC' . substr($organization_id, 3)); + $organization = $this->addTextElement($document, $association, 'nc', 'OrganizationReference'); + $organization->setAttributeNS(self::NAMESPACES['s'], 's:ref', $organization_id); + } + } + /** * Appends a namespaced element and safely escapes optional text. */ From e9662fbd9e58071fd74d12f4321f754dbb57ca31 Mon Sep 17 00:00:00 2001 From: Rob Ristroph Date: Tue, 22 Sep 2026 23:56:30 -0500 Subject: [PATCH 03/24] WS-472: Added Subsection(C), stubbed in values. --- .../custom/foia_raw_data_to_report/README.md | 8 +++++++ .../src/XmlReportBuilder.php | 23 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/docroot/modules/custom/foia_raw_data_to_report/README.md b/docroot/modules/custom/foia_raw_data_to_report/README.md index 4a99a7cce..01b49071c 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/README.md +++ b/docroot/modules/custom/foia_raw_data_to_report/README.md @@ -644,3 +644,11 @@ exceptions rather than silently dropping or rounding data. receive a two-decimal `FeesCollectedAmount`, including zero, and a fixed `FeesCollectedCostPercent` of `0.0000`. `FC1`, `FC2`, etc. and agency `FC0` use the usual Organization associations. + +## Subsection use placeholders + +`SubsectionUsedSection` follows fees collected. Every uploaded component and +the agency receive a `SubsectionUsed` entry with `TimesUsedQuantity` set to +`0`, since CSVs do not provide this information. `SU1`, `SU2`, etc. and agency +`SU0` link to Organization entries through `SubsectionUsedOrganizationAssociation`. +No additional CSV pass is needed. diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/XmlReportBuilder.php b/docroot/modules/custom/foia_raw_data_to_report/src/XmlReportBuilder.php index 53346fba1..f22b59185 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/src/XmlReportBuilder.php +++ b/docroot/modules/custom/foia_raw_data_to_report/src/XmlReportBuilder.php @@ -201,6 +201,8 @@ public function build(TermInterface $agency, array $components, int $fiscal_year $this->addFeesCollected($document, $root, $fees_collected, $component_map); } + $this->addSubsectionUsed($document, $root, $component_map); + $xml = $document->saveXML(); if ($xml === FALSE) { throw new \RuntimeException('Unable to serialize the raw data report XML.'); @@ -719,6 +721,27 @@ private function addFeesCollected(\DOMDocument $document, \DOMElement $root, arr } } + /** + * Adds zero subsection-use placeholders because CSVs lack this information. + */ + private function addSubsectionUsed(\DOMDocument $document, \DOMElement $root, array $component_map): void { + $section = $this->addTextElement($document, $root, 'foia', 'SubsectionUsedSection'); + $organizations = array_values($component_map); + $organizations[] = 'ORG0'; + foreach ($organizations as $organization_id) { + $entry = $this->addTextElement($document, $section, 'foia', 'SubsectionUsed'); + $entry->setAttributeNS(self::NAMESPACES['s'], 's:id', 'SU' . substr($organization_id, 3)); + $this->addTextElement($document, $entry, 'foia', 'TimesUsedQuantity', '0'); + } + foreach ($organizations as $organization_id) { + $association = $this->addTextElement($document, $section, 'foia', 'SubsectionUsedOrganizationAssociation'); + $reference = $this->addTextElement($document, $association, 'foia', 'ComponentDataReference'); + $reference->setAttributeNS(self::NAMESPACES['s'], 's:ref', 'SU' . substr($organization_id, 3)); + $organization = $this->addTextElement($document, $association, 'nc', 'OrganizationReference'); + $organization->setAttributeNS(self::NAMESPACES['s'], 's:ref', $organization_id); + } + } + /** * Appends a namespaced element and safely escapes optional text. */ From 0718d20e08525aeb98baa5f5b413a4d9a797c38b Mon Sep 17 00:00:00 2001 From: Rob Ristroph Date: Wed, 23 Sep 2026 12:26:25 -0500 Subject: [PATCH 04/24] WS-472: Added SubsectionPost section to XML output file. --- .../custom/foia_raw_data_to_report/README.md | 8 +++++++ .../src/XmlReportBuilder.php | 23 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/docroot/modules/custom/foia_raw_data_to_report/README.md b/docroot/modules/custom/foia_raw_data_to_report/README.md index 01b49071c..e3d4b1a48 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/README.md +++ b/docroot/modules/custom/foia_raw_data_to_report/README.md @@ -652,3 +652,11 @@ the agency receive a `SubsectionUsed` entry with `TimesUsedQuantity` set to `0`, since CSVs do not provide this information. `SU1`, `SU2`, etc. and agency `SU0` link to Organization entries through `SubsectionUsedOrganizationAssociation`. No additional CSV pass is needed. + +## Subsection posting placeholders + +`SubsectionPostSection` follows subsection use. Every uploaded component and +the agency receive a `Subsection` entry with `PostedbyFOIAQuantity` and +`PostedbyProgramQuantity` both set to `0`. `SP1`, `SP2`, etc. and agency `SP0` +link to Organization entries through `SubsectionPostOrganizationAssociation`. +These placeholders require no additional CSV processing. diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/XmlReportBuilder.php b/docroot/modules/custom/foia_raw_data_to_report/src/XmlReportBuilder.php index f22b59185..764a80280 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/src/XmlReportBuilder.php +++ b/docroot/modules/custom/foia_raw_data_to_report/src/XmlReportBuilder.php @@ -202,6 +202,7 @@ public function build(TermInterface $agency, array $components, int $fiscal_year } $this->addSubsectionUsed($document, $root, $component_map); + $this->addSubsectionPost($document, $root, $component_map); $xml = $document->saveXML(); if ($xml === FALSE) { @@ -742,6 +743,28 @@ private function addSubsectionUsed(\DOMDocument $document, \DOMElement $root, ar } } + /** + * Adds zero subsection-post placeholders because CSVs lack this information. + */ + private function addSubsectionPost(\DOMDocument $document, \DOMElement $root, array $component_map): void { + $section = $this->addTextElement($document, $root, 'foia', 'SubsectionPostSection'); + $organizations = array_values($component_map); + $organizations[] = 'ORG0'; + foreach ($organizations as $organization_id) { + $entry = $this->addTextElement($document, $section, 'foia', 'Subsection'); + $entry->setAttributeNS(self::NAMESPACES['s'], 's:id', 'SP' . substr($organization_id, 3)); + $this->addTextElement($document, $entry, 'foia', 'PostedbyFOIAQuantity', '0'); + $this->addTextElement($document, $entry, 'foia', 'PostedbyProgramQuantity', '0'); + } + foreach ($organizations as $organization_id) { + $association = $this->addTextElement($document, $section, 'foia', 'SubsectionPostOrganizationAssociation'); + $reference = $this->addTextElement($document, $association, 'foia', 'ComponentDataReference'); + $reference->setAttributeNS(self::NAMESPACES['s'], 's:ref', 'SP' . substr($organization_id, 3)); + $organization = $this->addTextElement($document, $association, 'nc', 'OrganizationReference'); + $organization->setAttributeNS(self::NAMESPACES['s'], 's:ref', $organization_id); + } + } + /** * Appends a namespaced element and safely escapes optional text. */ From 614381a7710ca16c8889e04157f1a5b19ff72fda Mon Sep 17 00:00:00 2001 From: Rob Ristroph Date: Wed, 23 Sep 2026 15:20:15 -0500 Subject: [PATCH 05/24] WS-472: Added Backlog section to XML, also adjusted some date counts to make sure they took holidays / weekends into account. --- .../custom/foia_raw_data_to_report/README.md | 23 +++- .../src/AppealResponseTimeAggregator.php | 7 +- .../src/BacklogAggregator.php | 104 ++++++++++++++++++ .../src/OldestPendingAppealAggregator.php | 5 +- .../QueueWorker/RawDataToReportProcessing.php | 4 +- .../src/XmlReportBuilder.php | 38 ++++++- 6 files changed, 170 insertions(+), 11 deletions(-) create mode 100644 docroot/modules/custom/foia_raw_data_to_report/src/BacklogAggregator.php diff --git a/docroot/modules/custom/foia_raw_data_to_report/README.md b/docroot/modules/custom/foia_raw_data_to_report/README.md index e3d4b1a48..b65718bc9 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/README.md +++ b/docroot/modules/custom/foia_raw_data_to_report/README.md @@ -478,8 +478,8 @@ Date Received are excluded; a closed date without a received date is rejected. The start is the later of Appeal Date Received and October 1 of the previous year. The end is Appeal Date Closed, or September 30 of the report year when Y is blank. -Elapsed calendar days exclude the starting day and include the ending day; -same-day completion is zero. Weekends and holidays count. +Elapsed working days exclude the starting day and include the ending day; +same-day completion is zero. Weekends and federal holidays do not count. Day-frequency maps keep memory independent of the number of rows. They yield the exact median (averaging the middle pair for even counts), average rounded @@ -497,7 +497,7 @@ blank Column Y. It retains only the ten earliest received dates per component and the ten earliest across the agency. Repeated dates remain separate items; fewer than ten pending appeals produce only the items available. -Pending days are elapsed calendar days from the actual received date through +Pending days are elapsed working days from the actual received date through September 30 of the report year, without clamping to the fiscal-year start. Same-day receipt is zero days. XML receipt dates use `YYYY-MM-DD`. `OldestPendingAppealSection` follows the appeal response times, with `OPA1`, @@ -596,7 +596,7 @@ by descending pending days, then ascending receipt date for ties. Duplicate rows remain separate entries. Empty components retain their empty container and association. `OldestPendingRequestSection` follows pending perfected requests, with ISO receipt dates and `OPR1`, `OPR2`, etc.; `OPR0` is the agency. -The existing oldest-appeal section continues using calendar days. +The oldest-appeal section also uses the shared working-day calendar. ## Expedited processing @@ -660,3 +660,18 @@ the agency receive a `Subsection` entry with `PostedbyFOIAQuantity` and `PostedbyProgramQuantity` both set to `0`. `SP1`, `SP2`, etc. and agency `SP0` link to Organization entries through `SubsectionPostOrganizationAssociation`. These placeholders require no additional CSV processing. + +## Backlog + +`BacklogAggregator` counts rows whose request or appeal interval exceeds twenty +working days (exactly twenty does not qualify). Requests use J, falling back +to I, through K; appeals use X through Y. Open rows use September 30 of the +report year. Actual start dates are retained, including prior fiscal years. +No track or disposition filter applies. Rows with no start or end dates for +an item are skipped; a closing date without a start produces an error. + +`BacklogSection` follows subsection posting, emits both counts including zeros, +and associates `BK1`, `BK2`, etc. and agency `BK0` with Organization entries. +Agency counts sum component counts. Appeal response-time summaries now also +use working days, retaining their existing fiscal-start clamp and year-end +fallback; backlog intervals use the full X-to-Y interval specified above. diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/AppealResponseTimeAggregator.php b/docroot/modules/custom/foia_raw_data_to_report/src/AppealResponseTimeAggregator.php index 4c042625d..d1cf01f5b 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/src/AppealResponseTimeAggregator.php +++ b/docroot/modules/custom/foia_raw_data_to_report/src/AppealResponseTimeAggregator.php @@ -3,7 +3,7 @@ namespace Drupal\foia_raw_data_to_report; /** - * Streams Columns X and Y into calendar-day response time distributions. + * Streams Columns X and Y into working-day response time distributions. */ final class AppealResponseTimeAggregator { @@ -22,6 +22,7 @@ public function aggregate(array $sources, int $fiscal_year): array { $timezone = new \DateTimeZone('UTC'); $start = new \DateTimeImmutable(($fiscal_year - 1) . '-10-01', $timezone); $end = new \DateTimeImmutable($fiscal_year . '-09-30', $timezone); + $working_days = new WorkingDays(); $histograms = []; foreach ($sources as $source) { $id = $source['component_id']; @@ -61,9 +62,9 @@ public function aggregate(array $sources, int $fiscal_year): array { if ($finish < $begin || $finish > $end) { throw new \RuntimeException($context . ': Response time dates must be in chronological order and within the report fiscal year.'); } - // Elapsed calendar days exclude the starting day; same-day is zero. + // Working days exclude the starting day; same-day is zero. // UTC dates avoid daylight-saving changes affecting the calculation. - $days = (int) $begin->diff($finish)->days; + $days = $working_days->count($begin->format('m/d/Y'), $finish->format('m/d/Y')); $histograms[$id][$days] = ($histograms[$id][$days] ?? 0) + 1; } if (!feof($stream)) { diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/BacklogAggregator.php b/docroot/modules/custom/foia_raw_data_to_report/src/BacklogAggregator.php new file mode 100644 index 000000000..db35ea0b3 --- /dev/null +++ b/docroot/modules/custom/foia_raw_data_to_report/src/BacklogAggregator.php @@ -0,0 +1,104 @@ + 0, 'appeals' => 0]; + $year_end = sprintf('09/30/%04d', $fiscal_year); + $working_days = new WorkingDays(); + $components = []; + foreach ($sources as $source) { + $id = $source['component_id']; + $components[$id] ??= $empty; + $stream = @fopen($source['uri'], 'rb'); + if ($stream === FALSE) { + throw new \RuntimeException('Unable to reopen a validated CSV for backlog counts.'); + } + try { + $header = TRUE; + $record = 0; + while (($columns = fgetcsv($stream, 0, ',', '"', '')) !== FALSE) { + $record++; + if ($columns === [NULL]) { + continue; + } + if (count($columns) !== CsvValidator::EXPECTED_COLUMNS) { + throw new \RuntimeException('CSV column count changed after validation.'); + } + if ($header) { + $header = FALSE; + continue; + } + $context = sprintf('Component %s, CSV %s, record %d', $id, basename($source['uri']), $record); + // Request age starts at J, falling back to I, without track filters. + $perfected = trim($columns[9]); + $request_start = $perfected !== '' ? $perfected : trim($columns[8]); + $intervals = [ + 'requests' => [$request_start, trim($columns[10]), 'J or I', 'K'], + 'appeals' => [trim($columns[23]), trim($columns[24]), 'X', 'Y'], + ]; + foreach ($intervals as $kind => [$start, $end, $start_column, $end_column]) { + // Rows without a received/perfected date do not identify an item. + if ($start === '') { + if ($end !== '') { + throw new \RuntimeException($context . ': Column ' . $end_column . ' requires a start date in Column ' . $start_column . '.'); + } + continue; + } + $start_date = $this->calendarDate($start, $context . ', Column ' . $start_column); + $end = $end === '' ? $year_end : $end; + $end_date = $this->calendarDate($end, $context . ', Column ' . $end_column); + if ($end_date < $start_date) { + throw new \RuntimeException($context . ': End date cannot precede start date for ' . $kind . '.'); + } + // Use actual receipt dates, including time before the fiscal year. + if ($working_days->count($start, $end) > 20) { + $components[$id][$kind]++; + } + } + } + if (!feof($stream)) { + throw new \RuntimeException('Unable to finish reading a CSV for backlog counts.'); + } + } + finally { + fclose($stream); + } + } + $overall = $empty; + foreach ($components as $counts) { + foreach ($counts as $code => $quantity) { + $overall[$code] += $quantity; + } + } + return ['components' => $components, 'overall' => $overall]; + } + + /** + * Parses calendar dates defensively if a file changed after validation. + */ + private function calendarDate(string $value, string $context): \DateTimeImmutable { + if (!preg_match('/^([0-9]{1,2})\/([0-9]{1,2})\/([0-9]{4})$/', $value, $parts) + || !checkdate((int) $parts[1], (int) $parts[2], (int) $parts[3])) { + throw new \RuntimeException($context . ': Expected a valid date in MM/DD/YYYY format.'); + } + return new \DateTimeImmutable(sprintf('%04d-%02d-%02d', $parts[3], $parts[1], $parts[2]), new \DateTimeZone('UTC')); + } + +} diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/OldestPendingAppealAggregator.php b/docroot/modules/custom/foia_raw_data_to_report/src/OldestPendingAppealAggregator.php index 9fd678d0d..9bdd9f09c 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/src/OldestPendingAppealAggregator.php +++ b/docroot/modules/custom/foia_raw_data_to_report/src/OldestPendingAppealAggregator.php @@ -20,6 +20,7 @@ final class OldestPendingAppealAggregator { */ public function aggregate(array $sources, int $fiscal_year): array { $end = new \DateTimeImmutable($fiscal_year . '-09-30', new \DateTimeZone('UTC')); + $working_days = new WorkingDays(); $components = []; $overall = []; foreach ($sources as $source) { @@ -54,10 +55,10 @@ public function aggregate(array $sources, int $fiscal_year): array { if ($received > $end) { throw new \RuntimeException($context . ': Appeal Date Received is after the report fiscal year.'); } - // Use the full elapsed calendar time, including prior fiscal years. + // Use elapsed working days, including time in prior fiscal years. $item = [ 'receipt_date' => $received->format('Y-m-d'), - 'pending_days' => (int) $received->diff($end)->days, + 'pending_days' => $working_days->count($received_text, $end->format('m/d/Y')), ]; $this->retainOldest($components[$id], $item); $this->retainOldest($overall, $item); diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/Plugin/QueueWorker/RawDataToReportProcessing.php b/docroot/modules/custom/foia_raw_data_to_report/src/Plugin/QueueWorker/RawDataToReportProcessing.php index b95fb7a42..1d7ed6b93 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/src/Plugin/QueueWorker/RawDataToReportProcessing.php +++ b/docroot/modules/custom/foia_raw_data_to_report/src/Plugin/QueueWorker/RawDataToReportProcessing.php @@ -26,6 +26,7 @@ use Drupal\foia_raw_data_to_report\ExpeditedProcessingAggregator; use Drupal\foia_raw_data_to_report\FeeWaiverAggregator; use Drupal\foia_raw_data_to_report\FeesCollectedAggregator; +use Drupal\foia_raw_data_to_report\BacklogAggregator; use Drupal\foia_raw_data_to_report\ProcessedResponseTimeAggregator; use Drupal\foia_raw_data_to_report\PendingPerfectedRequestsAggregator; use Drupal\foia_raw_data_to_report\AppealDispositionAggregator; @@ -205,7 +206,8 @@ protected function generateXmlReport(NodeInterface $node): void { $expedited_processing = (new ExpeditedProcessingAggregator())->aggregate($sources); $fee_waivers = (new FeeWaiverAggregator())->aggregate($sources); $fees_collected = (new FeesCollectedAggregator())->aggregate($sources); - $xml = (new XmlReportBuilder())->build($node->get('field_agency')->entity, $components, $fiscal_year, $statutes, $request_statistics, $dispositions, $other_reasons, $applied_exemptions, $appeal_statistics, $appeal_dispositions, $appeal_exemptions, $appeal_denials, $appeal_other_reasons, $appeal_response_times, $oldest_pending_appeals, $processed_response_times, $information_granted_response_times, $simple_response_increments, $complex_response_increments, $expedited_response_increments, $pending_perfected_requests, $oldest_pending_requests, $expedited_processing, $fee_waivers, $fees_collected); + $backlog = (new BacklogAggregator())->aggregate($sources, $fiscal_year); + $xml = (new XmlReportBuilder())->build($node->get('field_agency')->entity, $components, $fiscal_year, $statutes, $request_statistics, $dispositions, $other_reasons, $applied_exemptions, $appeal_statistics, $appeal_dispositions, $appeal_exemptions, $appeal_denials, $appeal_other_reasons, $appeal_response_times, $oldest_pending_appeals, $processed_response_times, $information_granted_response_times, $simple_response_increments, $complex_response_increments, $expedited_response_increments, $pending_perfected_requests, $oldest_pending_requests, $expedited_processing, $fee_waivers, $fees_collected, $backlog); $field = $node->get('field_request_data_xml'); $previous_file = $field->entity; $item = $field->first() ?? $field->appendItem(); diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/XmlReportBuilder.php b/docroot/modules/custom/foia_raw_data_to_report/src/XmlReportBuilder.php index 764a80280..23d430d2e 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/src/XmlReportBuilder.php +++ b/docroot/modules/custom/foia_raw_data_to_report/src/XmlReportBuilder.php @@ -75,11 +75,13 @@ final class XmlReportBuilder { * Component and agency counters from FeeWaiverAggregator. * @param array $fees_collected * Component and agency totals in cents from FeesCollectedAggregator. + * @param array $backlog + * Component and agency counters from BacklogAggregator. * * @return string * The serialized report XML. */ - public function build(TermInterface $agency, array $components, int $fiscal_year, array $statutes = [], array $request_statistics = [], array $dispositions = [], array $other_reasons = [], array $applied_exemptions = [], array $appeal_statistics = [], array $appeal_dispositions = [], array $appeal_exemptions = [], array $appeal_denials = [], array $appeal_other_reasons = [], array $appeal_response_times = [], array $oldest_pending_appeals = [], array $processed_response_times = [], array $information_granted_response_times = [], array $simple_response_increments = [], array $complex_response_increments = [], array $expedited_response_increments = [], array $pending_perfected_requests = [], array $oldest_pending_requests = [], array $expedited_processing = [], array $fee_waivers = [], array $fees_collected = []): string { + public function build(TermInterface $agency, array $components, int $fiscal_year, array $statutes = [], array $request_statistics = [], array $dispositions = [], array $other_reasons = [], array $applied_exemptions = [], array $appeal_statistics = [], array $appeal_dispositions = [], array $appeal_exemptions = [], array $appeal_denials = [], array $appeal_other_reasons = [], array $appeal_response_times = [], array $oldest_pending_appeals = [], array $processed_response_times = [], array $information_granted_response_times = [], array $simple_response_increments = [], array $complex_response_increments = [], array $expedited_response_increments = [], array $pending_perfected_requests = [], array $oldest_pending_requests = [], array $expedited_processing = [], array $fee_waivers = [], array $fees_collected = [], array $backlog = []): string { $document = new \DOMDocument('1.0', 'UTF-8'); $document->formatOutput = TRUE; $root = $document->createElementNS(self::NAMESPACES['iepd'], 'iepd:FoiaAnnualReport'); @@ -204,6 +206,10 @@ public function build(TermInterface $agency, array $components, int $fiscal_year $this->addSubsectionUsed($document, $root, $component_map); $this->addSubsectionPost($document, $root, $component_map); + if ($backlog !== []) { + $this->addBacklog($document, $root, $backlog, $component_map); + } + $xml = $document->saveXML(); if ($xml === FALSE) { throw new \RuntimeException('Unable to serialize the raw data report XML.'); @@ -765,6 +771,36 @@ private function addSubsectionPost(\DOMDocument $document, \DOMElement $root, ar } } + /** + * Adds request and appeal backlog counts with organization links. + */ + private function addBacklog(\DOMDocument $document, \DOMElement $root, array $statistics, array $component_map): void { + $section = $this->addTextElement($document, $root, 'foia', 'BacklogSection'); + $organizations = []; + foreach ($component_map as $component_id => $organization_id) { + $organizations[$organization_id] = $statistics['components'][$component_id]; + } + $organizations['ORG0'] = $statistics['overall']; + $fields = [ + 'requests' => 'BackloggedRequestQuantity', + 'appeals' => 'BackloggedAppealQuantity', + ]; + foreach ($organizations as $organization_id => $counts) { + $entry = $this->addTextElement($document, $section, 'foia', 'Backlog'); + $entry->setAttributeNS(self::NAMESPACES['s'], 's:id', 'BK' . substr($organization_id, 3)); + foreach ($fields as $key => $name) { + $this->addTextElement($document, $entry, 'foia', $name, (string) $counts[$key]); + } + } + foreach ($organizations as $organization_id => $counts) { + $association = $this->addTextElement($document, $section, 'foia', 'BacklogOrganizationAssociation'); + $reference = $this->addTextElement($document, $association, 'foia', 'ComponentDataReference'); + $reference->setAttributeNS(self::NAMESPACES['s'], 's:ref', 'BK' . substr($organization_id, 3)); + $organization = $this->addTextElement($document, $association, 'nc', 'OrganizationReference'); + $organization->setAttributeNS(self::NAMESPACES['s'], 's:ref', $organization_id); + } + } + /** * Appends a namespaced element and safely escapes optional text. */ From 08e40a0d2a0b8cc3e76e0e1fe723c79fc5fb68d4 Mon Sep 17 00:00:00 2001 From: Rob Ristroph Date: Wed, 23 Sep 2026 16:23:27 -0500 Subject: [PATCH 06/24] WS-472: Made Backlogged stats use the correct collumn in the CSV to get the days allowed. --- .../custom/foia_raw_data_to_report/README.md | 5 +++-- .../src/BacklogAggregator.php | 13 +++++++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/docroot/modules/custom/foia_raw_data_to_report/README.md b/docroot/modules/custom/foia_raw_data_to_report/README.md index b65718bc9..d2075140d 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/README.md +++ b/docroot/modules/custom/foia_raw_data_to_report/README.md @@ -663,8 +663,9 @@ These placeholders require no additional CSV processing. ## Backlog -`BacklogAggregator` counts rows whose request or appeal interval exceeds twenty -working days (exactly twenty does not qualify). Requests use J, falling back +`BacklogAggregator` counts rows whose request or appeal interval exceeds the +working days allowed in Column D (exactly that many days does not qualify). +Rows with blank Days Allowed are excluded from both counts. Requests use J, falling back to I, through K; appeals use X through Y. Open rows use September 30 of the report year. Actual start dates are retained, including prior fiscal years. No track or disposition filter applies. Rows with no start or end dates for diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/BacklogAggregator.php b/docroot/modules/custom/foia_raw_data_to_report/src/BacklogAggregator.php index db35ea0b3..988ebcb47 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/src/BacklogAggregator.php +++ b/docroot/modules/custom/foia_raw_data_to_report/src/BacklogAggregator.php @@ -3,7 +3,7 @@ namespace Drupal\foia_raw_data_to_report; /** - * Streams requests and appeals into counts exceeding twenty working days. + * Counts requests and appeals exceeding their allowed working days. */ final class BacklogAggregator { @@ -46,6 +46,15 @@ public function aggregate(array $sources, int $fiscal_year): array { continue; } $context = sprintf('Component %s, CSV %s, record %d', $id, basename($source['uri']), $record); + // Column D supplies the threshold for both requests and appeals. + // Consultations leave Days Allowed blank and are excluded. + $days_allowed = trim($columns[3]); + if ($days_allowed === '') { + continue; + } + if (!in_array($days_allowed, ['20', '30'], TRUE)) { + throw new \RuntimeException($context . ': Column D: Expected Days Allowed to be 20 or 30.'); + } // Request age starts at J, falling back to I, without track filters. $perfected = trim($columns[9]); $request_start = $perfected !== '' ? $perfected : trim($columns[8]); @@ -68,7 +77,7 @@ public function aggregate(array $sources, int $fiscal_year): array { throw new \RuntimeException($context . ': End date cannot precede start date for ' . $kind . '.'); } // Use actual receipt dates, including time before the fiscal year. - if ($working_days->count($start, $end) > 20) { + if ($working_days->count($start, $end) > (int) $days_allowed) { $components[$id][$kind]++; } } From eafbb55e76b235661f9e6c69efb88a081b7d3aef Mon Sep 17 00:00:00 2001 From: Rob Ristroph Date: Wed, 23 Sep 2026 17:03:13 -0500 Subject: [PATCH 07/24] WS-472: Added ProcessedConsultation section to XML report. --- .../custom/foia_raw_data_to_report/README.md | 14 ++ .../src/ConsultationStatisticsAggregator.php | 122 ++++++++++++++++++ .../QueueWorker/RawDataToReportProcessing.php | 4 +- .../src/XmlReportBuilder.php | 11 +- 4 files changed, 147 insertions(+), 4 deletions(-) create mode 100644 docroot/modules/custom/foia_raw_data_to_report/src/ConsultationStatisticsAggregator.php diff --git a/docroot/modules/custom/foia_raw_data_to_report/README.md b/docroot/modules/custom/foia_raw_data_to_report/README.md index d2075140d..538ffe45a 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/README.md +++ b/docroot/modules/custom/foia_raw_data_to_report/README.md @@ -676,3 +676,17 @@ and associates `BK1`, `BK2`, etc. and agency `BK0` with Organization entries. Agency counts sum component counts. Appeal response-time summaries now also use working days, retaining their existing fiscal-start clamp and year-end fallback; backlog intervals use the full X-to-Y interval specified above. + +## Processed consultations + +`ConsultationStatisticsAggregator` streams rows with Column C equal to `Y`. +Column I before October 1 counts as pending at start; October 1 through +September 30 (inclusive) counts as received. Column K within those inclusive +boundaries counts as processed; blank K or K after September 30 counts as +pending at end. Blank I contributes to neither receipt-date counter. + +`ProcessedConsultationSection` emits all four counters, including zeros, for +each component and the agency total. `PCN1`, `PCN2`, etc. and `PCN0` link to the +Organization entries through `ProcessingStatisticsOrganizationAssociation`. +Existing CSV validation rules remain in effect, including the restriction on +completion dates outside the fiscal year. diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/ConsultationStatisticsAggregator.php b/docroot/modules/custom/foia_raw_data_to_report/src/ConsultationStatisticsAggregator.php new file mode 100644 index 000000000..09a8fed03 --- /dev/null +++ b/docroot/modules/custom/foia_raw_data_to_report/src/ConsultationStatisticsAggregator.php @@ -0,0 +1,122 @@ + 0, + 'received' => 0, + 'processed' => 0, + 'pending_end' => 0, + ]; + + /** + * Returns component counters and agency totals without retaining CSV rows. + * + * @param array $sources + * Component/file pairs, each with component_id and uri keys. + * @param int $fiscal_year + * The validated report year. + * + * @return array + * Component counts keyed by entity ID, plus overall totals. + */ + public function aggregate(array $sources, int $fiscal_year): array { + $start = ($fiscal_year - 1) * 10000 + 1001; + $end = $fiscal_year * 10000 + 930; + $components = []; + foreach ($sources as $source) { + $counts = self::EMPTY_COUNTS; + $stream = @fopen($source['uri'], 'rb'); + if ($stream === FALSE) { + throw new \RuntimeException('Unable to reopen a validated CSV for consultation statistics.'); + } + try { + $header = TRUE; + $record = 0; + while (($columns = fgetcsv($stream, 0, ',', '"', '')) !== FALSE) { + $record++; + if ($columns === [NULL]) { + continue; + } + if (count($columns) !== CsvValidator::EXPECTED_COLUMNS) { + throw new \RuntimeException('CSV column count changed after validation.'); + } + if ($header) { + $header = FALSE; + continue; + } + // Only Column C = Y identifies a consultation. + if (trim($columns[2]) !== 'Y') { + continue; + } + $context = sprintf('Component %s, CSV %s, record %d', $source['component_id'], basename($source['uri']), $record); + // Count receipt dates before or within the inclusive fiscal year. + $received = trim($columns[8]); + if ($received !== '') { + $received_date = $this->calendarDate($received, $context . ', Column I (Date Initially Received)'); + if ($received_date < $start) { + $counts['pending_start']++; + } + elseif ($received_date <= $end) { + $counts['received']++; + } + } + + // Consultations closed after year-end or still open are pending. + $completed = trim($columns[10]); + if ($completed === '') { + $counts['pending_end']++; + } + else { + $completed_date = $this->calendarDate($completed, $context . ', Column K (Date Completed)'); + if ($completed_date >= $start && $completed_date <= $end) { + $counts['processed']++; + } + elseif ($completed_date > $end) { + $counts['pending_end']++; + } + } + } + if (!feof($stream)) { + throw new \RuntimeException('Unable to finish reading a CSV for consultation statistics.'); + } + } + finally { + fclose($stream); + } + $id = $source['component_id']; + $components[$id] ??= self::EMPTY_COUNTS; + foreach ($counts as $key => $value) { + $components[$id][$key] += $value; + } + } + + $overall = self::EMPTY_COUNTS; + foreach ($components as $counts) { + foreach ($counts as $key => $value) { + $overall[$key] += $value; + } + } + return ['components' => $components, 'overall' => $overall]; + } + + /** + * Converts a validated date to YYYYMMDD, guarding against changed CSV data. + */ + private function calendarDate(string $value, string $context): int { + if (!preg_match('/^([0-9]{1,2})\/([0-9]{1,2})\/([0-9]{4})$/', $value, $parts) + || !checkdate((int) $parts[1], (int) $parts[2], (int) $parts[3])) { + throw new \RuntimeException($context . ': CSV contains an invalid date after validation: ' . ($value === '' ? '[blank]' : $value) . '. Expected MM/DD/YYYY.'); + } + return (int) $parts[3] * 10000 + (int) $parts[1] * 100 + (int) $parts[2]; + } + +} diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/Plugin/QueueWorker/RawDataToReportProcessing.php b/docroot/modules/custom/foia_raw_data_to_report/src/Plugin/QueueWorker/RawDataToReportProcessing.php index 1d7ed6b93..0f36e10c0 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/src/Plugin/QueueWorker/RawDataToReportProcessing.php +++ b/docroot/modules/custom/foia_raw_data_to_report/src/Plugin/QueueWorker/RawDataToReportProcessing.php @@ -16,6 +16,7 @@ use Drupal\foia_raw_data_to_report\XmlReportBuilder; use Drupal\foia_raw_data_to_report\StatuteAggregator; use Drupal\foia_raw_data_to_report\RequestStatisticsAggregator; +use Drupal\foia_raw_data_to_report\ConsultationStatisticsAggregator; use Drupal\foia_raw_data_to_report\DispositionAggregator; use Drupal\foia_raw_data_to_report\OtherDenialReasonAggregator; use Drupal\foia_raw_data_to_report\AppliedExemptionsAggregator; @@ -207,7 +208,8 @@ protected function generateXmlReport(NodeInterface $node): void { $fee_waivers = (new FeeWaiverAggregator())->aggregate($sources); $fees_collected = (new FeesCollectedAggregator())->aggregate($sources); $backlog = (new BacklogAggregator())->aggregate($sources, $fiscal_year); - $xml = (new XmlReportBuilder())->build($node->get('field_agency')->entity, $components, $fiscal_year, $statutes, $request_statistics, $dispositions, $other_reasons, $applied_exemptions, $appeal_statistics, $appeal_dispositions, $appeal_exemptions, $appeal_denials, $appeal_other_reasons, $appeal_response_times, $oldest_pending_appeals, $processed_response_times, $information_granted_response_times, $simple_response_increments, $complex_response_increments, $expedited_response_increments, $pending_perfected_requests, $oldest_pending_requests, $expedited_processing, $fee_waivers, $fees_collected, $backlog); + $consultation_statistics = (new ConsultationStatisticsAggregator())->aggregate($sources, $fiscal_year); + $xml = (new XmlReportBuilder())->build($node->get('field_agency')->entity, $components, $fiscal_year, $statutes, $request_statistics, $dispositions, $other_reasons, $applied_exemptions, $appeal_statistics, $appeal_dispositions, $appeal_exemptions, $appeal_denials, $appeal_other_reasons, $appeal_response_times, $oldest_pending_appeals, $processed_response_times, $information_granted_response_times, $simple_response_increments, $complex_response_increments, $expedited_response_increments, $pending_perfected_requests, $oldest_pending_requests, $expedited_processing, $fee_waivers, $fees_collected, $backlog, $consultation_statistics); $field = $node->get('field_request_data_xml'); $previous_file = $field->entity; $item = $field->first() ?? $field->appendItem(); diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/XmlReportBuilder.php b/docroot/modules/custom/foia_raw_data_to_report/src/XmlReportBuilder.php index 23d430d2e..c470c10c3 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/src/XmlReportBuilder.php +++ b/docroot/modules/custom/foia_raw_data_to_report/src/XmlReportBuilder.php @@ -77,11 +77,13 @@ final class XmlReportBuilder { * Component and agency totals in cents from FeesCollectedAggregator. * @param array $backlog * Component and agency counters from BacklogAggregator. + * @param array $consultation_statistics + * Consultation counters by component and overall. * * @return string * The serialized report XML. */ - public function build(TermInterface $agency, array $components, int $fiscal_year, array $statutes = [], array $request_statistics = [], array $dispositions = [], array $other_reasons = [], array $applied_exemptions = [], array $appeal_statistics = [], array $appeal_dispositions = [], array $appeal_exemptions = [], array $appeal_denials = [], array $appeal_other_reasons = [], array $appeal_response_times = [], array $oldest_pending_appeals = [], array $processed_response_times = [], array $information_granted_response_times = [], array $simple_response_increments = [], array $complex_response_increments = [], array $expedited_response_increments = [], array $pending_perfected_requests = [], array $oldest_pending_requests = [], array $expedited_processing = [], array $fee_waivers = [], array $fees_collected = [], array $backlog = []): string { + public function build(TermInterface $agency, array $components, int $fiscal_year, array $statutes = [], array $request_statistics = [], array $dispositions = [], array $other_reasons = [], array $applied_exemptions = [], array $appeal_statistics = [], array $appeal_dispositions = [], array $appeal_exemptions = [], array $appeal_denials = [], array $appeal_other_reasons = [], array $appeal_response_times = [], array $oldest_pending_appeals = [], array $processed_response_times = [], array $information_granted_response_times = [], array $simple_response_increments = [], array $complex_response_increments = [], array $expedited_response_increments = [], array $pending_perfected_requests = [], array $oldest_pending_requests = [], array $expedited_processing = [], array $fee_waivers = [], array $fees_collected = [], array $backlog = [], array $consultation_statistics = []): string { $document = new \DOMDocument('1.0', 'UTF-8'); $document->formatOutput = TRUE; $root = $document->createElementNS(self::NAMESPACES['iepd'], 'iepd:FoiaAnnualReport'); @@ -209,6 +211,9 @@ public function build(TermInterface $agency, array $components, int $fiscal_year if ($backlog !== []) { $this->addBacklog($document, $root, $backlog, $component_map); } + if ($consultation_statistics !== []) { + $this->addProcessingStatistics($document, $root, $consultation_statistics, $component_map, 'ProcessedConsultationSection', 'PCN'); + } $xml = $document->saveXML(); if ($xml === FALSE) { @@ -256,7 +261,7 @@ private function addStatutes(\DOMDocument $document, \DOMElement $root, array $s } /** - * Adds request or appeal counters and their organization references. + * Adds processing counters and their organization references. */ private function addProcessingStatistics(\DOMDocument $document, \DOMElement $root, array $statistics, array $component_map, string $section_name, string $prefix): void { $section = $this->addTextElement($document, $root, 'foia', $section_name); @@ -272,7 +277,7 @@ private function addProcessingStatistics(\DOMDocument $document, \DOMElement $ro } $organizations['ORG0'] = $statistics['overall']; - // Match organization suffixes: PS/PA1 links to ORG1, PS/PA0 to ORG0. + // Match each statistics ID suffix to its corresponding organization. foreach ($organizations as $organization_id => $counts) { $entry = $this->addTextElement($document, $section, 'foia', 'ProcessingStatistics'); $entry->setAttributeNS(self::NAMESPACES['s'], 's:id', $prefix . substr($organization_id, 3)); From b03e8c1b3ef4a7a11b79f6ae33399cc56a414d52 Mon Sep 17 00:00:00 2001 From: Rob Ristroph Date: Wed, 23 Sep 2026 18:03:30 -0500 Subject: [PATCH 08/24] WS-472: Added ProcessedRequestComparison to the XML. --- .../custom/foia_raw_data_to_report/README.md | 29 ++++++++++++ .../src/OldestPendingRequestAggregator.php | 8 +++- .../QueueWorker/RawDataToReportProcessing.php | 3 +- .../src/XmlReportBuilder.php | 45 ++++++++++++++++++- 4 files changed, 82 insertions(+), 3 deletions(-) diff --git a/docroot/modules/custom/foia_raw_data_to_report/README.md b/docroot/modules/custom/foia_raw_data_to_report/README.md index 538ffe45a..f709dc729 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/README.md +++ b/docroot/modules/custom/foia_raw_data_to_report/README.md @@ -690,3 +690,32 @@ each component and the agency total. `PCN1`, `PCN2`, etc. and `PCN0` link to the Organization entries through `ProcessingStatisticsOrganizationAssociation`. Existing CSV validation rules remain in effect, including the restriction on completion dates outside the fiscal year. + +## Oldest pending consultations + +`OldestPendingRequestAggregator` also supports a consultation-only listing: +Column C must equal `Y`, Column I must contain a receipt date, and Column K +must be blank. Working days run from the actual receipt date through September +30 of the report year, excluding the receipt day, weekends, and the shared +federal holidays. Dates before the fiscal year are not clamped. + +`OldestPendingConsultationSection` follows processed consultations and uses +`OPC1`, `OPC2`, etc. and agency `OPC0` with the usual Organization associations. +Each listing retains at most ten items, ordered by pending working days +(descending), then receipt date (ascending). Duplicate dates remain separate +items. Empty components retain an empty `OldestPendingItems` entry; fewer than +ten items are emitted when fewer qualify. Only ten items per component and +ten agency-wide are retained in memory. + +## Processed request comparison + +`ProcessedRequestComparisonSection` reuses the existing request statistics: +Column I within the fiscal year supplies `ItemsReceivedCurrentYearQuantity`, +and Column K within the fiscal year supplies `ItemsProcessedCurrentYearQuantity`. +October 1 and September 30 are included. Both last-year quantities are `0`. +No additional CSV pass is needed. + +Each component and the agency have a `ProcessingComparison`, including zero +counts, with `PRC1`, `PRC2`, etc. and `PRC0` linked to Organization entries via +`ProcessingComparisonOrganizationAssociation`. Agency quantities are the sums +of component quantities. diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/OldestPendingRequestAggregator.php b/docroot/modules/custom/foia_raw_data_to_report/src/OldestPendingRequestAggregator.php index a5f5df04b..e4b0606bb 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/src/OldestPendingRequestAggregator.php +++ b/docroot/modules/custom/foia_raw_data_to_report/src/OldestPendingRequestAggregator.php @@ -14,11 +14,13 @@ final class OldestPendingRequestAggregator { * Component/file pairs, each with component_id and uri keys. * @param int $fiscal_year * The validated report year. + * @param bool $consultations_only + * Whether to include only rows with Column C equal to Y. * * @return array * Component and overall lists with receipt_date and pending_days entries. */ - public function aggregate(array $sources, int $fiscal_year): array { + public function aggregate(array $sources, int $fiscal_year, bool $consultations_only = FALSE): array { $end = new \DateTimeImmutable($fiscal_year . '-09-30', new \DateTimeZone('UTC')); $working_days = new WorkingDays(); $components = []; @@ -45,6 +47,10 @@ public function aggregate(array $sources, int $fiscal_year): array { $header = FALSE; continue; } + // Consultation listings include only rows marked Y in Column C. + if ($consultations_only && trim($columns[2]) !== 'Y') { + continue; + } // Pending requests have a received date and no completed date. $received_text = trim($columns[8]); if ($received_text === '' || trim($columns[10]) !== '') { diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/Plugin/QueueWorker/RawDataToReportProcessing.php b/docroot/modules/custom/foia_raw_data_to_report/src/Plugin/QueueWorker/RawDataToReportProcessing.php index 0f36e10c0..9d4cad2e1 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/src/Plugin/QueueWorker/RawDataToReportProcessing.php +++ b/docroot/modules/custom/foia_raw_data_to_report/src/Plugin/QueueWorker/RawDataToReportProcessing.php @@ -209,7 +209,8 @@ protected function generateXmlReport(NodeInterface $node): void { $fees_collected = (new FeesCollectedAggregator())->aggregate($sources); $backlog = (new BacklogAggregator())->aggregate($sources, $fiscal_year); $consultation_statistics = (new ConsultationStatisticsAggregator())->aggregate($sources, $fiscal_year); - $xml = (new XmlReportBuilder())->build($node->get('field_agency')->entity, $components, $fiscal_year, $statutes, $request_statistics, $dispositions, $other_reasons, $applied_exemptions, $appeal_statistics, $appeal_dispositions, $appeal_exemptions, $appeal_denials, $appeal_other_reasons, $appeal_response_times, $oldest_pending_appeals, $processed_response_times, $information_granted_response_times, $simple_response_increments, $complex_response_increments, $expedited_response_increments, $pending_perfected_requests, $oldest_pending_requests, $expedited_processing, $fee_waivers, $fees_collected, $backlog, $consultation_statistics); + $oldest_pending_consultations = (new OldestPendingRequestAggregator())->aggregate($sources, $fiscal_year, TRUE); + $xml = (new XmlReportBuilder())->build($node->get('field_agency')->entity, $components, $fiscal_year, $statutes, $request_statistics, $dispositions, $other_reasons, $applied_exemptions, $appeal_statistics, $appeal_dispositions, $appeal_exemptions, $appeal_denials, $appeal_other_reasons, $appeal_response_times, $oldest_pending_appeals, $processed_response_times, $information_granted_response_times, $simple_response_increments, $complex_response_increments, $expedited_response_increments, $pending_perfected_requests, $oldest_pending_requests, $expedited_processing, $fee_waivers, $fees_collected, $backlog, $consultation_statistics, $oldest_pending_consultations); $field = $node->get('field_request_data_xml'); $previous_file = $field->entity; $item = $field->first() ?? $field->appendItem(); diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/XmlReportBuilder.php b/docroot/modules/custom/foia_raw_data_to_report/src/XmlReportBuilder.php index c470c10c3..f9b939b96 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/src/XmlReportBuilder.php +++ b/docroot/modules/custom/foia_raw_data_to_report/src/XmlReportBuilder.php @@ -79,11 +79,13 @@ final class XmlReportBuilder { * Component and agency counters from BacklogAggregator. * @param array $consultation_statistics * Consultation counters by component and overall. + * @param array $oldest_pending_consultations + * Component and overall lists of the ten oldest pending consultations. * * @return string * The serialized report XML. */ - public function build(TermInterface $agency, array $components, int $fiscal_year, array $statutes = [], array $request_statistics = [], array $dispositions = [], array $other_reasons = [], array $applied_exemptions = [], array $appeal_statistics = [], array $appeal_dispositions = [], array $appeal_exemptions = [], array $appeal_denials = [], array $appeal_other_reasons = [], array $appeal_response_times = [], array $oldest_pending_appeals = [], array $processed_response_times = [], array $information_granted_response_times = [], array $simple_response_increments = [], array $complex_response_increments = [], array $expedited_response_increments = [], array $pending_perfected_requests = [], array $oldest_pending_requests = [], array $expedited_processing = [], array $fee_waivers = [], array $fees_collected = [], array $backlog = [], array $consultation_statistics = []): string { + public function build(TermInterface $agency, array $components, int $fiscal_year, array $statutes = [], array $request_statistics = [], array $dispositions = [], array $other_reasons = [], array $applied_exemptions = [], array $appeal_statistics = [], array $appeal_dispositions = [], array $appeal_exemptions = [], array $appeal_denials = [], array $appeal_other_reasons = [], array $appeal_response_times = [], array $oldest_pending_appeals = [], array $processed_response_times = [], array $information_granted_response_times = [], array $simple_response_increments = [], array $complex_response_increments = [], array $expedited_response_increments = [], array $pending_perfected_requests = [], array $oldest_pending_requests = [], array $expedited_processing = [], array $fee_waivers = [], array $fees_collected = [], array $backlog = [], array $consultation_statistics = [], array $oldest_pending_consultations = []): string { $document = new \DOMDocument('1.0', 'UTF-8'); $document->formatOutput = TRUE; $root = $document->createElementNS(self::NAMESPACES['iepd'], 'iepd:FoiaAnnualReport'); @@ -214,6 +216,13 @@ public function build(TermInterface $agency, array $components, int $fiscal_year if ($consultation_statistics !== []) { $this->addProcessingStatistics($document, $root, $consultation_statistics, $component_map, 'ProcessedConsultationSection', 'PCN'); } + if ($oldest_pending_consultations !== []) { + $this->addOldestPendingItems($document, $root, $oldest_pending_consultations, $component_map, 'OldestPendingConsultationSection', 'OPC'); + } + + if ($request_statistics !== []) { + $this->addProcessedRequestComparison($document, $root, $request_statistics, $component_map); + } $xml = $document->saveXML(); if ($xml === FALSE) { @@ -295,6 +304,40 @@ private function addProcessingStatistics(\DOMDocument $document, \DOMElement $ro } } + /** + * Adds current request counts with zero placeholders for last year's counts. + */ + private function addProcessedRequestComparison(\DOMDocument $document, \DOMElement $root, array $statistics, array $component_map): void { + $section = $this->addTextElement($document, $root, 'foia', 'ProcessedRequestComparisonSection'); + $organizations = []; + foreach ($component_map as $component_id => $organization_id) { + $organizations[$organization_id] = $statistics['components'][$component_id]; + } + $organizations['ORG0'] = $statistics['overall']; + + foreach ($organizations as $organization_id => $counts) { + $entry = $this->addTextElement($document, $section, 'foia', 'ProcessingComparison'); + $entry->setAttributeNS(self::NAMESPACES['s'], 's:id', 'PRC' . substr($organization_id, 3)); + // Reuse the inclusive fiscal-year counts from request statistics. + $fields = [ + 'ItemsReceivedLastYearQuantity' => 0, + 'ItemsReceivedCurrentYearQuantity' => $counts['received'], + 'ItemsProcessedLastYearQuantity' => 0, + 'ItemsProcessedCurrentYearQuantity' => $counts['processed'], + ]; + foreach ($fields as $name => $quantity) { + $this->addTextElement($document, $entry, 'foia', $name, (string) $quantity); + } + } + foreach ($organizations as $organization_id => $counts) { + $association = $this->addTextElement($document, $section, 'foia', 'ProcessingComparisonOrganizationAssociation'); + $reference = $this->addTextElement($document, $association, 'foia', 'ComponentDataReference'); + $reference->setAttributeNS(self::NAMESPACES['s'], 's:ref', 'PRC' . substr($organization_id, 3)); + $organization = $this->addTextElement($document, $association, 'nc', 'OrganizationReference'); + $organization->setAttributeNS(self::NAMESPACES['s'], 's:ref', $organization_id); + } + } + /** * Adds disposition counts, totals, and references to their organizations. */ From 059f80871bea6239264261204e7fa140ab8ad532 Mon Sep 17 00:00:00 2001 From: Rob Ristroph Date: Wed, 23 Sep 2026 18:28:55 -0500 Subject: [PATCH 09/24] WS-472: Added ProcessedAppealComparison section to the XML report. --- .../custom/foia_raw_data_to_report/README.md | 19 +++++++ .../src/XmlReportBuilder.php | 49 ++++++++++++++++--- 2 files changed, 61 insertions(+), 7 deletions(-) diff --git a/docroot/modules/custom/foia_raw_data_to_report/README.md b/docroot/modules/custom/foia_raw_data_to_report/README.md index f709dc729..41bf6f1ac 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/README.md +++ b/docroot/modules/custom/foia_raw_data_to_report/README.md @@ -719,3 +719,22 @@ Each component and the agency have a `ProcessingComparison`, including zero counts, with `PRC1`, `PRC2`, etc. and `PRC0` linked to Organization entries via `ProcessingComparisonOrganizationAssociation`. Agency quantities are the sums of component quantities. + +## Backlogged request comparison + +`BackloggedRequestComparisonSection` reuses the request counts from +`BacklogSection` for `BacklogCurrentYearQuantity`. `BacklogLastYearQuantity` +is always `0`. No additional CSV pass is needed. + +Each component and the agency have a `BacklogComparison`, including zero +counts. `BLR1`, `BLR2`, etc. and agency `BLR0` link to their Organization entries +through `BacklogComparisonOrganizationAssociation`. + +## Processed appeal comparison + +`ProcessedAppealComparisonSection` reuses the received and processed counts +from `ProcessedAppealSection`. Both last-year quantities are `0`. Each +component and the agency have a `ProcessingComparison`, including zero counts, +with `APC1`, `APC2`, etc. and `APC0` linked to Organization entries through +`ProcessingComparisonOrganizationAssociation`. No additional CSV pass is +needed; the request and appeal comparisons share the same XML formatter. diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/XmlReportBuilder.php b/docroot/modules/custom/foia_raw_data_to_report/src/XmlReportBuilder.php index f9b939b96..a13310821 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/src/XmlReportBuilder.php +++ b/docroot/modules/custom/foia_raw_data_to_report/src/XmlReportBuilder.php @@ -221,7 +221,15 @@ public function build(TermInterface $agency, array $components, int $fiscal_year } if ($request_statistics !== []) { - $this->addProcessedRequestComparison($document, $root, $request_statistics, $component_map); + $this->addProcessingComparison($document, $root, $request_statistics, $component_map, 'ProcessedRequestComparisonSection', 'PRC'); + } + + if ($backlog !== []) { + $this->addBackloggedRequestComparison($document, $root, $backlog, $component_map); + } + + if ($appeal_statistics !== []) { + $this->addProcessingComparison($document, $root, $appeal_statistics, $component_map, 'ProcessedAppealComparisonSection', 'APC'); } $xml = $document->saveXML(); @@ -305,10 +313,10 @@ private function addProcessingStatistics(\DOMDocument $document, \DOMElement $ro } /** - * Adds current request counts with zero placeholders for last year's counts. + * Adds request or appeal comparisons with zero counts for last year. */ - private function addProcessedRequestComparison(\DOMDocument $document, \DOMElement $root, array $statistics, array $component_map): void { - $section = $this->addTextElement($document, $root, 'foia', 'ProcessedRequestComparisonSection'); + private function addProcessingComparison(\DOMDocument $document, \DOMElement $root, array $statistics, array $component_map, string $section_name, string $prefix): void { + $section = $this->addTextElement($document, $root, 'foia', $section_name); $organizations = []; foreach ($component_map as $component_id => $organization_id) { $organizations[$organization_id] = $statistics['components'][$component_id]; @@ -317,8 +325,8 @@ private function addProcessedRequestComparison(\DOMDocument $document, \DOMEleme foreach ($organizations as $organization_id => $counts) { $entry = $this->addTextElement($document, $section, 'foia', 'ProcessingComparison'); - $entry->setAttributeNS(self::NAMESPACES['s'], 's:id', 'PRC' . substr($organization_id, 3)); - // Reuse the inclusive fiscal-year counts from request statistics. + $entry->setAttributeNS(self::NAMESPACES['s'], 's:id', $prefix . substr($organization_id, 3)); + // Reuse the inclusive fiscal-year counts already aggregated. $fields = [ 'ItemsReceivedLastYearQuantity' => 0, 'ItemsReceivedCurrentYearQuantity' => $counts['received'], @@ -332,7 +340,34 @@ private function addProcessedRequestComparison(\DOMDocument $document, \DOMEleme foreach ($organizations as $organization_id => $counts) { $association = $this->addTextElement($document, $section, 'foia', 'ProcessingComparisonOrganizationAssociation'); $reference = $this->addTextElement($document, $association, 'foia', 'ComponentDataReference'); - $reference->setAttributeNS(self::NAMESPACES['s'], 's:ref', 'PRC' . substr($organization_id, 3)); + $reference->setAttributeNS(self::NAMESPACES['s'], 's:ref', $prefix . substr($organization_id, 3)); + $organization = $this->addTextElement($document, $association, 'nc', 'OrganizationReference'); + $organization->setAttributeNS(self::NAMESPACES['s'], 's:ref', $organization_id); + } + } + + /** + * Adds request backlog comparisons with zero for the unavailable prior year. + */ + private function addBackloggedRequestComparison(\DOMDocument $document, \DOMElement $root, array $statistics, array $component_map): void { + $section = $this->addTextElement($document, $root, 'foia', 'BackloggedRequestComparisonSection'); + $organizations = []; + foreach ($component_map as $component_id => $organization_id) { + $organizations[$organization_id] = $statistics['components'][$component_id]; + } + $organizations['ORG0'] = $statistics['overall']; + + foreach ($organizations as $organization_id => $counts) { + $entry = $this->addTextElement($document, $section, 'foia', 'BacklogComparison'); + $entry->setAttributeNS(self::NAMESPACES['s'], 's:id', 'BLR' . substr($organization_id, 3)); + // Reuse the exact request count emitted in BacklogSection. + $this->addTextElement($document, $entry, 'foia', 'BacklogLastYearQuantity', '0'); + $this->addTextElement($document, $entry, 'foia', 'BacklogCurrentYearQuantity', (string) $counts['requests']); + } + foreach ($organizations as $organization_id => $counts) { + $association = $this->addTextElement($document, $section, 'foia', 'BacklogComparisonOrganizationAssociation'); + $reference = $this->addTextElement($document, $association, 'foia', 'ComponentDataReference'); + $reference->setAttributeNS(self::NAMESPACES['s'], 's:ref', 'BLR' . substr($organization_id, 3)); $organization = $this->addTextElement($document, $association, 'nc', 'OrganizationReference'); $organization->setAttributeNS(self::NAMESPACES['s'], 's:ref', $organization_id); } From 09981e666a2678238f446811574c147fe533c87e Mon Sep 17 00:00:00 2001 From: Rob Ristroph Date: Wed, 23 Sep 2026 18:41:43 -0500 Subject: [PATCH 10/24] WS-472: Added BackloggedAppealComparison section to the XML report. --- .../custom/foia_raw_data_to_report/README.md | 9 +++++++++ .../src/XmlReportBuilder.php | 20 +++++++++++-------- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/docroot/modules/custom/foia_raw_data_to_report/README.md b/docroot/modules/custom/foia_raw_data_to_report/README.md index 41bf6f1ac..3cddb8054 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/README.md +++ b/docroot/modules/custom/foia_raw_data_to_report/README.md @@ -738,3 +738,12 @@ component and the agency have a `ProcessingComparison`, including zero counts, with `APC1`, `APC2`, etc. and `APC0` linked to Organization entries through `ProcessingComparisonOrganizationAssociation`. No additional CSV pass is needed; the request and appeal comparisons share the same XML formatter. + +## Backlogged appeal comparison + +`BackloggedAppealComparisonSection` reuses `BackloggedAppealQuantity` from +`BacklogSection` for its current-year count and emits `0` for last year. +Every component and the agency have a `BacklogComparison`, including zeros, +with `ABC1`, `ABC2`, etc. and `ABC0` linked to Organization entries through +`BacklogComparisonOrganizationAssociation`. Request and appeal backlog +comparisons share the same XML formatter and need no additional CSV pass. diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/XmlReportBuilder.php b/docroot/modules/custom/foia_raw_data_to_report/src/XmlReportBuilder.php index a13310821..0fac75a60 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/src/XmlReportBuilder.php +++ b/docroot/modules/custom/foia_raw_data_to_report/src/XmlReportBuilder.php @@ -225,13 +225,17 @@ public function build(TermInterface $agency, array $components, int $fiscal_year } if ($backlog !== []) { - $this->addBackloggedRequestComparison($document, $root, $backlog, $component_map); + $this->addBacklogComparison($document, $root, $backlog, $component_map, 'BackloggedRequestComparisonSection', 'BLR', 'requests'); } if ($appeal_statistics !== []) { $this->addProcessingComparison($document, $root, $appeal_statistics, $component_map, 'ProcessedAppealComparisonSection', 'APC'); } + if ($backlog !== []) { + $this->addBacklogComparison($document, $root, $backlog, $component_map, 'BackloggedAppealComparisonSection', 'ABC', 'appeals'); + } + $xml = $document->saveXML(); if ($xml === FALSE) { throw new \RuntimeException('Unable to serialize the raw data report XML.'); @@ -347,10 +351,10 @@ private function addProcessingComparison(\DOMDocument $document, \DOMElement $ro } /** - * Adds request backlog comparisons with zero for the unavailable prior year. + * Adds request or appeal backlog comparisons with zero for last year. */ - private function addBackloggedRequestComparison(\DOMDocument $document, \DOMElement $root, array $statistics, array $component_map): void { - $section = $this->addTextElement($document, $root, 'foia', 'BackloggedRequestComparisonSection'); + private function addBacklogComparison(\DOMDocument $document, \DOMElement $root, array $statistics, array $component_map, string $section_name, string $prefix, string $counter): void { + $section = $this->addTextElement($document, $root, 'foia', $section_name); $organizations = []; foreach ($component_map as $component_id => $organization_id) { $organizations[$organization_id] = $statistics['components'][$component_id]; @@ -359,15 +363,15 @@ private function addBackloggedRequestComparison(\DOMDocument $document, \DOMElem foreach ($organizations as $organization_id => $counts) { $entry = $this->addTextElement($document, $section, 'foia', 'BacklogComparison'); - $entry->setAttributeNS(self::NAMESPACES['s'], 's:id', 'BLR' . substr($organization_id, 3)); - // Reuse the exact request count emitted in BacklogSection. + $entry->setAttributeNS(self::NAMESPACES['s'], 's:id', $prefix . substr($organization_id, 3)); + // Reuse the matching count emitted in BacklogSection. $this->addTextElement($document, $entry, 'foia', 'BacklogLastYearQuantity', '0'); - $this->addTextElement($document, $entry, 'foia', 'BacklogCurrentYearQuantity', (string) $counts['requests']); + $this->addTextElement($document, $entry, 'foia', 'BacklogCurrentYearQuantity', (string) $counts[$counter]); } foreach ($organizations as $organization_id => $counts) { $association = $this->addTextElement($document, $section, 'foia', 'BacklogComparisonOrganizationAssociation'); $reference = $this->addTextElement($document, $association, 'foia', 'ComponentDataReference'); - $reference->setAttributeNS(self::NAMESPACES['s'], 's:ref', 'BLR' . substr($organization_id, 3)); + $reference->setAttributeNS(self::NAMESPACES['s'], 's:ref', $prefix . substr($organization_id, 3)); $organization = $this->addTextElement($document, $association, 'nc', 'OrganizationReference'); $organization->setAttributeNS(self::NAMESPACES['s'], 's:ref', $organization_id); } From 9ec97eab9db16733318fc77f03615148e60f638a Mon Sep 17 00:00:00 2001 From: Rob Ristroph Date: Wed, 23 Sep 2026 23:04:15 -0500 Subject: [PATCH 11/24] WS-472: Fixed some errors with Column I date error when blank for Appeals. --- .../custom/foia_raw_data_to_report/README.md | 7 ++++--- .../src/RequestStatisticsAggregator.php | 10 ++++++++-- .../tests/integration/component_uploads.php | 14 ++++++++++++-- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/docroot/modules/custom/foia_raw_data_to_report/README.md b/docroot/modules/custom/foia_raw_data_to_report/README.md index 3cddb8054..830433b1d 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/README.md +++ b/docroot/modules/custom/foia_raw_data_to_report/README.md @@ -291,9 +291,10 @@ file is replaced. The existing `foia_export_xml` module is unchanged. ## Processed request statistics `RequestStatisticsAggregator` makes a separate streaming pass after validation, -retaining only four counters per component. Every data row counts, including -consultations and rows without statute codes. Headers and blank records are -skipped using the same rules as validation. +retaining only four counters per component. Only rows with a nonblank Column I +count, including consultations and rows without statute codes. Appeal-only rows +and other rows without Column I are excluded from all counters. Headers and +blank records are skipped using the same rules as validation. - Pending at start: I is before October 1 of the previous year. - Received: I is within the fiscal year, including both boundaries. diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/RequestStatisticsAggregator.php b/docroot/modules/custom/foia_raw_data_to_report/src/RequestStatisticsAggregator.php index d4110c167..dd24ee627 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/src/RequestStatisticsAggregator.php +++ b/docroot/modules/custom/foia_raw_data_to_report/src/RequestStatisticsAggregator.php @@ -53,9 +53,15 @@ public function aggregate(array $sources, int $fiscal_year): array { $header = FALSE; continue; } - // Every data row counts, including consultations and rows without E. + // Initial request statistics require a receipt date in Column I. + // Skip appeal-only rows and any other rows without that date before + // counting completions or pending requests for either XML section. + $received_text = trim($columns[8]); + if ($received_text === '') { + continue; + } $context = sprintf('Component %s, CSV %s, record %d', $source['component_id'], basename($source['uri']), $record); - $received = $this->calendarDate(trim($columns[8]), $context . ', Column I (Date Initially Received)'); + $received = $this->calendarDate($received_text, $context . ', Column I (Date Initially Received)'); if ($received < $start) { $counts['pending_start']++; } diff --git a/docroot/modules/custom/foia_raw_data_to_report/tests/integration/component_uploads.php b/docroot/modules/custom/foia_raw_data_to_report/tests/integration/component_uploads.php index 661e50b99..5a957a905 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/tests/integration/component_uploads.php +++ b/docroot/modules/custom/foia_raw_data_to_report/tests/integration/component_uploads.php @@ -9,6 +9,7 @@ use Drupal\Core\File\FileSystemInterface; use Drupal\file\Entity\File; use Drupal\foia_raw_data_to_report\UploadAssignments; +use Drupal\foia_raw_data_to_report\RequestStatisticsAggregator; use Drupal\node\Entity\Node; use Drupal\paragraphs\Entity\Paragraph; use Drupal\system\FileDownloadController; @@ -138,13 +139,22 @@ $row[23] = '01/02/2026'; $contents = implode(',', array_fill(0, 29, 'column')) . "\n" . implode(',', $row); file_put_contents($files[1]->getFileUri(), $contents); + // Appeal-only rows must not count as initial requests or pending requests. + $counts = (new RequestStatisticsAggregator())->aggregate([ + ['component_id' => $components[1]->id(), 'uri' => $files[1]->getFileUri()], + ], 2026); + $check($counts['overall'] === ['pending_start' => 0, 'received' => 0, 'processed' => 0, 'pending_end' => 0], 'Appeal-only row contributed to request statistics.'); + // Invalid appeal dates still exercise post-validation exception reporting. + $row[23] = 'invalid-date'; + $contents = implode(',', array_fill(0, 29, 'column')) . "\n" . implode(',', $row); + file_put_contents($files[1]->getFileUri(), $contents); for ($attempt = 0; $attempt < 2; $attempt++) { try { $process(); - throw new LogicException('Expected the request-statistics date exception.'); + throw new LogicException('Expected the appeal-statistics date exception.'); } catch (RuntimeException $exception) { - $check(str_contains($exception->getMessage(), 'Column I'), 'Unexpected processing exception.'); + $check(str_contains($exception->getMessage(), 'Column X'), 'Unexpected processing exception.'); $report = $node_storage->loadUnchanged($report->id()); $message = $report->get('field_messages')->value; $check(substr_count($message, 'CSV validated.') === 2, 'Exception lost validation messages.'); From b2bca24c7271816ec6e1c1d2ac254064f238f409 Mon Sep 17 00:00:00 2001 From: Rob Ristroph Date: Thu, 24 Sep 2026 10:14:06 -0500 Subject: [PATCH 12/24] WS-472: Parse whole CSV file, deliver all errorrs. --- .../custom/foia_raw_data_to_report/README.md | 10 +- .../src/CsvValidator.php | 204 ++++++++++-------- .../tests/integration/component_uploads.php | 3 + .../tests/src/Unit/CsvValidatorTest.php | 65 +++++- 4 files changed, 182 insertions(+), 100 deletions(-) diff --git a/docroot/modules/custom/foia_raw_data_to_report/README.md b/docroot/modules/custom/foia_raw_data_to_report/README.md index 830433b1d..bebbb8324 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/README.md +++ b/docroot/modules/custom/foia_raw_data_to_report/README.md @@ -93,8 +93,14 @@ data records are checked. Blank records are skipped; quoted commas, escaped quotes, and multiline values are supported. Record numbers include the header and blank records and are not physical line numbers for multiline CSVs. -The first validation error produces a human-readable message identifying the -CSV record. Column-count errors include the expected and actual counts. Missing, unreadable, empty, and non-CSV uploads also +Validation scans the entire CSV and collects all applicable errors, including +multiple errors on the same record. Each message identifies its CSV record. +Duplicate messages within a record are listed once. Column-count errors include +the expected and actual counts; other checks on that malformed record are +skipped, but later records are still checked. Invalid dates are reported without +running dependent date calculations. CSV rows are streamed; accumulated error +messages and seen request numbers consume memory in proportion to their count. +Missing, unreadable, empty, and non-CSV uploads also produce messages. Validation failures finish the queue item without generating XML or changing any existing XML attachment. Correct the CSV and click Generate XML Report again to retry. Each upload result identifies its component and filename, diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/CsvValidator.php b/docroot/modules/custom/foia_raw_data_to_report/src/CsvValidator.php index 7773388b5..b77c93244 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/src/CsvValidator.php +++ b/docroot/modules/custom/foia_raw_data_to_report/src/CsvValidator.php @@ -24,7 +24,7 @@ final class CsvValidator { * The report node's Year; the fiscal year ends on September 30. * * @return string[] - * Validation errors. Stops at the first error to keep output bounded. + * All validation errors, in record and validation-check order. */ public function validate(string $uri, int $fiscal_year): array { // A missing or invalid report year must not bypass fiscal-year checks. @@ -37,6 +37,7 @@ public function validate(string $uri, int $fiscal_year): array { } try { + $errors = []; $record = 0; $has_content = FALSE; $request_numbers = []; @@ -46,12 +47,15 @@ public function validate(string $uri, int $fiscal_year): array { if ($columns === [NULL]) { continue; } + // Even a malformed first record is the header, not a data row. + $is_header = !$has_content; + $has_content = TRUE; if (count($columns) !== self::EXPECTED_COLUMNS) { - return [sprintf('CSV record %d has %d columns; expected %d columns to match the OIP request raw data template. Please correct the file and try again.', $record, count($columns), self::EXPECTED_COLUMNS)]; + $errors[] = sprintf('CSV record %d has %d columns; expected %d columns to match the OIP request raw data template. Please correct the file and try again.', $record, count($columns), self::EXPECTED_COLUMNS); + // Column positions are unreliable; continue with the next record. + continue; } - // The first nonblank record is the header; only check its column count. - if (!$has_content) { - $has_content = TRUE; + if ($is_header) { continue; } @@ -60,14 +64,15 @@ public function validate(string $uri, int $fiscal_year): array { if ($appeal_received !== '') { foreach (array_merge(array_slice($columns, 4, 12), array_slice($columns, 19, 4)) as $value) { if (trim($value) !== '') { - return [sprintf('CSV record %d: If there is data in Column X, Columns E through W must be empty, except for Columns Q, R, and S; those may optionally have data, but Columns E through P and Columns T through W must be blank.', $record)]; + $errors[] = sprintf('CSV record %d: If there is data in Column X, Columns E through W must be empty, except for Columns Q, R, and S; those may optionally have data, but Columns E through P and Columns T through W must be blank.', $record); + break; } } } // Column A: Component is required, including for consultation rows. if (trim($columns[0]) === '') { - return [sprintf('CSV record %d: Column A: Component cannot be blank', $record)]; + $errors[] = sprintf('CSV record %d: Column A: Component cannot be blank', $record); } // Column B: Request Number is required and must be unique in this CSV. @@ -75,18 +80,20 @@ public function validate(string $uri, int $fiscal_year): array { // keys (including leading zeroes). Ignore surrounding whitespace. $request_number = trim($columns[1]); if ($request_number === '') { - return [sprintf('CSV record %d: Column B: Request Number cannot be blank', $record)]; + $errors[] = sprintf('CSV record %d: Column B: Request Number cannot be blank', $record); } $request_key = 'request:' . $request_number; - if (isset($request_numbers[$request_key])) { - return [sprintf('CSV record %d: Column B: Request Number is duplicate', $record)]; + if ($request_number !== '' && isset($request_numbers[$request_key])) { + $errors[] = sprintf('CSV record %d: Column B: Request Number is duplicate', $record); + } + if ($request_number !== '') { + $request_numbers[$request_key] = TRUE; } - $request_numbers[$request_key] = TRUE; // Column C: Is This a Consultation must be an uppercase Y or N. $consultation = trim($columns[2]); if (!in_array($consultation, ['Y', 'N'], TRUE)) { - return [sprintf("CSV record %d: Column C must have 'Y' or 'N' entered", $record)]; + $errors[] = sprintf("CSV record %d: Column C must have 'Y' or 'N' entered", $record); } // Column C: Consultations may contain values only in A, B, C, I and K. @@ -96,14 +103,14 @@ public function validate(string $uri, int $fiscal_year): array { if (!in_array($index, [0, 1, 2, 8, 10], TRUE) && trim($value) !== '') { // This 29-column template spans A through AC. $column = $index < 26 ? chr(65 + $index) : 'A' . chr(65 + $index - 26); - return [sprintf("CSV record %d: Column %s must be blank when Column C is 'Y'; only columns A, B, C, I, and K may contain values", $record, $column)]; + $errors[] = sprintf("CSV record %d: Column %s must be blank when Column C is 'Y'; only columns A, B, C, I, and K may contain values", $record, $column); } } } // Column D: Non-consultation rows require Days Allowed to be 20 or 30. if ($consultation !== 'Y' && !in_array(trim($columns[3]), ['20', '30'], TRUE)) { - return [sprintf('CSV record %d: Column D: Must complete Days Allowed with 20 or 30', $record)]; + $errors[] = sprintf('CSV record %d: Column D: Must complete Days Allowed with 20 or 30', $record); } // Column E: Exemption 3 Statutes is optional. The Column C check above @@ -115,7 +122,7 @@ public function validate(string $uri, int $fiscal_year): array { // Detect digit groups separated by non-comma characters; whitespace // around comma-separated IDs is allowed, as with other column checks. if (preg_match('/[0-9][^0-9,]+[0-9]/', $statutes)) { - return [sprintf('CSV record %d: Column E: Separate multiple entries in Column E with a comma', $record)]; + $errors[] = sprintf('CSV record %d: Column E: Separate multiple entries in Column E with a comma', $record); } // Column E: Every comma-separated entry must be an integer ID 1-77. @@ -124,7 +131,8 @@ public function validate(string $uri, int $fiscal_year): array { foreach (explode(',', $statutes) as $statute) { $statute = trim($statute); if (!ctype_digit($statute) || (int) $statute < 1 || (int) $statute > 77) { - return [sprintf('CSV record %d: Entries in Column E must be a valid ID between 1 - 77', $record)]; + $errors[] = sprintf('CSV record %d: Entries in Column E must be a valid ID between 1 - 77', $record); + continue; } $statute_ids[] = (int) $statute; } @@ -134,10 +142,10 @@ public function validate(string $uri, int $fiscal_year): array { $has_other_statute = in_array(77, $statute_ids, TRUE); foreach ([5, 6, 7] as $index) { if ($has_other_statute && trim($columns[$index]) === '') { - return [sprintf('CSV record %d: Column E: If Ex. 3 Code 77 is entered, columns F, G, and H must contain information', $record)]; + $errors[] = sprintf('CSV record %d: Column E: If Ex. 3 Code 77 is entered, columns F, G, and H must contain information', $record); } if (!$has_other_statute && trim($columns[$index]) !== '') { - return [sprintf('CSV record %d: Column E: If Ex. 3 Codes 1 - 76 is entered in Column E, columns F, G, and H must remain blank', $record)]; + $errors[] = sprintf('CSV record %d: Column E: If Ex. 3 Codes 1 - 76 is entered in Column E, columns F, G, and H must remain blank', $record); } } @@ -145,96 +153,92 @@ public function validate(string $uri, int $fiscal_year): array { // Match a complete comma-separated exemption, not a substring of 13. $exemptions = array_map('trim', explode(',', $columns[15])); if (!in_array('3', $exemptions, TRUE)) { - return [sprintf("CSV record %d: Column E: If Column E contains information, a '3' must be listed in Column P", $record)]; + $errors[] = sprintf("CSV record %d: Column E: If Column E contains information, a '3' must be listed in Column P", $record); } } // Column F: Other Exemption 3 Statutes requires code 77 in Column E. if (trim($columns[5]) !== '' && !$has_other_statute) { - return [sprintf('CSV record %d: Column F: Ex. 3 Code 77 must appear in Column E if there is data in Column F', $record)]; + $errors[] = sprintf('CSV record %d: Column F: Ex. 3 Code 77 must appear in Column E if there is data in Column F', $record); } // Column G: Information Withheld requires code 77 in E and data in F. if (trim($columns[6]) !== '' && (!$has_other_statute || trim($columns[5]) === '')) { - return [sprintf('CSV record %d: Column G: Ex. 3 Code 77 must appear in Column E if there is data in Column G', $record)]; + $errors[] = sprintf('CSV record %d: Column G: Ex. 3 Code 77 must appear in Column E if there is data in Column G', $record); } // Column H: Case Citation requires code 77 in E and data in F and G. if (trim($columns[7]) !== '' && (!$has_other_statute || trim($columns[5]) === '' || trim($columns[6]) === '')) { - return [sprintf('CSV record %d: Column H: Ex. 3 Code 77 must appear in Column E if there is data in Column H', $record)]; + $errors[] = sprintf('CSV record %d: Column H: Ex. 3 Code 77 must appear in Column E if there is data in Column H', $record); } // Column I: Optional, but the Column X check requires it to be blank // for appeal rows. Validate date contents only when present. $initially_received = trim($columns[8]); - $received_date = NULL; + $received_date = $this->parseDate($initially_received); if ($initially_received !== '') { // Column I: Require a real calendar date in month/day/four-digit year // order. Allow single-digit months/days as used in the reference CSV. - if (!preg_match('/^([0-9]{1,2})\/([0-9]{1,2})\/([0-9]{4})$/', $initially_received, $date_parts) - || !checkdate((int) $date_parts[1], (int) $date_parts[2], (int) $date_parts[3])) { - return [sprintf('CSV record %d: Column I: Date Initially Received must be a valid date in MM/DD/YYYY format', $record)]; + if ($received_date === NULL) { + $errors[] = sprintf('CSV record %d: Column I: Date Initially Received must be a valid date in MM/DD/YYYY format', $record); } // Column I: September 30 of the report year is the latest date. // Compare dates without time zones; earlier years are allowed. - $received_date = (int) $date_parts[3] * 10000 + (int) $date_parts[1] * 100 + (int) $date_parts[2]; - if ($received_date > $fiscal_year * 10000 + 930) { - return [sprintf('CSV record %d: Column I: Date Initially Received is later than the fiscal year', $record)]; + if ($received_date !== NULL && ($received_date > $fiscal_year * 10000 + 930)) { + $errors[] = sprintf('CSV record %d: Column I: Date Initially Received is later than the fiscal year', $record); } } // Column J: Date Perfected is optional; validate only nonblank values. // The Column C check already requires J to be blank for consultations. $perfected = trim($columns[9]); + $perfected_date = $this->parseDate($perfected); if ($perfected !== '') { // Column J: Require a real date using the same format as Column I. - if (!preg_match('/^([0-9]{1,2})\/([0-9]{1,2})\/([0-9]{4})$/', $perfected, $date_parts) - || !checkdate((int) $date_parts[1], (int) $date_parts[2], (int) $date_parts[3])) { - return [sprintf('CSV record %d: Column J: Date Perfected must be a valid date in MM/DD/YYYY format', $record)]; + if ($perfected_date === NULL) { + $errors[] = sprintf('CSV record %d: Column J: Date Perfected must be a valid date in MM/DD/YYYY format', $record); } // Column J: The date cannot exceed September 30 of the report year. - $perfected_date = (int) $date_parts[3] * 10000 + (int) $date_parts[1] * 100 + (int) $date_parts[2]; - if ($perfected_date > $fiscal_year * 10000 + 930) { - return [sprintf('CSV record %d: Column J: Date Perfected is later than the fiscal year', $record)]; + if ($perfected_date !== NULL && ($perfected_date > $fiscal_year * 10000 + 930)) { + $errors[] = sprintf('CSV record %d: Column J: Date Perfected is later than the fiscal year', $record); } // Column J: A perfected date requires an uppercase S, C or E in M. if (!in_array(trim($columns[12]), ['S', 'C', 'E'], TRUE)) { - return [sprintf('CSV record %d: Column J: If data is entered in Column J, Column M must contain capital S, C, or E', $record)]; + $errors[] = sprintf('CSV record %d: Column J: If data is entered in Column J, Column M must contain capital S, C, or E', $record); } // Column J: Perfection cannot precede the initially received date. - if ($received_date !== NULL && $perfected_date < $received_date) { - return [sprintf('CSV record %d: Column J: Date Perfected cannot be prior to Date Initially Received', $record)]; + if ($received_date !== NULL && $perfected_date !== NULL && $perfected_date < $received_date) { + $errors[] = sprintf('CSV record %d: Column J: Date Perfected cannot be prior to Date Initially Received', $record); } } // Column K: Date Completed is required when N contains a disposition. $completed = trim($columns[10]); + $completed_date = $this->parseDate($completed); if ($completed === '' && trim($columns[13]) !== '') { - return [sprintf('CSV record %d: Column K: Must complete Column K if Disposition is listed in Column N', $record)]; + $errors[] = sprintf('CSV record %d: Column K: Must complete Column K if Disposition is listed in Column N', $record); } // Column K: Otherwise optional, including for consultation rows. if ($completed !== '') { // Column K: Require a real date using the same format as I and J. - if (!preg_match('/^([0-9]{1,2})\/([0-9]{1,2})\/([0-9]{4})$/', $completed, $date_parts) - || !checkdate((int) $date_parts[1], (int) $date_parts[2], (int) $date_parts[3])) { - return [sprintf('CSV record %d: Column K: Date Completed must be a valid date in MM/DD/YYYY format', $record)]; + if ($completed_date === NULL) { + $errors[] = sprintf('CSV record %d: Column K: Date Completed must be a valid date in MM/DD/YYYY format', $record); } // Column K: Completion must fall within the report's fiscal year, // from October 1 of the previous year through September 30 inclusive. - $completed_date = (int) $date_parts[3] * 10000 + (int) $date_parts[1] * 100 + (int) $date_parts[2]; - if ($completed_date < ($fiscal_year - 1) * 10000 + 1001 || $completed_date > $fiscal_year * 10000 + 930) { - return [sprintf('CSV record %d: Column K: Date Completed must fall within the fiscal year (10/01/%04d through 09/30/%04d)', $record, $fiscal_year - 1, $fiscal_year)]; + if ($completed_date !== NULL && ($completed_date < ($fiscal_year - 1) * 10000 + 1001 || $completed_date > $fiscal_year * 10000 + 930)) { + $errors[] = sprintf('CSV record %d: Column K: Date Completed must fall within the fiscal year (10/01/%04d through 09/30/%04d)', $record, $fiscal_year - 1, $fiscal_year); } // Column K: Completion cannot precede J when a perfected date exists. - if ($perfected !== '' && $completed_date < $perfected_date) { - return [sprintf('CSV record %d: Column K: Date Completed cannot be prior to Date Perfected', $record)]; + if ($perfected_date !== NULL && $completed_date !== NULL && $completed_date < $perfected_date) { + $errors[] = sprintf('CSV record %d: Column K: Date Completed cannot be prior to Date Perfected', $record); } } @@ -242,19 +246,19 @@ public function validate(string $uri, int $fiscal_year): array { $days_tolled = trim($columns[11]); if ($days_tolled !== '') { if ($perfected === '') { - return [sprintf('CSV record %d: Column L: Days Tolled only permitted if Perfected Date is entered', $record)]; + $errors[] = sprintf('CSV record %d: Column L: Days Tolled only permitted if Perfected Date is entered', $record); } // Column L: Accept non-negative whole days, including zero. if (!ctype_digit($days_tolled)) { - return [sprintf('CSV record %d: Column L: Days Tolled must be a non-negative integer', $record)]; + $errors[] = sprintf('CSV record %d: Column L: Days Tolled must be a non-negative integer', $record); } // Column L: When K is present, limit tolling to elapsed working days. // Exclude J, include K, and subtract weekends and supplied holidays. // With no completed date, the upper limit cannot yet be checked. - if ($completed !== '' && (int) $days_tolled > $this->countWorkingDays($perfected, $completed)) { - return [sprintf('CSV record %d: Column L: Days Tolled exceeds working days between Perfected and Completed Dates (Columns J and K)', $record)]; + if (ctype_digit($days_tolled) && $perfected_date !== NULL && $completed_date !== NULL && $completed_date >= $perfected_date && (int) $days_tolled > $this->countWorkingDays($perfected, $completed)) { + $errors[] = sprintf('CSV record %d: Column L: Days Tolled exceeds working days between Perfected and Completed Dates (Columns J and K)', $record); } } @@ -262,163 +266,173 @@ public function validate(string $uri, int $fiscal_year): array { // requires uppercase S, C or E whenever a perfected date is present. // Non-appeal rows with G in S require E in M. Appeal rows keep M blank. if ($appeal_received === '' && trim($columns[18]) === 'G' && trim($columns[12]) !== 'E') { - return [sprintf('CSV record %d: Column M: If Column S contains a G, Column M must contain an E', $record)]; + $errors[] = sprintf('CSV record %d: Column M: If Column S contains a G, Column M must contain an E', $record); } // Column N: Completed non-consultation requests need a disposition. $disposition = trim($columns[13]); if ($completed !== '' && $consultation === 'N' && $disposition === '') { - return [sprintf('CSV record %d: Column N: If Column K has Date Completed AND Column C = N (No), then Column N must have Disposition entered', $record)]; + $errors[] = sprintf('CSV record %d: Column N: If Column K has Date Completed AND Column C = N (No), then Column N must have Disposition entered', $record); } // Column N: Disposition code 12 requires explanatory data in O. if ($disposition === '12' && trim($columns[14]) === '') { - return [sprintf('CSV record %d: Column N: Column O must contain data for Disposition Code 12', $record)]; + $errors[] = sprintf('CSV record %d: Column N: Column O must contain data for Disposition Code 12', $record); } // Column N: Codes 1, 2, 3, 4, 5 and 7 require a perfected date in J. if (in_array($disposition, ['1', '2', '3', '4', '5', '7'], TRUE) && $perfected === '') { - return [sprintf('CSV record %d: Column N: If Disposition Code 1, 2, 3, 4, 5, or 7 is entered in Column N, Column J must have a Perfected Date', $record)]; + $errors[] = sprintf('CSV record %d: Column N: If Disposition Code 1, 2, 3, 4, 5, or 7 is entered in Column N, Column J must have a Perfected Date', $record); } // Column N: Codes 8 and 9 require the perfected date in J to be blank. if (in_array($disposition, ['8', '9'], TRUE) && $perfected !== '') { - return [sprintf('CSV record %d: Column N: If Disposition Code 8 or 9 is listed in Column N, Column J must be blank for that request', $record)]; + $errors[] = sprintf('CSV record %d: Column N: If Disposition Code 8 or 9 is listed in Column N, Column J must be blank for that request', $record); } // Column O: An Other Reason requires disposition code 12 in N. // This is the reverse of the Column N check requiring O for code 12. if (trim($columns[14]) !== '' && $disposition !== '12') { - return [sprintf('CSV record %d: Column O: If Column O contains information, Column N must contain Disposition Code 12', $record)]; + $errors[] = sprintf('CSV record %d: Column O: If Column O contains information, Column N must contain Disposition Code 12', $record); } // Column P: Optional alphanumeric exemptions must be comma-separated. // Allow whitespace around entries, but not in place of a comma. $applied_exemptions = trim($columns[15]); if ($applied_exemptions !== '' && !preg_match('/^[a-zA-Z0-9]+(?:\s*,\s*[a-zA-Z0-9]+)*$/', $applied_exemptions)) { - return [sprintf('CSV record %d: Column P: Multiple exemptions must be separated with a comma (e.g., 3,5,7a,7c,7d)', $record)]; + $errors[] = sprintf('CSV record %d: Column P: Multiple exemptions must be separated with a comma (e.g., 3,5,7a,7c,7d)', $record); } // Column P: Disposition code 3 requires at least one exemption. if ($disposition === '3' && $applied_exemptions === '') { - return [sprintf('CSV record %d: Column P: Column P must contain exemption(s) for Disposition Code 3', $record)]; + $errors[] = sprintf('CSV record %d: Column P: Column P must contain exemption(s) for Disposition Code 3', $record); } // Column P: Exemptions are allowed only for disposition codes 2 or 3. if ($applied_exemptions !== '' && !in_array($disposition, ['2', '3'], TRUE)) { - return [sprintf('CSV record %d: Column P: Column N must list Disposition Code 2 or 3 if Column P contains exemptions', $record)]; + $errors[] = sprintf('CSV record %d: Column P: Column N must list Disposition Code 2 or 3 if Column P contains exemptions', $record); } // Column P: A complete exemption 3 entry requires statute data in E. // This complements the Column E check requiring exemption 3 in P. $exemptions = array_map('trim', explode(',', $applied_exemptions)); if (in_array('3', $exemptions, TRUE) && $statutes === '') { - return [sprintf("CSV record %d: Column P: If Column P contains a '3', Column E must contain information", $record)]; + $errors[] = sprintf("CSV record %d: Column P: If Column P contains a '3', Column E must contain information", $record); } // Column Q: Request for EP - Date Received is optional. $ep_received = trim($columns[16]); + $ep_received_date = $this->parseDate($ep_received); if ($ep_received !== '') { // Column Q: Require a real date using the same format as I, J and K. - if (!preg_match('/^([0-9]{1,2})\/([0-9]{1,2})\/([0-9]{4})$/', $ep_received, $date_parts) - || !checkdate((int) $date_parts[1], (int) $date_parts[2], (int) $date_parts[3])) { - return [sprintf('CSV record %d: Column Q: Request for EP - Date Received must be a valid date in MM/DD/YYYY format', $record)]; + if ($ep_received_date === NULL) { + $errors[] = sprintf('CSV record %d: Column Q: Request for EP - Date Received must be a valid date in MM/DD/YYYY format', $record); } // Column Q: September 30 of the report year is the latest date. // Earlier years are allowed, as for Date Initially Received. - $ep_received_date = (int) $date_parts[3] * 10000 + (int) $date_parts[1] * 100 + (int) $date_parts[2]; - if ($ep_received_date > $fiscal_year * 10000 + 930) { - return [sprintf('CSV record %d: Column Q: Request for EP - Date Received is later than the fiscal year', $record)]; + if ($ep_received_date !== NULL && ($ep_received_date > $fiscal_year * 10000 + 930)) { + $errors[] = sprintf('CSV record %d: Column Q: Request for EP - Date Received is later than the fiscal year', $record); } } // Column Q: A received date is required when R or S has data. $ep_determined = trim($columns[17]); + $ep_determined_date = $this->parseDate($ep_determined); if ($ep_received === '' && ($ep_determined !== '' || trim($columns[18]) !== '')) { - return [sprintf('CSV record %d: Column Q: Column Q must contain value if there is value in either Columns R or S', $record)]; + $errors[] = sprintf('CSV record %d: Column Q: Column Q must contain value if there is value in either Columns R or S', $record); } if ($ep_determined !== '') { // Column R: Require a real date using the same format as Column Q. - if (!preg_match('/^([0-9]{1,2})\/([0-9]{1,2})\/([0-9]{4})$/', $ep_determined, $date_parts) - || !checkdate((int) $date_parts[1], (int) $date_parts[2], (int) $date_parts[3])) { - return [sprintf('CSV record %d: Column R: Request for EP - Date of Determination must be a valid date in MM/DD/YYYY format', $record)]; + if ($ep_determined_date === NULL) { + $errors[] = sprintf('CSV record %d: Column R: Request for EP - Date of Determination must be a valid date in MM/DD/YYYY format', $record); } // Column R: Require the report fiscal year, including its boundaries. - $ep_determined_date = (int) $date_parts[3] * 10000 + (int) $date_parts[1] * 100 + (int) $date_parts[2]; - if ($ep_determined_date < ($fiscal_year - 1) * 10000 + 1001 || $ep_determined_date > $fiscal_year * 10000 + 930) { - return [sprintf('CSV record %d: Column R: Request for EP - Date of Determination is outside the fiscal year', $record)]; + if ($ep_determined_date !== NULL && ($ep_determined_date < ($fiscal_year - 1) * 10000 + 1001 || $ep_determined_date > $fiscal_year * 10000 + 930)) { + $errors[] = sprintf('CSV record %d: Column R: Request for EP - Date of Determination is outside the fiscal year', $record); } // Column R: Determination cannot precede a received date in Q. - if ($ep_received !== '' && $ep_determined_date < $ep_received_date) { - return [sprintf('CSV record %d: Column R: Request Expedited Processing - Date of Determination cannot be prior to Request Expedited Processing - Date Received', $record)]; + if ($ep_received_date !== NULL && $ep_determined_date !== NULL && $ep_determined_date < $ep_received_date) { + $errors[] = sprintf('CSV record %d: Column R: Request Expedited Processing - Date of Determination cannot be prior to Request Expedited Processing - Date Received', $record); } } // Column S: A determination date in R requires uppercase G or D. if ($ep_determined !== '' && !in_array(trim($columns[18]), ['G', 'D'], TRUE)) { - return [sprintf('CSV record %d: Column S: Must have G or D in Column S', $record)]; + $errors[] = sprintf('CSV record %d: Column S: Must have G or D in Column S', $record); } // Column T: Request for FW - Date Adjudication Began is optional. $fw_began = trim($columns[19]); + $fw_began_date = $this->parseDate($fw_began); if ($fw_began !== '') { // Column T: Require a real date using the same format as Column Q. - if (!preg_match('/^([0-9]{1,2})\/([0-9]{1,2})\/([0-9]{4})$/', $fw_began, $date_parts) - || !checkdate((int) $date_parts[1], (int) $date_parts[2], (int) $date_parts[3])) { - return [sprintf('CSV record %d: Column T: Request for FW - Date Adjudication Began must be a valid date in MM/DD/YYYY format', $record)]; + if ($fw_began_date === NULL) { + $errors[] = sprintf('CSV record %d: Column T: Request for FW - Date Adjudication Began must be a valid date in MM/DD/YYYY format', $record); } // Column T: September 30 of the report year is the latest date. // Earlier years are allowed, as for the other received dates. - $fw_began_date = (int) $date_parts[3] * 10000 + (int) $date_parts[1] * 100 + (int) $date_parts[2]; - if ($fw_began_date > $fiscal_year * 10000 + 930) { - return [sprintf('CSV record %d: Column T: Request for FW - Date Adjudication Began is later than the fiscal year', $record)]; + if ($fw_began_date !== NULL && ($fw_began_date > $fiscal_year * 10000 + 930)) { + $errors[] = sprintf('CSV record %d: Column T: Request for FW - Date Adjudication Began is later than the fiscal year', $record); } } // Column U: Request for FW - Date Adjudication Completed is optional. $fw_completed = trim($columns[20]); + $fw_completed_date = $this->parseDate($fw_completed); if ($fw_completed !== '') { // Column U: Require a real date using the same format as Column T. - if (!preg_match('/^([0-9]{1,2})\/([0-9]{1,2})\/([0-9]{4})$/', $fw_completed, $date_parts) - || !checkdate((int) $date_parts[1], (int) $date_parts[2], (int) $date_parts[3])) { - return [sprintf('CSV record %d: Column U: Request for FW - Date Adjudication Completed must be a valid date in MM/DD/YYYY format', $record)]; + if ($fw_completed_date === NULL) { + $errors[] = sprintf('CSV record %d: Column U: Request for FW - Date Adjudication Completed must be a valid date in MM/DD/YYYY format', $record); } // Column U: Require the report fiscal year, including its boundaries. - $fw_completed_date = (int) $date_parts[3] * 10000 + (int) $date_parts[1] * 100 + (int) $date_parts[2]; - if ($fw_completed_date < ($fiscal_year - 1) * 10000 + 1001 || $fw_completed_date > $fiscal_year * 10000 + 930) { - return [sprintf('CSV record %d: Column U: Request for FW - Date Adjudication Completed is outside of the fiscal year', $record)]; + if ($fw_completed_date !== NULL && ($fw_completed_date < ($fiscal_year - 1) * 10000 + 1001 || $fw_completed_date > $fiscal_year * 10000 + 930)) { + $errors[] = sprintf('CSV record %d: Column U: Request for FW - Date Adjudication Completed is outside of the fiscal year', $record); } // Column U: Completion cannot precede the start date in T, if given. - if ($fw_began !== '' && $fw_completed_date < $fw_began_date) { - return [sprintf('CSV record %d: Column U: Request for Fee Waiver - Date Adjudication Completed cannot be prior to Request for Fee Waiver - Date Adjudication Began', $record)]; + if ($fw_began_date !== NULL && $fw_completed_date !== NULL && $fw_completed_date < $fw_began_date) { + $errors[] = sprintf('CSV record %d: Column U: Request for Fee Waiver - Date Adjudication Completed cannot be prior to Request for Fee Waiver - Date Adjudication Began', $record); } } // Column V: A completion date in U requires uppercase G or D. if ($fw_completed !== '' && !in_array(trim($columns[21]), ['G', 'D'], TRUE)) { - return [sprintf('CSV record %d: Column V: Must complete G or D in Column V', $record)]; + $errors[] = sprintf('CSV record %d: Column V: Must complete G or D in Column V', $record); } } if (!feof($stream)) { - return ['The CSV file could not be read completely. Please upload it again.']; + $errors[] = 'The CSV file could not be read completely. Please upload it again.'; } if (!$has_content) { - return ['The CSV file is empty. Please upload a CSV with 29 columns matching the OIP request raw data template.']; + $errors[] = 'The CSV file is empty. Please upload a CSV with 29 columns matching the OIP request raw data template.'; } - return []; + // A repeated list-entry failure should appear only once per record. + return array_values(array_unique($errors)); } finally { fclose($stream); } } + /** + * Parses a real calendar date, returning NULL for blank or invalid input. + * + * Dependent comparisons run only when both dates parsed successfully. + */ + private function parseDate(string $value): ?int { + if (!preg_match('/^([0-9]{1,2})\/([0-9]{1,2})\/([0-9]{4})$/', $value, $parts) + || !checkdate((int) $parts[1], (int) $parts[2], (int) $parts[3])) { + return NULL; + } + return (int) $parts[3] * 10000 + (int) $parts[1] * 100 + (int) $parts[2]; + } + /** * Counts weekdays after the start date through the end date, minus holidays. * diff --git a/docroot/modules/custom/foia_raw_data_to_report/tests/integration/component_uploads.php b/docroot/modules/custom/foia_raw_data_to_report/tests/integration/component_uploads.php index 5a957a905..465e79022 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/tests/integration/component_uploads.php +++ b/docroot/modules/custom/foia_raw_data_to_report/tests/integration/component_uploads.php @@ -92,9 +92,12 @@ $worker->processItem(['nid' => (int) $report->id()]); return $node_storage->loadUnchanged($report->id()); }; + // Multiple failures in one upload must all reach the persisted messages. + file_put_contents($files[1]->getFileUri(), "\nshort,row", FILE_APPEND); $report = $process(); $message = $report->get('field_messages')->value; $check(str_contains($message, 'CSV validated.') && str_contains($message, '28 columns'), 'All files must be checked, with per-file results.'); + $check(str_contains($message, 'CSV record 1 has 28 columns') && str_contains($message, 'CSV record 2 has 2 columns'), 'Messages lost errors from later records.'); foreach ($files as $file) { $check(str_contains($message, $file->getFilename()), 'Messages must identify each filename.'); } diff --git a/docroot/modules/custom/foia_raw_data_to_report/tests/src/Unit/CsvValidatorTest.php b/docroot/modules/custom/foia_raw_data_to_report/tests/src/Unit/CsvValidatorTest.php index b9c6000a9..9dc556042 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/tests/src/Unit/CsvValidatorTest.php +++ b/docroot/modules/custom/foia_raw_data_to_report/tests/src/Unit/CsvValidatorTest.php @@ -61,7 +61,7 @@ private function csvRow(array $overrides = []): string { public function testExpeditedReceivedDependency(): void { $header = implode(',', array_fill(0, 29, 'column')) . "\n"; foreach ([ - [17 => '01/03/2026'], + [17 => '01/03/2026', 18 => 'D'], [18 => 'D'], [16 => ' ', 17 => '01/03/2026', 18 => 'D'], ] as $overrides) { @@ -95,8 +95,8 @@ public function testAppealRequestDataExclusion(): void { $overrides = [8 => '', 23 => '01/02/2026']; // Zero is data too, even though PHP treats it as an empty value. $overrides[$column] = '0'; - $this->assertSame( - ['CSV record 2: If there is data in Column X, Columns E through W must be empty, except for Columns Q, R, and S; those may optionally have data, but Columns E through P and Columns T through W must be blank.'], + $this->assertContains( + 'CSV record 2: If there is data in Column X, Columns E through W must be empty, except for Columns Q, R, and S; those may optionally have data, but Columns E through P and Columns T through W must be blank.', $this->validateContents($header . $this->csvRow($overrides)), 'Column index ' . $column, ); @@ -193,6 +193,65 @@ public function testInvalidDataRecord(): void { $this->assertStringContainsString('record 3 has 30 columns; expected 29', $errors[0]); } + /** + * Collects multiple failures per row and checks duplicates after errors. + */ + public function testCollectsAllErrors(): void { + $header = implode(',', array_fill(0, 29, 'column')) . "\n"; + $first = $this->csvRow([0 => '', 3 => '10']); + $second = $this->csvRow([0 => '', 16 => '', 18 => 'D']); + $this->assertSame([ + 'CSV record 2: Column A: Component cannot be blank', + 'CSV record 2: Column D: Must complete Days Allowed with 20 or 30', + 'CSV record 3: Column A: Component cannot be blank', + 'CSV record 3: Column B: Request Number is duplicate', + 'CSV record 3: Column Q: Column Q must contain value if there is value in either Columns R or S', + ], $this->validateContents($header . $first . $second)); + } + + /** + * A malformed header or row must not suppress validation of later rows. + */ + public function testContinuesAfterMalformedRecords(): void { + $contents = "bad,header\nshort,row\n" . $this->csvRow([0 => '']); + $errors = $this->validateContents($contents); + $this->assertCount(3, $errors); + $this->assertStringContainsString('record 1 has 2 columns', $errors[0]); + $this->assertStringContainsString('record 2 has 2 columns', $errors[1]); + $this->assertSame('CSV record 3: Column A: Component cannot be blank', $errors[2]); + } + + /** + * Invalid dates cannot cause calculations or leak dates between rows. + */ + public function testInvalidDatesDoNotPreventOtherChecks(): void { + $header = implode(',', array_fill(0, 29, 'column')) . "\n"; + $valid = $this->csvRow([9 => '01/02/2026', 12 => 'S']); + $invalid = $this->csvRow([ + 1 => 'Request 2', + 8 => 'bad', + 9 => 'bad', + 10 => 'bad', + 11 => '1', + 12 => 'S', + 13 => '1', + 16 => 'bad', + 17 => 'bad', + 18 => 'D', + 19 => 'bad', + 20 => 'bad', + 21 => 'D', + ]); + $later = $this->csvRow([1 => 'Request 3', 0 => '']); + $errors = $this->validateContents($header . $valid . $invalid . $later); + $this->assertCount(8, $errors); + foreach (array_slice($errors, 0, 7) as $error) { + $this->assertStringContainsString('CSV record 3:', $error); + $this->assertStringContainsString('must be a valid date', $error); + } + $this->assertSame('CSV record 4: Column A: Component cannot be blank', $errors[7]); + } + /** * Rejects empty input rather than silently producing an XML report. */ From d26263e4d548f1551f94ea86b866e91c8e0030e8 Mon Sep 17 00:00:00 2001 From: Rob Ristroph Date: Thu, 24 Sep 2026 14:39:16 -0500 Subject: [PATCH 13/24] WS-472: Added CSV validation for final columns. --- .../custom/foia_raw_data_to_report/README.md | 34 ++++ .../src/CsvValidator.php | 79 ++++++++ .../tests/integration/component_uploads.php | 10 +- .../tests/src/Unit/CsvValidatorTest.php | 180 +++++++++++++++++- 4 files changed, 290 insertions(+), 13 deletions(-) diff --git a/docroot/modules/custom/foia_raw_data_to_report/README.md b/docroot/modules/custom/foia_raw_data_to_report/README.md index bebbb8324..3f2624843 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/README.md +++ b/docroot/modules/custom/foia_raw_data_to_report/README.md @@ -754,3 +754,37 @@ Every component and the agency have a `BacklogComparison`, including zeros, with `ABC1`, `ABC2`, etc. and `ABC0` linked to Organization entries through `BacklogComparisonOrganizationAssociation`. Request and appeal backlog comparisons share the same XML formatter and need no additional CSV pass. + +### Appeal receipt date validation + +Every data row must have exactly one receipt date: Column I for an initial +request or Column X for an appeal. If I is blank, X is required; both blank +produces "Appeal Date Received cannot be blank". The existing Column X +exclusion rule rejects rows containing both I and X. + +When populated, X must be a real MM/DD/YYYY date (single-digit months/days are +accepted), no later than September 30 of the report year. Earlier fiscal years +are allowed. Errors identify Column X and the CSV record; the queue adds the +filename and component when saving messages. + +Column Y (Appeal Date Closed) may be blank when Z (Appeal Disposition) is blank. +When populated, it must be a valid MM/DD/YYYY date within the inclusive fiscal +year and cannot precede a valid X date. Y requires Z, and Z requires Y; these +presence checks are independent of date validity. All failures identify Column +Y and the record and are collected with other validation messages. + +Column Z dispositions `Affirmed`, `Affirmed on Appeal`, and +`Partially Affirmed & Partially Reversed/Remanded` require a nonblank AA or AC +(or both). `Closed for Other Reasons` requires nonblank AA even when AC has +data. Surrounding whitespace is ignored. These presence checks do not change +the disposition labels recognized by XML aggregation. + +Column AB requires nonblank explanatory text when AA contains `Other` as a +complete comma-separated reason. Whitespace around entries is ignored; a +longer reason such as `Improper Request for Other Reasons` does not trigger +this rule. Failures identify Column AB and the CSV record. + +Nonblank Column AC requires Column Z to be exactly `Affirmed on Appeal` or +`Partially Affirmed & Partially Reversed/Remanded`, ignoring surrounding +whitespace. The shorter `Affirmed` label does not satisfy this rule. Blank AC +adds no disposition requirement. Errors identify Column AC and the record. diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/CsvValidator.php b/docroot/modules/custom/foia_raw_data_to_report/src/CsvValidator.php index b77c93244..7c120cf50 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/src/CsvValidator.php +++ b/docroot/modules/custom/foia_raw_data_to_report/src/CsvValidator.php @@ -405,6 +405,85 @@ public function validate(string $uri, int $fiscal_year): array { if ($fw_completed !== '' && !in_array(trim($columns[21]), ['G', 'D'], TRUE)) { $errors[] = sprintf('CSV record %d: Column V: Must complete G or D in Column V', $record); } + + // Column X: Require an appeal receipt date when I is blank. + // The earlier Column X exclusion rule already forbids I and X together. + if ($appeal_received === '' && $initially_received === '') { + $errors[] = sprintf('CSV record %d: Column X: Appeal Date Received cannot be blank', $record); + } + $appeal_received_date = NULL; + if ($appeal_received !== '') { + // Column X: Require a real date, no later than fiscal year-end. + $appeal_received_date = $this->parseDate($appeal_received); + if ($appeal_received_date === NULL) { + $errors[] = sprintf('CSV record %d: Column X: Appeal Date Received must be a valid date in MM/DD/YYYY format', $record); + } + elseif ($appeal_received_date > $fiscal_year * 10000 + 930) { + $errors[] = sprintf('CSV record %d: Column X: Appeal Date Received is later than the fiscal year', $record); + } + } + + // Column Y: A populated Appeal Date Closed must be a real date. + $appeal_closed = trim($columns[24]); + $appeal_closed_date = $this->parseDate($appeal_closed); + if ($appeal_closed !== '') { + if ($appeal_closed_date === NULL) { + $errors[] = sprintf('CSV record %d: Column Y: Appeal Date Closed must be a valid date in MM/DD/YYYY format', $record); + } + // Column Y: Closure must be within the inclusive fiscal year. + elseif ($appeal_closed_date < ($fiscal_year - 1) * 10000 + 1001 || $appeal_closed_date > $fiscal_year * 10000 + 930) { + $errors[] = sprintf('CSV record %d: Column Y: Appeal Date Closed is outside the fiscal year', $record); + } + } + + // Column Y: A closed date requires a disposition in Column Z. + $appeal_disposition = trim($columns[25]); + if ($appeal_closed !== '' && $appeal_disposition === '') { + $errors[] = sprintf('CSV record %d: Column Y: Appeal Disposition is required if Appeal Closed Date is entered ', $record); + } + + // Column Y: A disposition in Column Z requires a closed date. + if ($appeal_disposition !== '' && $appeal_closed === '') { + $errors[] = sprintf('CSV record %d: Column Y: Appeal Closed Date is required if Appeal Disposition is entered', $record); + } + + // Column Y: Compare valid dates only; closure cannot precede receipt. + if ($appeal_closed_date !== NULL && $appeal_received_date !== NULL && $appeal_closed_date < $appeal_received_date) { + $errors[] = sprintf('CSV record %d: Column Y: Appeal Date Closed cannot be prior to Appeal Date Received', $record); + } + + // Column Z: Affirmed appeals need denial reasons or exemptions. + // Include the reference CSV's full label, Affirmed on Appeal. + $affirmed_dispositions = [ + 'Affirmed', + 'Affirmed on Appeal', + 'Partially Affirmed & Partially Reversed/Remanded', + ]; + if (in_array($appeal_disposition, $affirmed_dispositions, TRUE) + && trim($columns[26]) === '' && trim($columns[28]) === '') { + $errors[] = sprintf('CSV record %d: Column Z: If Column Z has "Affirmed" or "Partially Affirmed & Partially Reversed/Remanded", there must be an entry in Column AA and/or Column AC', $record); + } + + // Column Z: Other closures require at least one reason in AA. + if ($appeal_disposition === 'Closed for Other Reasons' && trim($columns[26]) === '') { + $errors[] = sprintf('CSV record %d: Column Z: If Column Z has appeal disposition "Closed for Other Reasons", then Column AA must have at least one reason entered', $record); + } + + // Column AB: An Other reason in AA requires explanatory text. + // AA may contain multiple comma-separated reasons; match a whole entry. + $appeal_denial_reasons = array_map('trim', explode(',', $columns[26])); + if (in_array('Other', $appeal_denial_reasons, TRUE) && trim($columns[27]) === '') { + $errors[] = sprintf('CSV record %d: Column AB: If Column AA has "Other" entered, there must be a value in Column AB', $record); + } + + // Column AC: Appeal exemptions require one of these dispositions in Z. + $exemption_dispositions = [ + 'Affirmed on Appeal', + 'Partially Affirmed & Partially Reversed/Remanded', + ]; + if (trim($columns[28]) !== '' && !in_array($appeal_disposition, $exemption_dispositions, TRUE)) { + $errors[] = sprintf('CSV record %d: Column AC: If AC has exemptions, Column Z must be Affirmed on Appeal or Partially Affirmed & Partially Reversed/Remanded', $record); + } } if (!feof($stream)) { $errors[] = 'The CSV file could not be read completely. Please upload it again.'; diff --git a/docroot/modules/custom/foia_raw_data_to_report/tests/integration/component_uploads.php b/docroot/modules/custom/foia_raw_data_to_report/tests/integration/component_uploads.php index 465e79022..9c47f1a0f 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/tests/integration/component_uploads.php +++ b/docroot/modules/custom/foia_raw_data_to_report/tests/integration/component_uploads.php @@ -147,17 +147,19 @@ ['component_id' => $components[1]->id(), 'uri' => $files[1]->getFileUri()], ], 2026); $check($counts['overall'] === ['pending_start' => 0, 'received' => 0, 'processed' => 0, 'pending_end' => 0], 'Appeal-only row contributed to request statistics.'); - // Invalid appeal dates still exercise post-validation exception reporting. - $row[23] = 'invalid-date'; + // An unknown appeal exemption exercises post-validation exception reporting. + $row[24] = '01/03/2026'; + $row[25] = 'Affirmed on Appeal'; + $row[28] = 'invalid-code'; $contents = implode(',', array_fill(0, 29, 'column')) . "\n" . implode(',', $row); file_put_contents($files[1]->getFileUri(), $contents); for ($attempt = 0; $attempt < 2; $attempt++) { try { $process(); - throw new LogicException('Expected the appeal-statistics date exception.'); + throw new LogicException('Expected the appeal-exemption exception.'); } catch (RuntimeException $exception) { - $check(str_contains($exception->getMessage(), 'Column X'), 'Unexpected processing exception.'); + $check(str_contains($exception->getMessage(), 'Column AC'), 'Unexpected processing exception.'); $report = $node_storage->loadUnchanged($report->id()); $message = $report->get('field_messages')->value; $check(substr_count($message, 'CSV validated.') === 2, 'Exception lost validation messages.'); diff --git a/docroot/modules/custom/foia_raw_data_to_report/tests/src/Unit/CsvValidatorTest.php b/docroot/modules/custom/foia_raw_data_to_report/tests/src/Unit/CsvValidatorTest.php index 9dc556042..9abc27f29 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/tests/src/Unit/CsvValidatorTest.php +++ b/docroot/modules/custom/foia_raw_data_to_report/tests/src/Unit/CsvValidatorTest.php @@ -28,6 +28,158 @@ private function validateContents(string $contents): array { } } + /** + * Requires exactly one receipt date and checks appeal dates and year-end. + */ + public function testAppealReceivedRequiredAndFiscalYear(): void { + $header = implode(',', array_fill(0, 29, 'column')) . "\n"; + foreach (['', ' '] as $value) { + $row = $this->csvRow([8 => ' ', 23 => $value]); + $this->assertSame(['CSV record 2: Column X: Appeal Date Received cannot be blank'], $this->validateContents($header . $row)); + } + $this->assertSame([], $this->validateContents($header . $this->csvRow())); + $this->assertSame([], $this->validateContents($header . $this->csvRow([2 => 'Y', 3 => '']))); + $both = $this->validateContents($header . $this->csvRow([23 => '01/02/2026'])); + $this->assertCount(1, $both); + $this->assertStringContainsString('If there is data in Column X, Columns E through W must be empty', $both[0]); + foreach (['9/30/2026', '10/01/2025', '01/01/2020'] as $value) { + $this->assertSame([], $this->validateContents($header . $this->csvRow([8 => '', 23 => $value]))); + } + $rows = $this->csvRow([8 => '', 23 => '10/01/2026']); + $rows .= $this->csvRow([1 => 'Request 2', 8 => '', 23 => '02/30/2026']); + $rows .= $this->csvRow([1 => 'Request 3', 8 => '', 23 => '']); + $this->assertSame([ + 'CSV record 2: Column X: Appeal Date Received is later than the fiscal year', + 'CSV record 3: Column X: Appeal Date Received must be a valid date in MM/DD/YYYY format', + 'CSV record 4: Column X: Appeal Date Received cannot be blank', + ], $this->validateContents($header . $rows)); + } + + /** + * Checks appeal closure dates, dispositions, and independent row state. + */ + public function testAppealClosedRules(): void { + $header = implode(',', array_fill(0, 29, 'column')) . "\n"; + $base = [8 => '', 23 => '01/01/2025']; + foreach (['10/01/2025', '09/30/2026'] as $date) { + $row = $this->csvRow($base + [24 => $date, 25 => 'Affirmed on Appeal', 26 => 'No Records']); + $this->assertSame([], $this->validateContents($header . $row)); + } + $cases = [ + [ + [24 => '09/30/2025', 25 => 'Affirmed on Appeal', 26 => 'No Records'], + 'Appeal Date Closed is outside the fiscal year', + ], + [ + [24 => '10/01/2026', 25 => 'Affirmed on Appeal', 26 => 'No Records'], + 'Appeal Date Closed is outside the fiscal year', + ], + [ + [24 => '01/02/2026'], + 'Appeal Disposition is required if Appeal Closed Date is entered ', + ], + [ + [24 => ' ', 25 => 'Affirmed on Appeal', 26 => 'No Records'], + 'Appeal Closed Date is required if Appeal Disposition is entered', + ], + [ + [23 => '01/03/2026', 24 => '01/02/2026', 25 => 'Affirmed on Appeal', 26 => 'No Records'], + 'Appeal Date Closed cannot be prior to Appeal Date Received', + ], + [ + [24 => '02/30/2026', 25 => 'Affirmed on Appeal', 26 => 'No Records'], + 'Appeal Date Closed must be a valid date in MM/DD/YYYY format', + ], + ]; + foreach ($cases as [$values, $message]) { + $row = $this->csvRow(array_replace($base, $values)); + $this->assertSame(['CSV record 2: Column Y: ' . $message], $this->validateContents($header . $row)); + } + // A previous row's received date must not leak into a blank X comparison. + $first = $this->csvRow([8 => '', 23 => '09/30/2026']); + $second = $this->csvRow([1 => 'Request 2', 24 => '01/02/2026', 25 => 'Affirmed on Appeal', 26 => 'No Records']); + $this->assertSame([], $this->validateContents($header . $first . $second)); + $row = $this->csvRow([8 => '', 23 => '01/03/2026', 24 => '09/30/2025']); + $this->assertCount(3, $this->validateContents($header . $row)); + } + + /** + * Checks denial reasons and exemptions required by appeal dispositions. + */ + public function testAppealDispositionReasons(): void { + $header = implode(',', array_fill(0, 29, 'column')) . "\n"; + $base = [8 => '', 23 => '01/01/2026', 24 => '01/02/2026']; + foreach (['Affirmed', 'Affirmed on Appeal', 'Partially Affirmed & Partially Reversed/Remanded'] as $label) { + $row = $this->csvRow($base + [25 => $label, 26 => ' ', 28 => '']); + $this->assertSame([ + 'CSV record 2: Column Z: If Column Z has "Affirmed" or "Partially Affirmed & Partially Reversed/Remanded", there must be an entry in Column AA and/or Column AC', + ], $this->validateContents($header . $row)); + foreach ([[26 => 'No Records'], [28 => '3'], [26 => 'No Records', 28 => '3']] as $values) { + $row = $this->csvRow($base + [25 => ' ' . $label . ' '] + $values); + $expected = $label === 'Affirmed' && isset($values[28]) + ? ['CSV record 2: Column AC: If AC has exemptions, Column Z must be Affirmed on Appeal or Partially Affirmed & Partially Reversed/Remanded'] + : []; + $this->assertSame($expected, $this->validateContents($header . $row)); + } + } + $row = $this->csvRow($base + [25 => 'Closed for Other Reasons']); + $this->assertSame([ + 'CSV record 2: Column Z: If Column Z has appeal disposition "Closed for Other Reasons", then Column AA must have at least one reason entered', + ], $this->validateContents($header . $row)); + $row = $this->csvRow($base + [25 => 'Closed for Other Reasons', 26 => 'No Records']); + $this->assertSame([], $this->validateContents($header . $row)); + $row = $this->csvRow($base + [25 => 'Completely Reversed/Remanded']); + $this->assertSame([], $this->validateContents($header . $row)); + } + + /** + * Requires explanation for the complete Other entry in appeal denial lists. + */ + public function testAppealOtherReasonExplanation(): void { + $header = implode(',', array_fill(0, 29, 'column')) . "\n"; + $base = [8 => '', 23 => '01/01/2026']; + foreach (['Other', 'No Records, Other', ' Other ,Other'] as $reasons) { + foreach (['', ' '] as $explanation) { + $row = $this->csvRow($base + [26 => $reasons, 27 => $explanation]); + $this->assertSame([ + 'CSV record 2: Column AB: If Column AA has "Other" entered, there must be a value in Column AB', + ], $this->validateContents($header . $row)); + } + $row = $this->csvRow($base + [26 => $reasons, 27 => 'Explanation']); + $this->assertSame([], $this->validateContents($header . $row)); + } + foreach (['', 'No Records', 'Improper Request for Other Reasons'] as $reasons) { + $row = $this->csvRow($base + [26 => $reasons]); + $this->assertSame([], $this->validateContents($header . $row)); + } + } + + /** + * Restricts appeal exemptions to the two specified disposition labels. + */ + public function testAppealExemptionDisposition(): void { + $header = implode(',', array_fill(0, 29, 'column')) . "\n"; + $base = [8 => '', 23 => '01/01/2026', 24 => '01/02/2026', 26 => 'No Records']; + foreach (['Affirmed on Appeal', 'Partially Affirmed & Partially Reversed/Remanded'] as $label) { + $row = $this->csvRow($base + [25 => ' ' . $label . ' ', 28 => '3, 5']); + $this->assertSame([], $this->validateContents($header . $row)); + } + foreach (['Affirmed', 'Closed for Other Reasons', 'Completely Reversed/Remanded'] as $label) { + $row = $this->csvRow($base + [25 => $label, 28 => '3']); + $this->assertSame([ + 'CSV record 2: Column AC: If AC has exemptions, Column Z must be Affirmed on Appeal or Partially Affirmed & Partially Reversed/Remanded', + ], $this->validateContents($header . $row)); + } + $row = $this->csvRow([8 => '', 23 => '01/01/2026', 28 => '0']); + $this->assertSame([ + 'CSV record 2: Column AC: If AC has exemptions, Column Z must be Affirmed on Appeal or Partially Affirmed & Partially Reversed/Remanded', + ], $this->validateContents($header . $row)); + foreach (['', ' '] as $value) { + $row = $this->csvRow([8 => '', 23 => '01/01/2026', 28 => $value]); + $this->assertSame([], $this->validateContents($header . $row)); + } + } + /** * Accepts quoted commas, quotes, multiline values, and blank lines. */ @@ -120,10 +272,22 @@ public function testAppealExpeditedProcessing(): void { $this->assertSame([], $this->validateContents($header . $this->csvRow($appeal + $values))); } $cases = [ - [[18 => 'D'], 'Column Q must contain value'], - [[16 => 'bad-date'], 'Column Q: Request for EP - Date Received must be a valid date'], - [[16 => '01/03/2026', 17 => '01/02/2026', 18 => 'D'], 'cannot be prior'], - [[16 => '01/03/2026', 17 => '01/04/2026'], 'Must have G or D in Column S'], + [ + [18 => 'D'], + 'Column Q must contain value', + ], + [ + [16 => 'bad-date'], + 'Column Q: Request for EP - Date Received must be a valid date', + ], + [ + [16 => '01/03/2026', 17 => '01/02/2026', 18 => 'D'], + 'cannot be prior', + ], + [ + [16 => '01/03/2026', 17 => '01/04/2026'], + 'Must have G or D in Column S', + ], ]; foreach ($cases as [$values, $message]) { $errors = $this->validateContents($header . $this->csvRow($appeal + $values)); @@ -132,15 +296,13 @@ public function testAppealExpeditedProcessing(): void { } /** - * Allows blank I with or without X, and validates nonblank received dates. + * Allows blank I with X, and validates nonblank received dates. */ public function testOptionalInitiallyReceived(): void { $header = implode(',', array_fill(0, 29, 'column')) . "\n"; foreach ([ - [8 => ''], [8 => '', 23 => '01/02/2026'], - [8 => ' ', 23 => '01/02/2026', 24 => '01/03/2026'], - [8 => '', 9 => '01/02/2026', 12 => 'S'], + [8 => ' ', 23 => '01/02/2026', 24 => '01/03/2026', 25 => 'Affirmed on Appeal', 26 => 'No Records'], [8 => '01/01/2026'], ] as $overrides) { $this->assertSame([], $this->validateContents($header . $this->csvRow($overrides))); @@ -156,7 +318,7 @@ public function testOptionalInitiallyReceived(): void { // A blank I on the next row must not reuse the previous row's date. $first = $this->csvRow([8 => '09/01/2026']); $second = $this->csvRow([1 => 'Request 2', 8 => '', 9 => '01/02/2026', 12 => 'S']); - $this->assertSame([], $this->validateContents($header . $first . $second)); + $this->assertSame(['CSV record 3: Column X: Appeal Date Received cannot be blank'], $this->validateContents($header . $first . $second)); $row = $this->csvRow([8 => '01/03/2026', 9 => '01/02/2026', 12 => 'S']); $this->assertSame( ['CSV record 2: Column J: Date Perfected cannot be prior to Date Initially Received'], From 6513b50b4e82e1c7a1d21443c4431d35a33a5164 Mon Sep 17 00:00:00 2001 From: Rob Ristroph Date: Thu, 24 Sep 2026 15:12:45 -0500 Subject: [PATCH 14/24] WS-472: Added a notification message to the user when processing is finished. --- .../custom/foia_raw_data_to_report/README.md | 20 +++- .../foia_raw_data_to_report.services.yml | 11 +++ .../ReportNotificationSubscriber.php | 39 ++++++++ .../src/Form/GenerateXmlReportForm.php | 4 +- .../QueueWorker/RawDataToReportProcessing.php | 7 +- .../src/ReportNotifications.php | 97 +++++++++++++++++++ .../tests/integration/component_uploads.php | 62 +++++++++++- 7 files changed, 234 insertions(+), 6 deletions(-) create mode 100644 docroot/modules/custom/foia_raw_data_to_report/src/EventSubscriber/ReportNotificationSubscriber.php create mode 100644 docroot/modules/custom/foia_raw_data_to_report/src/ReportNotifications.php diff --git a/docroot/modules/custom/foia_raw_data_to_report/README.md b/docroot/modules/custom/foia_raw_data_to_report/README.md index 3f2624843..18ac34722 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/README.md +++ b/docroot/modules/custom/foia_raw_data_to_report/README.md @@ -250,7 +250,8 @@ changed. Import the exported configuration and rebuild caches before running the queue. The old node-level CSV field is replaced, with no migration of existing uploads. This follows the pre-launch assumption that existing upload content is disposable. -The queue payload remains the parent node ID. Edits during processing are not +The queue payload includes the parent node ID, requester UID, and a unique +notification ID. Edits during processing are not locked or snapshotted. CSV conversion produces one partial annual report XML document only after every component upload passes validation. @@ -788,3 +789,20 @@ Nonblank Column AC requires Column Z to be exactly `Affirmed on Appeal` or `Partially Affirmed & Partially Reversed/Remanded`, ignoring surrounding whitespace. The shorter `Affirmed` label does not satisfy this rule. Blank AC adds no disposition requirement. Errors identify Column AC and the record. + +## Completion notifications + +New queue items record the user who clicks Generate XML Report and a unique +notification ID. Successful XML generation, validation failure, and XML +processing exceptions store a personal notification. On the requester's next +normal HTML GET page visit, it becomes a Drupal status/error message linking +to the report. No email is sent and no polling is performed. Rebuild Drupal's +cache after deploying these service definitions (`ddev drush cr` locally). + +Delivery checks current node view access and suppresses deleted/inaccessible +reports. AJAX and non-HTML requests do not consume notices. Notices and delivery +receipts expire after 30 days; receipts suppress repeated failure notices for +retries of the same queue item. A successful retry supersedes an unread failure. +Older queue items without requester metadata still process normally but cannot +notify a user. Notification storage failures are logged without altering XML +processing or queue retry behavior. diff --git a/docroot/modules/custom/foia_raw_data_to_report/foia_raw_data_to_report.services.yml b/docroot/modules/custom/foia_raw_data_to_report/foia_raw_data_to_report.services.yml index a7a2b9e48..1e022f02b 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/foia_raw_data_to_report.services.yml +++ b/docroot/modules/custom/foia_raw_data_to_report/foia_raw_data_to_report.services.yml @@ -1,3 +1,14 @@ services: foia_raw_data_to_report.csv_validator: class: Drupal\foia_raw_data_to_report\CsvValidator + logger.channel.foia_raw_data_to_report: + parent: logger.channel_base + arguments: ['foia_raw_data_to_report'] + foia_raw_data_to_report.notifications: + class: Drupal\foia_raw_data_to_report\ReportNotifications + arguments: ['@keyvalue.expirable', '@entity_type.manager', '@current_user', '@messenger', '@lock', '@logger.channel.foia_raw_data_to_report'] + foia_raw_data_to_report.notification_subscriber: + class: Drupal\foia_raw_data_to_report\EventSubscriber\ReportNotificationSubscriber + arguments: ['@foia_raw_data_to_report.notifications'] + tags: + - { name: event_subscriber } diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/EventSubscriber/ReportNotificationSubscriber.php b/docroot/modules/custom/foia_raw_data_to_report/src/EventSubscriber/ReportNotificationSubscriber.php new file mode 100644 index 000000000..89d7f3e62 --- /dev/null +++ b/docroot/modules/custom/foia_raw_data_to_report/src/EventSubscriber/ReportNotificationSubscriber.php @@ -0,0 +1,39 @@ +getRequest(); + if ($event->isMainRequest() && $request->isMethod('GET') && !$request->isXmlHttpRequest() && $request->getRequestFormat() === 'html' && !$request->query->has('_wrapper_format')) { + $this->notifications->deliver(); + } + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + // After routing (32), before dynamic page cache (27). + return [KernelEvents::REQUEST => ['onRequest', 30]]; + } + +} diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/Form/GenerateXmlReportForm.php b/docroot/modules/custom/foia_raw_data_to_report/src/Form/GenerateXmlReportForm.php index ef3575714..bdc410b0f 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/src/Form/GenerateXmlReportForm.php +++ b/docroot/modules/custom/foia_raw_data_to_report/src/Form/GenerateXmlReportForm.php @@ -96,11 +96,13 @@ public function submitForm(array &$form, FormStateInterface $form_state) { } /** - * Enqueues the node ID without reading or processing the CSV file yet. + * Enqueues the node and requester without processing the CSV file yet. */ protected function generateXmlReport(NodeInterface $node): bool { return $this->queueFactory->get('raw_data_to_report_processing')->createItem([ 'nid' => (int) $node->id(), + 'requester_uid' => (int) $this->currentUser()->id(), + 'notification_id' => bin2hex(random_bytes(16)), ]) !== FALSE; } diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/Plugin/QueueWorker/RawDataToReportProcessing.php b/docroot/modules/custom/foia_raw_data_to_report/src/Plugin/QueueWorker/RawDataToReportProcessing.php index 9d4cad2e1..aa34ae7ed 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/src/Plugin/QueueWorker/RawDataToReportProcessing.php +++ b/docroot/modules/custom/foia_raw_data_to_report/src/Plugin/QueueWorker/RawDataToReportProcessing.php @@ -12,6 +12,7 @@ use Drupal\node\NodeInterface; use Drupal\taxonomy\TermInterface; use Drupal\foia_raw_data_to_report\CsvValidator; +use Drupal\foia_raw_data_to_report\ReportNotifications; use Drupal\foia_raw_data_to_report\UploadAssignments; use Drupal\foia_raw_data_to_report\XmlReportBuilder; use Drupal\foia_raw_data_to_report\StatuteAggregator; @@ -47,7 +48,7 @@ final class RawDataToReportProcessing extends QueueWorkerBase implements Contain /** * Constructs the report queue worker. */ - public function __construct(array $configuration, $plugin_id, $plugin_definition, protected EntityTypeManagerInterface $entityTypeManager, protected FileSystemInterface $fileSystem, protected FileRepositoryInterface $fileRepository, protected CsvValidator $csvValidator) { + public function __construct(array $configuration, $plugin_id, $plugin_definition, protected EntityTypeManagerInterface $entityTypeManager, protected FileSystemInterface $fileSystem, protected FileRepositoryInterface $fileRepository, protected CsvValidator $csvValidator, protected ReportNotifications $notifications) { parent::__construct($configuration, $plugin_id, $plugin_definition); } @@ -63,6 +64,7 @@ public static function create(ContainerInterface $container, array $configuratio $container->get('file_system'), $container->get('file.repository'), $container->get('foia_raw_data_to_report.csv_validator'), + $container->get('foia_raw_data_to_report.notifications'), ); } @@ -147,6 +149,7 @@ public function processItem($data) { $node->save(); if ($has_errors) { // Invalid input is a completed task, not a retryable exception. + $this->notifications->record($data, 'validation_failed'); return; } try { @@ -164,9 +167,11 @@ public function processItem($data) { ]); $stored_node->save(); } + $this->notifications->record($data, 'failed'); // Preserve Drush logging and the queue's existing retry behavior. throw $exception; } + $this->notifications->record($data, 'success'); } /** diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/ReportNotifications.php b/docroot/modules/custom/foia_raw_data_to_report/src/ReportNotifications.php new file mode 100644 index 000000000..d2e2d1c77 --- /dev/null +++ b/docroot/modules/custom/foia_raw_data_to_report/src/ReportNotifications.php @@ -0,0 +1,97 @@ +storeFactory->get('foia_report_notifications.' . (int) $item['requester_uid']); + $store->setWithExpireIfNotExists($item['notification_id'] . ':' . $outcome, [ + 'nid' => $item['nid'], + 'job' => $item['notification_id'], + 'outcome' => $outcome, + 'delivered' => FALSE, + ], self::LIFETIME); + } + catch (\Throwable $exception) { + // A notification storage problem must not regenerate or discard XML. + $this->logger->error('Unable to store report notification: @message', ['@message' => $exception->getMessage()]); + } + } + + /** + * Transfers pending notices to the current user's Drupal message session. + */ + public function deliver(): void { + if (!$this->currentUser->isAuthenticated()) { + return; + } + $uid = (int) $this->currentUser->id(); + $lock_name = 'foia_report_notifications.' . $uid; + // Concurrent tabs must not deliver the same notice twice. + if (!$this->lock->acquire($lock_name)) { + return; + } + try { + $store = $this->storeFactory->get($lock_name); + foreach ($store->getAll() as $key => $notice) { + if ($notice['delivered']) { + continue; + } + // A successful retry supersedes an unread processing failure. + $superseded = $notice['outcome'] === 'failed' && $store->has($notice['job'] . ':success'); + $node = $this->entityTypeManager->getStorage('node')->load($notice['nid']); + if (!$superseded && $node instanceof NodeInterface && $node->bundle() === 'raw_data_to_report' && $node->access('view', $this->currentUser)) { + $arguments = ['@report' => $node->toLink()->toString()]; + $message = match ($notice['outcome']) { + 'success' => new TranslatableMarkup('CSV to XML processing finished for @report.', $arguments), + 'validation_failed' => new TranslatableMarkup('CSV validation failed for @report. Review the report’s messages.', $arguments), + default => new TranslatableMarkup('CSV to XML processing failed for @report. Review the report’s messages.', $arguments), + }; + $this->messenger->addMessage($message, $notice['outcome'] === 'success' ? MessengerInterface::TYPE_STATUS : MessengerInterface::TYPE_ERROR); + } + // Retain a receipt to suppress notifications from retries of this job. + $notice['delivered'] = TRUE; + $store->setWithExpire($key, $notice, self::LIFETIME); + } + } + catch (\Throwable $exception) { + $this->logger->error('Unable to deliver report notification: @message', ['@message' => $exception->getMessage()]); + } + finally { + $this->lock->release($lock_name); + } + } + +} diff --git a/docroot/modules/custom/foia_raw_data_to_report/tests/integration/component_uploads.php b/docroot/modules/custom/foia_raw_data_to_report/tests/integration/component_uploads.php index 9c47f1a0f..13ddde26c 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/tests/integration/component_uploads.php +++ b/docroot/modules/custom/foia_raw_data_to_report/tests/integration/component_uploads.php @@ -16,6 +16,8 @@ use Drupal\taxonomy\Entity\Term; use Drupal\user\Entity\User; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Event\RequestEvent; +use Symfony\Component\HttpKernel\HttpKernelInterface; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; if (getenv('IS_DDEV_PROJECT') !== 'true') { @@ -33,6 +35,7 @@ $paragraphs = []; $files = []; $report = NULL; +$notification_store = NULL; $suffix = bin2hex(random_bytes(6)); $node_storage = \Drupal::entityTypeManager()->getStorage('node'); $worker = \Drupal::service('plugin.manager.queue_worker')->createInstance('raw_data_to_report_processing'); @@ -88,8 +91,13 @@ $check(count($paragraph->get('field_agency_component')->validate()) === 0, 'Component reference rejected on an unsaved report.'); } $report->save(); - $process = static function () use ($worker, $report, $node_storage) { - $worker->processItem(['nid' => (int) $report->id()]); + $notification_store = \Drupal::service('keyvalue.expirable')->get('foia_report_notifications.' . $manager->id()); + $process = static function () use ($worker, $report, $node_storage, $manager, $suffix) { + $worker->processItem([ + 'nid' => (int) $report->id(), + 'requester_uid' => (int) $manager->id(), + 'notification_id' => $suffix, + ]); return $node_storage->loadUnchanged($report->id()); }; // Multiple failures in one upload must all reach the persisted messages. @@ -219,9 +227,57 @@ finally { $switcher->switchBack(); } - print "PASS: multi-file validation, retry, empty uploads, XML preservation, duplicate/agency constraints, component choices, private and detached downloads.\n"; + // All worker outcomes are durable and addressed to the requesting user. + $notices = $notification_store->getAll(); + $check(count($notices) === 3, 'Expected success, validation, and processing failure notices without retry duplicates.'); + $messenger = \Drupal::messenger(); + $messenger->deleteAll(); + $notifications = \Drupal::service('foia_raw_data_to_report.notifications'); + $notifications->deliver(); + $check(!$messenger->all(), 'Notices leaked to a different account.'); + $switcher->switchTo($manager); + try { + $subscriber = \Drupal::service('foia_raw_data_to_report.notification_subscriber'); + $request = Request::create('/user'); + $request->headers->set('X-Requested-With', 'XMLHttpRequest'); + $event = new RequestEvent(\Drupal::service('http_kernel'), $request, HttpKernelInterface::MAIN_REQUEST); + $subscriber->onRequest($event); + $check(!$messenger->all(), 'AJAX consumed a notice.'); + $request->headers->remove('X-Requested-With'); + $request->setRequestFormat('json'); + $subscriber->onRequest($event); + $check(!$messenger->all(), 'JSON consumed a notice.'); + $request->setRequestFormat('html'); + $subscriber->onRequest($event); + $messages = $messenger->deleteAll(); + $check(count($messages['status'] ?? []) === 1 && count($messages['error'] ?? []) === 1, 'Missing completion notices or unread failure was not superseded.'); + $check(str_contains((string) $messages['status'][0], 'label()), 'Completion notice lacks the report link/title.'); + $notifications->deliver(); + $check(!$messenger->all(), 'Delivered notices appeared twice.'); + $notifications->record([ + 'nid' => (int) $report->id(), + 'requester_uid' => (int) $manager->id(), + 'notification_id' => $suffix, + ], 'failed'); + $notifications->deliver(); + $check(!$messenger->all(), 'Retry repeated a delivered failure notice.'); + $notifications->record(['nid' => (int) $report->id()], 'success'); + $check(count($notification_store->getAll()) === 3, 'Legacy queue metadata created a notice.'); + $notifications->record([ + 'nid' => 0, + 'requester_uid' => (int) $manager->id(), + 'notification_id' => $suffix . '-missing', + ], 'success'); + $notifications->deliver(); + $check(!$messenger->all(), 'Missing report produced a broken notification.'); + } + finally { + $switcher->switchBack(); + } + print "PASS: multi-file validation, retry, empty uploads, XML preservation, duplicate/agency constraints, component choices, private and detached downloads, requester notifications and one-time delivery.\n"; } finally { + $notification_store?->deleteAll(); if ($report) { $report->delete(); } From de972c50b853432564912753f9482f660ed2e293 Mon Sep 17 00:00:00 2001 From: Rob Ristroph Date: Thu, 24 Sep 2026 15:49:25 -0500 Subject: [PATCH 15/24] WS-472: Added Section IX-XI data csv file. --- ...raph.raw_data_component_upload.default.yml | 8 ++++++ ...raph.raw_data_component_upload.default.yml | 9 +++++++ ...omponent_upload.field_request_data_csv.yml | 2 +- ...ta_component_upload.section_ix_xi_data.yml | 27 +++++++++++++++++++ ...d.storage.paragraph.section_ix_xi_data.yml | 23 ++++++++++++++++ .../custom/foia_raw_data_to_report/README.md | 12 +++++++++ .../foia_raw_data_to_report.module | 3 ++- .../src/UploadAssignments.php | 3 +++ .../tests/integration/component_uploads.php | 19 +++++++++++-- tests/behat/features/RawDataToReport.feature | 13 +++++++-- 10 files changed, 113 insertions(+), 6 deletions(-) create mode 100644 config/default/field.field.paragraph.raw_data_component_upload.section_ix_xi_data.yml create mode 100644 config/default/field.storage.paragraph.section_ix_xi_data.yml diff --git a/config/default/core.entity_form_display.paragraph.raw_data_component_upload.default.yml b/config/default/core.entity_form_display.paragraph.raw_data_component_upload.default.yml index 19ccbefd5..f4383a6a4 100644 --- a/config/default/core.entity_form_display.paragraph.raw_data_component_upload.default.yml +++ b/config/default/core.entity_form_display.paragraph.raw_data_component_upload.default.yml @@ -5,6 +5,7 @@ dependencies: config: - field.field.paragraph.raw_data_component_upload.field_agency_component - field.field.paragraph.raw_data_component_upload.field_request_data_csv + - field.field.paragraph.raw_data_component_upload.section_ix_xi_data - paragraphs.paragraphs_type.raw_data_component_upload module: - file @@ -30,6 +31,13 @@ content: settings: progress_indicator: throbber third_party_settings: { } + section_ix_xi_data: + type: file_generic + weight: 2 + region: content + settings: + progress_indicator: throbber + third_party_settings: { } hidden: created: true status: true diff --git a/config/default/core.entity_view_display.paragraph.raw_data_component_upload.default.yml b/config/default/core.entity_view_display.paragraph.raw_data_component_upload.default.yml index 8f9da98b3..8ab1464d7 100644 --- a/config/default/core.entity_view_display.paragraph.raw_data_component_upload.default.yml +++ b/config/default/core.entity_view_display.paragraph.raw_data_component_upload.default.yml @@ -5,6 +5,7 @@ dependencies: config: - field.field.paragraph.raw_data_component_upload.field_agency_component - field.field.paragraph.raw_data_component_upload.field_request_data_csv + - field.field.paragraph.raw_data_component_upload.section_ix_xi_data - paragraphs.paragraphs_type.raw_data_component_upload module: - file @@ -29,5 +30,13 @@ content: third_party_settings: { } weight: 1 region: content + section_ix_xi_data: + type: file_default + label: above + settings: + use_description_as_link_text: false + third_party_settings: { } + weight: 2 + region: content hidden: search_api_excerpt: true diff --git a/config/default/field.field.paragraph.raw_data_component_upload.field_request_data_csv.yml b/config/default/field.field.paragraph.raw_data_component_upload.field_request_data_csv.yml index daabf956b..d2497f59e 100644 --- a/config/default/field.field.paragraph.raw_data_component_upload.field_request_data_csv.yml +++ b/config/default/field.field.paragraph.raw_data_component_upload.field_request_data_csv.yml @@ -12,7 +12,7 @@ field_name: field_request_data_csv entity_type: paragraph bundle: raw_data_component_upload label: 'CSV file' -description: '' +description: 'Component Raw Data Report' required: true translatable: false default_value: { } diff --git a/config/default/field.field.paragraph.raw_data_component_upload.section_ix_xi_data.yml b/config/default/field.field.paragraph.raw_data_component_upload.section_ix_xi_data.yml new file mode 100644 index 000000000..a6e205f8c --- /dev/null +++ b/config/default/field.field.paragraph.raw_data_component_upload.section_ix_xi_data.yml @@ -0,0 +1,27 @@ +uuid: 36d4670e-76ee-47d2-bf44-c6cf284376f0 +langcode: en +status: true +dependencies: + config: + - field.storage.paragraph.section_ix_xi_data + - paragraphs.paragraphs_type.raw_data_component_upload + module: + - file +id: paragraph.raw_data_component_upload.section_ix_xi_data +field_name: section_ix_xi_data +entity_type: paragraph +bundle: raw_data_component_upload +label: 'Section IX-XI CSV file' +description: 'Section IX-XI Data' +required: true +translatable: false +default_value: { } +default_value_callback: '' +settings: + handler: 'default:file' + handler_settings: { } + file_directory: 'request_data_tool/components/[date:custom:Y]-[date:custom:m]' + file_extensions: csv + max_filesize: '' + description_field: false +field_type: file diff --git a/config/default/field.storage.paragraph.section_ix_xi_data.yml b/config/default/field.storage.paragraph.section_ix_xi_data.yml new file mode 100644 index 000000000..3c414b3b9 --- /dev/null +++ b/config/default/field.storage.paragraph.section_ix_xi_data.yml @@ -0,0 +1,23 @@ +uuid: 496fcb4b-f83e-46b1-baae-3a22a3dedee7 +langcode: en +status: true +dependencies: + module: + - file + - paragraphs +id: paragraph.section_ix_xi_data +field_name: section_ix_xi_data +entity_type: paragraph +type: file +settings: + target_type: file + display_field: false + display_default: false + uri_scheme: private +module: file +locked: false +cardinality: 1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/docroot/modules/custom/foia_raw_data_to_report/README.md b/docroot/modules/custom/foia_raw_data_to_report/README.md index 18ac34722..8a7599eb6 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/README.md +++ b/docroot/modules/custom/foia_raw_data_to_report/README.md @@ -234,6 +234,7 @@ field. `field_component_uploads` is an unlimited Paragraphs reference to - Required `field_agency_component`, reusing the existing paragraph field storage. - Required, single-value `field_request_data_csv`, accepting CSV files only and storing them privately. +- Required, single-value `section_ix_xi_data`, also accepting private CSV files. A subset of the agency's components is allowed. At least one upload is needed for processing; reports can otherwise be saved without uploads. Duplicate @@ -806,3 +807,14 @@ retries of the same queue item. A successful retry supersedes an unread failure. Older queue items without requester metadata still process normally but cannot notify a user. Notification storage failures are logged without altering XML processing or queue retry behavior. + +## Section IX-XI upload + +Each component paragraph now requires two single-file private CSV uploads: +`field_request_data_csv` (help: Component Raw Data Report) and +`section_ix_xi_data` (help: Section IX-XI Data). The second upload follows the +same private download access rules, CSV extension restriction, and upload size +settings as the first. It appears after the original upload on edit and view +displays. Its presence is required; its contents are not yet validated or used +in XML generation. Existing paragraphs need the second file when edited. +Import the field and display configuration before running the updated code. diff --git a/docroot/modules/custom/foia_raw_data_to_report/foia_raw_data_to_report.module b/docroot/modules/custom/foia_raw_data_to_report/foia_raw_data_to_report.module index 668d5db60..74942908f 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/foia_raw_data_to_report.module +++ b/docroot/modules/custom/foia_raw_data_to_report/foia_raw_data_to_report.module @@ -61,11 +61,12 @@ function foia_raw_data_to_report_file_access(FileInterface $file, $operation, Ac $access = AccessResult::neutral()->cachePerUser()->addCacheableDependency($file); $is_report_file = FALSE; $report_fields = ['field_request_data_csv', 'field_request_data_xml']; + $upload_fields = ['field_request_data_csv', 'section_ix_xi_data']; foreach ($resolver->getReferences($file) as $usage) { $entity = $resolver->loadEntityFromUsage($usage); $node = $entity; $parent_field = $usage->fieldName; - if ($entity instanceof ParagraphInterface && $entity->bundle() === 'raw_data_component_upload' && $usage->fieldName === 'field_request_data_csv') { + if ($entity instanceof ParagraphInterface && $entity->bundle() === 'raw_data_component_upload' && in_array($usage->fieldName, $upload_fields, TRUE)) { $is_report_file = TRUE; $access->addCacheableDependency($entity); $node = $entity->getParentEntity(); diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/UploadAssignments.php b/docroot/modules/custom/foia_raw_data_to_report/src/UploadAssignments.php index 5080b96f8..52ce36d3b 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/src/UploadAssignments.php +++ b/docroot/modules/custom/foia_raw_data_to_report/src/UploadAssignments.php @@ -35,6 +35,9 @@ public static function validate(NodeInterface $node): array { } $seen[$component->id()] = TRUE; } + if ($upload->get('section_ix_xi_data')->isEmpty()) { + $errors[$delta][] = 'Upload a Section IX-XI CSV file for this Agency Component.'; + } if ($upload->get('field_request_data_csv')->isEmpty()) { $errors[$delta][] = 'Upload a CSV file for this Agency Component.'; } diff --git a/docroot/modules/custom/foia_raw_data_to_report/tests/integration/component_uploads.php b/docroot/modules/custom/foia_raw_data_to_report/tests/integration/component_uploads.php index 13ddde26c..0a65e4dde 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/tests/integration/component_uploads.php +++ b/docroot/modules/custom/foia_raw_data_to_report/tests/integration/component_uploads.php @@ -34,6 +34,7 @@ $components = []; $paragraphs = []; $files = []; +$section_files = []; $report = NULL; $notification_store = NULL; $suffix = bin2hex(random_bytes(6)); @@ -76,6 +77,12 @@ ]); $paragraphs[] = $paragraph; } + foreach ($paragraphs as $delta => $paragraph) { + $section_file = \Drupal::service('file.repository')->writeData('Section IX-XI fixture', "private://section-check-$suffix-$delta.csv"); + $section_file->setOwnerId($manager->id())->save(); + $section_files[] = $section_file; + $paragraph->set('section_ix_xi_data', $section_file->id()); + } $report = Node::create([ 'type' => 'raw_data_to_report', 'title' => "CSV report $suffix", @@ -111,6 +118,14 @@ } $check($report->get('field_request_data_xml')->isEmpty(), 'Invalid input generated XML.'); + // The additional upload is required independently of its future contents. + $first_upload = $report->get('field_component_uploads')->get(0)->entity; + $section_id = $first_upload->get('section_ix_xi_data')->target_id; + $first_upload->set('section_ix_xi_data', []); + $check(count($first_upload->get('section_ix_xi_data')->validate()) > 0, 'Section IX-XI field was not required.'); + $check(isset(UploadAssignments::validate($report)[0]), 'Missing Section IX-XI upload passed assignment validation.'); + $first_upload->set('section_ix_xi_data', $section_id); + // Entity validation and queue processing must reject bad assignments. $second = $report->get('field_component_uploads')->get(1)->entity; $second->set('field_agency_component', $components[0]->id()); @@ -194,7 +209,7 @@ foreach ([new AnonymousUserSession(), $manager] as $account) { $switcher->switchTo($account); try { - foreach ($files as $file) { + foreach (array_merge($files, $section_files) as $file) { try { $response = $controller->download(new Request(['file' => substr($file->getFileUri(), 10)])); $check(!$account->isAnonymous() && $response->getStatusCode() === 200, 'Anonymous download allowed.'); @@ -284,7 +299,7 @@ foreach (array_reverse($entities) as $entity) { $entity->delete(); } - foreach ($files as $file) { + foreach (array_merge($files, $section_files) as $file) { if ($stored = File::load($file->id())) { $stored->delete(); } diff --git a/tests/behat/features/RawDataToReport.feature b/tests/behat/features/RawDataToReport.feature index cd05a1497..fd213ae80 100644 --- a/tests/behat/features/RawDataToReport.feature +++ b/tests/behat/features/RawDataToReport.feature @@ -74,7 +74,10 @@ Feature: Raw data XML report action And I wait 2 seconds And I press the "down" key in the "Agency Component" field And I press the "enter" key in the "Agency Component" field - And I attach the file "raw-data-valid.csv" to "CSV file" + And I attach the file "raw-data-valid.csv" to "files[field_component_uploads_0_subform_field_request_data_csv_0]" + And I wait 2 seconds + And I wait for AJAX to finish + And I attach the file "raw-data-valid.csv" to "files[field_component_uploads_0_subform_section_ix_xi_data_0]" And I wait 2 seconds And I wait for AJAX to finish And I press "Save" @@ -88,6 +91,9 @@ Feature: Raw data XML report action And I attach the file "raw-data-valid.csv" to "files[field_component_uploads_1_subform_field_request_data_csv_0]" And I wait 2 seconds And I wait for AJAX to finish + And I attach the file "raw-data-valid.csv" to "files[field_component_uploads_1_subform_section_ix_xi_data_0]" + And I wait 2 seconds + And I wait for AJAX to finish And I press "Save" Then I should see "Each Agency Component may have only one CSV upload." When I fill in "field_component_uploads[1][subform][field_agency_component][0][target_id]" with "Second Upload Component" @@ -114,7 +120,10 @@ Feature: Raw data XML report action And I press the "tab" key in the "Agency" field And I wait for AJAX to finish And I fill in "Agency Component" with "New Report Component" - And I attach the file "raw-data-valid.csv" to "CSV file" + And I attach the file "raw-data-valid.csv" to "files[field_component_uploads_0_subform_field_request_data_csv_0]" + And I wait 2 seconds + And I wait for AJAX to finish + And I attach the file "raw-data-valid.csv" to "files[field_component_uploads_0_subform_section_ix_xi_data_0]" And I wait 2 seconds And I wait for AJAX to finish And I press "Save" From e47ade850b604af024cde83f72d856a18a9780ed Mon Sep 17 00:00:00 2001 From: Rob Ristroph Date: Thu, 24 Sep 2026 16:01:57 -0500 Subject: [PATCH 16/24] WS-472: Added some validation of 2d CSV file. --- .../custom/foia_raw_data_to_report/README.md | 18 +++- .../foia_raw_data_to_report.services.yml | 2 + .../QueueWorker/RawDataToReportProcessing.php | 20 ++++- .../src/SectionDataCsvValidator.php | 90 +++++++++++++++++++ .../tests/integration/component_uploads.php | 14 ++- .../src/Unit/SectionDataCsvValidatorTest.php | 80 +++++++++++++++++ 6 files changed, 219 insertions(+), 5 deletions(-) create mode 100644 docroot/modules/custom/foia_raw_data_to_report/src/SectionDataCsvValidator.php create mode 100644 docroot/modules/custom/foia_raw_data_to_report/tests/src/Unit/SectionDataCsvValidatorTest.php diff --git a/docroot/modules/custom/foia_raw_data_to_report/README.md b/docroot/modules/custom/foia_raw_data_to_report/README.md index 8a7599eb6..e86324d9c 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/README.md +++ b/docroot/modules/custom/foia_raw_data_to_report/README.md @@ -815,6 +815,22 @@ Each component paragraph now requires two single-file private CSV uploads: `section_ix_xi_data` (help: Section IX-XI Data). The second upload follows the same private download access rules, CSV extension restriction, and upload size settings as the first. It appears after the original upload on edit and view -displays. Its presence is required; its contents are not yet validated or used +displays. Its presence and contents are validated; its values are not yet used in XML generation. Existing paragraphs need the second file when edited. Import the field and display configuration before running the updated code. + +## Section IX-XI CSV validation + +The first nonblank record must exactly match the eight header names and order +from `OIP FY23 Section IX-XI Datav final.csv`, stored in +`SectionDataCsvValidator::HEADERS`. Exactly one nonblank data row must follow. +All eight cells are required: B accepts decimal notation (including whole +numbers), and the other seven require integers. Optional signs and surrounding +numeric whitespace are accepted; scientific notation, separators, currency, +and blank cells are rejected. Headers are not trimmed. UTF-8 BOMs and blank +lines are ignored, and record numbers include blank lines. + +The queue checks both CSVs for every component, collects all errors with file, +component, record, and column context, and retains the existing XML if either +file fails. Successful files receive `CSV validated.` messages. Section IX-XI +values are not yet used in the generated XML. diff --git a/docroot/modules/custom/foia_raw_data_to_report/foia_raw_data_to_report.services.yml b/docroot/modules/custom/foia_raw_data_to_report/foia_raw_data_to_report.services.yml index 1e022f02b..5725026b1 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/foia_raw_data_to_report.services.yml +++ b/docroot/modules/custom/foia_raw_data_to_report/foia_raw_data_to_report.services.yml @@ -12,3 +12,5 @@ services: arguments: ['@foia_raw_data_to_report.notifications'] tags: - { name: event_subscriber } + foia_raw_data_to_report.section_csv_validator: + class: Drupal\foia_raw_data_to_report\SectionDataCsvValidator diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/Plugin/QueueWorker/RawDataToReportProcessing.php b/docroot/modules/custom/foia_raw_data_to_report/src/Plugin/QueueWorker/RawDataToReportProcessing.php index aa34ae7ed..0cf4b2e06 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/src/Plugin/QueueWorker/RawDataToReportProcessing.php +++ b/docroot/modules/custom/foia_raw_data_to_report/src/Plugin/QueueWorker/RawDataToReportProcessing.php @@ -12,6 +12,7 @@ use Drupal\node\NodeInterface; use Drupal\taxonomy\TermInterface; use Drupal\foia_raw_data_to_report\CsvValidator; +use Drupal\foia_raw_data_to_report\SectionDataCsvValidator; use Drupal\foia_raw_data_to_report\ReportNotifications; use Drupal\foia_raw_data_to_report\UploadAssignments; use Drupal\foia_raw_data_to_report\XmlReportBuilder; @@ -48,7 +49,7 @@ final class RawDataToReportProcessing extends QueueWorkerBase implements Contain /** * Constructs the report queue worker. */ - public function __construct(array $configuration, $plugin_id, $plugin_definition, protected EntityTypeManagerInterface $entityTypeManager, protected FileSystemInterface $fileSystem, protected FileRepositoryInterface $fileRepository, protected CsvValidator $csvValidator, protected ReportNotifications $notifications) { + public function __construct(array $configuration, $plugin_id, $plugin_definition, protected EntityTypeManagerInterface $entityTypeManager, protected FileSystemInterface $fileSystem, protected FileRepositoryInterface $fileRepository, protected CsvValidator $csvValidator, protected ReportNotifications $notifications, protected SectionDataCsvValidator $sectionCsvValidator) { parent::__construct($configuration, $plugin_id, $plugin_definition); } @@ -65,6 +66,7 @@ public static function create(ContainerInterface $container, array $configuratio $container->get('file.repository'), $container->get('foia_raw_data_to_report.csv_validator'), $container->get('foia_raw_data_to_report.notifications'), + $container->get('foia_raw_data_to_report.section_csv_validator'), ); } @@ -125,6 +127,22 @@ public function processItem($data) { } $has_errors = $has_errors || (bool) $errors; $messages[] = $prefix . "\n" . ($errors ? implode("\n", array_unique($errors)) : 'CSV validated.'); + + // Check the Section IX-XI file independently, even if raw data failed. + $section_source = $supported ? $upload->get('section_ix_xi_data')->entity : NULL; + $section_errors = []; + if (!$section_source) { + $section_errors[] = 'No Section IX-XI CSV file is attached. Please upload it again.'; + } + elseif (strtolower(pathinfo($section_source->getFilename(), PATHINFO_EXTENSION)) !== 'csv') { + $section_errors[] = 'Please upload a CSV file. Excel workbooks are not supported by this processor.'; + } + else { + $section_errors = $this->sectionCsvValidator->validate($section_source->getFileUri()); + } + $has_errors = $has_errors || (bool) $section_errors; + $section_prefix = sprintf('Upload %d — Component: %s — Section IX-XI file: %s', $delta + 1, $component?->label() ?? '(not selected)', $section_source?->getFilename() ?? '(not attached)'); + $messages[] = $section_prefix . "\n" . ($section_errors ? implode("\n", $section_errors) : 'CSV validated.'); } // Compare against every linked component, regardless of publication/access. // Missing uploads are a warning only; CSV validation still controls errors. diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/SectionDataCsvValidator.php b/docroot/modules/custom/foia_raw_data_to_report/src/SectionDataCsvValidator.php new file mode 100644 index 000000000..311085f6f --- /dev/null +++ b/docroot/modules/custom/foia_raw_data_to_report/src/SectionDataCsvValidator.php @@ -0,0 +1,90 @@ + $header) { + $column = chr(65 + $index); + // Header spelling, capitalization, whitespace, and order must match. + if ($is_header) { + if ($columns[$index] !== $header) { + $errors[] = sprintf('CSV record %d: Column %s: Header must be exactly "%s".', $record, $column, $header); + } + continue; + } + // B accepts decimal notation; all other cells require whole numbers. + // Match text rather than casting, so large integer values stay exact. + $value = trim($columns[$index]); + $pattern = $index === 1 ? '/^[+-]?[0-9]+(?:\.[0-9]+)?$/' : '/^[+-]?[0-9]+$/'; + if (!preg_match($pattern, $value)) { + $errors[] = sprintf('CSV record %d: Column %s: %s must be %s.', $record, $column, $header, $index === 1 ? 'a decimal number' : 'an integer'); + } + } + } + if (!feof($stream)) { + $errors[] = 'The Section IX-XI CSV file could not be read completely. Please upload it again.'; + } + if (!$has_header) { + $errors[] = 'The Section IX-XI CSV file is empty. Upload the header and one data row.'; + } + elseif ($data_rows !== 1) { + $errors[] = sprintf('The Section IX-XI CSV must contain exactly one data row after the header; found %d.', $data_rows); + } + return $errors; + } + finally { + fclose($stream); + } + } + +} diff --git a/docroot/modules/custom/foia_raw_data_to_report/tests/integration/component_uploads.php b/docroot/modules/custom/foia_raw_data_to_report/tests/integration/component_uploads.php index 0a65e4dde..aa57a32b3 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/tests/integration/component_uploads.php +++ b/docroot/modules/custom/foia_raw_data_to_report/tests/integration/component_uploads.php @@ -9,6 +9,7 @@ use Drupal\Core\File\FileSystemInterface; use Drupal\file\Entity\File; use Drupal\foia_raw_data_to_report\UploadAssignments; +use Drupal\foia_raw_data_to_report\SectionDataCsvValidator; use Drupal\foia_raw_data_to_report\RequestStatisticsAggregator; use Drupal\node\Entity\Node; use Drupal\paragraphs\Entity\Paragraph; @@ -78,7 +79,7 @@ $paragraphs[] = $paragraph; } foreach ($paragraphs as $delta => $paragraph) { - $section_file = \Drupal::service('file.repository')->writeData('Section IX-XI fixture', "private://section-check-$suffix-$delta.csv"); + $section_file = \Drupal::service('file.repository')->writeData(implode(',', SectionDataCsvValidator::HEADERS) . "\n39,1.85,7164103,1918485,0,0,145,823", "private://section-check-$suffix-$delta.csv"); $section_file->setOwnerId($manager->id())->save(); $section_files[] = $section_file; $paragraph->set('section_ix_xi_data', $section_file->id()); @@ -151,11 +152,18 @@ $check(!$selection->getReferenceableEntities(), 'Unselected agency must have no component choices.'); file_put_contents($files[1]->getFileUri(), implode(',', array_fill(0, 29, 'column'))); + $valid_section = file_get_contents($section_files[1]->getFileUri()); + file_put_contents($section_files[1]->getFileUri(), implode(',', SectionDataCsvValidator::HEADERS) . "\ninvalid,1.85,0,0,0,0,0,0"); + $report = $process(); + $check($report->get('field_request_data_xml')->isEmpty(), 'Invalid Section IX-XI data generated XML.'); + $section_messages = $report->get('field_messages')->value; + $check(str_contains($section_messages, $section_files[1]->getFilename()) && str_contains($section_messages, 'Column A: Full-Time Employees must be an integer'), 'Section IX-XI error lacks file/column context.'); + file_put_contents($section_files[1]->getFileUri(), $valid_section); $report = $process(); $xml = $report->get('field_request_data_xml')->entity; $check($xml && str_starts_with($xml->getFileUri(), 'private://'), 'Valid uploads did not produce private XML.'); $files[] = $xml; - $check(substr_count($report->get('field_messages')->value, 'CSV validated.') === 2, 'Successful retry did not replace earlier messages.'); + $check(substr_count($report->get('field_messages')->value, 'CSV validated.') === 4, 'Successful retry did not replace earlier messages.'); // An aggregation exception must append to validation messages and retain XML. $row = array_fill(0, 29, ''); $row[0] = 'Component'; @@ -185,7 +193,7 @@ $check(str_contains($exception->getMessage(), 'Column AC'), 'Unexpected processing exception.'); $report = $node_storage->loadUnchanged($report->id()); $message = $report->get('field_messages')->value; - $check(substr_count($message, 'CSV validated.') === 2, 'Exception lost validation messages.'); + $check(substr_count($message, 'CSV validated.') === 4, 'Exception lost validation messages.'); $check(substr_count($message, 'XML report processing failed:') === 1, 'Retry duplicated exception messages.'); $check(str_ends_with($message, $exception->getMessage()), 'Exception details were not appended.'); $check($report->get('field_messages')->format === 'plain_text', 'Exception message must be plain text.'); diff --git a/docroot/modules/custom/foia_raw_data_to_report/tests/src/Unit/SectionDataCsvValidatorTest.php b/docroot/modules/custom/foia_raw_data_to_report/tests/src/Unit/SectionDataCsvValidatorTest.php new file mode 100644 index 000000000..bc38b731c --- /dev/null +++ b/docroot/modules/custom/foia_raw_data_to_report/tests/src/Unit/SectionDataCsvValidatorTest.php @@ -0,0 +1,80 @@ +validate($path); + } + finally { + unlink($path); + } + } + + /** + * Accepts the sample, zero values, signs, whitespace, BOM, and blank lines. + */ + public function testValidData(): void { + $row = [39, '1.85', 7164103, 1918485, 0, 0, 145, 823]; + $this->assertSame([], $this->validateCsv([SectionDataCsvValidator::HEADERS, $row], TRUE)); + $row = ['-1', ' 0 ', '+1', 0, 0, 0, 0, 0]; + $this->assertSame([], $this->validateCsv([[], SectionDataCsvValidator::HEADERS, $row, []])); + } + + /** + * Requires exact headers and exactly one complete data record. + */ + public function testStructure(): void { + $headers = SectionDataCsvValidator::HEADERS; + $this->assertNotEmpty($this->validateCsv([])); + $this->assertStringContainsString('found 0', implode(' ', $this->validateCsv([$headers]))); + $row = array_fill(0, 8, '0'); + $this->assertStringContainsString('found 2', implode(' ', $this->validateCsv([$headers, $row, $row]))); + $errors = $this->validateCsv([$headers, array_fill(0, 7, '0')]); + $this->assertStringContainsString('record 2 has 7 columns', implode(' ', $errors)); + $headers[0] .= ' '; + $headers[1] = 'equivalent full-time employees'; + $errors = $this->validateCsv([$headers, $row]); + $this->assertCount(2, $errors); + $this->assertStringContainsString('record 1: Column A: Header', $errors[0]); + $this->assertStringContainsString('record 1: Column B: Header', $errors[1]); + } + + /** + * Reports all invalid numeric cells without converting or rounding them. + */ + public function testNumbers(): void { + $row = ['1.5', '1e3', '', '1,000', '$2', 'NaN', ' ', '2.0']; + $errors = $this->validateCsv([SectionDataCsvValidator::HEADERS, $row]); + $this->assertCount(8, $errors); + $this->assertStringContainsString('record 2: Column B: Equivalent Full-Time Employees must be a decimal number', $errors[1]); + foreach ([0, 2, 3, 4, 5, 6, 7] as $index) { + $this->assertStringContainsString('must be an integer', $errors[$index]); + } + } + +} From beae5745c6fe3b494f04f7d71b5e1f8a491d02e7 Mon Sep 17 00:00:00 2001 From: Rob Ristroph Date: Thu, 24 Sep 2026 16:54:46 -0500 Subject: [PATCH 17/24] WS-472: Put real values in for PernsonnelAndCost. --- .../custom/foia_raw_data_to_report/README.md | 26 ++++-- .../src/PersonnelAndCostAggregator.php | 86 +++++++++++++++++++ .../QueueWorker/RawDataToReportProcessing.php | 9 +- .../src/XmlReportBuilder.php | 23 +++-- .../tests/integration/component_uploads.php | 24 ++++++ 5 files changed, 151 insertions(+), 17 deletions(-) create mode 100644 docroot/modules/custom/foia_raw_data_to_report/src/PersonnelAndCostAggregator.php diff --git a/docroot/modules/custom/foia_raw_data_to_report/README.md b/docroot/modules/custom/foia_raw_data_to_report/README.md index e86324d9c..1da13e578 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/README.md +++ b/docroot/modules/custom/foia_raw_data_to_report/README.md @@ -634,12 +634,13 @@ to be blank. Q, R, and S may contain expedited-processing data and retain their date and dependency checks. Appeal rows are exempt from the rule that S = G requires M = E, since M must remain blank on these rows. -## Personnel and cost placeholders +## Personnel and cost `PersonnelAndCostSection` follows fee waivers. Every uploaded component and -the agency receive all six personnel/staffing/cost fields with literal `N/A`, -since the CSV has no source data for these values. `PC1`, `PC2`, etc. link to -component organizations; `PC0` links to the agency. No CSV pass is needed. +the agency have the six personnel and cost values sourced from Section IX-XI +Columns A-D, with calculated staff and cost totals. `PC1`, `PC2`, etc. and +agency `PC0` link to Organization entries through +`PersonnelAndCostOrganizationAssociation`. ## Fees collected @@ -815,8 +816,8 @@ Each component paragraph now requires two single-file private CSV uploads: `section_ix_xi_data` (help: Section IX-XI Data). The second upload follows the same private download access rules, CSV extension restriction, and upload size settings as the first. It appears after the original upload on edit and view -displays. Its presence and contents are validated; its values are not yet used -in XML generation. Existing paragraphs need the second file when edited. +displays. Its presence and contents are validated; Columns A-D populate personnel +and cost XML totals. Existing paragraphs need the second file when edited. Import the field and display configuration before running the updated code. ## Section IX-XI CSV validation @@ -833,4 +834,15 @@ lines are ignored, and record numbers include blank lines. The queue checks both CSVs for every component, collects all errors with file, component, record, and column context, and retains the existing XML if either file fails. Successful files receive `CSV validated.` messages. Section IX-XI -values are not yet used in the generated XML. +Columns A-D populate the personnel and cost section. + +## Personnel and cost from Section IX-XI data + +Queue processing now supplies `PersonnelAndCostSection` with Column A full-time +employees, Column B equivalent full-time employees, Column C processing costs, +and Column D litigation costs. Total staff is A+B and total cost is C+D. +Agency quantities sum the corresponding component values. Existing PC/ORG +references and zero values are preserved. Exact decimal arithmetic uses the +existing Brick Math dependency, avoiding floating-point rounding or integer +overflow; output does not impose an additional decimal precision limit. +Other Section IX-XI columns are not yet used for XML generation. diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/PersonnelAndCostAggregator.php b/docroot/modules/custom/foia_raw_data_to_report/src/PersonnelAndCostAggregator.php new file mode 100644 index 000000000..b39adfc12 --- /dev/null +++ b/docroot/modules/custom/foia_raw_data_to_report/src/PersonnelAndCostAggregator.php @@ -0,0 +1,86 @@ +validate($source['uri']); + $context = sprintf('Component %s, CSV %s', $source['component_id'], basename($source['uri'])); + if ($errors) { + throw new \RuntimeException($context . ': ' . implode(' ', $errors)); + } + $stream = @fopen($source['uri'], 'rb'); + if ($stream === FALSE) { + throw new \RuntimeException($context . ': Unable to reopen Section IX-XI CSV.'); + } + try { + if (fread($stream, 3) !== "\xEF\xBB\xBF") { + rewind($stream); + } + $rows = []; + while (($row = fgetcsv($stream, 0, ',', '"', '')) !== FALSE) { + if ($row !== [NULL]) { + $rows[] = $row; + } + // Retain only this template's two records, even if the file changed. + if (count($rows) > 2) { + break; + } + } + if (!feof($stream) || count($rows) !== 2 || $rows[0] !== SectionDataCsvValidator::HEADERS || count($rows[1]) !== 8) { + throw new \RuntimeException($context . ': Section IX-XI CSV changed after validation.'); + } + $values = []; + foreach (array_slice($rows[1], 0, 4) as $index => $value) { + $value = trim($value); + $pattern = $index === 1 ? '/^[+-]?[0-9]+(?:\.[0-9]+)?$/' : '/^[+-]?[0-9]+$/'; + if (!preg_match($pattern, $value)) { + throw new \RuntimeException($context . ': Column ' . chr(65 + $index) . ': Invalid number after validation.'); + } + $values[] = BigDecimal::of($value); + } + [$employees, $equivalent, $processing, $litigation] = $values; + $totals = [ + $employees, + $equivalent, + $employees->plus($equivalent), + $processing, + $litigation, + $processing->plus($litigation), + ]; + $id = $source['component_id']; + $components[$id] ??= array_fill_keys($fields, '0'); + foreach ($fields as $index => $field) { + $components[$id][$field] = (string) BigDecimal::of($components[$id][$field])->plus($totals[$index])->strippedOfTrailingZeros(); + $overall[$field] = (string) BigDecimal::of($overall[$field])->plus($totals[$index])->strippedOfTrailingZeros(); + } + } + finally { + fclose($stream); + } + } + return ['components' => $components, 'overall' => $overall]; + } + +} diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/Plugin/QueueWorker/RawDataToReportProcessing.php b/docroot/modules/custom/foia_raw_data_to_report/src/Plugin/QueueWorker/RawDataToReportProcessing.php index 0cf4b2e06..8eb91fe63 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/src/Plugin/QueueWorker/RawDataToReportProcessing.php +++ b/docroot/modules/custom/foia_raw_data_to_report/src/Plugin/QueueWorker/RawDataToReportProcessing.php @@ -12,6 +12,7 @@ use Drupal\node\NodeInterface; use Drupal\taxonomy\TermInterface; use Drupal\foia_raw_data_to_report\CsvValidator; +use Drupal\foia_raw_data_to_report\PersonnelAndCostAggregator; use Drupal\foia_raw_data_to_report\SectionDataCsvValidator; use Drupal\foia_raw_data_to_report\ReportNotifications; use Drupal\foia_raw_data_to_report\UploadAssignments; @@ -198,9 +199,14 @@ public function processItem($data) { protected function generateXmlReport(NodeInterface $node): void { $components = []; $sources = []; + $section_sources = []; foreach ($node->get('field_component_uploads') as $item) { $component = $item->entity->get('field_agency_component')->entity; $components[] = $component; + $section_sources[] = [ + 'component_id' => $component->id(), + 'uri' => $item->entity->get('section_ix_xi_data')->entity->getFileUri(), + ]; $sources[] = [ 'component_id' => $component->id(), 'uri' => $item->entity->get('field_request_data_csv')->entity->getFileUri(), @@ -233,7 +239,8 @@ protected function generateXmlReport(NodeInterface $node): void { $backlog = (new BacklogAggregator())->aggregate($sources, $fiscal_year); $consultation_statistics = (new ConsultationStatisticsAggregator())->aggregate($sources, $fiscal_year); $oldest_pending_consultations = (new OldestPendingRequestAggregator())->aggregate($sources, $fiscal_year, TRUE); - $xml = (new XmlReportBuilder())->build($node->get('field_agency')->entity, $components, $fiscal_year, $statutes, $request_statistics, $dispositions, $other_reasons, $applied_exemptions, $appeal_statistics, $appeal_dispositions, $appeal_exemptions, $appeal_denials, $appeal_other_reasons, $appeal_response_times, $oldest_pending_appeals, $processed_response_times, $information_granted_response_times, $simple_response_increments, $complex_response_increments, $expedited_response_increments, $pending_perfected_requests, $oldest_pending_requests, $expedited_processing, $fee_waivers, $fees_collected, $backlog, $consultation_statistics, $oldest_pending_consultations); + $personnel_and_cost = (new PersonnelAndCostAggregator())->aggregate($section_sources); + $xml = (new XmlReportBuilder())->build($node->get('field_agency')->entity, $components, $fiscal_year, $statutes, $request_statistics, $dispositions, $other_reasons, $applied_exemptions, $appeal_statistics, $appeal_dispositions, $appeal_exemptions, $appeal_denials, $appeal_other_reasons, $appeal_response_times, $oldest_pending_appeals, $processed_response_times, $information_granted_response_times, $simple_response_increments, $complex_response_increments, $expedited_response_increments, $pending_perfected_requests, $oldest_pending_requests, $expedited_processing, $fee_waivers, $fees_collected, $backlog, $consultation_statistics, $oldest_pending_consultations, $personnel_and_cost); $field = $node->get('field_request_data_xml'); $previous_file = $field->entity; $item = $field->first() ?? $field->appendItem(); diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/XmlReportBuilder.php b/docroot/modules/custom/foia_raw_data_to_report/src/XmlReportBuilder.php index 0fac75a60..22924e7c2 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/src/XmlReportBuilder.php +++ b/docroot/modules/custom/foia_raw_data_to_report/src/XmlReportBuilder.php @@ -81,11 +81,13 @@ final class XmlReportBuilder { * Consultation counters by component and overall. * @param array $oldest_pending_consultations * Component and overall lists of the ten oldest pending consultations. + * @param array $personnel_and_cost + * Exact component and agency totals from PersonnelAndCostAggregator. * * @return string * The serialized report XML. */ - public function build(TermInterface $agency, array $components, int $fiscal_year, array $statutes = [], array $request_statistics = [], array $dispositions = [], array $other_reasons = [], array $applied_exemptions = [], array $appeal_statistics = [], array $appeal_dispositions = [], array $appeal_exemptions = [], array $appeal_denials = [], array $appeal_other_reasons = [], array $appeal_response_times = [], array $oldest_pending_appeals = [], array $processed_response_times = [], array $information_granted_response_times = [], array $simple_response_increments = [], array $complex_response_increments = [], array $expedited_response_increments = [], array $pending_perfected_requests = [], array $oldest_pending_requests = [], array $expedited_processing = [], array $fee_waivers = [], array $fees_collected = [], array $backlog = [], array $consultation_statistics = [], array $oldest_pending_consultations = []): string { + public function build(TermInterface $agency, array $components, int $fiscal_year, array $statutes = [], array $request_statistics = [], array $dispositions = [], array $other_reasons = [], array $applied_exemptions = [], array $appeal_statistics = [], array $appeal_dispositions = [], array $appeal_exemptions = [], array $appeal_denials = [], array $appeal_other_reasons = [], array $appeal_response_times = [], array $oldest_pending_appeals = [], array $processed_response_times = [], array $information_granted_response_times = [], array $simple_response_increments = [], array $complex_response_increments = [], array $expedited_response_increments = [], array $pending_perfected_requests = [], array $oldest_pending_requests = [], array $expedited_processing = [], array $fee_waivers = [], array $fees_collected = [], array $backlog = [], array $consultation_statistics = [], array $oldest_pending_consultations = [], array $personnel_and_cost = []): string { $document = new \DOMDocument('1.0', 'UTF-8'); $document->formatOutput = TRUE; $root = $document->createElementNS(self::NAMESPACES['iepd'], 'iepd:FoiaAnnualReport'); @@ -201,7 +203,7 @@ public function build(TermInterface $agency, array $components, int $fiscal_year $this->addFeeWaivers($document, $root, $fee_waivers, $component_map); } - $this->addPersonnelAndCost($document, $root, $component_map); + $this->addPersonnelAndCost($document, $root, $component_map, $personnel_and_cost); if ($fees_collected !== []) { $this->addFeesCollected($document, $root, $fees_collected, $component_map); @@ -757,12 +759,15 @@ private function addFeeWaivers(\DOMDocument $document, \DOMElement $root, array } /** - * Adds personnel and cost placeholders because CSVs lack these values. + * Adds personnel and cost totals with their organization references. */ - private function addPersonnelAndCost(\DOMDocument $document, \DOMElement $root, array $component_map): void { + private function addPersonnelAndCost(\DOMDocument $document, \DOMElement $root, array $component_map, array $statistics): void { $section = $this->addTextElement($document, $root, 'foia', 'PersonnelAndCostSection'); - $organizations = array_values($component_map); - $organizations[] = 'ORG0'; + $organizations = []; + foreach ($component_map as $component_id => $organization_id) { + $organizations[$organization_id] = $statistics['components'][$component_id] ?? []; + } + $organizations['ORG0'] = $statistics['overall'] ?? []; $fields = [ 'FullTimeEmployeeQuantity', 'EquivalentFullTimeEmployeeQuantity', @@ -771,14 +776,14 @@ private function addPersonnelAndCost(\DOMDocument $document, \DOMElement $root, 'LitigationCostAmount', 'TotalCostAmount', ]; - foreach ($organizations as $organization_id) { + foreach ($organizations as $organization_id => $values) { $entry = $this->addTextElement($document, $section, 'foia', 'PersonnelAndCost'); $entry->setAttributeNS(self::NAMESPACES['s'], 's:id', 'PC' . substr($organization_id, 3)); foreach ($fields as $name) { - $this->addTextElement($document, $entry, 'foia', $name, 'N/A'); + $this->addTextElement($document, $entry, 'foia', $name, $values[$name] ?? 'N/A'); } } - foreach ($organizations as $organization_id) { + foreach ($organizations as $organization_id => $values) { $association = $this->addTextElement($document, $section, 'foia', 'PersonnelAndCostOrganizationAssociation'); $reference = $this->addTextElement($document, $association, 'foia', 'ComponentDataReference'); $reference->setAttributeNS(self::NAMESPACES['s'], 's:ref', 'PC' . substr($organization_id, 3)); diff --git a/docroot/modules/custom/foia_raw_data_to_report/tests/integration/component_uploads.php b/docroot/modules/custom/foia_raw_data_to_report/tests/integration/component_uploads.php index aa57a32b3..da5675082 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/tests/integration/component_uploads.php +++ b/docroot/modules/custom/foia_raw_data_to_report/tests/integration/component_uploads.php @@ -163,6 +163,30 @@ $xml = $report->get('field_request_data_xml')->entity; $check($xml && str_starts_with($xml->getFileUri(), 'private://'), 'Valid uploads did not produce private XML.'); $files[] = $xml; + $document = new DOMDocument(); + $check($document->loadXML(file_get_contents($xml->getFileUri())), 'Generated XML could not be parsed.'); + $xpath = new DOMXPath($document); + $xpath->registerNamespace('foia', 'http://leisp.usdoj.gov/niem/FoiaAnnualReport/extension/1.03'); + $xpath->registerNamespace('s', 'http://niem.gov/niem/structures/2.0'); + $expected_totals = [ + 1 => ['39', '1.85', '40.85', '7164103', '1918485', '9082588'], + 0 => ['78', '3.7', '81.7', '14328206', '3836970', '18165176'], + ]; + foreach ($expected_totals as $id => $expected) { + $fields = [ + 'FullTimeEmployeeQuantity', + 'EquivalentFullTimeEmployeeQuantity', + 'TotalFullTimeStaffQuantity', + 'ProcessingCostAmount', + 'LitigationCostAmount', + 'TotalCostAmount', + ]; + foreach ($fields as $index => $field) { + $actual = $xpath->evaluate('string(//foia:PersonnelAndCost[@s:id="PC' . $id . '"]/foia:' . $field . ')'); + $check($actual === $expected[$index], 'Incorrect personnel/cost XML value for ' . $field); + } + } + $check(substr_count($report->get('field_messages')->value, 'CSV validated.') === 4, 'Successful retry did not replace earlier messages.'); // An aggregation exception must append to validation messages and retain XML. $row = array_fill(0, 29, ''); From da9bb92763c4e31212d6b365e2ed5126131865ba Mon Sep 17 00:00:00 2001 From: Rob Ristroph Date: Thu, 24 Sep 2026 17:48:39 -0500 Subject: [PATCH 18/24] WS-472: Updated FeesCollected with real values. --- .../custom/foia_raw_data_to_report/README.md | 19 ++-- .../src/FeesCollectedAggregator.php | 95 ++++--------------- .../src/PersonnelAndCostAggregator.php | 8 +- .../QueueWorker/RawDataToReportProcessing.php | 2 +- .../src/XmlReportBuilder.php | 13 +-- .../tests/integration/component_uploads.php | 13 ++- 6 files changed, 53 insertions(+), 97 deletions(-) diff --git a/docroot/modules/custom/foia_raw_data_to_report/README.md b/docroot/modules/custom/foia_raw_data_to_report/README.md index 1da13e578..077686359 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/README.md +++ b/docroot/modules/custom/foia_raw_data_to_report/README.md @@ -644,16 +644,15 @@ agency `PC0` link to Organization entries through ## Fees collected -`FeesCollectedAggregator` sums Column W across all rows without track, outcome, -or date filters. Blank cells contribute zero. Plain decimal amounts with up -to two decimal places (and optional sign) are summed as integer cents to avoid -floating-point drift. Unsupported formats and overflow produce contextual -exceptions rather than silently dropping or rounding data. - -`FeesCollectedSection` follows personnel and cost. Each component and the agency -receive a two-decimal `FeesCollectedAmount`, including zero, and a fixed -`FeesCollectedCostPercent` of `0.0000`. `FC1`, `FC2`, etc. and agency `FC0` -use the usual Organization associations. +`FeesCollectedAggregator` uses Section IX-XI Column E for each component's +fees, with agency fees summed across components. `FeesCollectedCostPercent` +is fees divided by the matching `TotalCostAmount` from personnel and cost, +rounded half-up to four decimal places without multiplying by 100. The agency +ratio uses total agency fees divided by total agency costs. A zero total cost +produces `0.0000`, even when fees are nonzero. Fee amounts are also formatted +to four decimal places, including `0.0000` for zero. Exact decimal arithmetic +avoids floating-point rounding. Column W in the raw request CSV no longer +supplies this section. Existing FC/ORG links and zero-count entries remain. ## Subsection use placeholders diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/FeesCollectedAggregator.php b/docroot/modules/custom/foia_raw_data_to_report/src/FeesCollectedAggregator.php index c124a2120..ba23a2e2a 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/src/FeesCollectedAggregator.php +++ b/docroot/modules/custom/foia_raw_data_to_report/src/FeesCollectedAggregator.php @@ -2,92 +2,39 @@ namespace Drupal\foia_raw_data_to_report; +use Brick\Math\BigDecimal; +use Brick\Math\RoundingMode; + /** - * Streams Column W into exact fee totals per component and agency. + * Formats Section IX-XI fees and their ratios to personnel/cost totals. */ final class FeesCollectedAggregator { /** - * Sums decimal fee amounts as integer cents, without retaining CSV rows. - * - * @param array $sources - * Component/file pairs, each with component_id and uri keys. - * - * @return array - * Component totals keyed by entity ID and overall total, in integer cents. + * Uses exact component/agency totals from PersonnelAndCostAggregator. */ - public function aggregate(array $sources): array { + public function aggregate(array $statistics): array { $components = []; - foreach ($sources as $source) { - $id = $source['component_id']; - $components[$id] ??= 0; - $stream = @fopen($source['uri'], 'rb'); - if ($stream === FALSE) { - throw new \RuntimeException('Unable to reopen a validated CSV for fees collected.'); - } - try { - $header = TRUE; - $record = 0; - while (($columns = fgetcsv($stream, 0, ',', '"', '')) !== FALSE) { - $record++; - if ($columns === [NULL]) { - continue; - } - if (count($columns) !== CsvValidator::EXPECTED_COLUMNS) { - throw new \RuntimeException('CSV column count changed after validation.'); - } - if ($header) { - $header = FALSE; - continue; - } - $value = trim($columns[22]); - if ($value === '') { - continue; - } - $context = sprintf('Component %s, CSV %s, record %d, Column W', $id, basename($source['uri']), $record); - $cents = $this->parseCents($value, $context); - $components[$id] = $this->addCents($components[$id], $cents, $context); - } - if (!feof($stream)) { - throw new \RuntimeException('Unable to finish reading a CSV for fees collected.'); - } - } - finally { - fclose($stream); - } - } - $overall = 0; - foreach ($components as $amount) { - $overall = $this->addCents($overall, $amount, 'Agency fees collected'); + foreach ($statistics['components'] as $id => $values) { + $components[$id] = $this->calculate($values); } - return ['components' => $components, 'overall' => $overall]; + return [ + 'components' => $components, + 'overall' => $this->calculate($statistics['overall']), + ]; } /** - * Parses plain decimal amounts exactly, allowing signed adjustments. + * Divides fees by total cost, without multiplying the ratio by 100. */ - private function parseCents(string $value, string $context): int { - if (!preg_match('/^([+-]?)([0-9]+)(?:\.([0-9]{1,2}))?$/', $value, $parts)) { - throw new \RuntimeException($context . ': Expected a decimal fee amount with at most two decimal places; received "' . $value . '".'); - } - $digits = ltrim($parts[2] . str_pad($parts[3] ?? '', 2, '0'), '0'); - $limit = (string) PHP_INT_MAX; - if (strlen($digits) > strlen($limit) || (strlen($digits) === strlen($limit) && strcmp($digits, $limit) > 0)) { - throw new \RuntimeException($context . ': Fee amount exceeds the supported range.'); - } - $cents = (int) $digits; - return $parts[1] === '-' ? -$cents : $cents; - } - - /** - * Prevents an overflowing integer sum from silently becoming a float. - */ - private function addCents(int $total, int $amount, string $context): int { - $sum = $total + $amount; - if (!is_int($sum) || $sum === PHP_INT_MIN) { - throw new \RuntimeException($context . ': Fee total exceeds the supported range.'); - } - return $sum; + private function calculate(array $values): array { + $amount = BigDecimal::of($values['FeesCollectedAmount']); + $cost = BigDecimal::of($values['TotalCostAmount']); + return [ + 'amount' => (string) $amount->toScale(4), + // A zero denominator produces zero, even if fees are nonzero. + 'ratio' => $cost->isZero() ? '0.0000' : (string) $amount->dividedBy($cost, 4, RoundingMode::HalfUp), + ]; } } diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/PersonnelAndCostAggregator.php b/docroot/modules/custom/foia_raw_data_to_report/src/PersonnelAndCostAggregator.php index b39adfc12..c1f988e22 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/src/PersonnelAndCostAggregator.php +++ b/docroot/modules/custom/foia_raw_data_to_report/src/PersonnelAndCostAggregator.php @@ -5,7 +5,7 @@ use Brick\Math\BigDecimal; /** - * Collects exact personnel and cost totals from the Section IX-XI CSVs. + * Collects exact personnel, cost, and fee totals from Section IX-XI CSVs. */ final class PersonnelAndCostAggregator { @@ -20,6 +20,7 @@ public function aggregate(array $sources): array { 'ProcessingCostAmount', 'LitigationCostAmount', 'TotalCostAmount', + 'FeesCollectedAmount', ]; $overall = array_fill_keys($fields, '0'); $components = []; @@ -52,7 +53,7 @@ public function aggregate(array $sources): array { throw new \RuntimeException($context . ': Section IX-XI CSV changed after validation.'); } $values = []; - foreach (array_slice($rows[1], 0, 4) as $index => $value) { + foreach (array_slice($rows[1], 0, 5) as $index => $value) { $value = trim($value); $pattern = $index === 1 ? '/^[+-]?[0-9]+(?:\.[0-9]+)?$/' : '/^[+-]?[0-9]+$/'; if (!preg_match($pattern, $value)) { @@ -60,7 +61,7 @@ public function aggregate(array $sources): array { } $values[] = BigDecimal::of($value); } - [$employees, $equivalent, $processing, $litigation] = $values; + [$employees, $equivalent, $processing, $litigation, $fees] = $values; $totals = [ $employees, $equivalent, @@ -68,6 +69,7 @@ public function aggregate(array $sources): array { $processing, $litigation, $processing->plus($litigation), + $fees, ]; $id = $source['component_id']; $components[$id] ??= array_fill_keys($fields, '0'); diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/Plugin/QueueWorker/RawDataToReportProcessing.php b/docroot/modules/custom/foia_raw_data_to_report/src/Plugin/QueueWorker/RawDataToReportProcessing.php index 8eb91fe63..61a4df756 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/src/Plugin/QueueWorker/RawDataToReportProcessing.php +++ b/docroot/modules/custom/foia_raw_data_to_report/src/Plugin/QueueWorker/RawDataToReportProcessing.php @@ -235,11 +235,11 @@ protected function generateXmlReport(NodeInterface $node): void { $oldest_pending_requests = (new OldestPendingRequestAggregator())->aggregate($sources, $fiscal_year); $expedited_processing = (new ExpeditedProcessingAggregator())->aggregate($sources); $fee_waivers = (new FeeWaiverAggregator())->aggregate($sources); - $fees_collected = (new FeesCollectedAggregator())->aggregate($sources); $backlog = (new BacklogAggregator())->aggregate($sources, $fiscal_year); $consultation_statistics = (new ConsultationStatisticsAggregator())->aggregate($sources, $fiscal_year); $oldest_pending_consultations = (new OldestPendingRequestAggregator())->aggregate($sources, $fiscal_year, TRUE); $personnel_and_cost = (new PersonnelAndCostAggregator())->aggregate($section_sources); + $fees_collected = (new FeesCollectedAggregator())->aggregate($personnel_and_cost); $xml = (new XmlReportBuilder())->build($node->get('field_agency')->entity, $components, $fiscal_year, $statutes, $request_statistics, $dispositions, $other_reasons, $applied_exemptions, $appeal_statistics, $appeal_dispositions, $appeal_exemptions, $appeal_denials, $appeal_other_reasons, $appeal_response_times, $oldest_pending_appeals, $processed_response_times, $information_granted_response_times, $simple_response_increments, $complex_response_increments, $expedited_response_increments, $pending_perfected_requests, $oldest_pending_requests, $expedited_processing, $fee_waivers, $fees_collected, $backlog, $consultation_statistics, $oldest_pending_consultations, $personnel_and_cost); $field = $node->get('field_request_data_xml'); $previous_file = $field->entity; diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/XmlReportBuilder.php b/docroot/modules/custom/foia_raw_data_to_report/src/XmlReportBuilder.php index 22924e7c2..d77422707 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/src/XmlReportBuilder.php +++ b/docroot/modules/custom/foia_raw_data_to_report/src/XmlReportBuilder.php @@ -793,7 +793,7 @@ private function addPersonnelAndCost(\DOMDocument $document, \DOMElement $root, } /** - * Adds exact fee totals and the unavailable cost percentage placeholder. + * Adds Section IX-XI fee totals and their ratios to total costs. */ private function addFeesCollected(\DOMDocument $document, \DOMElement $root, array $fees, array $component_map): void { $section = $this->addTextElement($document, $root, 'foia', 'FeesCollectedSection'); @@ -802,16 +802,13 @@ private function addFeesCollected(\DOMDocument $document, \DOMElement $root, arr $organizations[$organization_id] = $fees['components'][$component_id]; } $organizations['ORG0'] = $fees['overall']; - foreach ($organizations as $organization_id => $cents) { + foreach ($organizations as $organization_id => $values) { $entry = $this->addTextElement($document, $section, 'foia', 'FeesCollected'); $entry->setAttributeNS(self::NAMESPACES['s'], 's:id', 'FC' . substr($organization_id, 3)); - // Format integer cents without converting the total to floating point. - $absolute = abs($cents); - $amount = ($cents < 0 ? '-' : '') . intdiv($absolute, 100) . '.' . str_pad((string) ($absolute % 100), 2, '0', STR_PAD_LEFT); - $this->addTextElement($document, $entry, 'foia', 'FeesCollectedAmount', $amount); - $this->addTextElement($document, $entry, 'foia', 'FeesCollectedCostPercent', '0.0000'); + $this->addTextElement($document, $entry, 'foia', 'FeesCollectedAmount', $values['amount']); + $this->addTextElement($document, $entry, 'foia', 'FeesCollectedCostPercent', $values['ratio']); } - foreach ($organizations as $organization_id => $cents) { + foreach ($organizations as $organization_id => $values) { $association = $this->addTextElement($document, $section, 'foia', 'FeesCollectedOrganizationAssociation'); $reference = $this->addTextElement($document, $association, 'foia', 'ComponentDataReference'); $reference->setAttributeNS(self::NAMESPACES['s'], 's:ref', 'FC' . substr($organization_id, 3)); diff --git a/docroot/modules/custom/foia_raw_data_to_report/tests/integration/component_uploads.php b/docroot/modules/custom/foia_raw_data_to_report/tests/integration/component_uploads.php index da5675082..ce5101908 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/tests/integration/component_uploads.php +++ b/docroot/modules/custom/foia_raw_data_to_report/tests/integration/component_uploads.php @@ -79,7 +79,8 @@ $paragraphs[] = $paragraph; } foreach ($paragraphs as $delta => $paragraph) { - $section_file = \Drupal::service('file.repository')->writeData(implode(',', SectionDataCsvValidator::HEADERS) . "\n39,1.85,7164103,1918485,0,0,145,823", "private://section-check-$suffix-$delta.csv"); + $section_fees = $delta === 0 ? '908259' : '1816518'; + $section_file = \Drupal::service('file.repository')->writeData(implode(',', SectionDataCsvValidator::HEADERS) . "\n39,1.85,7164103,1918485,$section_fees,0,145,823", "private://section-check-$suffix-$delta.csv"); $section_file->setOwnerId($manager->id())->save(); $section_files[] = $section_file; $paragraph->set('section_ix_xi_data', $section_file->id()); @@ -187,6 +188,16 @@ } } + $expected_fees = [ + 1 => ['908259.0000', '0.1000'], + 2 => ['1816518.0000', '0.2000'], + 0 => ['2724777.0000', '0.1500'], + ]; + foreach ($expected_fees as $id => [$amount, $ratio]) { + $base = '//foia:FeesCollected[@s:id="FC' . $id . '"]'; + $check($xpath->evaluate('string(' . $base . '/foia:FeesCollectedAmount)') === $amount, 'Incorrect Section IX-XI fees.'); + $check($xpath->evaluate('string(' . $base . '/foia:FeesCollectedCostPercent)') === $ratio, 'Incorrect fee/cost ratio.'); + } $check(substr_count($report->get('field_messages')->value, 'CSV validated.') === 4, 'Successful retry did not replace earlier messages.'); // An aggregation exception must append to validation messages and retain XML. $row = array_fill(0, 29, ''); From 31e378aab965ec8ee6a6a86430f17fa39d83b038 Mon Sep 17 00:00:00 2001 From: Rob Ristroph Date: Thu, 24 Sep 2026 17:58:54 -0500 Subject: [PATCH 19/24] WS-472: Added real values for Subsection C Exclusions in XML. --- .../custom/foia_raw_data_to_report/README.md | 15 ++++++++------- .../src/PersonnelAndCostAggregator.php | 8 +++++--- .../src/XmlReportBuilder.php | 19 +++++++++++-------- .../tests/integration/component_uploads.php | 7 ++++++- 4 files changed, 30 insertions(+), 19 deletions(-) diff --git a/docroot/modules/custom/foia_raw_data_to_report/README.md b/docroot/modules/custom/foia_raw_data_to_report/README.md index 077686359..4066aae8a 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/README.md +++ b/docroot/modules/custom/foia_raw_data_to_report/README.md @@ -654,13 +654,14 @@ to four decimal places, including `0.0000` for zero. Exact decimal arithmetic avoids floating-point rounding. Column W in the raw request CSV no longer supplies this section. Existing FC/ORG links and zero-count entries remain. -## Subsection use placeholders - -`SubsectionUsedSection` follows fees collected. Every uploaded component and -the agency receive a `SubsectionUsed` entry with `TimesUsedQuantity` set to -`0`, since CSVs do not provide this information. `SU1`, `SU2`, etc. and agency -`SU0` link to Organization entries through `SubsectionUsedOrganizationAssociation`. -No additional CSV pass is needed. +## Subsection use + +`SubsectionUsedSection` uses Column F (Subsection (c) Exclusions) from each +component's Section IX-XI CSV for `TimesUsedQuantity`. Agency quantities sum +component quantities with exact integer arithmetic. Zero values remain present. +`SU1`, `SU2`, etc. and agency `SU0` retain the existing Organization links through +`SubsectionUsedOrganizationAssociation`. These values are collected in the same +pass as personnel, cost, and fee totals. ## Subsection posting placeholders diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/PersonnelAndCostAggregator.php b/docroot/modules/custom/foia_raw_data_to_report/src/PersonnelAndCostAggregator.php index c1f988e22..2b74dfc11 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/src/PersonnelAndCostAggregator.php +++ b/docroot/modules/custom/foia_raw_data_to_report/src/PersonnelAndCostAggregator.php @@ -5,7 +5,7 @@ use Brick\Math\BigDecimal; /** - * Collects exact personnel, cost, and fee totals from Section IX-XI CSVs. + * Collects exact personnel, cost, fee, and exclusion totals from Section IX-XI. */ final class PersonnelAndCostAggregator { @@ -21,6 +21,7 @@ public function aggregate(array $sources): array { 'LitigationCostAmount', 'TotalCostAmount', 'FeesCollectedAmount', + 'TimesUsedQuantity', ]; $overall = array_fill_keys($fields, '0'); $components = []; @@ -53,7 +54,7 @@ public function aggregate(array $sources): array { throw new \RuntimeException($context . ': Section IX-XI CSV changed after validation.'); } $values = []; - foreach (array_slice($rows[1], 0, 5) as $index => $value) { + foreach (array_slice($rows[1], 0, 6) as $index => $value) { $value = trim($value); $pattern = $index === 1 ? '/^[+-]?[0-9]+(?:\.[0-9]+)?$/' : '/^[+-]?[0-9]+$/'; if (!preg_match($pattern, $value)) { @@ -61,7 +62,7 @@ public function aggregate(array $sources): array { } $values[] = BigDecimal::of($value); } - [$employees, $equivalent, $processing, $litigation, $fees] = $values; + [$employees, $equivalent, $processing, $litigation, $fees, $exclusions] = $values; $totals = [ $employees, $equivalent, @@ -70,6 +71,7 @@ public function aggregate(array $sources): array { $litigation, $processing->plus($litigation), $fees, + $exclusions, ]; $id = $source['component_id']; $components[$id] ??= array_fill_keys($fields, '0'); diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/XmlReportBuilder.php b/docroot/modules/custom/foia_raw_data_to_report/src/XmlReportBuilder.php index d77422707..ecb3dd8a6 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/src/XmlReportBuilder.php +++ b/docroot/modules/custom/foia_raw_data_to_report/src/XmlReportBuilder.php @@ -209,7 +209,7 @@ public function build(TermInterface $agency, array $components, int $fiscal_year $this->addFeesCollected($document, $root, $fees_collected, $component_map); } - $this->addSubsectionUsed($document, $root, $component_map); + $this->addSubsectionUsed($document, $root, $component_map, $personnel_and_cost); $this->addSubsectionPost($document, $root, $component_map); if ($backlog !== []) { @@ -818,18 +818,21 @@ private function addFeesCollected(\DOMDocument $document, \DOMElement $root, arr } /** - * Adds zero subsection-use placeholders because CSVs lack this information. + * Adds Section IX-XI subsection-use counts and organization references. */ - private function addSubsectionUsed(\DOMDocument $document, \DOMElement $root, array $component_map): void { + private function addSubsectionUsed(\DOMDocument $document, \DOMElement $root, array $component_map, array $statistics): void { $section = $this->addTextElement($document, $root, 'foia', 'SubsectionUsedSection'); - $organizations = array_values($component_map); - $organizations[] = 'ORG0'; - foreach ($organizations as $organization_id) { + $organizations = []; + foreach ($component_map as $component_id => $organization_id) { + $organizations[$organization_id] = $statistics['components'][$component_id]['TimesUsedQuantity'] ?? '0'; + } + $organizations['ORG0'] = $statistics['overall']['TimesUsedQuantity'] ?? '0'; + foreach ($organizations as $organization_id => $quantity) { $entry = $this->addTextElement($document, $section, 'foia', 'SubsectionUsed'); $entry->setAttributeNS(self::NAMESPACES['s'], 's:id', 'SU' . substr($organization_id, 3)); - $this->addTextElement($document, $entry, 'foia', 'TimesUsedQuantity', '0'); + $this->addTextElement($document, $entry, 'foia', 'TimesUsedQuantity', $quantity); } - foreach ($organizations as $organization_id) { + foreach ($organizations as $organization_id => $quantity) { $association = $this->addTextElement($document, $section, 'foia', 'SubsectionUsedOrganizationAssociation'); $reference = $this->addTextElement($document, $association, 'foia', 'ComponentDataReference'); $reference->setAttributeNS(self::NAMESPACES['s'], 's:ref', 'SU' . substr($organization_id, 3)); diff --git a/docroot/modules/custom/foia_raw_data_to_report/tests/integration/component_uploads.php b/docroot/modules/custom/foia_raw_data_to_report/tests/integration/component_uploads.php index ce5101908..d8cc0802e 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/tests/integration/component_uploads.php +++ b/docroot/modules/custom/foia_raw_data_to_report/tests/integration/component_uploads.php @@ -80,7 +80,8 @@ } foreach ($paragraphs as $delta => $paragraph) { $section_fees = $delta === 0 ? '908259' : '1816518'; - $section_file = \Drupal::service('file.repository')->writeData(implode(',', SectionDataCsvValidator::HEADERS) . "\n39,1.85,7164103,1918485,$section_fees,0,145,823", "private://section-check-$suffix-$delta.csv"); + $section_exclusions = $delta === 0 ? '7' : '0'; + $section_file = \Drupal::service('file.repository')->writeData(implode(',', SectionDataCsvValidator::HEADERS) . "\n39,1.85,7164103,1918485,$section_fees,$section_exclusions,145,823", "private://section-check-$suffix-$delta.csv"); $section_file->setOwnerId($manager->id())->save(); $section_files[] = $section_file; $paragraph->set('section_ix_xi_data', $section_file->id()); @@ -188,6 +189,10 @@ } } + foreach ([1 => '7', 2 => '0', 0 => '7'] as $id => $expected) { + $actual = $xpath->evaluate('string(//foia:SubsectionUsed[@s:id="SU' . $id . '"]/foia:TimesUsedQuantity)'); + $check($actual === $expected, 'Incorrect Section IX-XI subsection-use count.'); + } $expected_fees = [ 1 => ['908259.0000', '0.1000'], 2 => ['1816518.0000', '0.2000'], From 53d1a801c74ed31600a4eecc417e33cd1a0436a9 Mon Sep 17 00:00:00 2001 From: Rob Ristroph Date: Thu, 24 Sep 2026 18:09:55 -0500 Subject: [PATCH 20/24] WS-472: Added SubsectionPostSection with real values to the XML. --- .../custom/foia_raw_data_to_report/README.md | 15 ++++++------- .../src/PersonnelAndCostAggregator.php | 10 ++++++--- .../src/XmlReportBuilder.php | 21 +++++++++++-------- .../tests/integration/component_uploads.php | 9 +++++++- 4 files changed, 35 insertions(+), 20 deletions(-) diff --git a/docroot/modules/custom/foia_raw_data_to_report/README.md b/docroot/modules/custom/foia_raw_data_to_report/README.md index 4066aae8a..3904090cf 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/README.md +++ b/docroot/modules/custom/foia_raw_data_to_report/README.md @@ -663,13 +663,14 @@ component quantities with exact integer arithmetic. Zero values remain present. `SubsectionUsedOrganizationAssociation`. These values are collected in the same pass as personnel, cost, and fee totals. -## Subsection posting placeholders +## Subsection posting -`SubsectionPostSection` follows subsection use. Every uploaded component and -the agency receive a `Subsection` entry with `PostedbyFOIAQuantity` and -`PostedbyProgramQuantity` both set to `0`. `SP1`, `SP2`, etc. and agency `SP0` -link to Organization entries through `SubsectionPostOrganizationAssociation`. -These placeholders require no additional CSV processing. +`SubsectionPostSection` uses Section IX-XI Column G for `PostedbyFOIAQuantity` +and Column H for `PostedbyProgramQuantity`. Agency quantities sum component +quantities using exact integer arithmetic, including zeros. `SP1`, `SP2`, etc. +and agency `SP0` retain Organization links through +`SubsectionPostOrganizationAssociation`. These values are collected in the +same pass as personnel, cost, fee, and exclusion totals. ## Backlog @@ -845,4 +846,4 @@ Agency quantities sum the corresponding component values. Existing PC/ORG references and zero values are preserved. Exact decimal arithmetic uses the existing Brick Math dependency, avoiding floating-point rounding or integer overflow; output does not impose an additional decimal precision limit. -Other Section IX-XI columns are not yet used for XML generation. +Section IX-XI columns E-H supply fees and subsection statistics. diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/PersonnelAndCostAggregator.php b/docroot/modules/custom/foia_raw_data_to_report/src/PersonnelAndCostAggregator.php index 2b74dfc11..c47f2f129 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/src/PersonnelAndCostAggregator.php +++ b/docroot/modules/custom/foia_raw_data_to_report/src/PersonnelAndCostAggregator.php @@ -5,7 +5,7 @@ use Brick\Math\BigDecimal; /** - * Collects exact personnel, cost, fee, and exclusion totals from Section IX-XI. + * Collects exact totals from all eight Section IX-XI CSV columns. */ final class PersonnelAndCostAggregator { @@ -22,6 +22,8 @@ public function aggregate(array $sources): array { 'TotalCostAmount', 'FeesCollectedAmount', 'TimesUsedQuantity', + 'PostedbyFOIAQuantity', + 'PostedbyProgramQuantity', ]; $overall = array_fill_keys($fields, '0'); $components = []; @@ -54,7 +56,7 @@ public function aggregate(array $sources): array { throw new \RuntimeException($context . ': Section IX-XI CSV changed after validation.'); } $values = []; - foreach (array_slice($rows[1], 0, 6) as $index => $value) { + foreach ($rows[1] as $index => $value) { $value = trim($value); $pattern = $index === 1 ? '/^[+-]?[0-9]+(?:\.[0-9]+)?$/' : '/^[+-]?[0-9]+$/'; if (!preg_match($pattern, $value)) { @@ -62,7 +64,7 @@ public function aggregate(array $sources): array { } $values[] = BigDecimal::of($value); } - [$employees, $equivalent, $processing, $litigation, $fees, $exclusions] = $values; + [$employees, $equivalent, $processing, $litigation, $fees, $exclusions, $foia_posts, $program_posts] = $values; $totals = [ $employees, $equivalent, @@ -72,6 +74,8 @@ public function aggregate(array $sources): array { $processing->plus($litigation), $fees, $exclusions, + $foia_posts, + $program_posts, ]; $id = $source['component_id']; $components[$id] ??= array_fill_keys($fields, '0'); diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/XmlReportBuilder.php b/docroot/modules/custom/foia_raw_data_to_report/src/XmlReportBuilder.php index ecb3dd8a6..3b9fe42c2 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/src/XmlReportBuilder.php +++ b/docroot/modules/custom/foia_raw_data_to_report/src/XmlReportBuilder.php @@ -210,7 +210,7 @@ public function build(TermInterface $agency, array $components, int $fiscal_year } $this->addSubsectionUsed($document, $root, $component_map, $personnel_and_cost); - $this->addSubsectionPost($document, $root, $component_map); + $this->addSubsectionPost($document, $root, $component_map, $personnel_and_cost); if ($backlog !== []) { $this->addBacklog($document, $root, $backlog, $component_map); @@ -842,19 +842,22 @@ private function addSubsectionUsed(\DOMDocument $document, \DOMElement $root, ar } /** - * Adds zero subsection-post placeholders because CSVs lack this information. + * Adds Section IX-XI posting counts and their organization references. */ - private function addSubsectionPost(\DOMDocument $document, \DOMElement $root, array $component_map): void { + private function addSubsectionPost(\DOMDocument $document, \DOMElement $root, array $component_map, array $statistics): void { $section = $this->addTextElement($document, $root, 'foia', 'SubsectionPostSection'); - $organizations = array_values($component_map); - $organizations[] = 'ORG0'; - foreach ($organizations as $organization_id) { + $organizations = []; + foreach ($component_map as $component_id => $organization_id) { + $organizations[$organization_id] = $statistics['components'][$component_id] ?? []; + } + $organizations['ORG0'] = $statistics['overall'] ?? []; + foreach ($organizations as $organization_id => $values) { $entry = $this->addTextElement($document, $section, 'foia', 'Subsection'); $entry->setAttributeNS(self::NAMESPACES['s'], 's:id', 'SP' . substr($organization_id, 3)); - $this->addTextElement($document, $entry, 'foia', 'PostedbyFOIAQuantity', '0'); - $this->addTextElement($document, $entry, 'foia', 'PostedbyProgramQuantity', '0'); + $this->addTextElement($document, $entry, 'foia', 'PostedbyFOIAQuantity', $values['PostedbyFOIAQuantity'] ?? '0'); + $this->addTextElement($document, $entry, 'foia', 'PostedbyProgramQuantity', $values['PostedbyProgramQuantity'] ?? '0'); } - foreach ($organizations as $organization_id) { + foreach ($organizations as $organization_id => $values) { $association = $this->addTextElement($document, $section, 'foia', 'SubsectionPostOrganizationAssociation'); $reference = $this->addTextElement($document, $association, 'foia', 'ComponentDataReference'); $reference->setAttributeNS(self::NAMESPACES['s'], 's:ref', 'SP' . substr($organization_id, 3)); diff --git a/docroot/modules/custom/foia_raw_data_to_report/tests/integration/component_uploads.php b/docroot/modules/custom/foia_raw_data_to_report/tests/integration/component_uploads.php index d8cc0802e..02191f2af 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/tests/integration/component_uploads.php +++ b/docroot/modules/custom/foia_raw_data_to_report/tests/integration/component_uploads.php @@ -81,7 +81,9 @@ foreach ($paragraphs as $delta => $paragraph) { $section_fees = $delta === 0 ? '908259' : '1816518'; $section_exclusions = $delta === 0 ? '7' : '0'; - $section_file = \Drupal::service('file.repository')->writeData(implode(',', SectionDataCsvValidator::HEADERS) . "\n39,1.85,7164103,1918485,$section_fees,$section_exclusions,145,823", "private://section-check-$suffix-$delta.csv"); + $section_foia_posts = $delta === 0 ? '145' : '0'; + $section_program_posts = $delta === 0 ? '823' : '17'; + $section_file = \Drupal::service('file.repository')->writeData(implode(',', SectionDataCsvValidator::HEADERS) . "\n39,1.85,7164103,1918485,$section_fees,$section_exclusions,$section_foia_posts,$section_program_posts", "private://section-check-$suffix-$delta.csv"); $section_file->setOwnerId($manager->id())->save(); $section_files[] = $section_file; $paragraph->set('section_ix_xi_data', $section_file->id()); @@ -193,6 +195,11 @@ $actual = $xpath->evaluate('string(//foia:SubsectionUsed[@s:id="SU' . $id . '"]/foia:TimesUsedQuantity)'); $check($actual === $expected, 'Incorrect Section IX-XI subsection-use count.'); } + foreach ([1 => ['145', '823'], 2 => ['0', '17'], 0 => ['145', '840']] as $id => [$foia_posts, $program_posts]) { + $base = '//foia:Subsection[@s:id="SP' . $id . '"]'; + $check($xpath->evaluate('string(' . $base . '/foia:PostedbyFOIAQuantity)') === $foia_posts, 'Incorrect FOIA posting count.'); + $check($xpath->evaluate('string(' . $base . '/foia:PostedbyProgramQuantity)') === $program_posts, 'Incorrect program posting count.'); + } $expected_fees = [ 1 => ['908259.0000', '0.1000'], 2 => ['1816518.0000', '0.2000'], From 156e8d83b7b277999344bd5d30c710b72b5c3ad2 Mon Sep 17 00:00:00 2001 From: Rob Ristroph Date: Thu, 24 Sep 2026 18:39:26 -0500 Subject: [PATCH 21/24] WS-472: Added Component Placeholders button to form. --- .../foia_raw_data_to_report.module | 4 +- .../src/Form/ComponentPlaceholders.php | 134 ++++++++++++++++++ tests/behat/features/RawDataToReport.feature | 49 +++++-- 3 files changed, 178 insertions(+), 9 deletions(-) create mode 100644 docroot/modules/custom/foia_raw_data_to_report/src/Form/ComponentPlaceholders.php diff --git a/docroot/modules/custom/foia_raw_data_to_report/foia_raw_data_to_report.module b/docroot/modules/custom/foia_raw_data_to_report/foia_raw_data_to_report.module index 74942908f..6f6736d96 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/foia_raw_data_to_report.module +++ b/docroot/modules/custom/foia_raw_data_to_report/foia_raw_data_to_report.module @@ -18,6 +18,7 @@ use Drupal\node\NodeInterface; use Drupal\file\FileInterface; use Drupal\file\FileReferenceResolver; use Drupal\foia_raw_data_to_report\Form\GenerateXmlReportForm; +use Drupal\foia_raw_data_to_report\Form\ComponentPlaceholders; /** * Implements hook_node_view(). @@ -169,10 +170,11 @@ function foia_raw_data_to_report_form_node_form_alter(&$form, FormStateInterface } $form['field_component_uploads']['#prefix'] = '
'; $form['field_component_uploads']['#suffix'] = '
'; + ComponentPlaceholders::build($form, $form_state); $form['field_agency']['widget'][0]['target_id']['#ajax'] = [ 'callback' => 'foia_raw_data_to_report_refresh_components', 'wrapper' => 'raw-data-component-uploads', - 'event' => 'change', + 'event' => 'autocompleteclose change', ]; } diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/Form/ComponentPlaceholders.php b/docroot/modules/custom/foia_raw_data_to_report/src/Form/ComponentPlaceholders.php new file mode 100644 index 000000000..5ce3221f8 --- /dev/null +++ b/docroot/modules/custom/foia_raw_data_to_report/src/Form/ComponentPlaceholders.php @@ -0,0 +1,134 @@ + $label) { + $label = Html::decodeEntities($label); + $options[$id] = ['title' => ['data' => ['#title' => $label, '#plain_text' => $label]]]; + } + $form['field_component_uploads']['placeholders'] = [ + '#type' => 'details', + '#title' => t('Add component placeholders'), + '#open' => TRUE, + '#weight' => -10, + 'components' => [ + '#type' => 'tableselect', + '#header' => ['title' => t('Agency Component')], + '#options' => $options, + '#empty' => t('Select an agency to see its available components.'), + '#parents' => ['raw_data_placeholder_components'], + '#description' => t('Select components, or use the header checkbox to select or clear all. Existing component uploads are kept; duplicates are skipped.'), + ], + 'add' => [ + '#type' => 'submit', + '#value' => t('Add placeholders for component data below'), + '#name' => 'add_component_placeholders', + '#submit' => [[static::class, 'submit']], + '#limit_validation_errors' => [], + '#ajax' => [ + 'callback' => 'foia_raw_data_to_report_refresh_components', + 'wrapper' => 'raw-data-component-uploads', + ], + ], + 'result' => [ + '#type' => 'container', + '#attributes' => ['role' => 'status'], + 'text' => ['#plain_text' => $form_state->get('component_placeholder_result') ?? ''], + ], + ]; + } + + /** + * Returns accessible components for the currently submitted agency. + */ + protected static function components(FormStateInterface $form_state): array { + $node = $form_state->getFormObject()->getEntity(); + $agency = $form_state->getUserInput()['field_agency'][0]['target_id'] ?? $node->get('field_agency')->target_id; + if (is_string($agency) && !ctype_digit($agency)) { + $agency = EntityAutocomplete::extractEntityIdFromAutocompleteInput($agency); + } + $handler = \Drupal::service('plugin.manager.entity_reference_selection')->getInstance([ + 'target_type' => 'node', + 'handler' => 'raw_data_component', + 'target_bundles' => ['agency_component' => 'agency_component'], + 'agency_id' => $agency ?: 0, + 'sort' => ['field' => 'title', 'direction' => 'ASC'], + ]); + return $handler->getReferenceableEntities()['agency_component'] ?? []; + } + + /** + * Appends selected components without saving the node or paragraphs. + */ + public static function submit(array &$form, FormStateInterface $form_state): void { + $field = 'field_component_uploads'; + $parents = $form[$field]['widget']['#field_parents']; + $state = WidgetBase::getWidgetState($parents, $field, $form_state); + $input = $form_state->getUserInput(); + // Recheck membership and access on submission, including tampered input. + $selected = array_intersect_key(static::components($form_state), array_filter($input['raw_data_placeholder_components'] ?? [])); + $empty = []; + foreach ($state['paragraphs'] ?? [] as $delta => $item) { + if (in_array($item['mode'], ['remove', 'removed'], TRUE)) { + continue; + } + $paragraph = $item['entity']; + $component = $input[$field][$delta]['subform']['field_agency_component'][0]['target_id'] ?? $paragraph->get('field_agency_component')->target_id; + $has_component_input = !empty($component); + if (is_string($component) && !ctype_digit($component)) { + $component = EntityAutocomplete::extractEntityIdFromAutocompleteInput($component); + } + if ($component) { + unset($selected[$component]); + } + elseif (!$has_component_input && $paragraph->get('field_request_data_csv')->isEmpty() && $paragraph->get('section_ix_xi_data')->isEmpty() && empty($input[$field][$delta]['subform']['field_request_data_csv'][0]['fids']) && empty($input[$field][$delta]['subform']['section_ix_xi_data'][0]['fids'])) { + $empty[] = $delta; + } + } + $count = count($selected); + foreach ($selected as $id => $label) { + if ($empty) { + $delta = array_shift($empty); + $state['paragraphs'][$delta]['entity']->set('field_agency_component', $id); + // Submitted empty input would otherwise override the new default. + unset($input[$field][$delta]['subform']['field_agency_component']); + } + else { + $delta = $state['items_count']++; + $paragraph = Paragraph::create([ + 'type' => 'raw_data_component_upload', + 'field_agency_component' => $id, + ]); + $paragraph->setParentEntity($form_state->getFormObject()->getEntity(), $field); + $state['paragraphs'][$delta] = [ + 'entity' => $paragraph, + 'display' => EntityFormDisplay::collectRenderDisplay($paragraph, 'default'), + 'mode' => 'edit', + ]; + } + } + unset($input['raw_data_placeholder_components']); + $form_state->setUserInput($input); + WidgetBase::setWidgetState($parents, $field, $form_state, $state); + $form_state->set('component_placeholder_result', t('Added @count component placeholder(s). Existing uploads were preserved.', ['@count' => $count])); + $form_state->setRebuild(); + } + +} diff --git a/tests/behat/features/RawDataToReport.feature b/tests/behat/features/RawDataToReport.feature index fd213ae80..ba8fbaff0 100644 --- a/tests/behat/features/RawDataToReport.feature +++ b/tests/behat/features/RawDataToReport.feature @@ -68,19 +68,21 @@ Feature: Raw data XML report action And I am logged in as "raw_block_manager" When I visit the entity of type "node" with the title "Component upload form" And I click "Edit" - And I press "Add Component CSV upload" - And I fill in "Agency Component" with "Upload Fixture Component" - And I press the "down" key in the "Agency Component" field - And I wait 2 seconds - And I press the "down" key in the "Agency Component" field - And I press the "enter" key in the "Agency Component" field + And I check "Update Upload Fixture Component" + And I press "Add placeholders for component data below" + And I wait for AJAX to finish + Then I should see "Added 1 component placeholder(s)." And I attach the file "raw-data-valid.csv" to "files[field_component_uploads_0_subform_field_request_data_csv_0]" And I wait 2 seconds And I wait for AJAX to finish And I attach the file "raw-data-valid.csv" to "files[field_component_uploads_0_subform_section_ix_xi_data_0]" And I wait 2 seconds And I wait for AJAX to finish - And I press "Save" + And I check "Update Upload Fixture Component" + And I press "Add placeholders for component data below" + And I wait for AJAX to finish + Then I should see "Added 0 component placeholder(s)." + When I press "Save" Then I should see "Upload Fixture Component" And I should see "raw-data-valid" And the URL should match "/node/[0-9]+$" @@ -119,7 +121,10 @@ Feature: Raw data XML report action And I press the "enter" key in the "Agency" field And I press the "tab" key in the "Agency" field And I wait for AJAX to finish - And I fill in "Agency Component" with "New Report Component" + And I check "Update New Report Component" + And I press "Add placeholders for component data below" + And I wait for AJAX to finish + Then I should see "Added 1 component placeholder(s)." And I attach the file "raw-data-valid.csv" to "files[field_component_uploads_0_subform_field_request_data_csv_0]" And I wait 2 seconds And I wait for AJAX to finish @@ -131,6 +136,34 @@ Feature: Raw data XML report action And I should see "New Report Component" And I should see "raw-data-valid" + @javascript + Scenario: Multiple placeholders can be added without uploading files first + Given agency_component content: + | title | field_agency | status | + | First Bulk Component | Raw Block Agency One | 1 | + | Second Bulk Component | Raw Block Agency One | 1 | + | Foreign Bulk Component | Raw Block Agency Two | 1 | + And raw_data_to_report content: + | title | field_agency | field_foia_annual_report_yr | field_agency_comp_abbreviation | status | + | Bulk placeholder form | Raw Block Agency One | 2026 | RBA1 | 1 | + And I am logged in as "raw_block_manager" + When I visit the entity of type "node" with the title "Bulk placeholder form" + And I click "Edit" + Then I should not see "Foreign Bulk Component" + When I check "Update First Bulk Component" + And I check "Update Second Bulk Component" + And I press "Add placeholders for component data below" + And I wait for AJAX to finish + Then I should see "Added 2 component placeholder(s)." + When I check "Update First Bulk Component" + And I check "Update Second Bulk Component" + And I press "Add placeholders for component data below" + And I wait for AJAX to finish + Then I should see "Added 0 component placeholder(s)." + When I press "Save" + Then I should see "CSV file field is required." + And I should see "Section IX-XI CSV file field is required." + @javascript Scenario: Additional uploads autocomplete components on an unsaved report Given agency_component content: From 3c9b60a3759b5725c260d893c34a56c1b057b4da Mon Sep 17 00:00:00 2001 From: Rob Ristroph Date: Fri, 25 Sep 2026 09:42:16 -0500 Subject: [PATCH 22/24] WS-472: Added explicit terms allowed for Column P. --- .../foia_raw_data_to_report/src/CsvValidator.php | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/CsvValidator.php b/docroot/modules/custom/foia_raw_data_to_report/src/CsvValidator.php index 7c120cf50..879fca882 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/src/CsvValidator.php +++ b/docroot/modules/custom/foia_raw_data_to_report/src/CsvValidator.php @@ -12,6 +12,14 @@ final class CsvValidator { */ public const EXPECTED_COLUMNS = 29; + /** + * Allowed exemption codes for Column P (Disposition Exemption(s) Applied). + */ + public const ALLOWED_DISPOSITION_EXEMPTIONS = [ + '1', '2', '3', '4', '5', '6', + '7a', '7b', '7c', '7d', '7e', '7f', '8', '9', + ]; + /** * Returns human-readable errors, or an empty array when validation passes. * @@ -302,6 +310,11 @@ public function validate(string $uri, int $fiscal_year): array { if ($applied_exemptions !== '' && !preg_match('/^[a-zA-Z0-9]+(?:\s*,\s*[a-zA-Z0-9]+)*$/', $applied_exemptions)) { $errors[] = sprintf('CSV record %d: Column P: Multiple exemptions must be separated with a comma (e.g., 3,5,7a,7c,7d)', $record); } + // Column P: Each comma-separated entry must match an allowed code. + $exemptions = array_map('trim', explode(',', $applied_exemptions)); + if ($applied_exemptions !== '' && array_diff($exemptions, self::ALLOWED_DISPOSITION_EXEMPTIONS)) { + $errors[] = sprintf('CSV record %d: Column P: Disposition Exemption(s) Applied must contain only these values: %s', $record, implode(', ', self::ALLOWED_DISPOSITION_EXEMPTIONS)); + } // Column P: Disposition code 3 requires at least one exemption. if ($disposition === '3' && $applied_exemptions === '') { @@ -315,7 +328,6 @@ public function validate(string $uri, int $fiscal_year): array { // Column P: A complete exemption 3 entry requires statute data in E. // This complements the Column E check requiring exemption 3 in P. - $exemptions = array_map('trim', explode(',', $applied_exemptions)); if (in_array('3', $exemptions, TRUE) && $statutes === '') { $errors[] = sprintf("CSV record %d: Column P: If Column P contains a '3', Column E must contain information", $record); } From def3f96216b42b33b6e213e225f1518ca85a4bee Mon Sep 17 00:00:00 2001 From: Rob Ristroph Date: Fri, 25 Sep 2026 09:52:05 -0500 Subject: [PATCH 23/24] WS-472: Added additional validation rule for Column T. --- .../custom/foia_raw_data_to_report/src/CsvValidator.php | 5 ++++- .../tests/src/Unit/CsvValidatorTest.php | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/CsvValidator.php b/docroot/modules/custom/foia_raw_data_to_report/src/CsvValidator.php index 879fca882..612ca874a 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/src/CsvValidator.php +++ b/docroot/modules/custom/foia_raw_data_to_report/src/CsvValidator.php @@ -377,8 +377,11 @@ public function validate(string $uri, int $fiscal_year): array { $errors[] = sprintf('CSV record %d: Column S: Must have G or D in Column S', $record); } - // Column T: Request for FW - Date Adjudication Began is optional. + // Column T: A value in U, V, or W requires an adjudication start date. $fw_began = trim($columns[19]); + if ($fw_began === '' && (trim($columns[20]) !== '' || trim($columns[21]) !== '' || trim($columns[22]) !== '')) { + $errors[] = sprintf('CSV record %d: Column T: Column T must contain a value if there is data in Columns U, V, or W', $record); + } $fw_began_date = $this->parseDate($fw_began); if ($fw_began !== '') { // Column T: Require a real date using the same format as Column Q. diff --git a/docroot/modules/custom/foia_raw_data_to_report/tests/src/Unit/CsvValidatorTest.php b/docroot/modules/custom/foia_raw_data_to_report/tests/src/Unit/CsvValidatorTest.php index 9abc27f29..b4acda3c8 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/tests/src/Unit/CsvValidatorTest.php +++ b/docroot/modules/custom/foia_raw_data_to_report/tests/src/Unit/CsvValidatorTest.php @@ -185,7 +185,7 @@ public function testAppealExemptionDisposition(): void { */ public function testValidCsv(): void { $header = implode(',', array_fill(0, 29, 'column')); - $row = $this->csvRow([0 => 'A, B', 1 => "First line\nSecond line", 22 => 'A "quote"']); + $row = $this->csvRow([0 => 'A, B', 1 => "First line\nSecond line", 19 => '01/01/2026', 22 => 'A "quote"']); $this->assertSame([], $this->validateContents("\xEF\xBB\xBF" . $header . "\r\n\r\n" . $row . "\r\n")); } From 1604c221ef03880975bc1159ece80b56d2362b34 Mon Sep 17 00:00:00 2001 From: Rob Ristroph Date: Fri, 25 Sep 2026 09:58:09 -0500 Subject: [PATCH 24/24] WS-472: Made XML parsing error messages more readable. --- .../src/AppealDispositionAggregator.php | 5 +++-- .../src/AppealNonExemptionDenialAggregator.php | 5 +++-- .../src/AppealResponseTimeAggregator.php | 5 +++-- .../src/AppealStatisticsAggregator.php | 5 +++-- .../src/AppliedExemptionsAggregator.php | 5 +++-- .../custom/foia_raw_data_to_report/src/BacklogAggregator.php | 5 +++-- .../src/ConsultationStatisticsAggregator.php | 5 +++-- .../foia_raw_data_to_report/src/DispositionAggregator.php | 5 +++-- .../src/ExpeditedProcessingAggregator.php | 5 +++-- .../src/OldestPendingAppealAggregator.php | 5 +++-- .../src/OldestPendingRequestAggregator.php | 5 +++-- .../src/PendingPerfectedRequestsAggregator.php | 5 +++-- .../src/PersonnelAndCostAggregator.php | 2 +- .../src/Plugin/QueueWorker/RawDataToReportProcessing.php | 2 ++ .../src/ProcessedResponseTimeAggregator.php | 5 +++-- .../src/RequestStatisticsAggregator.php | 5 +++-- .../tests/integration/component_uploads.php | 1 + 17 files changed, 46 insertions(+), 29 deletions(-) diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/AppealDispositionAggregator.php b/docroot/modules/custom/foia_raw_data_to_report/src/AppealDispositionAggregator.php index b55259a92..2a1a26f94 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/src/AppealDispositionAggregator.php +++ b/docroot/modules/custom/foia_raw_data_to_report/src/AppealDispositionAggregator.php @@ -21,7 +21,8 @@ final class AppealDispositionAggregator { * Counts each nonblank disposition once per row, then sums component counts. * * @param array $sources - * Component/file pairs, each with component_id and uri keys. + * Component/file pairs with component_id and uri keys, plus an optional + * component_label for error messages. * * @return array * Component counts keyed by entity ID and overall counts, keyed by outcome. @@ -57,7 +58,7 @@ public function aggregate(array $sources): array { } // Reject unmapped values rather than silently omitting requests. if (!isset(self::DISPOSITIONS[$code])) { - throw new \RuntimeException(sprintf('Component %s, CSV record %d: Unknown Appeal Disposition in Column Z.', $id, $record)); + throw new \RuntimeException(sprintf('Component %s, CSV record %d: Unknown Appeal Disposition in Column Z.', $source['component_label'] ?? $id, $record)); } $components[$id][self::DISPOSITIONS[$code]]++; } diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/AppealNonExemptionDenialAggregator.php b/docroot/modules/custom/foia_raw_data_to_report/src/AppealNonExemptionDenialAggregator.php index 5dbbd64a7..a2b1cb45e 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/src/AppealNonExemptionDenialAggregator.php +++ b/docroot/modules/custom/foia_raw_data_to_report/src/AppealNonExemptionDenialAggregator.php @@ -28,7 +28,8 @@ final class AppealNonExemptionDenialAggregator { * Counts each distinct reason once per row, then sums component counts. * * @param array $sources - * Component/file pairs, each with component_id and uri keys. + * Component/file pairs with component_id and uri keys, plus an optional + * component_label for error messages. * * @return array * Component and overall counts keyed by XML reason code. @@ -67,7 +68,7 @@ public function aggregate(array $sources): array { foreach ($reasons as $reason) { // Unknown labels must not silently disappear from the report. if (!isset(self::REASONS[$reason])) { - throw new \RuntimeException(sprintf('Component %s, CSV record %d: Unknown appeal denial reason "%s" in Column AA.', $id, $record, $reason)); + throw new \RuntimeException(sprintf('Component %s, CSV record %d: Unknown appeal denial reason "%s" in Column AA.', $source['component_label'] ?? $id, $record, $reason)); } $components[$id][self::REASONS[$reason]]++; } diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/AppealResponseTimeAggregator.php b/docroot/modules/custom/foia_raw_data_to_report/src/AppealResponseTimeAggregator.php index d1cf01f5b..732c640cb 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/src/AppealResponseTimeAggregator.php +++ b/docroot/modules/custom/foia_raw_data_to_report/src/AppealResponseTimeAggregator.php @@ -11,7 +11,8 @@ final class AppealResponseTimeAggregator { * Calculates component and agency statistics without retaining CSV rows. * * @param array $sources - * Component/file pairs, each with component_id and uri keys. + * Component/file pairs with component_id and uri keys, plus an optional + * component_label for error messages. * @param int $fiscal_year * The validated report year. * @@ -46,7 +47,7 @@ public function aggregate(array $sources, int $fiscal_year): array { $header = FALSE; continue; } - $context = sprintf('Component %s, CSV record %d', $id, $record); + $context = sprintf('Component %s, CSV record %d', $source['component_label'] ?? $id, $record); // Only rows with an appeal contribute to response time statistics. $received_text = trim($columns[23]); $completed = trim($columns[24]); diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/AppealStatisticsAggregator.php b/docroot/modules/custom/foia_raw_data_to_report/src/AppealStatisticsAggregator.php index 0ffe9488b..8dc87f492 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/src/AppealStatisticsAggregator.php +++ b/docroot/modules/custom/foia_raw_data_to_report/src/AppealStatisticsAggregator.php @@ -21,7 +21,8 @@ final class AppealStatisticsAggregator { * Returns component counters and agency totals without retaining CSV rows. * * @param array $sources - * Component/file pairs, each with component_id and uri keys. + * Component/file pairs with component_id and uri keys, plus an optional + * component_label for error messages. * @param int $fiscal_year * The validated report year. * @@ -56,7 +57,7 @@ public function aggregate(array $sources, int $fiscal_year): array { // Rows without an appeal do not contribute to any of the counters. $received_text = trim($columns[23]); $completed = trim($columns[24]); - $context = sprintf('Component %s, CSV record %d', $source['component_id'], $record); + $context = sprintf('Component %s, CSV record %d', $source['component_label'] ?? $source['component_id'], $record); if ($received_text === '') { if ($completed !== '') { throw new \RuntimeException($context . ': Appeal Date Closed requires an Appeal Date Received.'); diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/AppliedExemptionsAggregator.php b/docroot/modules/custom/foia_raw_data_to_report/src/AppliedExemptionsAggregator.php index 3d8c2ed21..a4f3fdbec 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/src/AppliedExemptionsAggregator.php +++ b/docroot/modules/custom/foia_raw_data_to_report/src/AppliedExemptionsAggregator.php @@ -31,7 +31,8 @@ final class AppliedExemptionsAggregator { * Counts each distinct exemption once per row, then sums component counts. * * @param array $sources - * Component/file pairs, each with component_id and uri keys. + * Component/file pairs with component_id and uri keys, plus an optional + * component_label for error messages. * @param int $column * Zero-based CSV column: 15 for requests, 28 for appeals. * @@ -77,7 +78,7 @@ public function aggregate(array $sources, int $column = 15): array { foreach ($codes as $code) { // Unknown codes must not silently disappear from the report. if (!isset(self::EXEMPTIONS[$code])) { - throw new \RuntimeException(sprintf('Component %s, CSV record %d: Unknown exemption code "%s" in Column %s.', $id, $record, $code, $column_name)); + throw new \RuntimeException(sprintf('Component %s, CSV record %d: Unknown exemption code "%s" in Column %s.', $source['component_label'] ?? $id, $record, $code, $column_name)); } $components[$id][$code]++; } diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/BacklogAggregator.php b/docroot/modules/custom/foia_raw_data_to_report/src/BacklogAggregator.php index 988ebcb47..4da1faa82 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/src/BacklogAggregator.php +++ b/docroot/modules/custom/foia_raw_data_to_report/src/BacklogAggregator.php @@ -11,7 +11,8 @@ final class BacklogAggregator { * Counts requests and appeals, using fiscal year-end for open items. * * @param array $sources - * Component/file pairs, each with component_id and uri keys. + * Component/file pairs with component_id and uri keys, plus an optional + * component_label for error messages. * @param int $fiscal_year * The validated report year. * @@ -45,7 +46,7 @@ public function aggregate(array $sources, int $fiscal_year): array { $header = FALSE; continue; } - $context = sprintf('Component %s, CSV %s, record %d', $id, basename($source['uri']), $record); + $context = sprintf('Component %s, CSV %s, record %d', $source['component_label'] ?? $id, basename($source['uri']), $record); // Column D supplies the threshold for both requests and appeals. // Consultations leave Days Allowed blank and are excluded. $days_allowed = trim($columns[3]); diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/ConsultationStatisticsAggregator.php b/docroot/modules/custom/foia_raw_data_to_report/src/ConsultationStatisticsAggregator.php index 09a8fed03..78b17a34e 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/src/ConsultationStatisticsAggregator.php +++ b/docroot/modules/custom/foia_raw_data_to_report/src/ConsultationStatisticsAggregator.php @@ -21,7 +21,8 @@ final class ConsultationStatisticsAggregator { * Returns component counters and agency totals without retaining CSV rows. * * @param array $sources - * Component/file pairs, each with component_id and uri keys. + * Component/file pairs with component_id and uri keys, plus an optional + * component_label for error messages. * @param int $fiscal_year * The validated report year. * @@ -57,7 +58,7 @@ public function aggregate(array $sources, int $fiscal_year): array { if (trim($columns[2]) !== 'Y') { continue; } - $context = sprintf('Component %s, CSV %s, record %d', $source['component_id'], basename($source['uri']), $record); + $context = sprintf('Component %s, CSV %s, record %d', $source['component_label'] ?? $source['component_id'], basename($source['uri']), $record); // Count receipt dates before or within the inclusive fiscal year. $received = trim($columns[8]); if ($received !== '') { diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/DispositionAggregator.php b/docroot/modules/custom/foia_raw_data_to_report/src/DispositionAggregator.php index 0046c18bc..59c8ca4ee 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/src/DispositionAggregator.php +++ b/docroot/modules/custom/foia_raw_data_to_report/src/DispositionAggregator.php @@ -44,7 +44,8 @@ final class DispositionAggregator { * Counts each nonblank disposition once per row, then sums component counts. * * @param array $sources - * Component/file pairs, each with component_id and uri keys. + * Component/file pairs with component_id and uri keys, plus an optional + * component_label for error messages. * * @return array * Component counts keyed by entity ID and overall counts, keyed by code. @@ -80,7 +81,7 @@ public function aggregate(array $sources): array { } // Reject unmapped values rather than silently omitting requests. if (!preg_match('/^(?:[1-9]|1[0-2])$/', $code)) { - throw new \RuntimeException(sprintf('Component %s, CSV record %d: Column N must contain a disposition code from 1 through 12.', $id, $record)); + throw new \RuntimeException(sprintf('Component %s, CSV record %d: Column N must contain a disposition code from 1 through 12.', $source['component_label'] ?? $id, $record)); } $components[$id][(int) $code]++; } diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/ExpeditedProcessingAggregator.php b/docroot/modules/custom/foia_raw_data_to_report/src/ExpeditedProcessingAggregator.php index 87ce2ea5d..ac5f47bf3 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/src/ExpeditedProcessingAggregator.php +++ b/docroot/modules/custom/foia_raw_data_to_report/src/ExpeditedProcessingAggregator.php @@ -16,7 +16,8 @@ final class ExpeditedProcessingAggregator { * Counts granted, denied, and adjudications within ten working days. * * @param array $sources - * Component/file pairs, each with component_id and uri keys. + * Component/file pairs with component_id and uri keys, plus an optional + * component_label for error messages. * * @return array * Component counts keyed by entity ID and overall counts, keyed by outcome. @@ -55,7 +56,7 @@ public function aggregate(array $sources): array { $components[$id][self::OUTCOMES[$outcome]]++; $received = trim($columns[16]); $determined = trim($columns[17]); - $context = sprintf('Component %s, CSV %s, record %d', $id, basename($source['uri']), $record); + $context = sprintf('Component %s, CSV %s, record %d', $source['component_label'] ?? $id, basename($source['uri']), $record); $start = $this->calendarDate($received, $context . ', Column Q'); // R may be blank: count the outcome, but no adjudication interval. if ($determined === '') { diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/OldestPendingAppealAggregator.php b/docroot/modules/custom/foia_raw_data_to_report/src/OldestPendingAppealAggregator.php index 9bdd9f09c..1577902b0 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/src/OldestPendingAppealAggregator.php +++ b/docroot/modules/custom/foia_raw_data_to_report/src/OldestPendingAppealAggregator.php @@ -11,7 +11,8 @@ final class OldestPendingAppealAggregator { * Collects at most ten receipt dates per component and for the agency. * * @param array $sources - * Component/file pairs, each with component_id and uri keys. + * Component/file pairs with component_id and uri keys, plus an optional + * component_label for error messages. * @param int $fiscal_year * The validated report year. * @@ -50,7 +51,7 @@ public function aggregate(array $sources, int $fiscal_year): array { if ($received_text === '' || trim($columns[24]) !== '') { continue; } - $context = sprintf('Component %s, CSV record %d, Column X', $id, $record); + $context = sprintf('Component %s, CSV record %d, Column X', $source['component_label'] ?? $id, $record); $received = $this->calendarDate($received_text, $context); if ($received > $end) { throw new \RuntimeException($context . ': Appeal Date Received is after the report fiscal year.'); diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/OldestPendingRequestAggregator.php b/docroot/modules/custom/foia_raw_data_to_report/src/OldestPendingRequestAggregator.php index e4b0606bb..ba26861f1 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/src/OldestPendingRequestAggregator.php +++ b/docroot/modules/custom/foia_raw_data_to_report/src/OldestPendingRequestAggregator.php @@ -11,7 +11,8 @@ final class OldestPendingRequestAggregator { * Collects at most ten receipt dates per component and for the agency. * * @param array $sources - * Component/file pairs, each with component_id and uri keys. + * Component/file pairs with component_id and uri keys, plus an optional + * component_label for error messages. * @param int $fiscal_year * The validated report year. * @param bool $consultations_only @@ -56,7 +57,7 @@ public function aggregate(array $sources, int $fiscal_year, bool $consultations_ if ($received_text === '' || trim($columns[10]) !== '') { continue; } - $context = sprintf('Component %s, CSV %s, record %d, Column I', $id, basename($source['uri']), $record); + $context = sprintf('Component %s, CSV %s, record %d, Column I', $source['component_label'] ?? $id, basename($source['uri']), $record); $received = $this->calendarDate($received_text, $context); if ($received > $end) { throw new \RuntimeException($context . ': Date Initially Received is after the report fiscal year.'); diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/PendingPerfectedRequestsAggregator.php b/docroot/modules/custom/foia_raw_data_to_report/src/PendingPerfectedRequestsAggregator.php index 781a6fbde..9b9090302 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/src/PendingPerfectedRequestsAggregator.php +++ b/docroot/modules/custom/foia_raw_data_to_report/src/PendingPerfectedRequestsAggregator.php @@ -20,7 +20,8 @@ final class PendingPerfectedRequestsAggregator { * Calculates component and agency counts, medians, and averages. * * @param array $sources - * Component/file pairs, each with component_id and uri keys. + * Component/file pairs with component_id and uri keys, plus an optional + * component_label for error messages. * @param int $fiscal_year * The validated report year, ending September 30. * @@ -59,7 +60,7 @@ public function aggregate(array $sources, int $fiscal_year): array { if ($perfected === '' || trim($columns[10]) !== '') { continue; } - $context = sprintf('Component %s, CSV %s, record %d', $id, basename($source['uri']), $record); + $context = sprintf('Component %s, CSV %s, record %d', $source['component_label'] ?? $id, basename($source['uri']), $record); $track = trim($columns[12]); if (!isset(self::TRACKS[$track])) { throw new \RuntimeException($context . ': Column M must contain S, C, or E for a perfected request.'); diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/PersonnelAndCostAggregator.php b/docroot/modules/custom/foia_raw_data_to_report/src/PersonnelAndCostAggregator.php index c47f2f129..8d89e3e92 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/src/PersonnelAndCostAggregator.php +++ b/docroot/modules/custom/foia_raw_data_to_report/src/PersonnelAndCostAggregator.php @@ -30,7 +30,7 @@ public function aggregate(array $sources): array { foreach ($sources as $source) { // Recheck the small file in case it changed after queue validation. $errors = (new SectionDataCsvValidator())->validate($source['uri']); - $context = sprintf('Component %s, CSV %s', $source['component_id'], basename($source['uri'])); + $context = sprintf('Component %s, CSV %s', $source['component_label'] ?? $source['component_id'], basename($source['uri'])); if ($errors) { throw new \RuntimeException($context . ': ' . implode(' ', $errors)); } diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/Plugin/QueueWorker/RawDataToReportProcessing.php b/docroot/modules/custom/foia_raw_data_to_report/src/Plugin/QueueWorker/RawDataToReportProcessing.php index 61a4df756..b8f67bc7c 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/src/Plugin/QueueWorker/RawDataToReportProcessing.php +++ b/docroot/modules/custom/foia_raw_data_to_report/src/Plugin/QueueWorker/RawDataToReportProcessing.php @@ -205,10 +205,12 @@ protected function generateXmlReport(NodeInterface $node): void { $components[] = $component; $section_sources[] = [ 'component_id' => $component->id(), + 'component_label' => $component->label(), 'uri' => $item->entity->get('section_ix_xi_data')->entity->getFileUri(), ]; $sources[] = [ 'component_id' => $component->id(), + 'component_label' => $component->label(), 'uri' => $item->entity->get('field_request_data_csv')->entity->getFileUri(), ]; } diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/ProcessedResponseTimeAggregator.php b/docroot/modules/custom/foia_raw_data_to_report/src/ProcessedResponseTimeAggregator.php index 5543f81e2..e1aa9b73a 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/src/ProcessedResponseTimeAggregator.php +++ b/docroot/modules/custom/foia_raw_data_to_report/src/ProcessedResponseTimeAggregator.php @@ -20,7 +20,8 @@ final class ProcessedResponseTimeAggregator { * Returns component and agency summaries without retaining individual rows. * * @param array $sources - * Component/file pairs, each with component_id and uri keys. + * Component/file pairs with component_id and uri keys, plus an optional + * component_label for error messages. * @param bool $information_granted_only * Whether to include only disposition codes 1 and 2 in Column N. * @@ -152,7 +153,7 @@ private function collectHistograms(array $sources, bool $information_granted_onl // Prefer J; fall back to I without clamping to the fiscal year. $perfected = trim($columns[9]); $start = $perfected !== '' ? $perfected : trim($columns[8]); - $context = sprintf('Component %s, CSV record %d', $id, $record); + $context = sprintf('Component %s, CSV record %d', $source['component_label'] ?? $id, $record); if ($start === '') { throw new \RuntimeException($context . ': Completed requests require a date in Column J or I to calculate response time.'); } diff --git a/docroot/modules/custom/foia_raw_data_to_report/src/RequestStatisticsAggregator.php b/docroot/modules/custom/foia_raw_data_to_report/src/RequestStatisticsAggregator.php index dd24ee627..2a26f9828 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/src/RequestStatisticsAggregator.php +++ b/docroot/modules/custom/foia_raw_data_to_report/src/RequestStatisticsAggregator.php @@ -21,7 +21,8 @@ final class RequestStatisticsAggregator { * Returns component counters and agency totals without retaining CSV rows. * * @param array $sources - * Component/file pairs, each with component_id and uri keys. + * Component/file pairs with component_id and uri keys, plus an optional + * component_label for error messages. * @param int $fiscal_year * The validated report year. * @@ -60,7 +61,7 @@ public function aggregate(array $sources, int $fiscal_year): array { if ($received_text === '') { continue; } - $context = sprintf('Component %s, CSV %s, record %d', $source['component_id'], basename($source['uri']), $record); + $context = sprintf('Component %s, CSV %s, record %d', $source['component_label'] ?? $source['component_id'], basename($source['uri']), $record); $received = $this->calendarDate($received_text, $context . ', Column I (Date Initially Received)'); if ($received < $start) { $counts['pending_start']++; diff --git a/docroot/modules/custom/foia_raw_data_to_report/tests/integration/component_uploads.php b/docroot/modules/custom/foia_raw_data_to_report/tests/integration/component_uploads.php index 02191f2af..f674a0bff 100644 --- a/docroot/modules/custom/foia_raw_data_to_report/tests/integration/component_uploads.php +++ b/docroot/modules/custom/foia_raw_data_to_report/tests/integration/component_uploads.php @@ -238,6 +238,7 @@ } catch (RuntimeException $exception) { $check(str_contains($exception->getMessage(), 'Column AC'), 'Unexpected processing exception.'); + $check(str_starts_with($exception->getMessage(), 'Component ' . $components[1]->label() . ', CSV'), 'Processing exception must identify the component by name.'); $report = $node_storage->loadUnchanged($report->id()); $message = $report->get('field_messages')->value; $check(substr_count($message, 'CSV validated.') === 4, 'Exception lost validation messages.');