210 lines
7.4 KiB
PHP
210 lines
7.4 KiB
PHP
<?php
|
|
|
|
namespace Leantime\Command;
|
|
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Contracts\Container\BindingResolutionException;
|
|
use Leantime\Domain\Goalcanvas\Repositories\Goalcanvas as GoalcanvasRepo;
|
|
use Symfony\Component\Console\Attribute\AsCommand;
|
|
use Symfony\Component\Console\Input\InputInterface;
|
|
use Symfony\Component\Console\Input\InputOption;
|
|
use Symfony\Component\Console\Output\OutputInterface;
|
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
|
|
|
/**
|
|
* Import historical goal values from a CSV so a program three years into a
|
|
* grant doesn't have to wait until year 5 to see an arc. Rows land in
|
|
* `zp_goal_history` in exactly the same shape as the nightly capture
|
|
* (`itemId`, `value`, `userId`, `dateRecorded`) — a value is a value; a
|
|
* consumer cannot tell the difference and shouldn't need to.
|
|
*/
|
|
#[AsCommand(
|
|
name: 'goals:backfillHistory',
|
|
description: 'Import historical goal values from a CSV into zp_goal_history.',
|
|
)]
|
|
class BackfillGoalHistoryCommand extends Command
|
|
{
|
|
protected function configure(): void
|
|
{
|
|
parent::configure();
|
|
|
|
$this->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 <path.csv>');
|
|
|
|
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;
|
|
}
|
|
}
|