This commit is contained in:
2026-11-02 08:08:05 +02:00
parent 13a6cb9922
commit 504b01b244
246 changed files with 24499 additions and 185 deletions
@@ -0,0 +1,158 @@
<?php
namespace Tempo\Purchasing\Internal;
use Brick\Math\BigDecimal;
use Brick\Math\RoundingMode;
/** Same lifecycle in both compositions; hosts cannot replace independent approval or budget ordering. */
final class InternalRequisitions
{
public function __construct(private RequisitionPort $port) {}
/**
* @param array<string,mixed> $record
* @return array<string,mixed>|null
*/
public static function nextStep(array $record, int $actor): ?array
{
if ($record['status'] !== 'pending' || $record['requester_id'] === $actor) {
return null;
}
$next = null;
foreach ($record['steps'] as $step) {
if (($step['approver_id'] ?? null) === $actor) {
return null;
}
if ($next === null && $step['decision'] === 'pending') {
$next = $step;
}
}
return $next;
}
/** @return array{subtotal_minor:int,tax_minor:int,total_minor:int,unit_price_minor:int} */
public static function line(string $quantity, string $price, string $taxRate): array
{
$minor = BigDecimal::of($price)->multipliedBy(100)->toScale(0)->toInt();
$subtotal = BigDecimal::of($quantity)->multipliedBy($minor)->toScale(0, RoundingMode::HalfUp)->toInt();
$tax = BigDecimal::of($subtotal)->multipliedBy($taxRate)->dividedBy(100, 0, RoundingMode::HalfUp)->toInt();
return ['unit_price_minor' => $minor, 'subtotal_minor' => $subtotal, 'tax_minor' => $tax, 'total_minor' => $subtotal + $tax];
}
/**
* @param array<string,mixed> $record
* @param array<string,mixed> $policy
* @return list<string>
*/
public static function roles(array $record, array $policy): array
{
$amount = $policy['approval_includes_tax'] ? $record['total_minor'] : $record['subtotal_minor'];
$roles = ['department_head'];
if ($amount > $policy['first_limit_minor']) {
$roles[] = 'procurement';
}
if ($amount > $policy['second_limit_minor'] || $record['has_equipment']) {
$roles[] = 'finance';
}
return $roles;
}
private static function canonical(mixed $value): mixed
{
if (! is_array($value)) {
return $value;
}
if (! array_is_list($value)) {
ksort($value);
}
return array_map(self::canonical(...), $value);
}
/**
* @param array<string,mixed> $input
* @return array<string,mixed>
*/
public function command(string $action, ?int $id, ?int $version, ?string $key, array $input = []): array
{
if ($key !== null && ! preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/iD', $key)) {
throw new RequisitionFailure('Chave de operação inválida.', 422);
}
$hash = hash('sha256', json_encode(self::canonical([$action, $id, $version, $input]), JSON_THROW_ON_ERROR));
return $this->port->atomic(function () use ($action, $id, $version, $key, $hash, $input) {
$record = $id === null ? null : $this->port->load($id);
$this->port->authorize($action, $record);
if ($key !== null && ($old = $this->port->replay($key, $hash)) !== null) {
$current = $this->port->load($old['id']);
$this->port->authorize('view', $current);
if (isset($old['operation_role'])) {
$this->port->authorize('step:'.$old['operation_role'], $current);
}
return $old + ['replayed' => true];
}
if ($record !== null && $version !== null && $record['version'] !== $version) {
throw new RequisitionFailure('A requisição mudou. Actualize antes de continuar.');
}
$role = null;
if ($action === 'save') {
if ($record !== null && ($record['status'] !== 'draft' || $record['requester_id'] !== $this->port->actorId())) {
throw new RequisitionFailure('Rascunho não autorizado.', 403);
}
$result = $this->port->draft($input, $id);
} elseif ($action === 'submit') {
if ($record === null || $record['status'] !== 'draft' || $record['requester_id'] !== $this->port->actorId()) {
throw new RequisitionFailure('Submissão não autorizada.', 403);
}
$policy = $this->port->policy($record);
$this->port->reserve($record);
$result = $this->port->submitted($record, $policy, self::roles($record, $policy));
} elseif ($action === 'decide') {
$actor = $this->port->actorId();
if ($record === null || $record['status'] !== 'pending' || $record['requester_id'] === $actor) {
throw new RequisitionFailure('A decisão exige outra identidade autorizada.', 403);
}
$step = self::nextStep($record, $actor);
if ($step === null) {
throw new RequisitionFailure('A decisão exige outra identidade para cada etapa.', 403);
}
if (! in_array($input['decision'] ?? '', ['approved', 'rejected'], true)) {
throw new RequisitionFailure('Decisão inválida.', 422);
}
$role = $step['role'];
$this->port->authorize('step:'.$role, $record);
$status = $input['decision'] === 'rejected' ? 'rejected' : (count(array_filter($record['steps'], fn ($s) => $s['decision'] === 'pending')) > 1 ? 'pending' : 'approved');
$result = $this->port->decided($record, $step, $input['decision'], $input['comment'] ?? null, $status);
if ($status === 'rejected') {
$this->port->release($record);
}
} else {
throw new RequisitionFailure('Operação desconhecida.', 404);
}
if ($role !== null) {
$result['operation_role'] = $role;
}
if ($key !== null) {
$this->port->remember($key, $hash, $result);
}
return $result;
});
}
/** @return array<string,mixed> */
public function view(int $id): array
{
return $this->port->atomic(function () use ($id) {
$record = $this->port->load($id);
$this->port->authorize('view', $record);
return $record;
});
}
}
@@ -0,0 +1,74 @@
<?php
namespace Tempo\Purchasing\Economic;
use Brick\Math\BigRational;
use Brick\Math\RoundingMode;
use InvalidArgumentException;
/** The host supplies authorised eligibility/evaluations. This class never awards a contract. */
final class ProposalScoring
{
/** @param list<array<string,mixed>> $rows
* @return array{scores:array<int,int>,top_ids:list<int>}
*/
public function rank(array $rows, int $minimumEvaluators): array
{
if ($minimumEvaluators < 1) {
throw new InvalidArgumentException('O mínimo de avaliadores deve ser positivo.');
}
$minimum = null;
$days = null;
$ids = [];
foreach ($rows as $row) {
if (! is_int($row['id']) || isset($ids[$row['id']])) {
throw new InvalidArgumentException('As propostas devem ter referências distintas.');
}
$ids[$row['id']] = true;
if (! $row['eligible']) {
continue;
}
$weights = $row['weights'];
$keys = ['price', 'quality', 'delivery', 'history', 'payment'];
if (array_diff($keys, array_keys($weights)) !== [] || count($weights) !== 5 || count(array_filter($weights, fn ($weight) => is_int($weight) && $weight >= 0 && $weight <= 100)) !== 5
|| array_sum($weights) !== 100 || ! is_int($row['subtotal_minor']) || $row['subtotal_minor'] < 1 || ! is_int($row['delivery_days']) || $row['delivery_days'] < 1
|| $row['evaluation_count'] !== count($row['qualified_evaluations'])) {
throw new InvalidArgumentException('Base de pontuação inconsistente.');
}
$minimum = $minimum === null ? $row['subtotal_minor'] : min($minimum, $row['subtotal_minor']);
$days = $days === null ? $row['delivery_days'] : min($days, $row['delivery_days']);
}
$scores = [];
$topIds = [];
$best = null;
foreach ($rows as $row) {
if (! $row['eligible'] || $row['evaluation_count'] < $minimumEvaluators) {
continue;
}
$weights = $row['weights'];
$score = BigRational::of($minimum)->dividedBy($row['subtotal_minor'])->multipliedBy(10 * $weights['price'])
->plus(BigRational::of($days)->dividedBy($row['delivery_days'])->multipliedBy(10 * $weights['delivery']));
foreach (['quality', 'history', 'payment'] as $criterion) {
$sum = BigRational::zero();
foreach ($row['qualified_evaluations'] as $evaluation) {
$value = BigRational::of($evaluation[$criterion]);
if ($value->isNegative() || $value->isGreaterThan(10)) {
throw new InvalidArgumentException('Avaliação fora da escala de comparação.');
}
$sum = $sum->plus($value);
}
$score = $score->plus($sum->dividedBy($row['evaluation_count'])->multipliedBy($weights[$criterion]));
}
$minor = $score->dividedBy(100)->toScale(2, RoundingMode::HalfUp)->multipliedBy(100)->toInt();
$scores[$row['id']] = $minor;
if ($best === null || $minor > $best) {
$best = $minor;
$topIds = [$row['id']];
} elseif ($minor === $best) {
$topIds[] = $row['id'];
}
}
return ['scores' => $scores, 'top_ids' => $topIds];
}
}
@@ -0,0 +1,33 @@
<?php
namespace Tempo\Purchasing\Economic;
use Brick\Math\BigDecimal;
use Brick\Math\RoundingMode;
use InvalidArgumentException;
/** Unit discounts before VAT; no document, fiscal determination or persistence. */
final class QuotationPricing
{
/** @return array{gross_unit_minor:int,discount_type:string,discount_value:string,net_unit_minor:int} */
public function unit(string $price, string $type = 'none', string $value = '0'): array
{
$gross = BigDecimal::of($price)->multipliedBy(100)->toInt();
$discount = BigDecimal::of($value);
if ($gross < 1 || $discount->isNegative() || ! in_array($type, ['none', 'percent', 'amount'], true)
|| ($type === 'none' && ! $discount->isZero()) || ($type === 'percent' && $discount->isGreaterThan(100))) {
throw new InvalidArgumentException('Indique um desconto unitário válido: percentagem ou valor em MZN, antes do IVA.');
}
$reduction = match ($type) {
'percent' => BigDecimal::of($gross)->multipliedBy($discount)->dividedBy(100, 4),
'amount' => $discount->multipliedBy(100),
default => BigDecimal::zero(),
};
$net = BigDecimal::of($gross)->minus($reduction)->toScale(0, RoundingMode::HalfUp)->toInt();
if ($net < 1) {
throw new InvalidArgumentException('O preço unitário após desconto deve ser pelo menos 0,01 MZN.');
}
return ['gross_unit_minor' => $gross, 'discount_type' => $type, 'discount_value' => (string) $discount->toScale(2), 'net_unit_minor' => $net];
}
}
@@ -0,0 +1,115 @@
<?php
namespace Tempo\Purchasing\Supply;
use Brick\Math\BigDecimal;
use Brick\Math\RoundingMode;
use Carbon\CarbonImmutable;
/** Quantities use integer thousandths, consistently with the existing stock ledger. */
final class ReplenishmentProjection
{
/** @param array<string,mixed> $policy
* @param list<array{date:string,quantity:int,additional?:bool}> $reservations
* @param list<array{date:string,quantity:int,confirmed:bool}> $receipts
* @param list<array{date:string,quantity:int}> $fulfilledNeeds
* @param list<array{quantity:int,last_usable_on:?string}>|null $lotBuckets
* @return array<string,mixed>
*/
public function calculate(array $policy, int $physical, string $start, array $reservations = [], array $receipts = [], int $consumedToday = 0, array $fulfilledNeeds = [], ?array $lotBuckets = null): array
{
$day = CarbonImmutable::parse($start)->startOfDay();
$daily = $policy['daily_milli'] ?? null;
$rate = $policy['daily_rate'] ?? null;
if ($rate !== null) {
if (! is_int($rate['numerator_milli']) || $rate['numerator_milli'] < 0 || ! is_int($rate['denominator_days']) || $rate['denominator_days'] < 1) {
throw new \InvalidArgumentException('A média histórica exige numerador não negativo e denominador positivo.');
}
$daily = 0;
}
if ($daily === null && $reservations === []) {
return ['available' => false, 'reason' => 'Previsão indisponível: configure consumo manual ou histórico validado.'];
}
$knownOnly = $daily === null;
$daily ??= 0;
$balance = $physical;
$risk = null;
$shortage = null;
$before = null;
$series = [];
$safety = $policy['safety_milli'];
if ($balance <= $safety) {
$risk = $day->toDateString();
$before = $balance;
}
for ($offset = 0; $offset < $policy['horizon_days']; $offset++) {
$date = $day->addDays($offset)->toDateString();
$expired = 0;
if ($lotBuckets !== null) {
foreach ($lotBuckets as &$bucket) {
if ($bucket['last_usable_on'] !== null && $bucket['last_usable_on'] < $date) {
$expired += $bucket['quantity'];
$bucket['quantity'] = 0;
}
}
unset($bucket);
}
$incoming = array_sum(array_column(array_filter($receipts, fn ($r) => $r['confirmed'] && $r['date'] === $date), 'quantity'));
$known = array_filter($reservations, fn ($r) => $r['date'] === $date);
$substitutes = array_sum(array_column(array_filter($known, fn ($r) => ! ($r['additional'] ?? false)), 'quantity'));
$additional = array_sum(array_column(array_filter($known, fn ($r) => $r['additional'] ?? false), 'quantity'));
$satisfied = array_sum(array_column(array_filter($fulfilledNeeds, fn ($n) => $n['date'] === $date), 'quantity'));
$expected = $daily;
if ($rate !== null) {
$cumulative = BigDecimal::of($rate['numerator_milli'])->multipliedBy($offset + 1)->dividedBy($rate['denominator_days'], 0, RoundingMode::Ceiling)->toInt();
$previous = BigDecimal::of($rate['numerator_milli'])->multipliedBy($offset)->dividedBy($rate['denominator_days'], 0, RoundingMode::Ceiling)->toInt();
$expected = $cumulative - $previous;
}
$residualForecast = max(0, $expected - $satisfied - ($offset === 0 ? $consumedToday : 0));
$demand = max($residualForecast, $substitutes) + $additional;
$balance += $incoming - $demand - $expired;
if ($lotBuckets !== null) {
$remaining = $demand;
foreach ($lotBuckets as &$bucket) {
$take = min($remaining, $bucket['quantity']);
$bucket['quantity'] -= $take;
$remaining -= $take;
}
unset($bucket);
}
if ($risk === null && $balance <= $safety) {
$risk = $date;
$before = $balance;
}
if ($shortage === null && $balance < 0) {
$shortage = $date;
}
$series[] = ['date' => $date, 'balance_milli' => $balance, 'incoming_milli' => $incoming, 'demand_milli' => $demand, ...($lotBuckets !== null ? ['expired_milli' => $expired] : [])];
}
$base = $before === null ? 0 : max(0, $policy['target_milli'] - $before);
$quantity = $base === 0 ? 0 : max($base, $policy['minimum_milli']);
$multiple = $policy['pack_milli'];
$quantity = $quantity === 0 ? 0 : intdiv($quantity + $multiple - 1, $multiple) * $multiple;
$excess = $before === null ? 0 : max(0, $before + $quantity - $policy['maximum_milli']);
$futureExcess = 0;
if ($risk !== null && $quantity > 0) {
foreach ($series as $point) {
if ($point['date'] >= $risk) {
$futureExcess = max($futureExcess, $point['balance_milli'] + $quantity - $policy['maximum_milli']);
}
}
}
$mean = $rate === null ? $daily : (string) BigDecimal::of($rate['numerator_milli'])->dividedBy($rate['denominator_days'], 6, RoundingMode::HalfUp);
$reorder = $rate === null ? $daily * $policy['lead_days'] + $safety : BigDecimal::of($rate['numerator_milli'])->multipliedBy($policy['lead_days'])->dividedBy($rate['denominator_days'], 0, RoundingMode::Ceiling)->plus($safety)->toInt();
return ['available' => true, 'method' => $knownOnly ? 'known_needs_only' : ($rate === null ? 'manual_calendar_days' : 'historical_mean_calendar_days'), 'demand_complete' => ! $knownOnly, 'physical_milli' => $physical,
'daily_milli' => $mean, 'reorder_point_milli' => $reorder,
'risk_date' => $risk, 'shortage_date' => $shortage,
'order_date' => $risk === null ? null : CarbonImmutable::parse($risk)->subDays($policy['lead_days'])->toDateString(),
'request_date' => $risk === null ? null : CarbonImmutable::parse($risk)->subDays($policy['lead_days'] + $policy['approval_days'])->toDateString(),
'base_milli' => $base, 'quantity_milli' => $quantity, 'excess_milli' => $excess,
'future_excess_milli' => $futureExcess, 'requires_exception' => $excess > 0 || $futureExcess > 0, 'series' => $series,
'convention' => 'Stock inicial na abertura; entradas confirmadas de manhã; consumo e nova reposição no fim do dia.'];
}
}
@@ -0,0 +1,11 @@
<?php
namespace Tempo\Purchasing\Internal;
final class RequisitionFailure extends \RuntimeException
{
public function __construct(string $message, public readonly int $status = 409)
{
parent::__construct($message);
}
}
@@ -0,0 +1,59 @@
<?php
namespace Tempo\Purchasing\Internal;
/** Host-owned identity, scope, persistence and effects. All mutations share one transaction. */
interface RequisitionPort
{
public function atomic(callable $work): mixed;
public function actorId(): int;
/** @param array<string,mixed>|null $record */
public function authorize(string $action, ?array $record): void;
/** @return array<string,mixed> */
public function load(int $id): array;
/**
* @param array<string,mixed> $data
* @return array<string,mixed>
*/
public function draft(array $data, ?int $id): array;
/**
* @param array<string,mixed> $record
* @return array<string,mixed>
*/
public function policy(array $record): array;
/**
* @param array<string,mixed> $record
* @return array<string,mixed>|null
*/
public function reserve(array $record): ?array;
/**
* @param array<string,mixed> $record
* @param array<string,mixed> $policy
* @param list<string> $roles
* @return array<string,mixed>
*/
public function submitted(array $record, array $policy, array $roles): array;
/**
* @param array<string,mixed> $record
* @param array<string,mixed> $step
* @return array<string,mixed>
*/
public function decided(array $record, array $step, string $decision, ?string $comment, string $status): array;
/** @param array<string,mixed> $record */
public function release(array $record): void;
/** @return array<string,mixed>|null */
public function replay(string $key, string $hash): ?array;
/** @param array<string,mixed> $result */
public function remember(string $key, string $hash, array $result): void;
}