OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)

This commit is contained in:
wangruiguo
2026-09-03 18:49:20 +08:00
commit d647428529
3501 changed files with 1988906 additions and 0 deletions

View File

@@ -0,0 +1,209 @@
<?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;
}
}