Add HL3D branding, Etsy Live/Draft paging and Projects lifecycle
Deploy HickLab3D Dashboard / Deploy to production (push) Failing after 23s
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.
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use OverflowException;
|
||||
|
||||
/**
|
||||
* Exact decimal fixed-point arithmetic using bounded native integers.
|
||||
*
|
||||
* Neither the bcmath nor gmp extensions are available in this application's
|
||||
* runtime, so costing math is done by scaling decimal strings into integers
|
||||
* (never floats) and checking every operation for overflow against the
|
||||
* platform's native integer range.
|
||||
*/
|
||||
class FixedPoint
|
||||
{
|
||||
private const MAX_INT = PHP_INT_MAX;
|
||||
|
||||
private const MIN_INT = PHP_INT_MIN;
|
||||
|
||||
/**
|
||||
* Parse a decimal string (or int/numeric string) into an integer scaled by 10^$scale.
|
||||
*/
|
||||
public static function toScaled(string $decimal, int $scale, ?int $maxAbs = null, bool $allowNegative = true): int
|
||||
{
|
||||
$decimal = trim($decimal);
|
||||
if ($decimal === '' || ! preg_match('/^-?\d+(\.\d+)?$/', $decimal)) {
|
||||
throw new InvalidArgumentException("Invalid decimal value: {$decimal}");
|
||||
}
|
||||
|
||||
$negative = $decimal[0] === '-';
|
||||
if ($negative) {
|
||||
if (! $allowNegative) {
|
||||
throw new InvalidArgumentException("Negative values are not allowed: {$decimal}");
|
||||
}
|
||||
$decimal = substr($decimal, 1);
|
||||
}
|
||||
|
||||
[$whole, $fraction] = array_pad(explode('.', $decimal, 2), 2, '');
|
||||
if (strlen($fraction) > $scale) {
|
||||
throw new InvalidArgumentException("Value has more than {$scale} decimal places: {$decimal}");
|
||||
}
|
||||
$fraction = str_pad($fraction, $scale, '0');
|
||||
$whole = $whole === '' ? '0' : $whole;
|
||||
|
||||
$combined = ltrim($whole.$fraction, '0');
|
||||
$combined = $combined === '' ? '0' : $combined;
|
||||
|
||||
if (strlen($combined) > 18) {
|
||||
throw new InvalidArgumentException("Value out of bounds: {$decimal}");
|
||||
}
|
||||
|
||||
$value = (int) $combined;
|
||||
if ($value < 0 || $combined !== (string) $value) {
|
||||
throw new InvalidArgumentException("Value out of bounds: {$decimal}");
|
||||
}
|
||||
|
||||
if ($maxAbs !== null && $value > $maxAbs * (10 ** $scale)) {
|
||||
throw new InvalidArgumentException("Value exceeds the allowed maximum: {$decimal}");
|
||||
}
|
||||
|
||||
return $negative ? -$value : $value;
|
||||
}
|
||||
|
||||
public static function add(int $a, int $b): int
|
||||
{
|
||||
if (($b > 0 && $a > self::MAX_INT - $b) || ($b < 0 && $a < self::MIN_INT - $b)) {
|
||||
throw new OverflowException('Fixed-point addition overflowed.');
|
||||
}
|
||||
|
||||
return $a + $b;
|
||||
}
|
||||
|
||||
public static function subtract(int $a, int $b): int
|
||||
{
|
||||
return self::add($a, -$b);
|
||||
}
|
||||
|
||||
public static function multiply(int $a, int $b): int
|
||||
{
|
||||
if ($a === 0 || $b === 0) {
|
||||
return 0;
|
||||
}
|
||||
$result = $a * $b;
|
||||
if (! is_int($result) || intdiv($result, $b) !== $a) {
|
||||
throw new OverflowException('Fixed-point multiplication overflowed.');
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Divide a scaled value by a plain positive integer (e.g. batch units), rounding half away from zero.
|
||||
*/
|
||||
public static function divideByInt(int $value, int $divisor): int
|
||||
{
|
||||
if ($divisor === 0) {
|
||||
throw new InvalidArgumentException('Cannot divide by zero.');
|
||||
}
|
||||
|
||||
return self::roundDivide($value, $divisor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rescale a value from one decimal scale to another, rounding half away from zero.
|
||||
*/
|
||||
public static function rescale(int $value, int $fromScale, int $toScale): int
|
||||
{
|
||||
if ($toScale === $fromScale) {
|
||||
return $value;
|
||||
}
|
||||
if ($toScale > $fromScale) {
|
||||
$factor = 10 ** ($toScale - $fromScale);
|
||||
$result = $value * $factor;
|
||||
if ($factor !== 0 && intdiv($result, $factor) !== $value) {
|
||||
throw new OverflowException('Fixed-point rescale overflowed.');
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
return self::roundDivide($value, 10 ** ($fromScale - $toScale));
|
||||
}
|
||||
|
||||
public static function format(int $scaled, int $scale): string
|
||||
{
|
||||
$negative = $scaled < 0;
|
||||
$abs = (string) abs($scaled);
|
||||
$abs = str_pad($abs, $scale + 1, '0', STR_PAD_LEFT);
|
||||
$whole = substr($abs, 0, strlen($abs) - $scale);
|
||||
$fraction = $scale > 0 ? substr($abs, strlen($abs) - $scale) : '';
|
||||
|
||||
$formatted = $scale > 0 ? "{$whole}.{$fraction}" : $whole;
|
||||
|
||||
return ($negative && $scaled !== 0 ? '-' : '').$formatted;
|
||||
}
|
||||
|
||||
private static function roundDivide(int $value, int $divisor): int
|
||||
{
|
||||
$sign = ($value < 0) !== ($divisor < 0) ? -1 : 1;
|
||||
$numerator = abs($value);
|
||||
$denominator = abs($divisor);
|
||||
$quotient = intdiv($numerator, $denominator);
|
||||
$remainder = $numerator % $denominator;
|
||||
|
||||
if ($remainder * 2 >= $denominator) {
|
||||
$quotient++;
|
||||
}
|
||||
|
||||
return $sign * $quotient;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
class Navigation
|
||||
{
|
||||
/**
|
||||
* @return list<array{key: string, label: string, items: list<array{route: string, label: string, activePatterns: list<string>}>}>
|
||||
*/
|
||||
public static function groups(User $user): array
|
||||
{
|
||||
$isAdmin = $user->role === 'admin';
|
||||
|
||||
$groups = [
|
||||
[
|
||||
'key' => 'workspace',
|
||||
'label' => 'Workspace',
|
||||
'items' => [
|
||||
['route' => 'dashboard', 'label' => 'Overview', 'activePatterns' => ['dashboard'], 'admin' => false],
|
||||
['route' => 'projects.index', 'label' => 'Projects', 'activePatterns' => ['projects.*'], 'admin' => true],
|
||||
],
|
||||
],
|
||||
[
|
||||
'key' => 'workshop',
|
||||
'label' => 'Workshop',
|
||||
'items' => [
|
||||
['route' => 'printers.index', 'label' => 'Print farm', 'activePatterns' => ['printers.*'], 'admin' => false],
|
||||
['route' => 'dryer', 'label' => 'Dryer', 'activePatterns' => ['dryer', 'dryer.*'], 'admin' => false],
|
||||
],
|
||||
],
|
||||
[
|
||||
'key' => 'commerce',
|
||||
'label' => 'Commerce',
|
||||
'items' => [
|
||||
['route' => 'etsy.index', 'label' => 'Etsy', 'activePatterns' => ['etsy.*'], 'admin' => true],
|
||||
['route' => 'shipping', 'label' => 'Shipping', 'activePatterns' => ['shipping', 'shipping.*'], 'admin' => true],
|
||||
],
|
||||
],
|
||||
[
|
||||
'key' => 'administration',
|
||||
'label' => 'Administration',
|
||||
'items' => [
|
||||
['route' => 'settings.index', 'label' => 'Settings', 'activePatterns' => ['settings.*'], 'admin' => true],
|
||||
['route' => 'users.index', 'label' => 'Users', 'activePatterns' => ['users.*'], 'admin' => true],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
return array_values(array_map(
|
||||
fn (array $group): array => [
|
||||
'key' => $group['key'],
|
||||
'label' => $group['label'],
|
||||
'items' => array_values(array_filter(
|
||||
array_map(fn (array $item): array => [
|
||||
'route' => $item['route'],
|
||||
'label' => $item['label'],
|
||||
'activePatterns' => $item['activePatterns'],
|
||||
], $group['items']),
|
||||
fn (array $item, int $index) => ! $group['items'][$index]['admin'] || $isAdmin,
|
||||
ARRAY_FILTER_USE_BOTH,
|
||||
)),
|
||||
],
|
||||
$groups,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\Project;
|
||||
|
||||
/**
|
||||
* Simple, reversible internal lifecycle: Design, Ready, Live, Retired. This is
|
||||
* distinct from Etsy's own listing state; moving a project to Live never
|
||||
* publishes or mutates anything upstream. The only guarded transition is into
|
||||
* Live, which requires every relevant (non-detached) listing mapping to carry
|
||||
* an active release — a single unreleased mapping blocks Live, not just the
|
||||
* absence of all of them.
|
||||
*/
|
||||
class ProjectLifecycle
|
||||
{
|
||||
public static function canMoveToLive(Project $project): bool
|
||||
{
|
||||
$relevant = $project->listingLinks()->whereNull('detached_at');
|
||||
|
||||
return $relevant->exists() && $relevant->clone()->whereNull('live_release_id')->doesntExist();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user