Files
HickLab3D_Dashboard/app/Services/ProjectCosting.php
T
hermes d4928789c4
Deploy HickLab3D Dashboard / Deploy to production (push) Failing after 23s
Add HL3D branding, Etsy Live/Draft paging and Projects lifecycle
Replace the old touchmark/PNG identity with the supplied lockup and
monogram, segmented role-aware navigation, Live/Draft listing filters
with generation-based cache invalidation, and admin Projects covering
immutable file revisions, listing live pointers, and exact-decimal
filament/packaging/shipping costing. Additive schema only; no Etsy
publication. Isolated clone tests: 301 tests, 1162 assertions.
2026-11-08 12:50:56 +00:00

186 lines
7.5 KiB
PHP

<?php
namespace App\Services;
use App\Models\ProjectCostLine;
use App\Support\FixedPoint;
use InvalidArgumentException;
/**
* Exact decimal costing math for project cost snapshots, using bounded integer
* fixed-point arithmetic (see FixedPoint) instead of floats. Filament, packaging
* and shipping are kept as distinct totals; a missing category stays null
* ("not costed") rather than being coerced to zero.
*/
class ProjectCosting
{
private const QUANTITY_SCALE = 3;
private const RATE_SCALE = 6;
private const AMOUNT_SCALE = 4;
private const MAX_QUANTITY = 1_000_000;
private const MAX_RATE = 1_000_000;
/**
* Matches the project_cost_lines/project_cost_snapshots decimal(14,4) columns:
* at most 10 integer digits, so amounts must be checked before insert rather
* than left for the database to reject (or silently truncate) at write time.
*/
private const MAX_AMOUNT_SCALED = 99_999_999_999_999;
/** @var array<string, string> */
private const UNIT_BY_CATEGORY = [
ProjectCostLine::CATEGORY_FILAMENT => 'g',
ProjectCostLine::CATEGORY_PACKAGING => 'unit',
ProjectCostLine::CATEGORY_SHIPPING => 'parcel',
];
/** @var list<string> */
public const ALLOWED_CURRENCIES = [
'GBP', 'USD', 'EUR', 'CAD', 'AUD', 'NZD', 'JPY', 'CHF', 'SEK', 'NOK',
'DKK', 'PLN', 'CZK', 'SGD', 'HKD', 'MXN', 'ZAR', 'ILS',
];
public static function isValidCurrency(string $currency): bool
{
return in_array(strtoupper($currency), self::ALLOWED_CURRENCIES, true);
}
/**
* Each category has exactly one accepted unit so a mistyped or mislabelled
* unit (e.g. "G", "grams", "kg") is rejected at validation time instead of
* silently skipping or mis-applying the gram-to-kilogram conversion.
*/
public static function isValidUnitForCategory(string $category, string $unit): bool
{
return isset(self::UNIT_BY_CATEGORY[$category]) && self::UNIT_BY_CATEGORY[$category] === $unit;
}
/**
* @param list<string> $currencies
*/
public static function assertSameCurrency(array $currencies): void
{
$unique = array_unique(array_map('strtoupper', $currencies));
if (count($unique) > 1) {
throw new InvalidArgumentException('Cannot combine cost figures in different currencies without an explicit conversion: '.implode(', ', $unique));
}
}
public static function lineTotal(string $category, string $quantity, string $quantityUnit, string $unitRate, string $waste = '0'): string
{
if (! in_array($category, ProjectCostLine::CATEGORIES, true)) {
throw new InvalidArgumentException("Unknown cost category: {$category}");
}
if (! self::isValidUnitForCategory($category, $quantityUnit)) {
throw new InvalidArgumentException("Invalid quantity unit '{$quantityUnit}' for category '{$category}'; expected '".self::UNIT_BY_CATEGORY[$category]."'.");
}
$quantityScaled = FixedPoint::toScaled($quantity, self::QUANTITY_SCALE, self::MAX_QUANTITY, allowNegative: false);
$wasteScaled = FixedPoint::toScaled($waste, self::QUANTITY_SCALE, self::MAX_QUANTITY, allowNegative: false);
$rateScaled = FixedPoint::toScaled($unitRate, self::RATE_SCALE, self::MAX_RATE, allowNegative: false);
$effectiveQuantity = FixedPoint::add($quantityScaled, $wasteScaled);
$product = FixedPoint::multiply($effectiveQuantity, $rateScaled);
$productScale = self::QUANTITY_SCALE + self::RATE_SCALE;
if ($category === ProjectCostLine::CATEGORY_FILAMENT && $quantityUnit === 'g') {
// Rate is per kilogram, so the result needs an extra /1000. Treating the
// product as if it carried 3 more scale places folds that division into
// the normal rescale-down-to-amount-scale step.
$amount = FixedPoint::rescale($product, $productScale + 3, self::AMOUNT_SCALE);
self::assertWithinStorableAmount($amount);
return FixedPoint::format($amount, self::AMOUNT_SCALE);
}
$amount = FixedPoint::rescale($product, $productScale, self::AMOUNT_SCALE);
self::assertWithinStorableAmount($amount);
return FixedPoint::format($amount, self::AMOUNT_SCALE);
}
/**
* Shipping's units-per-parcel allocation is a distinct concept from the
* batch's manufacturing yield (see perUnit()): it divides a parcel's cost by
* how many saleable units that one parcel carries, never by how many units
* the print batch itself produced. The two divisions must never be chained.
*/
public static function shippingUnitContribution(string $unitRate, int $unitsPerParcel): string
{
if ($unitsPerParcel < 1) {
throw new InvalidArgumentException('Units per parcel must be a positive number of saleable units.');
}
$rateScaled = FixedPoint::toScaled($unitRate, self::RATE_SCALE, self::MAX_RATE, allowNegative: false);
$amount = FixedPoint::rescale($rateScaled, self::RATE_SCALE, self::AMOUNT_SCALE);
self::assertWithinStorableAmount($amount);
return FixedPoint::format(FixedPoint::divideByInt($amount, $unitsPerParcel), self::AMOUNT_SCALE);
}
private static function assertWithinStorableAmount(int $amountScaled): void
{
if (abs($amountScaled) > self::MAX_AMOUNT_SCALED) {
throw new InvalidArgumentException('Cost amount exceeds the storable precision bound.');
}
}
/**
* @param list<array{category: string, line_total: string}> $lines
* @return array{filament_total: ?string, packaging_total: ?string, shipping_total: ?string, grand_total: ?string}
*/
public static function aggregate(array $lines): array
{
$totals = [];
foreach (ProjectCostLine::CATEGORIES as $category) {
$categoryLines = array_values(array_filter($lines, fn (array $line): bool => $line['category'] === $category));
if ($categoryLines === []) {
$totals[$category] = null;
continue;
}
$sum = 0;
foreach ($categoryLines as $line) {
$sum = FixedPoint::add($sum, FixedPoint::toScaled((string) $line['line_total'], self::AMOUNT_SCALE));
}
self::assertWithinStorableAmount($sum);
$totals[$category] = FixedPoint::format($sum, self::AMOUNT_SCALE);
}
$grand = null;
foreach ($totals as $value) {
if ($value === null) {
continue;
}
$scaled = FixedPoint::toScaled($value, self::AMOUNT_SCALE);
$grand = $grand === null ? $scaled : FixedPoint::add($grand, $scaled);
}
if ($grand !== null) {
self::assertWithinStorableAmount($grand);
}
return [
'filament_total' => $totals[ProjectCostLine::CATEGORY_FILAMENT],
'packaging_total' => $totals[ProjectCostLine::CATEGORY_PACKAGING],
'shipping_total' => $totals[ProjectCostLine::CATEGORY_SHIPPING],
'grand_total' => $grand === null ? null : FixedPoint::format($grand, self::AMOUNT_SCALE),
];
}
public static function perUnit(string $grandTotal, int $batchUnits): string
{
if ($batchUnits < 1) {
throw new InvalidArgumentException('Batch yield must be a positive number of saleable units.');
}
$scaled = FixedPoint::toScaled($grandTotal, self::AMOUNT_SCALE);
return FixedPoint::format(FixedPoint::divideByInt($scaled, $batchUnits), self::AMOUNT_SCALE);
}
}