addOption('file', null, InputOption::VALUE_REQUIRED, 'Path to a CSV with header row: itemId,value,dateRecorded[,userId]') ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Parse and validate the CSV without inserting.'); } /** * @throws BindingResolutionException */ protected function execute(InputInterface $input, OutputInterface $output): int { ! defined('BASE_URL') && define('BASE_URL', ''); ! defined('CURRENT_URL') && define('CURRENT_URL', ''); $io = new SymfonyStyle($input, $output); $path = $input->getOption('file'); $dryRun = (bool) $input->getOption('dry-run'); if ($path === null || $path === '') { $io->error('Missing --file '); return Command::INVALID; } if (! is_file($path) || ! is_readable($path)) { $io->error("File not found or unreadable: {$path}"); return Command::INVALID; } $handle = fopen($path, 'r'); if ($handle === false) { $io->error("Could not open {$path}"); return Command::FAILURE; } $header = fgetcsv($handle); if ($header === false) { fclose($handle); $io->error('CSV is empty.'); return Command::INVALID; } $header = array_map(static fn ($h) => is_string($h) ? strtolower(trim($h)) : '', $header); // Match by header name so column order doesn't matter — a spreadsheet // exported from anywhere can be fed in without hand-editing. $idxItem = array_search('itemid', $header, true); $idxValue = array_search('value', $header, true); $idxDate = array_search('daterecorded', $header, true); $idxUser = array_search('userid', $header, true); if ($idxItem === false || $idxValue === false || $idxDate === false) { fclose($handle); $io->error('CSV header must include: itemId, value, dateRecorded (userId optional).'); return Command::INVALID; } $rows = []; $errors = []; $lineNo = 1; while (($record = fgetcsv($handle)) !== false) { $lineNo++; if ($record === [null] || $record === []) { continue; } $itemId = (int) ($record[$idxItem] ?? 0); $rawValue = $record[$idxValue] ?? ''; $rawDate = trim((string) ($record[$idxDate] ?? '')); if ($itemId <= 0) { $errors[] = "line {$lineNo}: itemId missing or invalid"; continue; } if ($rawValue === '' || ! is_numeric($rawValue)) { $errors[] = "line {$lineNo}: value missing or not numeric"; continue; } // zp_goal_history rows are stored in UTC (the period reads in the Goalcanvas // repository normalize to UTC) — parse offset-less inputs AS UTC and normalize // offset-carrying inputs TO UTC, never the server timezone. try { $dateRecorded = (new \Carbon\CarbonImmutable($rawDate, 'UTC'))->setTimezone('UTC'); } catch (\Exception $e) { $errors[] = "line {$lineNo}: unparseable dateRecorded '{$rawDate}'"; continue; } $rows[] = [ 'itemId' => $itemId, 'value' => (float) $rawValue, 'userId' => ($idxUser !== false && isset($record[$idxUser]) && $record[$idxUser] !== '') ? (int) $record[$idxUser] : null, 'dateRecorded' => $dateRecorded->format('Y-m-d H:i:s'), ]; } fclose($handle); if ($errors !== []) { $io->section('Validation errors'); foreach ($errors as $e) { $io->writeln(' - '.$e); } $io->error(sprintf('%d row(s) invalid; nothing written.', count($errors))); return Command::INVALID; } if ($rows === []) { $io->warning('No data rows after header — nothing to import.'); return Command::SUCCESS; } // Reject itemIds that aren't actually goals BEFORE writing. The // history table has no FK to zp_canvas_items so nothing else would // catch a CSV pointing at a non-goal id (or an id that doesn't // exist). Silent inserts against phantom ids would just be dead // rows nobody ever reads. try { $repo = app()->make(GoalcanvasRepo::class); $csvItemIds = array_map(static fn ($r) => (int) $r['itemId'], $rows); $validItemIds = array_flip($repo->filterGoalItemIds($csvItemIds)); } catch (\Throwable $e) { $io->error($e->getMessage()); return Command::FAILURE; } $rejected = []; $rows = array_values(array_filter($rows, static function ($row) use ($validItemIds, &$rejected): bool { if (! isset($validItemIds[(int) $row['itemId']])) { $rejected[(int) $row['itemId']] = true; return false; } return true; })); if ($rejected !== []) { $io->warning(sprintf( '%d row(s) skipped — itemId not found or not a goal: %s', count($rejected), implode(', ', array_keys($rejected)) )); } if ($rows === []) { $io->error('No valid rows remain after filtering; nothing written.'); return Command::INVALID; } if ($dryRun) { $io->success(sprintf('Dry run: %d row(s) would be inserted.', count($rows))); return Command::SUCCESS; } try { $written = $repo->insertGoalHistoryRows($rows); } catch (\Throwable $e) { $io->error($e->getMessage()); return Command::FAILURE; } // Backfill is NOT idempotent per-day the way the nightly job is — // re-running with the same CSV duplicates rows. Loud in the success // message so re-runs don't quietly double the data. $io->success(sprintf('Imported %d row(s) into zp_goal_history.', $written)); $io->note('Re-running this command with the same CSV will duplicate rows. This command is one-shot; the nightly reports:goalValueSnapshot job handles ongoing captures.'); return Command::SUCCESS; } }