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
+15
View File
@@ -0,0 +1,15 @@
# Purchasing: fonte única de capacidades transferíveis
A origem deste pacote é Compras. O SGF recebe artefactos versionados desta origem, sem depender da pasta de desenvolvimento de Compras e sem manter outra implementação.
Capacidades extraídas: projecção de reposição (consumo manual/histórico racional, reservas, entregas confirmadas, necessidades satisfeitas e validade de lotes), descontos unitários antes do IVA e pontuação comparativa exacta das propostas. Não lêem bases, movimentam stock, emitem documentos ou adjudicam. Conservam as convenções e unidades do percurso Compras. Requerem Carbon 3 e Brick Math já presentes nos dois hosts; não substituem dependências.
O host continua responsável por autorizações, conflitos, elegibilidade, avaliações válidas, política aplicada, mínimos de propostas e decisão independente. `ProposalScoring` recebe apenas essa projecção e devolve pontuações/todos os empates; não escolhe um vencedor nem declara o processo pronto. A fachada de preços de Compras traduz os erros de domínio para os mesmos erros de formulário anteriores. Nenhuma interpretação fiscal ou regra de emissão do SGF é substituída por estes cálculos de propostas.
Os restantes percursos de Compras ainda não fazem parte deste pacote. A presença do pacote não equivale a transferência integral, integração de utilizadores/empresas, concessão de permissões ou activação de módulos comerciais. O SGF não recebe contratações de pessoas para terceiros.
Após melhorias neste pacote, publicar pelo mecanismo `purchasing-release.php`. O manifesto regista versões e hashes; o SGF confere e instala os mesmos bytes. Nunca editar uma versão distribuída no SGF: corrigir a origem e publicar uma versão seguinte.
Percurso verificado no desenvolvimento: `composer purchasing:transfer -- <instalação-SGF> <canal-de-versões>`. Executa testes na origem, prova a fotografia candidata, publica uma nova versão apenas se houver alterações e usa o PHP fixado pelo SGF para conferir a candidata antes da activação. Repetir sem melhorias não reinstala código. Clientes recebem o ZIP e a referência SHA-256 por canal de distribuição de confiança e não precisam do checkout de Compras.
Este contrato não executa migrações nem substitui dependências, dados, acessos ou configuração empresarial. Pacotes que peçam alterações de base de dados são recusados até existir um contrato de migração ensaiado. Versões de código anteriores permanecem disponíveis para reversão explícita. A activação de módulos adquiridos e as permissões continuam sob a configuração e os mecanismos de acesso da instalação.
+8
View File
@@ -0,0 +1,8 @@
<?php
require_once __DIR__.'/src/ReplenishmentProjection.php';
require_once __DIR__.'/src/QuotationPricing.php';
require_once __DIR__.'/src/ProposalScoring.php';
require_once __DIR__.'/src/RequisitionPort.php';
require_once __DIR__.'/src/RequisitionFailure.php';
require_once __DIR__.'/src/InternalRequisitions.php';
+8
View File
@@ -0,0 +1,8 @@
{
"package": "tempo/purchasing",
"host_contract": 1,
"minimum_php": "8.3.0",
"capabilities": ["inventory.replenishment_projection", "procurement.unit_discounts", "procurement.proposal_scoring"],
"database_changes": false,
"commercial_permissions": false
}
@@ -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;
}