commit12
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
use Tempo\Distribution\Release;
|
||||
|
||||
// Installed host loader. No runtime dependency on the source checkout.
|
||||
require_once dirname(__DIR__).'/module-updates/Release.php';
|
||||
$purchasingRelease = Release::current(dirname(__DIR__, 2));
|
||||
if ($purchasingRelease === null) {
|
||||
throw new RuntimeException('Instale uma versão verificada do módulo de Compras.');
|
||||
}
|
||||
require_once $purchasingRelease['path'].'/bootstrap.php';
|
||||
unset($purchasingRelease);
|
||||
@@ -0,0 +1,9 @@
|
||||
# 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.
|
||||
|
||||
Primeira capacidade extraída: projecção de reposição (consumo manual/histórico racional, reservas, entregas confirmadas, necessidades satisfeitas e validade de lotes). O cálculo não lê bases, não movimenta stock nem cria encomendas. Conserva as convenções e unidades do percurso Compras. Requer Carbon 3 e Brick Math já presentes nos dois hosts; não substitui dependências.
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__.'/src/ReplenishmentProjection.php';
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"package": "tempo/purchasing",
|
||||
"version": "0.1.0",
|
||||
"source_sha256": "2bb77aa1b723e0f616393aee739929399151548089693f38bdaffb0d75f3b186",
|
||||
"module": {
|
||||
"package": "tempo/purchasing",
|
||||
"host_contract": 1,
|
||||
"minimum_php": "8.3.0",
|
||||
"capabilities": [
|
||||
"inventory.replenishment_projection"
|
||||
],
|
||||
"database_changes": false,
|
||||
"commercial_permissions": false
|
||||
},
|
||||
"files": {
|
||||
"README.md": "ad51be37229d2891b8e9c3a5ca5f6edc90f69feb11b9fa6f5f384a09a35d138e",
|
||||
"bootstrap.php": "829c9f55ea850e64203927302a8daa1ebee791fe2d7763383b2b1a69cfe5f3f5",
|
||||
"module.json": "f70d4553eec85f6f1f6f50d6c3e7a22678649bfb17248b45342787c405eb3752",
|
||||
"src/ReplenishmentProjection.php": "446c9b32249c5fa639d00209c6d3cb9d2d26b085f3a77655e8ca6ff66403a48d"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"package": "tempo/purchasing",
|
||||
"host_contract": 1,
|
||||
"minimum_php": "8.3.0",
|
||||
"capabilities": ["inventory.replenishment_projection"],
|
||||
"database_changes": false,
|
||||
"commercial_permissions": false
|
||||
}
|
||||
@@ -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.'];
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
# 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.
|
||||
|
||||
Primeira capacidade extraída: projecção de reposição (consumo manual/histórico racional, reservas, entregas confirmadas, necessidades satisfeitas e validade de lotes). O cálculo não lê bases, não movimenta stock nem cria encomendas. Conserva as convenções e unidades do percurso Compras. Requer Carbon 3 e Brick Math já presentes nos dois hosts; não substitui dependências.
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__.'/src/ReplenishmentProjection.php';
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"package": "tempo/purchasing",
|
||||
"version": "0.1.1",
|
||||
"source_sha256": "692921d9e7e57579df876fbd9239d37790fc49d7b96b92fa10514bdc9a1dc250",
|
||||
"module": {
|
||||
"package": "tempo/purchasing",
|
||||
"host_contract": 1,
|
||||
"minimum_php": "8.3.0",
|
||||
"capabilities": [
|
||||
"inventory.replenishment_projection"
|
||||
],
|
||||
"database_changes": false,
|
||||
"commercial_permissions": false
|
||||
},
|
||||
"files": {
|
||||
"README.md": "44b6358d36e12fb9db7f897b7223a347b0fea1f98aa4cf5955245671ae171c81",
|
||||
"bootstrap.php": "829c9f55ea850e64203927302a8daa1ebee791fe2d7763383b2b1a69cfe5f3f5",
|
||||
"module.json": "f70d4553eec85f6f1f6f50d6c3e7a22678649bfb17248b45342787c405eb3752",
|
||||
"src/ReplenishmentProjection.php": "446c9b32249c5fa639d00209c6d3cb9d2d26b085f3a77655e8ca6ff66403a48d"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"package": "tempo/purchasing",
|
||||
"host_contract": 1,
|
||||
"minimum_php": "8.3.0",
|
||||
"capabilities": ["inventory.replenishment_projection"],
|
||||
"database_changes": false,
|
||||
"commercial_permissions": false
|
||||
}
|
||||
@@ -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.'];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user