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,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class ListingAlreadyMappedException extends RuntimeException
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class OptimisticLockConflictException extends RuntimeException
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -11,25 +11,73 @@ use Throwable;
|
||||
|
||||
class EtsyController extends Controller
|
||||
{
|
||||
/** @var list<string> */
|
||||
private const UI_STATES = ['active', 'draft'];
|
||||
|
||||
private const PER_PAGE = 20;
|
||||
|
||||
public function index(Request $request, Etsy $etsy): View
|
||||
{
|
||||
abort_unless($request->user()->role === 'admin', 403);
|
||||
$connection = EtsyConnection::first();
|
||||
$receipts = [];
|
||||
$listings = [];
|
||||
$integrationError = null;
|
||||
$listingCount = 0;
|
||||
$receiptsError = null;
|
||||
$listingsError = null;
|
||||
$stateError = null;
|
||||
$pageError = null;
|
||||
|
||||
$rawState = $request->query('state', 'active');
|
||||
$state = is_string($rawState) ? strtolower($rawState) : '';
|
||||
if (! in_array($state, self::UI_STATES, true)) {
|
||||
$stateError = 'This is not a supported listing state. Select Live or Drafts.';
|
||||
$state = 'active';
|
||||
}
|
||||
|
||||
$rawPage = $request->query('page', '1');
|
||||
if (! is_string($rawPage) || ! ctype_digit($rawPage) || strlen($rawPage) > 5 || (int) $rawPage < 1 || (int) $rawPage > 10000) {
|
||||
$pageError = 'This is not a valid page number. Use pages 1–10000.';
|
||||
$page = 1;
|
||||
} else {
|
||||
$page = (int) $rawPage;
|
||||
}
|
||||
|
||||
if ($connection && $etsy->configured()) {
|
||||
try {
|
||||
$receipts = $etsy->receipts($connection)['results'] ?? [];
|
||||
$listings = $etsy->listings($connection)['results'] ?? [];
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$integrationError = 'Etsy data is temporarily unavailable. Reconnect the shop if this continues.';
|
||||
$receiptsError = 'Order data is temporarily unavailable. Reconnect the shop if this continues.';
|
||||
}
|
||||
|
||||
if (! $stateError && ! $pageError) {
|
||||
try {
|
||||
$listingPage = $etsy->listListings($connection, $state, self::PER_PAGE, (max(1, $page) - 1) * self::PER_PAGE);
|
||||
$listings = $listingPage['results'];
|
||||
$listingCount = $listingPage['count'];
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$listingsError = 'Listing data is temporarily unavailable. Reconnect the shop if this continues.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return view('etsy', compact('connection', 'receipts', 'listings', 'integrationError') + ['configured' => $etsy->configured()]);
|
||||
return view('etsy', [
|
||||
'connection' => $connection,
|
||||
'receipts' => $receipts,
|
||||
'listings' => $listings,
|
||||
'listingSnapshot' => $listingPage ?? null,
|
||||
'listingCount' => $listingCount,
|
||||
'state' => $state,
|
||||
'page' => $page,
|
||||
'perPage' => self::PER_PAGE,
|
||||
'receiptsError' => $receiptsError,
|
||||
'listingsError' => $listingsError,
|
||||
'stateError' => $stateError,
|
||||
'pageError' => $pageError,
|
||||
'configured' => $etsy->configured(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function connect(Request $request, Etsy $etsy): RedirectResponse
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectEvent;
|
||||
use App\Services\ProjectCosting;
|
||||
use App\Support\ProjectLifecycle;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ProjectController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
abort_unless($request->user()->can('viewAny', Project::class), 403);
|
||||
|
||||
$query = Project::query()->with('creator')->withCount(['revisions', 'listingLinks']);
|
||||
if ($request->filled('stage') && in_array($request->string('stage')->toString(), Project::STAGES, true)) {
|
||||
$query->where('stage', $request->string('stage')->toString());
|
||||
}
|
||||
if ($request->filled('search')) {
|
||||
$term = '%'.$request->string('search')->toString().'%';
|
||||
$query->where(fn ($q) => $q->where('title', 'like', $term)->orWhere('reference', 'like', $term));
|
||||
}
|
||||
|
||||
return view('projects.index', [
|
||||
'projects' => $query->orderByDesc('created_at')->paginate(20)->withQueryString(),
|
||||
'stages' => Project::STAGES,
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(Request $request): View
|
||||
{
|
||||
abort_unless($request->user()->can('create', Project::class), 403);
|
||||
|
||||
return view('projects.create');
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
abort_unless($request->user()->can('create', Project::class), 403);
|
||||
$data = $request->validate([
|
||||
'reference' => ['required', 'string', 'max:64', 'unique:projects,reference'],
|
||||
'title' => ['required', 'string', 'max:255'],
|
||||
'notes' => ['nullable', 'string'],
|
||||
'currency' => ['required', 'string', 'size:3', Rule::in(ProjectCosting::ALLOWED_CURRENCIES)],
|
||||
]);
|
||||
|
||||
$project = Project::create($data + ['created_by' => $request->user()->id]);
|
||||
ProjectEvent::record($project->id, 'created', $request->user()->id);
|
||||
|
||||
return redirect()->route('projects.show', $project)->with('status', 'Project created.');
|
||||
}
|
||||
|
||||
public function show(Request $request, Project $project): View
|
||||
{
|
||||
abort_unless($request->user()->can('view', $project), 403);
|
||||
$project->load([
|
||||
'revisions' => fn ($q) => $q->orderByDesc('revision_number'),
|
||||
'revisions.files', 'revisions.creator', 'revisions.approver',
|
||||
'listingLinks' => fn ($q) => $q->orderByDesc('created_at'),
|
||||
'listingLinks.liveRelease.revision', 'listingLinks.liveRelease.costSnapshot',
|
||||
'costSnapshots' => fn ($q) => $q->orderByDesc('recorded_at'),
|
||||
'costSnapshots.lines', 'costSnapshots.revision',
|
||||
'events.actor',
|
||||
]);
|
||||
|
||||
return view('projects.show', ['project' => $project]);
|
||||
}
|
||||
|
||||
public function edit(Request $request, Project $project): View
|
||||
{
|
||||
abort_unless($request->user()->can('update', $project), 403);
|
||||
|
||||
return view('projects.edit', ['project' => $project]);
|
||||
}
|
||||
|
||||
public function update(Request $request, Project $project): RedirectResponse
|
||||
{
|
||||
abort_unless($request->user()->can('update', $project), 403);
|
||||
$data = $request->validate([
|
||||
'title' => ['sometimes', 'string', 'max:255'],
|
||||
'notes' => ['sometimes', 'nullable', 'string'],
|
||||
'stage' => ['sometimes', Rule::in(Project::STAGES)],
|
||||
]);
|
||||
|
||||
if (($data['stage'] ?? null) === 'live' && ! ProjectLifecycle::canMoveToLive($project)) {
|
||||
return back()->withErrors(['stage' => 'A project can only move to Live once a listing mapping has an active release.']);
|
||||
}
|
||||
|
||||
$fromStage = $project->stage;
|
||||
$project->update($data);
|
||||
|
||||
if (isset($data['stage']) && $data['stage'] !== $fromStage) {
|
||||
ProjectEvent::record($project->id, 'stage_changed', $request->user()->id, metadata: ['from' => $fromStage, 'to' => $data['stage']]);
|
||||
}
|
||||
|
||||
return redirect()->route('projects.show', $project)->with('status', 'Project updated.');
|
||||
}
|
||||
|
||||
public function archive(Request $request, Project $project): RedirectResponse
|
||||
{
|
||||
abort_unless($request->user()->can('archive', $project), 403);
|
||||
$project->update(['archived_at' => now()]);
|
||||
ProjectEvent::record($project->id, 'archived', $request->user()->id);
|
||||
|
||||
return redirect()->route('projects.show', $project)->with('status', 'Project archived.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectCostLine;
|
||||
use App\Models\ProjectCostSnapshot;
|
||||
use App\Models\ProjectEvent;
|
||||
use App\Services\ProjectCosting;
|
||||
use App\Support\FixedPoint;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Throwable;
|
||||
|
||||
class ProjectCostSnapshotController extends Controller
|
||||
{
|
||||
public function store(Request $request, Project $project): RedirectResponse
|
||||
{
|
||||
abort_unless($request->user()->can('manageCosts', $project), 403);
|
||||
$data = $request->validate([
|
||||
'revision_id' => ['required', 'integer'],
|
||||
'basis' => ['required', Rule::in([ProjectCostSnapshot::BASIS_ESTIMATE, ProjectCostSnapshot::BASIS_ACTUAL])],
|
||||
'currency' => ['required', 'string', 'size:3', Rule::in(ProjectCosting::ALLOWED_CURRENCIES)],
|
||||
'batch_units' => ['required', 'integer', 'min:1', 'max:100000'],
|
||||
'lines.*.confirmed_reference' => ['nullable', 'string', 'max:255'],
|
||||
'source_reference' => ['nullable', 'string', 'max:255'],
|
||||
'notes' => ['nullable', 'string'],
|
||||
'lines' => ['required', 'array', 'min:1'],
|
||||
'lines.*.category' => ['required', Rule::in(ProjectCostLine::CATEGORIES)],
|
||||
'lines.*.description' => ['required', 'string', 'max:255'],
|
||||
'lines.*.quantity' => ['required', 'string'],
|
||||
'lines.*.quantity_unit' => ['required', 'string', 'max:16'],
|
||||
'lines.*.unit_rate' => ['required', 'string'],
|
||||
'lines.*.waste_quantity' => ['sometimes', 'string'],
|
||||
'lines.*.units_per_parcel' => ['required_if:lines.*.category,shipping', 'nullable', 'integer', 'min:1'],
|
||||
'lines.*.source_price_date' => ['nullable', 'date'],
|
||||
'lines.*.source_reference' => ['nullable', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
if ($data['currency'] !== $project->currency) {
|
||||
throw ValidationException::withMessages(['currency' => 'Use the project currency; no currency conversion is implied.']);
|
||||
}
|
||||
$revision = $project->revisions()->find($data['revision_id']);
|
||||
abort_unless($revision, 404);
|
||||
|
||||
try {
|
||||
$linesWithTotals = array_map(function (array $line) use ($data): array {
|
||||
$line['line_total'] = ProjectCosting::lineTotal(
|
||||
$line['category'], (string) $line['quantity'], $line['quantity_unit'],
|
||||
(string) $line['unit_rate'], (string) ($line['waste_quantity'] ?? '0'),
|
||||
);
|
||||
if ($line['category'] === ProjectCostLine::CATEGORY_SHIPPING) {
|
||||
if ($data['basis'] === 'actual' && empty($line['source_reference'])) {
|
||||
throw new \InvalidArgumentException('Actual shipping needs a confirmed shipment or invoice reference.');
|
||||
}
|
||||
$scaled = FixedPoint::toScaled($line['line_total'], 4);
|
||||
$allocated = FixedPoint::divideByInt(
|
||||
FixedPoint::multiply($scaled, (int) $data['batch_units']),
|
||||
(int) $line['units_per_parcel'],
|
||||
);
|
||||
$line['line_total'] = FixedPoint::format($allocated, 4);
|
||||
}
|
||||
|
||||
return $line;
|
||||
}, $data['lines']);
|
||||
$totals = ProjectCosting::aggregate($linesWithTotals);
|
||||
} catch (Throwable $exception) {
|
||||
throw ValidationException::withMessages(['lines' => $exception->getMessage()]);
|
||||
}
|
||||
|
||||
$snapshot = DB::transaction(function () use ($request, $project, $revision, $data, $linesWithTotals, $totals): ProjectCostSnapshot {
|
||||
$snapshot = ProjectCostSnapshot::create([
|
||||
'project_id' => $project->id,
|
||||
'revision_id' => $revision->id,
|
||||
'currency' => $data['currency'],
|
||||
'basis' => $data['basis'],
|
||||
'batch_units' => $data['batch_units'],
|
||||
'filament_total' => $totals['filament_total'],
|
||||
'packaging_total' => $totals['packaging_total'],
|
||||
'shipping_total' => $totals['shipping_total'],
|
||||
'grand_total' => $totals['grand_total'],
|
||||
'source_reference' => $data['source_reference'] ?? null,
|
||||
'notes' => $data['notes'] ?? null,
|
||||
'recorded_by' => $request->user()->id,
|
||||
'recorded_at' => now(),
|
||||
]);
|
||||
|
||||
foreach ($linesWithTotals as $line) {
|
||||
ProjectCostLine::create([
|
||||
'cost_snapshot_id' => $snapshot->id,
|
||||
'category' => $line['category'],
|
||||
'description' => $line['description'],
|
||||
'quantity' => $line['quantity'],
|
||||
'quantity_unit' => $line['quantity_unit'],
|
||||
'unit_rate' => $line['unit_rate'],
|
||||
'waste_quantity' => $line['waste_quantity'] ?? '0',
|
||||
'units_per_parcel' => $line['units_per_parcel'] ?? null,
|
||||
'line_total' => $line['line_total'],
|
||||
'source_price_date' => $line['source_price_date'] ?? null,
|
||||
'source_reference' => $line['source_reference'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
ProjectEvent::record($project->id, 'cost_snapshot_recorded', $request->user()->id, ProjectCostSnapshot::class, $snapshot->id, [
|
||||
'basis' => $data['basis'], 'grand_total' => $totals['grand_total'],
|
||||
]);
|
||||
|
||||
return $snapshot;
|
||||
});
|
||||
|
||||
return redirect()->route('projects.show', $project)->with('status', 'Cost snapshot recorded.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectFile;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class ProjectFileController extends Controller
|
||||
{
|
||||
public function download(Request $request, Project $project, ProjectFile $file): StreamedResponse
|
||||
{
|
||||
abort_unless($file->revision->project_id === $project->id, 404);
|
||||
abort_unless($request->user()->can('download', [$file, $project]), 403);
|
||||
|
||||
return Storage::disk('project_files')->download($file->storage_key, $file->original_filename);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Exceptions\ListingAlreadyMappedException;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectEvent;
|
||||
use App\Models\ProjectListingLink;
|
||||
use App\Services\Etsy;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Throwable;
|
||||
|
||||
class ProjectListingLinkController extends Controller
|
||||
{
|
||||
public function store(Request $request, Project $project, Etsy $etsy): RedirectResponse
|
||||
{
|
||||
abort_unless($request->user()->can('manageListings', $project), 403);
|
||||
$data = $request->validate([
|
||||
'listing_id' => ['required', 'integer', 'min:1'],
|
||||
// Per-variant mapping is not implemented or verified against Etsy yet,
|
||||
// so only the "all variations" mapping is accepted — anything else
|
||||
// would be an unverified ownership claim over a specific variant.
|
||||
'variation_key' => ['sometimes', Rule::in([ProjectListingLink::ALL_VARIATIONS])],
|
||||
]);
|
||||
$variationKey = $data['variation_key'] ?? ProjectListingLink::ALL_VARIATIONS;
|
||||
|
||||
try {
|
||||
$connection = $etsy->connectedShop();
|
||||
$listing = $etsy->getListing($connection, (int) $data['listing_id']);
|
||||
} catch (Throwable $exception) {
|
||||
return back()->withErrors(['listing_id' => $exception->getMessage()]);
|
||||
}
|
||||
|
||||
try {
|
||||
$link = DB::transaction(function () use ($request, $project, $connection, $data, $listing, $variationKey): ProjectListingLink {
|
||||
$locked = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
|
||||
|
||||
$existing = ProjectListingLink::query()
|
||||
->where('shop_id', $connection->shop_id)
|
||||
->where('listing_id', $data['listing_id'])
|
||||
->where('variation_key', $variationKey)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if ($existing && $existing->project_id !== $locked->id) {
|
||||
throw new ListingAlreadyMappedException('That listing is already mapped to a different project.');
|
||||
}
|
||||
|
||||
$wasReleased = $existing !== null && $existing->live_release_id !== null;
|
||||
|
||||
$link = ProjectListingLink::updateOrCreate(
|
||||
['shop_id' => $connection->shop_id, 'listing_id' => $data['listing_id'], 'variation_key' => $variationKey],
|
||||
[
|
||||
'project_id' => $locked->id,
|
||||
'title_snapshot' => $listing['title'] ?? null,
|
||||
'state_snapshot' => $listing['state'] ?? null,
|
||||
'remote_updated_at' => isset($listing['last_modified_timestamp'])
|
||||
? Carbon::createFromTimestamp((int) $listing['last_modified_timestamp']) : null,
|
||||
'synced_at' => now(),
|
||||
'created_by' => $request->user()->id,
|
||||
'detached_at' => null,
|
||||
],
|
||||
);
|
||||
|
||||
// A brand-new (or re-synced-but-unreleased) mapping on a project already
|
||||
// marked Live would otherwise leave Live claiming full release coverage
|
||||
// it no longer has. Drop back to Design rather than silently keep Live.
|
||||
if ($locked->stage === 'live' && ! $wasReleased) {
|
||||
$locked->update(['stage' => 'design']);
|
||||
ProjectEvent::record($locked->id, 'stage_changed', $request->user()->id, metadata: [
|
||||
'from' => 'live', 'to' => 'design', 'reason' => 'unready_listing_mapping_attached',
|
||||
]);
|
||||
}
|
||||
|
||||
ProjectEvent::record(
|
||||
$locked->id,
|
||||
$existing ? 'listing_resynced' : 'listing_attached',
|
||||
$request->user()->id,
|
||||
ProjectListingLink::class,
|
||||
$link->id,
|
||||
['listing_id' => (int) $data['listing_id'], 'variation_key' => $variationKey],
|
||||
);
|
||||
|
||||
return $link;
|
||||
});
|
||||
} catch (ListingAlreadyMappedException $exception) {
|
||||
return back()->withErrors(['listing_id' => $exception->getMessage()]);
|
||||
} catch (QueryException $exception) {
|
||||
if (! $this->isUniqueConstraintViolation($exception)) {
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
return back()->withErrors(['listing_id' => 'That listing was just claimed by another request. Please try again.']);
|
||||
}
|
||||
|
||||
return redirect()->route('projects.show', $project)->with('status', 'Listing linked.');
|
||||
}
|
||||
|
||||
public function destroy(Request $request, Project $project, ProjectListingLink $listingLink): RedirectResponse
|
||||
{
|
||||
abort_unless($listingLink->project_id === $project->id, 404);
|
||||
abort_unless($request->user()->can('manageListings', $project), 403);
|
||||
|
||||
DB::transaction(function () use ($request, $project, $listingLink): void {
|
||||
Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
|
||||
$listingLink->update(['detached_at' => now()]);
|
||||
ProjectEvent::record($project->id, 'listing_detached', $request->user()->id, ProjectListingLink::class, $listingLink->id);
|
||||
});
|
||||
|
||||
return redirect()->route('projects.show', $project)->with('status', 'Listing detached.');
|
||||
}
|
||||
|
||||
private function isUniqueConstraintViolation(QueryException $exception): bool
|
||||
{
|
||||
return in_array($exception->getCode(), ['23000', '23505'], true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Exceptions\OptimisticLockConflictException;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectCostSnapshot;
|
||||
use App\Models\ProjectEvent;
|
||||
use App\Models\ProjectListingLink;
|
||||
use App\Models\ProjectRelease;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Releases pin an exact immutable revision+cost bundle as "live" for one listing
|
||||
* mapping. Uploading a new revision never moves this pointer on its own; only an
|
||||
* explicit release or revert does, and both create a new append-only
|
||||
* project_releases row rather than mutating history, under an optimistic
|
||||
* concurrency check on the listing link's lock_version.
|
||||
*/
|
||||
class ProjectReleaseController extends Controller
|
||||
{
|
||||
public function store(Request $request, Project $project, ProjectListingLink $listingLink): RedirectResponse
|
||||
{
|
||||
abort_unless($listingLink->project_id === $project->id, 404);
|
||||
abort_unless($request->user()->can('release', $project), 403);
|
||||
|
||||
$data = $request->validate([
|
||||
'revision_id' => ['required', 'integer'],
|
||||
'cost_snapshot_id' => ['required', 'integer'],
|
||||
'lock_version' => ['required', 'integer'],
|
||||
'notes' => ['nullable', 'string'],
|
||||
]);
|
||||
|
||||
if ($listingLink->isDetached()) {
|
||||
return back()->withErrors(['listing_link' => 'That listing mapping has been detached and can no longer be released to.'])->setStatusCode(422);
|
||||
}
|
||||
|
||||
$revision = $project->revisions()->find($data['revision_id']);
|
||||
if (! $revision) {
|
||||
return back()->withErrors(['revision_id' => 'That revision does not belong to this project.'])->setStatusCode(422);
|
||||
}
|
||||
if (! $revision->isApproved()) {
|
||||
return back()->withErrors(['revision_id' => 'Only an approved revision can be released.'])->setStatusCode(422);
|
||||
}
|
||||
|
||||
$costSnapshot = $project->costSnapshots()->find($data['cost_snapshot_id']);
|
||||
if (! $costSnapshot || (int) $costSnapshot->revision_id !== $revision->id) {
|
||||
return back()->withErrors(['cost_snapshot_id' => 'That cost snapshot does not match this project and revision.'])->setStatusCode(422);
|
||||
}
|
||||
if (! $costSnapshot->isComplete()) {
|
||||
return back()->withErrors(['cost_snapshot_id' => 'The cost snapshot must have filament, packaging and shipping all costed (even as an explicit zero) before it can be released.'])->setStatusCode(422);
|
||||
}
|
||||
|
||||
try {
|
||||
$release = DB::transaction(function () use ($request, $project, $listingLink, $revision, $costSnapshot, $data): ProjectRelease {
|
||||
Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
|
||||
$locked = ProjectListingLink::query()->whereKey($listingLink->id)->lockForUpdate()->firstOrFail();
|
||||
if ($locked->lock_version !== $data['lock_version']) {
|
||||
throw new OptimisticLockConflictException('The listing mapping changed since you loaded it.');
|
||||
}
|
||||
|
||||
$release = ProjectRelease::create([
|
||||
'project_id' => $project->id,
|
||||
'revision_id' => $revision->id,
|
||||
'cost_snapshot_id' => $costSnapshot->id,
|
||||
'action' => ProjectRelease::ACTION_RELEASE,
|
||||
'released_by' => $request->user()->id,
|
||||
'released_at' => now(),
|
||||
'notes' => $data['notes'] ?? null,
|
||||
]);
|
||||
$locked->update(['live_release_id' => $release->id, 'lock_version' => $locked->lock_version + 1]);
|
||||
|
||||
ProjectEvent::record($project->id, 'released', $request->user()->id, ProjectRelease::class, $release->id, [
|
||||
'listing_link_id' => $listingLink->id, 'revision_id' => $revision->id,
|
||||
]);
|
||||
|
||||
return $release;
|
||||
});
|
||||
} catch (OptimisticLockConflictException $exception) {
|
||||
return back()->withErrors(['lock_version' => $exception->getMessage()])->setStatusCode(409);
|
||||
}
|
||||
|
||||
return redirect()->route('projects.show', $project)->with('status', 'Revision released.');
|
||||
}
|
||||
|
||||
public function revert(Request $request, Project $project, ProjectListingLink $listingLink): RedirectResponse
|
||||
{
|
||||
abort_unless($listingLink->project_id === $project->id, 404);
|
||||
abort_unless($request->user()->can('release', $project), 403);
|
||||
|
||||
$data = $request->validate([
|
||||
'release_id' => ['required', 'integer'],
|
||||
'lock_version' => ['required', 'integer'],
|
||||
'notes' => ['nullable', 'string'],
|
||||
]);
|
||||
|
||||
if ($listingLink->isDetached()) {
|
||||
return back()->withErrors(['listing_link' => 'That listing mapping has been detached and can no longer be reverted.'])->setStatusCode(422);
|
||||
}
|
||||
|
||||
$target = $project->releases()->find($data['release_id']);
|
||||
if (! $target) {
|
||||
return back()->withErrors(['release_id' => 'That release does not belong to this project.'])->setStatusCode(422);
|
||||
}
|
||||
$targetSnapshot = $target->cost_snapshot_id ? ProjectCostSnapshot::find($target->cost_snapshot_id) : null;
|
||||
if (! $targetSnapshot || ! $targetSnapshot->isComplete()) {
|
||||
return back()->withErrors(['release_id' => 'That release no longer has a complete cost snapshot and cannot be restored.'])->setStatusCode(422);
|
||||
}
|
||||
|
||||
try {
|
||||
$release = DB::transaction(function () use ($request, $project, $listingLink, $target, $data): ProjectRelease {
|
||||
Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
|
||||
$locked = ProjectListingLink::query()->whereKey($listingLink->id)->lockForUpdate()->firstOrFail();
|
||||
if ($locked->lock_version !== $data['lock_version']) {
|
||||
throw new OptimisticLockConflictException('The listing mapping changed since you loaded it.');
|
||||
}
|
||||
|
||||
$release = ProjectRelease::create([
|
||||
'project_id' => $project->id,
|
||||
'revision_id' => $target->revision_id,
|
||||
'cost_snapshot_id' => $target->cost_snapshot_id,
|
||||
'action' => ProjectRelease::ACTION_REVERT,
|
||||
'released_by' => $request->user()->id,
|
||||
'released_at' => now(),
|
||||
'notes' => $data['notes'] ?? null,
|
||||
]);
|
||||
$locked->update(['live_release_id' => $release->id, 'lock_version' => $locked->lock_version + 1]);
|
||||
|
||||
ProjectEvent::record($project->id, 'release_reverted', $request->user()->id, ProjectRelease::class, $release->id, [
|
||||
'listing_link_id' => $listingLink->id, 'reverted_to_release_id' => $target->id,
|
||||
]);
|
||||
|
||||
return $release;
|
||||
});
|
||||
} catch (OptimisticLockConflictException $exception) {
|
||||
return back()->withErrors(['lock_version' => $exception->getMessage()])->setStatusCode(409);
|
||||
}
|
||||
|
||||
return redirect()->route('projects.show', $project)->with('status', 'Reverted to a previous release.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectEvent;
|
||||
use App\Models\ProjectFile;
|
||||
use App\Models\ProjectRevision;
|
||||
use App\Services\ManufacturingFileValidator;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class ProjectRevisionController extends Controller
|
||||
{
|
||||
public function store(Request $request, Project $project): RedirectResponse
|
||||
{
|
||||
abort_unless($request->user()->can('manageFiles', $project), 403);
|
||||
$data = $request->validate([
|
||||
'change_notes' => ['nullable', 'string', 'max:2000'],
|
||||
'revision_id' => ['nullable', 'integer'],
|
||||
'files' => ['required', 'array', 'min:1', 'max:10'],
|
||||
'files.*' => ['file', 'max:'.(ManufacturingFileValidator::MAX_FILE_SIZE / 1024)],
|
||||
'roles' => ['sometimes', 'array'],
|
||||
'roles.*' => ['nullable', 'string', 'max:64'],
|
||||
'fusion_project_id' => ['nullable', 'string', 'max:255'],
|
||||
'fusion_document_id' => ['nullable', 'string', 'max:255'],
|
||||
'fusion_version' => ['nullable', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
$totalBytes = array_sum(array_map(fn ($file) => (int) $file->getSize(), $data['files']));
|
||||
if ($totalBytes > ManufacturingFileValidator::MAX_TOTAL_BATCH_SIZE) {
|
||||
throw ValidationException::withMessages(['files' => 'The combined size of all files in this upload is too large.']);
|
||||
}
|
||||
|
||||
$validator = new ManufacturingFileValidator;
|
||||
$disk = Storage::disk('project_files');
|
||||
$storedKeys = [];
|
||||
|
||||
try {
|
||||
$revision = DB::transaction(function () use ($request, $project, $data, $validator, $disk, &$storedKeys): ProjectRevision {
|
||||
Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
|
||||
$revision = $this->resolveWritableRevision($project, $request->user()->id, $data);
|
||||
|
||||
foreach ($data['files'] as $index => $uploadedFile) {
|
||||
try {
|
||||
$validated = $validator->validate($uploadedFile);
|
||||
} catch (RuntimeException $exception) {
|
||||
throw ValidationException::withMessages(["files.{$index}" => $exception->getMessage()]);
|
||||
}
|
||||
|
||||
$key = bin2hex(random_bytes(20)).'.'.$validated['extension'];
|
||||
$stream = fopen($uploadedFile->getRealPath(), 'rb');
|
||||
if ($stream === false || $disk->put($key, $stream) === false) {
|
||||
if (is_resource($stream)) {
|
||||
fclose($stream);
|
||||
}
|
||||
|
||||
throw new RuntimeException("Unable to store uploaded file: {$uploadedFile->getClientOriginalName()}.");
|
||||
}
|
||||
if (is_resource($stream)) {
|
||||
fclose($stream);
|
||||
}
|
||||
$storedKeys[] = $key;
|
||||
|
||||
ProjectFile::create([
|
||||
'revision_id' => $revision->id,
|
||||
'role' => $data['roles'][$index] ?? 'component',
|
||||
'original_filename' => $uploadedFile->getClientOriginalName(),
|
||||
'storage_key' => $key,
|
||||
'extension' => $validated['extension'],
|
||||
'media_type' => $validated['media_type'],
|
||||
'byte_size' => $uploadedFile->getSize(),
|
||||
'sha256' => hash_file('sha256', $uploadedFile->getRealPath()),
|
||||
'uploaded_by' => $request->user()->id,
|
||||
]);
|
||||
}
|
||||
|
||||
ProjectEvent::record($project->id, 'revision_uploaded', $request->user()->id, ProjectRevision::class, $revision->id, [
|
||||
'revision_number' => $revision->revision_number,
|
||||
'file_count' => count($data['files']),
|
||||
]);
|
||||
|
||||
return $revision;
|
||||
});
|
||||
} catch (Throwable $exception) {
|
||||
foreach ($storedKeys as $key) {
|
||||
$disk->delete($key);
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
return redirect()->route('projects.show', $project)->with('status', 'Revision uploaded.');
|
||||
}
|
||||
|
||||
public function approve(Request $request, Project $project, ProjectRevision $revision): RedirectResponse
|
||||
{
|
||||
abort_unless($revision->project_id === $project->id, 404);
|
||||
abort_unless($request->user()->can('manageFiles', $project), 403);
|
||||
|
||||
DB::transaction(function () use ($request, $project, $revision): void {
|
||||
Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
|
||||
$locked = ProjectRevision::query()->whereKey($revision->id)->lockForUpdate()->firstOrFail();
|
||||
|
||||
abort_if($locked->isApproved(), 422, 'This revision is already approved.');
|
||||
abort_if($locked->files()->count() === 0, 422, 'A revision needs at least one file before it can be approved.');
|
||||
|
||||
$locked->update([
|
||||
'status' => ProjectRevision::STATUS_APPROVED,
|
||||
'approved_by' => $request->user()->id,
|
||||
'approved_at' => now(),
|
||||
]);
|
||||
ProjectEvent::record($project->id, 'revision_approved', $request->user()->id, ProjectRevision::class, $locked->id, [
|
||||
'revision_number' => $locked->revision_number,
|
||||
]);
|
||||
});
|
||||
|
||||
return redirect()->route('projects.show', $project)->with('status', 'Revision approved.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private function resolveWritableRevision(Project $project, int $userId, array $data): ProjectRevision
|
||||
{
|
||||
if (! empty($data['revision_id'])) {
|
||||
$candidate = $project->revisions()->find($data['revision_id']);
|
||||
if ($candidate && ! $candidate->isApproved()) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
$nextNumber = (int) $project->revisions()->max('revision_number') + 1;
|
||||
|
||||
return $project->revisions()->create([
|
||||
'revision_number' => $nextNumber,
|
||||
'change_notes' => $data['change_notes'] ?? null,
|
||||
'fusion_project_id' => $data['fusion_project_id'] ?? null,
|
||||
'fusion_document_id' => $data['fusion_document_id'] ?? null,
|
||||
'fusion_version' => $data['fusion_version'] ?? null,
|
||||
'status' => ProjectRevision::STATUS_DRAFT,
|
||||
'created_by' => $userId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\ProjectFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Project extends Model
|
||||
{
|
||||
/** @use HasFactory<ProjectFactory> */
|
||||
use HasFactory;
|
||||
|
||||
/** @var list<string> */
|
||||
public const STAGES = ['design', 'ready', 'live', 'retired'];
|
||||
|
||||
protected $fillable = ['reference', 'title', 'notes', 'stage', 'currency', 'created_by', 'archived_at'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'archived_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function revisions(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProjectRevision::class);
|
||||
}
|
||||
|
||||
public function listingLinks(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProjectListingLink::class);
|
||||
}
|
||||
|
||||
public function releases(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProjectRelease::class);
|
||||
}
|
||||
|
||||
public function costSnapshots(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProjectCostSnapshot::class);
|
||||
}
|
||||
|
||||
public function events(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProjectEvent::class)->latest('created_at');
|
||||
}
|
||||
|
||||
public function isArchived(): bool
|
||||
{
|
||||
return $this->archived_at !== null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\ProjectCostLineFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ProjectCostLine extends Model
|
||||
{
|
||||
/** @use HasFactory<ProjectCostLineFactory> */
|
||||
use HasFactory;
|
||||
|
||||
public const CATEGORY_FILAMENT = 'filament';
|
||||
|
||||
public const CATEGORY_PACKAGING = 'packaging';
|
||||
|
||||
public const CATEGORY_SHIPPING = 'shipping';
|
||||
|
||||
/** @var list<string> */
|
||||
public const CATEGORIES = [self::CATEGORY_FILAMENT, self::CATEGORY_PACKAGING, self::CATEGORY_SHIPPING];
|
||||
|
||||
protected $fillable = [
|
||||
'cost_snapshot_id', 'category', 'description', 'quantity', 'quantity_unit',
|
||||
'unit_rate', 'waste_quantity', 'units_per_parcel', 'line_total', 'source_price_date', 'source_reference',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'quantity' => 'decimal:3',
|
||||
'unit_rate' => 'decimal:6',
|
||||
'waste_quantity' => 'decimal:3',
|
||||
'units_per_parcel' => 'integer',
|
||||
'line_total' => 'decimal:4',
|
||||
'source_price_date' => 'date',
|
||||
];
|
||||
}
|
||||
|
||||
public function snapshot(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProjectCostSnapshot::class, 'cost_snapshot_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Services\ProjectCosting;
|
||||
use Database\Factories\ProjectCostSnapshotFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class ProjectCostSnapshot extends Model
|
||||
{
|
||||
/** @use HasFactory<ProjectCostSnapshotFactory> */
|
||||
use HasFactory;
|
||||
|
||||
public const BASIS_ESTIMATE = 'estimate';
|
||||
|
||||
public const BASIS_ACTUAL = 'actual';
|
||||
|
||||
protected $fillable = [
|
||||
'project_id', 'revision_id', 'currency', 'basis', 'batch_units',
|
||||
'filament_total', 'packaging_total', 'shipping_total', 'grand_total',
|
||||
'source_reference', 'notes', 'recorded_by', 'recorded_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'filament_total' => 'decimal:4',
|
||||
'packaging_total' => 'decimal:4',
|
||||
'shipping_total' => 'decimal:4',
|
||||
'grand_total' => 'decimal:4',
|
||||
'recorded_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function project(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Project::class);
|
||||
}
|
||||
|
||||
public function revision(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProjectRevision::class, 'revision_id');
|
||||
}
|
||||
|
||||
public function recorder(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'recorded_by');
|
||||
}
|
||||
|
||||
public function lines(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProjectCostLine::class, 'cost_snapshot_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* A release requires every cost category to have been explicitly costed —
|
||||
* even as an intentional zero — rather than merely recorded. A category left
|
||||
* null ("not costed") is incomplete and must block a release going live.
|
||||
*/
|
||||
public function isComplete(): bool
|
||||
{
|
||||
return $this->filament_total !== null
|
||||
&& $this->packaging_total !== null
|
||||
&& $this->shipping_total !== null;
|
||||
}
|
||||
|
||||
public function perUnitGrandTotal(): ?string
|
||||
{
|
||||
if ($this->grand_total === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ProjectCosting::perUnit((string) $this->grand_total, (int) $this->batch_units);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ProjectEvent extends Model
|
||||
{
|
||||
public const UPDATED_AT = null;
|
||||
|
||||
protected $fillable = ['project_id', 'event_type', 'actor_id', 'subject_type', 'subject_id', 'metadata', 'created_at'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'metadata' => 'array',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function project(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Project::class);
|
||||
}
|
||||
|
||||
public function actor(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'actor_id');
|
||||
}
|
||||
|
||||
public static function record(int $projectId, string $eventType, ?int $actorId, ?string $subjectType = null, ?int $subjectId = null, array $metadata = []): self
|
||||
{
|
||||
return self::create([
|
||||
'project_id' => $projectId,
|
||||
'event_type' => $eventType,
|
||||
'actor_id' => $actorId,
|
||||
'subject_type' => $subjectType,
|
||||
'subject_id' => $subjectId,
|
||||
'metadata' => $metadata,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\ProjectFileFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ProjectFile extends Model
|
||||
{
|
||||
/** @use HasFactory<ProjectFileFactory> */
|
||||
use HasFactory;
|
||||
|
||||
/** @var list<string> */
|
||||
public const EXTENSIONS = ['stl', '3mf', 'gcode'];
|
||||
|
||||
protected $fillable = [
|
||||
'revision_id', 'role', 'original_filename', 'storage_key', 'extension',
|
||||
'media_type', 'byte_size', 'sha256', 'uploaded_by', 'printer_profile', 'material',
|
||||
];
|
||||
|
||||
public function revision(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProjectRevision::class, 'revision_id');
|
||||
}
|
||||
|
||||
public function uploader(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'uploaded_by');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\ProjectListingLinkFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ProjectListingLink extends Model
|
||||
{
|
||||
/** @use HasFactory<ProjectListingLinkFactory> */
|
||||
use HasFactory;
|
||||
|
||||
public const ALL_VARIATIONS = 'all';
|
||||
|
||||
protected $fillable = [
|
||||
'project_id', 'shop_id', 'listing_id', 'variation_key', 'title_snapshot',
|
||||
'state_snapshot', 'remote_updated_at', 'synced_at', 'live_release_id',
|
||||
'lock_version', 'created_by', 'detached_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'remote_updated_at' => 'datetime',
|
||||
'synced_at' => 'datetime',
|
||||
'detached_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function project(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Project::class);
|
||||
}
|
||||
|
||||
public function liveRelease(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProjectRelease::class, 'live_release_id');
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function isDetached(): bool
|
||||
{
|
||||
return $this->detached_at !== null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\ProjectReleaseFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ProjectRelease extends Model
|
||||
{
|
||||
/** @use HasFactory<ProjectReleaseFactory> */
|
||||
use HasFactory;
|
||||
|
||||
public const ACTION_RELEASE = 'release';
|
||||
|
||||
public const ACTION_REVERT = 'revert';
|
||||
|
||||
protected $fillable = [
|
||||
'project_id', 'revision_id', 'cost_snapshot_id', 'action', 'released_by', 'released_at', 'notes',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'released_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function project(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Project::class);
|
||||
}
|
||||
|
||||
public function revision(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProjectRevision::class, 'revision_id');
|
||||
}
|
||||
|
||||
public function costSnapshot(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProjectCostSnapshot::class, 'cost_snapshot_id');
|
||||
}
|
||||
|
||||
public function releasedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'released_by');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\ProjectRevisionFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class ProjectRevision extends Model
|
||||
{
|
||||
/** @use HasFactory<ProjectRevisionFactory> */
|
||||
use HasFactory;
|
||||
|
||||
public const STATUS_DRAFT = 'draft';
|
||||
|
||||
public const STATUS_APPROVED = 'approved';
|
||||
|
||||
public const STATUS_SUPERSEDED = 'superseded';
|
||||
|
||||
protected $fillable = [
|
||||
'project_id', 'revision_number', 'change_notes', 'status', 'created_by',
|
||||
'approved_by', 'approved_at', 'fusion_project_id', 'fusion_document_id', 'fusion_version',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'approved_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function project(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Project::class);
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function approver(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'approved_by');
|
||||
}
|
||||
|
||||
public function files(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProjectFile::class, 'revision_id');
|
||||
}
|
||||
|
||||
public function costSnapshots(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProjectCostSnapshot::class, 'revision_id');
|
||||
}
|
||||
|
||||
public function isApproved(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_APPROVED;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectFile;
|
||||
use App\Models\User;
|
||||
|
||||
class ProjectFilePolicy
|
||||
{
|
||||
/**
|
||||
* Guards against IDOR: a file only downloads through the project it actually belongs to.
|
||||
*/
|
||||
public function download(User $user, ProjectFile $file, Project $project): bool
|
||||
{
|
||||
return $user->role === 'admin' && $file->revision->project_id === $project->id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\User;
|
||||
|
||||
/**
|
||||
* Projects and their commercial/cost/file/link/release actions are admin-only
|
||||
* for this initial slice, matching existing Etsy/Shipping/Settings access.
|
||||
*/
|
||||
class ProjectPolicy
|
||||
{
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return $user->role === 'admin';
|
||||
}
|
||||
|
||||
public function view(User $user, Project $project): bool
|
||||
{
|
||||
return $user->role === 'admin';
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return $user->role === 'admin';
|
||||
}
|
||||
|
||||
public function update(User $user, Project $project): bool
|
||||
{
|
||||
return $user->role === 'admin';
|
||||
}
|
||||
|
||||
public function archive(User $user, Project $project): bool
|
||||
{
|
||||
return $user->role === 'admin';
|
||||
}
|
||||
|
||||
public function manageFiles(User $user, Project $project): bool
|
||||
{
|
||||
return $user->role === 'admin';
|
||||
}
|
||||
|
||||
public function manageListings(User $user, Project $project): bool
|
||||
{
|
||||
return $user->role === 'admin';
|
||||
}
|
||||
|
||||
public function manageCosts(User $user, Project $project): bool
|
||||
{
|
||||
return $user->role === 'admin';
|
||||
}
|
||||
|
||||
public function release(User $user, Project $project): bool
|
||||
{
|
||||
return $user->role === 'admin';
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Services\IntegrationCache;
|
||||
use App\Support\Navigation;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
@@ -20,6 +22,9 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
View::composer('layouts.app', function ($view): void {
|
||||
$user = auth()->user();
|
||||
$view->with('navigationGroups', $user ? Navigation::groups($user) : []);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+36
-11
@@ -5,6 +5,7 @@ namespace App\Services;
|
||||
use App\Models\EtsyAppCredential;
|
||||
use App\Models\EtsyConnection;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
@@ -188,24 +189,34 @@ class Etsy
|
||||
/**
|
||||
* @return array{count: int, limit: int, offset: int, state: string, results: list<array<string, mixed>>}
|
||||
*/
|
||||
public function listListings(EtsyConnection $connection, string $state = 'active', int $limit = 25, int $offset = 0): array
|
||||
public function listListings(EtsyConnection $connection, string $state = 'active', int $limit = 25, int $offset = 0, bool $fresh = false): array
|
||||
{
|
||||
$state = strtolower($state);
|
||||
if (! in_array($state, self::LISTING_STATES, true)) {
|
||||
throw new RuntimeException('Listing state must be one of: '.implode(', ', self::LISTING_STATES).'.');
|
||||
}
|
||||
[$limit, $offset] = $this->page($limit, $offset);
|
||||
$response = $this->request('GET', 'application/shops/'.$connection->shop_id.'/listings', $this->accessToken($connection), [
|
||||
'state' => $state, 'limit' => $limit, 'offset' => $offset,
|
||||
]);
|
||||
$generation = Cache::get('etsy.listing-generation.'.$connection->shop_id, '0');
|
||||
$cacheKey = 'etsy.listListings.'.$connection->shop_id.'.'.$generation.'.'.$state.'.'.$limit.'.'.$offset;
|
||||
|
||||
return [
|
||||
'count' => (int) ($response['count'] ?? count($response['results'] ?? [])),
|
||||
'limit' => $limit,
|
||||
'offset' => $offset,
|
||||
'state' => $state,
|
||||
'results' => array_map(fn (array $listing): array => $this->summarizeListing($listing), $this->results($response)),
|
||||
];
|
||||
$result = $this->cache->remember($cacheKey, 600, function () use ($connection, $state, $limit, $offset): array {
|
||||
$response = $this->request('GET', 'application/shops/'.$connection->shop_id.'/listings', $this->accessToken($connection), [
|
||||
'state' => $state, 'limit' => $limit, 'offset' => $offset,
|
||||
]);
|
||||
|
||||
return [
|
||||
'count' => (int) ($response['count'] ?? count($response['results'] ?? [])),
|
||||
'limit' => $limit,
|
||||
'offset' => $offset,
|
||||
'state' => $state,
|
||||
'results' => array_map(fn (array $listing): array => $this->summarizeListing($listing), $this->results($response)),
|
||||
];
|
||||
}, $fresh);
|
||||
$entry = Cache::get('integration:'.$cacheKey);
|
||||
$result['fetched_at'] = is_array($entry) ? $entry['fetched_at'] : null;
|
||||
$result['stale'] = $result['fetched_at'] === null || time() >= $result['fetched_at'] + 600;
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -270,6 +281,7 @@ class Etsy
|
||||
$payload = $this->validatedListingPayload($payload, true);
|
||||
$listing = $this->request('POST', 'application/shops/'.$connection->shop_id.'/listings', $this->accessToken($connection), body: $payload);
|
||||
$this->cache->forget('etsy.listings.'.$connection->shop_id);
|
||||
$this->forgetListingStateCaches($connection);
|
||||
|
||||
return $this->summarizeListing($listing, detailed: true);
|
||||
}
|
||||
@@ -289,6 +301,7 @@ class Etsy
|
||||
$listing = $this->request('PATCH', 'application/shops/'.$connection->shop_id.'/listings/'.$listingId, $this->accessToken($connection), body: $payload);
|
||||
$this->assertListingBelongsToShop($connection, $listing);
|
||||
$this->cache->forget('etsy.listings.'.$connection->shop_id);
|
||||
$this->forgetListingStateCaches($connection);
|
||||
|
||||
return $this->summarizeListing($listing, detailed: true);
|
||||
}
|
||||
@@ -306,10 +319,21 @@ class Etsy
|
||||
$listing = $this->request('PATCH', 'application/shops/'.$connection->shop_id.'/listings/'.$listingId, $this->accessToken($connection), body: ['state' => 'active']);
|
||||
$this->assertListingBelongsToShop($connection, $listing);
|
||||
$this->cache->forget('etsy.listings.'.$connection->shop_id);
|
||||
$this->forgetListingStateCaches($connection);
|
||||
|
||||
return $this->summarizeListing($listing, detailed: true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate the first page of both Live and Draft state caches so a
|
||||
* write immediately shown to the operator doesn't keep serving a stale
|
||||
* cached page. Older pages expire on their own TTL.
|
||||
*/
|
||||
private function forgetListingStateCaches(EtsyConnection $connection): void
|
||||
{
|
||||
Cache::put('etsy.listing-generation.'.$connection->shop_id, bin2hex(random_bytes(16)), 172800);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
@@ -359,6 +383,7 @@ class Etsy
|
||||
}
|
||||
$inventory = $this->request('PUT', 'application/listings/'.$listingId.'/inventory', $this->accessToken($connection), body: $body, jsonBody: true);
|
||||
$this->cache->forget('etsy.listings.'.$connection->shop_id);
|
||||
$this->forgetListingStateCaches($connection);
|
||||
|
||||
return $inventory;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,610 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\ProjectFile;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use RuntimeException;
|
||||
use XMLReader;
|
||||
use ZipArchive;
|
||||
|
||||
/**
|
||||
* Structural validation for private manufacturing file uploads (STL, 3MF, G-code).
|
||||
*
|
||||
* MIME sniffing alone is not trustworthy for these formats, so each extension is
|
||||
* combined with bounded, format-specific structural checks:
|
||||
*
|
||||
* - STL: a real facet/vertex grammar for ASCII (not just "starts with solid,
|
||||
* contains endsolid somewhere"), a triangle-count vs file-size cross-check for
|
||||
* binary with a positive triangle requirement, and a finite-float check on
|
||||
* every normal/vertex component so a NaN/Infinity payload is rejected.
|
||||
* - 3MF: a bounded zip reader (entry count, per-entry and total uncompressed
|
||||
* size, compression ratio, encrypted entries, duplicate entry names, unsafe
|
||||
* paths, symlink entries) feeding a real, safe XML parse (XMLReader, no DTD
|
||||
* loading, no network, rejecting any DOCTYPE outright) of every XML/.model/
|
||||
* .rels part — not the root relationships file alone — checking the 3MF
|
||||
* model namespace/resources/build structure and rejecting any relationship
|
||||
* with an external TargetMode or a Target outside the package, regardless of
|
||||
* quoting style. Every XML part is read through a bounded stream (per-entry
|
||||
* and whole-archive byte budgets) rather than trusting the zip's own declared
|
||||
* sizes, so a size lie can't blow past the caps.
|
||||
* - G-code: a plain-text check; it is never parsed or executed.
|
||||
*
|
||||
* No slicer, shell command or print job is ever invoked here, and nothing is
|
||||
* ever extracted to disk.
|
||||
*/
|
||||
class ManufacturingFileValidator
|
||||
{
|
||||
public const MAX_FILE_SIZE = 100 * 1024 * 1024;
|
||||
|
||||
public const MAX_TOTAL_BATCH_SIZE = 250 * 1024 * 1024;
|
||||
|
||||
private const MAX_STL_TRIANGLES = 5_000_000;
|
||||
|
||||
private const MAX_3MF_ENTRIES = 2000;
|
||||
|
||||
private const MAX_3MF_TOTAL_UNCOMPRESSED = 128 * 1024 * 1024;
|
||||
|
||||
private const MAX_3MF_RATIO = 100;
|
||||
|
||||
private const MAX_3MF_MODEL_PART_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
private const MAX_3MF_XML_ENTRY_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
private const MAX_3MF_TOTAL_PARSED_BYTES = 128 * 1024 * 1024;
|
||||
|
||||
private const MODEL_NAMESPACE_PREFIX = 'http://schemas.microsoft.com/3dmanufacturing/core/';
|
||||
|
||||
private const FLOAT_PATTERN = '[-+]?(?:\d+\.\d+|\.\d+|\d+)(?:[eE][-+]?\d+)?';
|
||||
|
||||
/** @var array<string, string> */
|
||||
private const MEDIA_TYPES = [
|
||||
'stl' => 'model/stl',
|
||||
'3mf' => 'model/3mf',
|
||||
'gcode' => 'text/x-gcode',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array{extension: string, media_type: string}
|
||||
*/
|
||||
public function validate(UploadedFile $file): array
|
||||
{
|
||||
$size = $file->getSize();
|
||||
if ($size === false || $size <= 0) {
|
||||
throw new RuntimeException('File is empty or unreadable.');
|
||||
}
|
||||
if ($size > self::MAX_FILE_SIZE) {
|
||||
throw new RuntimeException('File exceeds the maximum allowed size of 100 MiB.');
|
||||
}
|
||||
|
||||
$extension = strtolower((string) $file->getClientOriginalExtension());
|
||||
if (! in_array($extension, ProjectFile::EXTENSIONS, true)) {
|
||||
throw new RuntimeException("Unsupported file type: .{$extension}");
|
||||
}
|
||||
|
||||
$path = $file->getRealPath() ?: $file->getPathname();
|
||||
|
||||
match ($extension) {
|
||||
'stl' => $this->validateStl($path, $size),
|
||||
'3mf' => $this->validate3mf($path),
|
||||
'gcode' => $this->validateGcode($path, $size),
|
||||
default => throw new RuntimeException("Unsupported file type: .{$extension}"),
|
||||
};
|
||||
|
||||
return ['extension' => $extension, 'media_type' => self::MEDIA_TYPES[$extension]];
|
||||
}
|
||||
|
||||
private function validateStl(string $path, int $size): void
|
||||
{
|
||||
if ($size >= 84) {
|
||||
$header = $this->readPrefix($path, 84);
|
||||
if (strlen($header) === 84) {
|
||||
$count = unpack('V', substr($header, 80, 4))[1] ?? 0;
|
||||
if ($count > 0 && $count <= self::MAX_STL_TRIANGLES && $size === 84 + $count * 50) {
|
||||
$this->assertBinaryStlTrianglesFinite($path, $count);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$prefix = strtolower(ltrim($this->readPrefix($path, 16)));
|
||||
if (! str_starts_with($prefix, 'solid')) {
|
||||
throw new RuntimeException('File does not match the declared STL format.');
|
||||
}
|
||||
if (! $this->isPlainText($path)) {
|
||||
throw new RuntimeException('ASCII STL contains unexpected binary content.');
|
||||
}
|
||||
$this->validateAsciiStlGrammar($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* A real facet/vertex grammar: "solid garbage endsolid" with zero facets,
|
||||
* or any facet missing its outer loop/vertices, is rejected — not just
|
||||
* files that merely start with "solid" and contain "endsolid" somewhere.
|
||||
* Streamed line-by-line so an up-to-100-MiB file never needs to be loaded
|
||||
* into memory at once.
|
||||
*/
|
||||
private function validateAsciiStlGrammar(string $path): void
|
||||
{
|
||||
$handle = fopen($path, 'rb');
|
||||
if ($handle === false) {
|
||||
throw new RuntimeException('Unable to read uploaded file.');
|
||||
}
|
||||
|
||||
try {
|
||||
$first = fgets($handle);
|
||||
if ($first === false || preg_match('/^\s*solid\b/i', $first) !== 1) {
|
||||
throw new RuntimeException('ASCII STL must begin with a "solid" declaration.');
|
||||
}
|
||||
|
||||
$facetCount = 0;
|
||||
$state = 'facet_or_end';
|
||||
$done = false;
|
||||
|
||||
while (($line = fgets($handle)) !== false) {
|
||||
$trimmed = trim($line);
|
||||
if ($trimmed === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
switch ($state) {
|
||||
case 'facet_or_end':
|
||||
if (preg_match('/^endsolid\b/i', $trimmed) === 1) {
|
||||
$done = true;
|
||||
break 2;
|
||||
}
|
||||
if (preg_match('/^facet\s+normal\s+('.self::FLOAT_PATTERN.')\s+('.self::FLOAT_PATTERN.')\s+('.self::FLOAT_PATTERN.')\s*$/i', $trimmed, $m) !== 1) {
|
||||
throw new RuntimeException('ASCII STL contains an unexpected line outside a facet block.');
|
||||
}
|
||||
$this->assertFiniteTokens([$m[1], $m[2], $m[3]], 'facet normal');
|
||||
$state = 'outer_loop';
|
||||
break;
|
||||
|
||||
case 'outer_loop':
|
||||
if (preg_match('/^outer\s+loop$/i', $trimmed) !== 1) {
|
||||
throw new RuntimeException('Expected "outer loop" after a facet normal.');
|
||||
}
|
||||
$state = 'vertex1';
|
||||
break;
|
||||
|
||||
case 'vertex1':
|
||||
case 'vertex2':
|
||||
case 'vertex3':
|
||||
if (preg_match('/^vertex\s+('.self::FLOAT_PATTERN.')\s+('.self::FLOAT_PATTERN.')\s+('.self::FLOAT_PATTERN.')\s*$/i', $trimmed, $m) !== 1) {
|
||||
throw new RuntimeException('Expected a "vertex x y z" line inside a facet loop.');
|
||||
}
|
||||
$this->assertFiniteTokens([$m[1], $m[2], $m[3]], 'vertex');
|
||||
$state = match ($state) {
|
||||
'vertex1' => 'vertex2',
|
||||
'vertex2' => 'vertex3',
|
||||
'vertex3' => 'endloop',
|
||||
};
|
||||
break;
|
||||
|
||||
case 'endloop':
|
||||
if (preg_match('/^endloop$/i', $trimmed) !== 1) {
|
||||
throw new RuntimeException('Expected "endloop" after three vertices.');
|
||||
}
|
||||
$state = 'endfacet';
|
||||
break;
|
||||
|
||||
case 'endfacet':
|
||||
if (preg_match('/^endfacet$/i', $trimmed) !== 1) {
|
||||
throw new RuntimeException('Expected "endfacet" to close a facet.');
|
||||
}
|
||||
$facetCount++;
|
||||
if ($facetCount > self::MAX_STL_TRIANGLES) {
|
||||
throw new RuntimeException('ASCII STL exceeds the maximum supported facet count.');
|
||||
}
|
||||
$state = 'facet_or_end';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $done || $state !== 'facet_or_end') {
|
||||
throw new RuntimeException('ASCII STL is missing an endsolid terminator or has an incomplete facet.');
|
||||
}
|
||||
if ($facetCount < 1) {
|
||||
throw new RuntimeException('ASCII STL must contain at least one facet.');
|
||||
}
|
||||
} finally {
|
||||
fclose($handle);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $tokens
|
||||
*/
|
||||
private function assertFiniteTokens(array $tokens, string $context): void
|
||||
{
|
||||
foreach ($tokens as $token) {
|
||||
$value = (float) $token;
|
||||
if (! is_finite($value)) {
|
||||
throw new RuntimeException("ASCII STL {$context} contains a non-finite value.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function assertBinaryStlTrianglesFinite(string $path, int $count): void
|
||||
{
|
||||
$handle = fopen($path, 'rb');
|
||||
if ($handle === false) {
|
||||
throw new RuntimeException('Unable to read uploaded file.');
|
||||
}
|
||||
|
||||
try {
|
||||
fseek($handle, 84);
|
||||
$remaining = $count;
|
||||
while ($remaining > 0) {
|
||||
$batch = min($remaining, 2000);
|
||||
$bytes = $batch * 50;
|
||||
$chunk = fread($handle, $bytes);
|
||||
if ($chunk === false || strlen($chunk) !== $bytes) {
|
||||
throw new RuntimeException('Binary STL triangle data is truncated.');
|
||||
}
|
||||
for ($i = 0; $i < $batch; $i++) {
|
||||
$floats = unpack('f12', substr($chunk, $i * 50, 48));
|
||||
if ($floats === false) {
|
||||
throw new RuntimeException('Binary STL triangle data is malformed.');
|
||||
}
|
||||
foreach ($floats as $value) {
|
||||
if (! is_finite($value)) {
|
||||
throw new RuntimeException('Binary STL contains a non-finite (NaN/Infinity) normal or vertex value.');
|
||||
}
|
||||
}
|
||||
}
|
||||
$remaining -= $batch;
|
||||
}
|
||||
} finally {
|
||||
fclose($handle);
|
||||
}
|
||||
}
|
||||
|
||||
private function validateGcode(string $path, int $size): void
|
||||
{
|
||||
if (! $this->isPlainText($path)) {
|
||||
throw new RuntimeException('G-code file contains unexpected binary content.');
|
||||
}
|
||||
}
|
||||
|
||||
private function validate3mf(string $path): void
|
||||
{
|
||||
$zip = new ZipArchive;
|
||||
if ($zip->open($path) !== true) {
|
||||
throw new RuntimeException('Unable to read 3MF archive.');
|
||||
}
|
||||
|
||||
try {
|
||||
$count = $zip->count();
|
||||
if ($count > self::MAX_3MF_ENTRIES) {
|
||||
throw new RuntimeException('3MF archive has too many entries.');
|
||||
}
|
||||
|
||||
$totalUncompressed = 0;
|
||||
/** @var array<string, true> $seenNames */
|
||||
$seenNames = [];
|
||||
/** @var list<string> $modelParts */
|
||||
$modelParts = [];
|
||||
/** @var list<string> $relsEntries */
|
||||
$relsEntries = [];
|
||||
/** @var list<string> $xmlEntries */
|
||||
$xmlEntries = [];
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$stat = $zip->statIndex($i);
|
||||
if ($stat === false) {
|
||||
throw new RuntimeException('Unable to read 3MF archive entry.');
|
||||
}
|
||||
$name = (string) $stat['name'];
|
||||
|
||||
if (isset($seenNames[$name])) {
|
||||
throw new RuntimeException("3MF archive contains a duplicate entry path: {$name}.");
|
||||
}
|
||||
$seenNames[$name] = true;
|
||||
|
||||
$this->assertSafeZipEntryName($name);
|
||||
$this->assertNotSymlinkEntry($zip, $i, $name);
|
||||
|
||||
if (($stat['encryption_method'] ?? ZipArchive::EM_NONE) !== ZipArchive::EM_NONE) {
|
||||
throw new RuntimeException("3MF archive contains an encrypted entry: {$name}.");
|
||||
}
|
||||
|
||||
$uncompressed = (int) $stat['size'];
|
||||
$compressed = (int) $stat['comp_size'];
|
||||
$totalUncompressed += $uncompressed;
|
||||
|
||||
if ($compressed > 0 && ($uncompressed / $compressed) > self::MAX_3MF_RATIO) {
|
||||
throw new RuntimeException('3MF entry has a suspicious compression ratio.');
|
||||
}
|
||||
if ($compressed === 0 && $uncompressed > 10 * 1024 * 1024) {
|
||||
throw new RuntimeException('3MF entry has a suspicious compression ratio.');
|
||||
}
|
||||
|
||||
if (str_ends_with($name, '.model')) {
|
||||
$modelParts[] = $name;
|
||||
} elseif (str_ends_with($name, '.rels')) {
|
||||
$relsEntries[] = $name;
|
||||
} elseif (str_ends_with($name, '.xml')) {
|
||||
$xmlEntries[] = $name;
|
||||
}
|
||||
}
|
||||
|
||||
if ($totalUncompressed > self::MAX_3MF_TOTAL_UNCOMPRESSED) {
|
||||
throw new RuntimeException('3MF archive exceeds the maximum uncompressed size.');
|
||||
}
|
||||
if (! in_array('3D/3dmodel.model', $modelParts, true)) {
|
||||
throw new RuntimeException('3MF archive is missing the required 3D model part.');
|
||||
}
|
||||
if (! in_array('_rels/.rels', $relsEntries, true)) {
|
||||
throw new RuntimeException('3MF archive is missing the required root relationships part.');
|
||||
}
|
||||
|
||||
$budget = self::MAX_3MF_TOTAL_PARSED_BYTES;
|
||||
|
||||
$rootTargets = [];
|
||||
foreach ($relsEntries as $relsName) {
|
||||
$xml = $this->readBoundedZipEntry($zip, $relsName, self::MAX_3MF_XML_ENTRY_BYTES, $budget);
|
||||
$targets = $this->parseRelationships($xml, $relsName);
|
||||
if ($relsName === '_rels/.rels') {
|
||||
$rootTargets = $targets;
|
||||
}
|
||||
}
|
||||
|
||||
$hasModelRelationship = false;
|
||||
foreach ($rootTargets as $target) {
|
||||
$resolved = ltrim($target, '/');
|
||||
if (isset($seenNames[$resolved]) && str_ends_with($resolved, '.model')) {
|
||||
$hasModelRelationship = true;
|
||||
}
|
||||
}
|
||||
if (! $hasModelRelationship) {
|
||||
throw new RuntimeException('3MF root relationships do not point to an internal model part.');
|
||||
}
|
||||
|
||||
foreach ($xmlEntries as $xmlName) {
|
||||
$xml = $this->readBoundedZipEntry($zip, $xmlName, self::MAX_3MF_XML_ENTRY_BYTES, $budget);
|
||||
$this->assertGenericWellFormedXml($xml, $xmlName);
|
||||
}
|
||||
|
||||
foreach ($modelParts as $modelName) {
|
||||
$cap = $modelName === '3D/3dmodel.model' ? self::MAX_3MF_MODEL_PART_BYTES : self::MAX_3MF_XML_ENTRY_BYTES;
|
||||
$xml = $this->readBoundedZipEntry($zip, $modelName, $cap, $budget);
|
||||
$this->assertWellFormedModelXml($xml, $modelName);
|
||||
}
|
||||
} finally {
|
||||
$zip->close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a zip entry through a bounded stream rather than trusting the
|
||||
* archive's own declared size, so a size lie in the central directory
|
||||
* can't blow past the per-entry or whole-archive parse budgets.
|
||||
*/
|
||||
private function readBoundedZipEntry(ZipArchive $zip, string $name, int $perEntryCap, int &$remainingBudget): string
|
||||
{
|
||||
$stream = $zip->getStream($name);
|
||||
if ($stream === false) {
|
||||
throw new RuntimeException("Unable to read 3MF archive entry: {$name}.");
|
||||
}
|
||||
|
||||
$buffer = '';
|
||||
$total = 0;
|
||||
try {
|
||||
while (! feof($stream)) {
|
||||
$chunk = fread($stream, 65536);
|
||||
if ($chunk === false) {
|
||||
throw new RuntimeException("Unable to read 3MF archive entry: {$name}.");
|
||||
}
|
||||
$total += strlen($chunk);
|
||||
if ($total > $perEntryCap) {
|
||||
throw new RuntimeException("3MF archive entry exceeds its size cap: {$name}.");
|
||||
}
|
||||
$remainingBudget -= strlen($chunk);
|
||||
if ($remainingBudget < 0) {
|
||||
throw new RuntimeException('3MF archive exceeds the total parse budget.');
|
||||
}
|
||||
$buffer .= $chunk;
|
||||
}
|
||||
} finally {
|
||||
fclose($stream);
|
||||
}
|
||||
|
||||
return $buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string> the raw Target attribute values of every Relationship
|
||||
*/
|
||||
private function parseRelationships(string $xml, string $context): array
|
||||
{
|
||||
$reader = $this->openSafeXmlReader($xml, $context);
|
||||
$targets = [];
|
||||
|
||||
try {
|
||||
while (@$reader->read()) {
|
||||
if ($reader->nodeType === XMLReader::DOC_TYPE) {
|
||||
throw new RuntimeException("3MF relationships part contains a disallowed DOCTYPE declaration: {$context}.");
|
||||
}
|
||||
if ($reader->nodeType !== XMLReader::ELEMENT || $reader->localName !== 'Relationship') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$targetMode = $reader->getAttribute('TargetMode');
|
||||
if ($targetMode !== null && strcasecmp($targetMode, 'External') === 0) {
|
||||
throw new RuntimeException("3MF relationship declares an external TargetMode: {$context}.");
|
||||
}
|
||||
|
||||
$target = $reader->getAttribute('Target');
|
||||
if ($target === null || $target === '') {
|
||||
throw new RuntimeException("3MF relationship is missing a Target attribute: {$context}.");
|
||||
}
|
||||
if (preg_match('#^[a-zA-Z][a-zA-Z0-9+.-]*:#', $target) === 1 || str_contains($target, '..')) {
|
||||
throw new RuntimeException("3MF relationship target is not a safe internal package reference: {$context}.");
|
||||
}
|
||||
|
||||
$targets[] = $target;
|
||||
}
|
||||
} finally {
|
||||
$this->closeSafeXmlReader($reader, $context);
|
||||
}
|
||||
|
||||
return $targets;
|
||||
}
|
||||
|
||||
private function assertGenericWellFormedXml(string $xml, string $context): void
|
||||
{
|
||||
$reader = $this->openSafeXmlReader($xml, $context);
|
||||
|
||||
try {
|
||||
while (@$reader->read()) {
|
||||
if ($reader->nodeType === XMLReader::DOC_TYPE) {
|
||||
throw new RuntimeException("3MF XML part contains a disallowed DOCTYPE declaration: {$context}.");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
$this->closeSafeXmlReader($reader, $context);
|
||||
}
|
||||
}
|
||||
|
||||
private function assertWellFormedModelXml(string $xml, string $context): void
|
||||
{
|
||||
$reader = $this->openSafeXmlReader($xml, $context);
|
||||
$sawRoot = false;
|
||||
$sawResources = false;
|
||||
$sawBuild = false;
|
||||
$rootDepth = null;
|
||||
|
||||
try {
|
||||
while (@$reader->read()) {
|
||||
if ($reader->nodeType === XMLReader::DOC_TYPE) {
|
||||
throw new RuntimeException("3MF model part contains a disallowed DOCTYPE declaration: {$context}.");
|
||||
}
|
||||
if ($reader->nodeType !== XMLReader::ELEMENT) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $sawRoot) {
|
||||
if ($reader->localName !== 'model' || ! str_starts_with((string) $reader->namespaceURI, self::MODEL_NAMESPACE_PREFIX)) {
|
||||
throw new RuntimeException("3MF model part does not have a valid <model> root element in the 3MF core namespace: {$context}.");
|
||||
}
|
||||
$sawRoot = true;
|
||||
$rootDepth = $reader->depth;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($reader->depth === $rootDepth + 1) {
|
||||
if ($reader->localName === 'resources') {
|
||||
$sawResources = true;
|
||||
}
|
||||
if ($reader->localName === 'build') {
|
||||
$sawBuild = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
$this->closeSafeXmlReader($reader, $context);
|
||||
}
|
||||
|
||||
if (! $sawRoot || ! $sawResources || ! $sawBuild) {
|
||||
throw new RuntimeException("3MF model part is missing a valid model root with resources/build elements: {$context}.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* No DTD loading, no network access, no entity substitution — any DOCTYPE
|
||||
* encountered while reading is explicitly rejected by the callers above
|
||||
* rather than relied upon to be harmless.
|
||||
*/
|
||||
private function openSafeXmlReader(string $xml, string $context): XMLReader
|
||||
{
|
||||
libxml_use_internal_errors(true);
|
||||
libxml_clear_errors();
|
||||
|
||||
$reader = XMLReader::XML($xml, null, LIBXML_NONET);
|
||||
if ($reader === false) {
|
||||
throw new RuntimeException("3MF part is not well-formed XML: {$context}.");
|
||||
}
|
||||
|
||||
return $reader;
|
||||
}
|
||||
|
||||
private function closeSafeXmlReader(XMLReader $reader, string $context): void
|
||||
{
|
||||
$errors = libxml_get_errors();
|
||||
libxml_clear_errors();
|
||||
$reader->close();
|
||||
|
||||
foreach ($errors as $error) {
|
||||
if ($error->level >= LIBXML_ERR_ERROR) {
|
||||
throw new RuntimeException("3MF part is not well-formed XML: {$context}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function assertSafeZipEntryName(string $name): void
|
||||
{
|
||||
if ($name === ''
|
||||
|| str_contains($name, '..')
|
||||
|| str_starts_with($name, '/')
|
||||
|| str_contains($name, '\\')
|
||||
|| preg_match('/^[A-Za-z]:/', $name) === 1) {
|
||||
throw new RuntimeException('3MF archive contains an unsafe entry path.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unix "external file attributes" pack the st_mode bits into the top 16 bits.
|
||||
* A symlink entry (S_IFLNK octal 0120000) must never be trusted, even though
|
||||
* nothing in this codebase currently extracts 3MF content to disk — a future
|
||||
* caller doing so should not inherit a symlink-following vulnerability.
|
||||
*/
|
||||
private function assertNotSymlinkEntry(ZipArchive $zip, int $index, string $name): void
|
||||
{
|
||||
$external = $zip->getExternalAttributesIndex($index, $opsys, $attr) ? $attr : null;
|
||||
if ($external === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$unixMode = ($external >> 16) & 0xFFFF;
|
||||
if (($unixMode & 0xF000) === 0xA000) {
|
||||
throw new RuntimeException("3MF archive contains a symlink entry: {$name}.");
|
||||
}
|
||||
}
|
||||
|
||||
private function readPrefix(string $path, int $length): string
|
||||
{
|
||||
$handle = fopen($path, 'rb');
|
||||
if ($handle === false) {
|
||||
throw new RuntimeException('Unable to read uploaded file.');
|
||||
}
|
||||
$data = fread($handle, $length);
|
||||
fclose($handle);
|
||||
|
||||
return $data === false ? '' : $data;
|
||||
}
|
||||
|
||||
private function isPlainText(string $path): bool
|
||||
{
|
||||
$handle = fopen($path, 'rb');
|
||||
if ($handle === false) {
|
||||
throw new RuntimeException('Unable to read uploaded file.');
|
||||
}
|
||||
while (! feof($handle)) {
|
||||
$chunk = fread($handle, 1024 * 1024);
|
||||
if ($chunk === false) {
|
||||
break;
|
||||
}
|
||||
if (preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $chunk) === 1) {
|
||||
fclose($handle);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
fclose($handle);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -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