OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
This commit is contained in:
53
app/Core/Resources/Contracts/ResourcesGateway.php
Normal file
53
app/Core/Resources/Contracts/ResourcesGateway.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Leantime\Core\Resources\Contracts;
|
||||
|
||||
use Leantime\Core\Resources\Models\ResourceSummary;
|
||||
|
||||
/**
|
||||
* Contract for a plugin that provides Resources data (people allocations, budget
|
||||
* lines, dependencies) to the rest of the app.
|
||||
*
|
||||
* Core does not implement this — a plugin (currently PgmPro) registers itself as
|
||||
* *the* provider via {@see \Leantime\Core\Resources\Services\ResourcesRegistry}.
|
||||
* Consumers (the stakeholder report's Resources section and any UI that wants
|
||||
* a Resources block) go through the registry and get null when no provider is
|
||||
* registered — the caller's graceful-degradation branch.
|
||||
*
|
||||
* The two methods mirror how a report calls the rest of its data providers:
|
||||
* - by explicit project id set (project read-out, plan read-out)
|
||||
* - by program root (used when a caller has a program id and wants the
|
||||
* canonical program-scoped roll-up including provider-specific caching)
|
||||
*
|
||||
* A provider MAY implement getForProgram() as a thin wrapper over
|
||||
* getForProjects() with the program's descendants pre-resolved.
|
||||
*/
|
||||
interface ResourcesGateway
|
||||
{
|
||||
/**
|
||||
* Aggregate resources across an explicit project set.
|
||||
*
|
||||
* @param array<int> $projectIds Projects to aggregate over. May include
|
||||
* the program row itself and its children,
|
||||
* or a hand-picked subset for a report.
|
||||
* @param string|null $actualsFrom Start of the logged-hours window
|
||||
* (Y-m-d, UTC — DB datetimes are stored
|
||||
* in UTC; do not pass user-local dates).
|
||||
* Null = provider default (current week).
|
||||
* @param string|null $actualsTo End of the logged-hours window (Y-m-d,
|
||||
* UTC, inclusive). Pass the report period
|
||||
* so actuals line up with what's reported on.
|
||||
* @return ResourceSummary Empty summary if none of the projects have
|
||||
* resources authored.
|
||||
*/
|
||||
public function getForProjects(array $projectIds, ?string $actualsFrom = null, ?string $actualsTo = null): ResourceSummary;
|
||||
|
||||
/**
|
||||
* Aggregate resources for a program and its child projects.
|
||||
*
|
||||
* @param int $programId A `zp_projects.id` where `type='program'`.
|
||||
*/
|
||||
public function getForProgram(int $programId): ResourceSummary;
|
||||
}
|
||||
26
app/Core/Resources/Models/BudgetLine.php
Normal file
26
app/Core/Resources/Models/BudgetLine.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Leantime\Core\Resources\Models;
|
||||
|
||||
/**
|
||||
* A budget line — a chunk of money allocated to one project inside a program.
|
||||
*
|
||||
* `spent` is a committed value: providers that don't track actuals pass 0.0.
|
||||
* We deliberately don't distinguish "unknown" from "zero" at the model level
|
||||
* because that ambiguity would leak into every consumer (report tile, UI, RPC
|
||||
* caller); providers that need to expose an "actuals unavailable" state
|
||||
* should surface it via their own domain, not through a nullable here.
|
||||
*/
|
||||
final class BudgetLine
|
||||
{
|
||||
public function __construct(
|
||||
public readonly int $itemId,
|
||||
public readonly int $projectId,
|
||||
public readonly string $label,
|
||||
public readonly float $budgeted,
|
||||
public readonly float $spent,
|
||||
public readonly ?string $color,
|
||||
) {}
|
||||
}
|
||||
32
app/Core/Resources/Models/Dependency.php
Normal file
32
app/Core/Resources/Models/Dependency.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Leantime\Core\Resources\Models;
|
||||
|
||||
/**
|
||||
* A dependency — an external commitment a program needs to succeed
|
||||
* (partnership, facility, grant, supplier, regulatory approval — things
|
||||
* outside your direct control).
|
||||
*
|
||||
* `confirmed` distinguishes "we have this locked in" from "we're still
|
||||
* counting on this happening" — the primary board-level risk signal.
|
||||
*
|
||||
* The four nullable trailing fields are optional context that surface in
|
||||
* the stakeholder report's Dependencies section when populated. Older
|
||||
* canvas items without these fields still hydrate cleanly (they just
|
||||
* render without owner/due-date/notes annotations).
|
||||
*/
|
||||
final class Dependency
|
||||
{
|
||||
public function __construct(
|
||||
public readonly int $itemId,
|
||||
public readonly string $partnerName,
|
||||
public readonly string $type,
|
||||
public readonly bool $confirmed,
|
||||
public readonly ?string $owner = null,
|
||||
public readonly ?string $dueDate = null,
|
||||
public readonly ?string $notes = null,
|
||||
public readonly ?string $lastModified = null,
|
||||
) {}
|
||||
}
|
||||
44
app/Core/Resources/Models/PersonAllocation.php
Normal file
44
app/Core/Resources/Models/PersonAllocation.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Leantime\Core\Resources\Models;
|
||||
|
||||
/**
|
||||
* A person allocated across one or more projects.
|
||||
*
|
||||
* `allocations` is a per-project map: `[projectId => weeklyHours]`. The
|
||||
* provider is responsible for keeping keys and hours in sync with its own
|
||||
* canvas storage. Consumers only read.
|
||||
*/
|
||||
final class PersonAllocation
|
||||
{
|
||||
/**
|
||||
* @param array<int, float> $allocations projectId => weekly hours
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly int $itemId,
|
||||
public readonly ?int $userId,
|
||||
public readonly string $displayName,
|
||||
public readonly float $capacity,
|
||||
public readonly array $allocations,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Weekly hours allocated across all projects.
|
||||
*/
|
||||
public function totalAllocated(): float
|
||||
{
|
||||
return array_sum($this->allocations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remaining weekly capacity (never negative). Over-allocation is a real
|
||||
* product state — callers that want to render an "over-allocated" badge
|
||||
* compare `totalAllocated()` against `capacity` directly.
|
||||
*/
|
||||
public function available(): float
|
||||
{
|
||||
return max(0.0, $this->capacity - $this->totalAllocated());
|
||||
}
|
||||
}
|
||||
114
app/Core/Resources/Models/ResourceSummary.php
Normal file
114
app/Core/Resources/Models/ResourceSummary.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Leantime\Core\Resources\Models;
|
||||
|
||||
/**
|
||||
* The shape of a Resources aggregation, as seen by every consumer (report
|
||||
* engine, UI section, JSON-RPC caller).
|
||||
*
|
||||
* Providers assemble this by reading their own storage and mapping into these
|
||||
* value objects. Consumers depend only on this class — never on the provider's
|
||||
* internal repository or canvas tables.
|
||||
*
|
||||
* Empty state ({@see self::empty()}) is a valid return, not an error. A
|
||||
* consumer with no gateway registered gets null from the registry; a consumer
|
||||
* with a gateway that has nothing authored gets an empty summary. Both branches
|
||||
* are legitimate design states — the caller decides which affordance to
|
||||
* render.
|
||||
*/
|
||||
final class ResourceSummary
|
||||
{
|
||||
/**
|
||||
* @param int[] $projectIds The projects this summary aggregates over.
|
||||
* @param array<int, PersonAllocation> $people
|
||||
* @param array<int, BudgetLine> $budget
|
||||
* @param array<int, Dependency> $dependencies
|
||||
* @param float $totalCapacity Sum of `people[].capacity`.
|
||||
* @param float $totalAllocated Sum of allocations across people.
|
||||
* @param float $totalBudgeted Sum of `budget[].budgeted`.
|
||||
* @param float $totalSpent Sum of `budget[].spent`.
|
||||
* @param float $totalActual Hours actually logged (timesheets) across
|
||||
* the project set in the CURRENT WEEK of the
|
||||
* requesting user's timezone. Weekly window
|
||||
* by design: capacity/allocated are weekly
|
||||
* rates (h/wk), so the comparable actual is
|
||||
* this week's logged hours. Period-scoped
|
||||
* actuals (e.g. a report quarter) come from
|
||||
* the report engine's own timesheet reads —
|
||||
* not this summary.
|
||||
* @param array<int, float> $actualsByProject projectId => hours logged
|
||||
* this week. Same window as $totalActual.
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly array $projectIds,
|
||||
public readonly array $people,
|
||||
public readonly array $budget,
|
||||
public readonly array $dependencies,
|
||||
public readonly float $totalCapacity,
|
||||
public readonly float $totalAllocated,
|
||||
public readonly float $totalBudgeted,
|
||||
public readonly float $totalSpent,
|
||||
public readonly float $totalActual = 0.0,
|
||||
public readonly array $actualsByProject = [],
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Plan-vs-actual drift for the current week: negative = under plan
|
||||
* (fewer hours logged than allocated), positive = over. Zero when
|
||||
* nothing is allocated AND nothing is logged — callers should treat
|
||||
* that case as "nothing to compare", not "on plan" (see the Resource
|
||||
* Allocation tab's Gap-column semantics).
|
||||
*/
|
||||
public function actualDrift(): float
|
||||
{
|
||||
return $this->totalActual - $this->totalAllocated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty summary — no resources authored across the given project set.
|
||||
* Consumers should treat this as "the plugin is installed and answered,
|
||||
* but there's nothing to show." Distinct from a null registry lookup.
|
||||
*
|
||||
* @param int[] $projectIds
|
||||
*/
|
||||
public static function empty(array $projectIds = []): self
|
||||
{
|
||||
return new self($projectIds, [], [], [], 0.0, 0.0, 0.0, 0.0);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the summary has no people, no budget, and no dependencies.
|
||||
*/
|
||||
public function isEmpty(): bool
|
||||
{
|
||||
return $this->people === [] && $this->budget === [] && $this->dependencies === [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Capacity utilization as a 0-100 percentage. Returns 0 when no capacity
|
||||
* has been declared to avoid divide-by-zero in report tiles.
|
||||
*/
|
||||
public function capacityUtilization(): float
|
||||
{
|
||||
if ($this->totalCapacity <= 0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return round(($this->totalAllocated / $this->totalCapacity) * 100, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Budget utilization as a 0-100 percentage. Returns 0 when no budget has
|
||||
* been declared.
|
||||
*/
|
||||
public function budgetUtilization(): float
|
||||
{
|
||||
if ($this->totalBudgeted <= 0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return round(($this->totalSpent / $this->totalBudgeted) * 100, 1);
|
||||
}
|
||||
}
|
||||
32
app/Core/Resources/ResourcesServiceProvider.php
Normal file
32
app/Core/Resources/ResourcesServiceProvider.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Leantime\Core\Resources;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Leantime\Core\Resources\Services\ResourcesRegistry;
|
||||
|
||||
/**
|
||||
* Registers the Resources contract surface.
|
||||
*
|
||||
* Resources are a plugin-provided data category (people allocations, budget
|
||||
* lines, dependencies at the program level). Core owns the contract and the
|
||||
* registry; a plugin (currently PgmPro) registers itself as *the* provider
|
||||
* from its own boot. Nothing seeded here — an install with no Resources
|
||||
* plugin gets a registry that resolves to null, which every consumer
|
||||
* handles as the honest "no provider" state.
|
||||
*
|
||||
* See {@see \Leantime\Core\Resources\Contracts\ResourcesGateway}.
|
||||
*/
|
||||
class ResourcesServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Bind the registry as a singleton — a single provider registration must
|
||||
* be visible to every consumer for the life of the request.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->singleton(ResourcesRegistry::class);
|
||||
}
|
||||
}
|
||||
75
app/Core/Resources/Services/ResourcesRegistry.php
Normal file
75
app/Core/Resources/Services/ResourcesRegistry.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Leantime\Core\Resources\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Leantime\Core\Resources\Contracts\ResourcesGateway;
|
||||
|
||||
/**
|
||||
* Registers the single Resources provider for this installation.
|
||||
*
|
||||
* The rest of the app (ReportEngine, StrategyReport, any UI wanting a
|
||||
* Resources section) asks this registry for a gateway and gets either the
|
||||
* one implementation a plugin registered, or null. Null is the honest
|
||||
* "no plugin installed" state — callers branch on it.
|
||||
*
|
||||
* v1 assumes a single provider (PgmPro). If a second plugin ever registers
|
||||
* a gateway on the same install, the first registration wins and the second
|
||||
* is logged. That's a conservative default — silently letting a second
|
||||
* plugin overwrite the first would cause a data-source ambiguity nobody
|
||||
* would notice until reports started disagreeing.
|
||||
*/
|
||||
class ResourcesRegistry
|
||||
{
|
||||
private ?ResourcesGateway $gateway = null;
|
||||
|
||||
private ?string $registeredBy = null;
|
||||
|
||||
/**
|
||||
* Called by the providing plugin's register.php on boot. Same-class
|
||||
* re-registration replaces the stored instance (last write wins; safe
|
||||
* because two instances of the same gateway are interchangeable).
|
||||
* Cross-class registration is refused and logged so double-installs
|
||||
* don't silently fight.
|
||||
*
|
||||
* @param ResourcesGateway $gateway The plugin's implementation.
|
||||
*/
|
||||
public function register(ResourcesGateway $gateway): void
|
||||
{
|
||||
$incoming = $gateway::class;
|
||||
|
||||
if ($this->gateway !== null && $this->registeredBy !== $incoming) {
|
||||
Log::warning(sprintf(
|
||||
'ResourcesRegistry: %s tried to register a Resources gateway, but %s is already registered. Keeping the first registration.',
|
||||
$incoming,
|
||||
$this->registeredBy,
|
||||
));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->gateway = $gateway;
|
||||
$this->registeredBy = $incoming;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the registered gateway, or null when no plugin has registered
|
||||
* one. This is the "no Resources provider installed" state — every
|
||||
* consumer must handle it.
|
||||
*/
|
||||
public function resolve(): ?ResourcesGateway
|
||||
{
|
||||
return $this->gateway;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a provider is registered. Convenience for template-level
|
||||
* checks that don't want to hold a gateway reference.
|
||||
*/
|
||||
public function hasProvider(): bool
|
||||
{
|
||||
return $this->gateway !== null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user