Deploy HickLab3D Dashboard / Deploy to production (push) Failing after 23s
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.
186 lines
7.5 KiB
PHP
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);
|
|
}
|
|
}
|