commit13
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user