Add HL3D branding, Etsy Live/Draft paging and Projects lifecycle
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.
@@ -63,6 +63,28 @@ For local tests in the existing serviceengineering toolchain image, explicitly s
|
||||
|
||||
Etsy Open API v3 has no conversation inbox (`conversations_*` scopes do not exist). Receipt notes are exposed; sending a buyer-inbox reply is not.
|
||||
|
||||
## Projects (admin)
|
||||
|
||||
Projects are the product aggregate: a reversible internal lifecycle (Design, Ready, Live, Retired — distinct from Etsy's own listing state), approved immutable revisions with private STL/3MF/G-code file bundles, Etsy listing mappings verified by reading the listing from Etsy (never an arbitrary shop/listing ID), and append-only cost snapshots and releases.
|
||||
|
||||
- Uploads are limited to `.stl`, `.3mf` and `.gcode`, 100 MiB per file, 250 MiB combined per request, and 10 files per request, and are structurally validated (STL triangle/size cross-check, bounded 3MF zip/XML with no path traversal, symlink entries, external references or DOCTYPE/ENTITY declarations, plain-text G-code). Nothing uploaded here is ever parsed as a slicer job, executed, or served from a public URL — files live on the `project_files` disk (`storage/app/project-files`, not web-served) under a random generated key, streamed rather than buffered in memory, and only stream back through the authenticated, policy-checked `projects.files.download` route. Any failure partway through a multi-file upload (validation or otherwise) deletes every byte already written for that request.
|
||||
- Releasing a revision to a listing mapping requires a cost snapshot for that same revision with every category costed (filament, packaging and shipping — even as an explicit `0`, but not missing) and pins an exact revision + cost snapshot; uploading a newer revision never moves that pointer on its own, and both release and revert create a new append-only `project_releases` row under an optimistic lock (`project_listing_links.lock_version`) rather than overwriting history. A detached listing mapping can no longer be released or reverted to.
|
||||
- Cost snapshots use fixed-point integer arithmetic (`App\Support\FixedPoint` / `App\Services\ProjectCosting`), not floats: the bcmath/gmp PHP extensions are not installed in this project's runtime, so exact-decimal math is done by scaling decimal strings into bounded native integers with explicit overflow and storable-precision checks. Estimate and actual snapshots are both append-only and never overwrite each other; a missing cost category is stored as `null` ("not costed"), distinct from an intentional `0.0000`. Each category accepts exactly one quantity unit (filament `g`, packaging `unit`, shipping `parcel`) so a mistyped unit is rejected rather than silently mis-costed. Snapshot currency must match the project (no implied FX). Shipping quantities are parcels; `units_per_parcel` allocates that parcel cost across the saleable batch yield (`batch_units / units_per_parcel * parcel cost`) independently of `perUnit()`. Actual shipping needs a confirmed shipment or invoice reference. The form starts with separate filament, packaging and shipping lines and can add more materials/components; Fusion project/document/version fields on a revision are optional manual provenance only.
|
||||
- Stage can only move to Live once *every* non-detached listing mapping has an active release (not merely one of several); moving a project's internal stage never calls the Etsy API or publishes anything. Attaching a new, not-yet-released listing mapping to a project already at Live drops its stage back to Design rather than leaving Live claiming coverage it no longer has.
|
||||
|
||||
## Branding
|
||||
|
||||
The sidebar/login logo, favicon and in-app colour tokens use the supplied HL3D brand assets under `public/branding/` (carbon `#111316`, alloy `#A7ADB5`, fluorescent yellow `#E7FF00`, ice `#F4F6F8`, slate `#515963`). The CSS font stack (`resources/css/app.css`) names "Avenir Next" first and falls back to the local system sans/monospace stack; no font files are bundled or fetched from Google/Bunny Fonts, and the wordmark is supplied as vector outlines so no font is needed to render the logo itself. **Follow-up:** no redistributable Avenir Next web-font licence has been secured — if a licensed web font becomes available, add it as a self-hosted asset (no external font CDN calls) and keep the system-stack fallback.
|
||||
|
||||
## Backups
|
||||
|
||||
A restorable backup needs **both** of the following, taken together (the private files reference projects/revisions by ID, so restoring one without the other breaks file bundles):
|
||||
|
||||
1. The database (`mysqldump` in production; `database/database.sqlite` only exists for local/dev use).
|
||||
2. The private file store at `storage/app/project-files/` (dedicated, non-public disk for uploaded STL/3MF/G-code bytes — distinct from `storage/app/public`).
|
||||
|
||||
Verify a restore against an isolated copy before trusting it, and spot-check a `project_files.sha256` value against the restored bytes on disk.
|
||||
|
||||
## Verification
|
||||
|
||||
```sh
|
||||
|
||||
@@ -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) : []);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,14 @@ return [
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
'project_files' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/project-files'),
|
||||
'serve' => false,
|
||||
'throw' => true,
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
'public' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/public'),
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\ProjectCostLine;
|
||||
use App\Models\ProjectCostSnapshot;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<ProjectCostLine>
|
||||
*/
|
||||
class ProjectCostLineFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'cost_snapshot_id' => ProjectCostSnapshot::factory(),
|
||||
'category' => ProjectCostLine::CATEGORY_FILAMENT,
|
||||
'description' => 'PLA black',
|
||||
'quantity' => '100.000',
|
||||
'quantity_unit' => 'g',
|
||||
'unit_rate' => '20.000000',
|
||||
'waste_quantity' => '0.000',
|
||||
'line_total' => '2.0000',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectCostSnapshot;
|
||||
use App\Models\ProjectRevision;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<ProjectCostSnapshot>
|
||||
*/
|
||||
class ProjectCostSnapshotFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'project_id' => Project::factory(),
|
||||
'revision_id' => ProjectRevision::factory(),
|
||||
'currency' => 'GBP',
|
||||
'basis' => ProjectCostSnapshot::BASIS_ESTIMATE,
|
||||
'batch_units' => 1,
|
||||
'filament_total' => null,
|
||||
'packaging_total' => null,
|
||||
'shipping_total' => null,
|
||||
'grand_total' => null,
|
||||
'recorded_by' => User::factory()->create(['role' => 'admin'])->id,
|
||||
'recorded_at' => now(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<Project>
|
||||
*/
|
||||
class ProjectFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'reference' => 'PRJ-'.fake()->unique()->numerify('####'),
|
||||
'title' => fake()->words(3, true),
|
||||
'notes' => null,
|
||||
'stage' => 'design',
|
||||
'currency' => 'GBP',
|
||||
'created_by' => User::factory()->create(['role' => 'admin'])->id,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\ProjectFile;
|
||||
use App\Models\ProjectRevision;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<ProjectFile>
|
||||
*/
|
||||
class ProjectFileFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'revision_id' => ProjectRevision::factory(),
|
||||
'role' => 'body',
|
||||
'original_filename' => 'body.stl',
|
||||
'storage_key' => fake()->unique()->uuid().'.stl',
|
||||
'extension' => 'stl',
|
||||
'media_type' => 'model/stl',
|
||||
'byte_size' => 1024,
|
||||
'sha256' => hash('sha256', fake()->unique()->uuid()),
|
||||
'uploaded_by' => User::factory()->create(['role' => 'admin'])->id,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectListingLink;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<ProjectListingLink>
|
||||
*/
|
||||
class ProjectListingLinkFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'project_id' => Project::factory(),
|
||||
'shop_id' => 42,
|
||||
'listing_id' => fake()->unique()->numberBetween(1000, 999999),
|
||||
'variation_key' => ProjectListingLink::ALL_VARIATIONS,
|
||||
'title_snapshot' => fake()->words(3, true),
|
||||
'state_snapshot' => 'active',
|
||||
'synced_at' => now(),
|
||||
'created_by' => User::factory()->create(['role' => 'admin'])->id,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectRelease;
|
||||
use App\Models\ProjectRevision;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<ProjectRelease>
|
||||
*/
|
||||
class ProjectReleaseFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'project_id' => Project::factory(),
|
||||
'revision_id' => ProjectRevision::factory(),
|
||||
'cost_snapshot_id' => null,
|
||||
'action' => ProjectRelease::ACTION_RELEASE,
|
||||
'released_by' => User::factory()->create(['role' => 'admin'])->id,
|
||||
'released_at' => now(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectRevision;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<ProjectRevision>
|
||||
*/
|
||||
class ProjectRevisionFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'project_id' => Project::factory(),
|
||||
'revision_number' => 1,
|
||||
'change_notes' => 'Initial revision.',
|
||||
'status' => ProjectRevision::STATUS_DRAFT,
|
||||
'created_by' => User::factory()->create(['role' => 'admin'])->id,
|
||||
];
|
||||
}
|
||||
|
||||
public function approved(): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'status' => ProjectRevision::STATUS_APPROVED,
|
||||
'approved_by' => User::factory()->create(['role' => 'admin'])->id,
|
||||
'approved_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('projects', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('reference')->unique();
|
||||
$table->string('title');
|
||||
$table->text('notes')->nullable();
|
||||
$table->string('stage')->default('design');
|
||||
$table->string('currency', 3)->default('GBP');
|
||||
$table->foreignId('created_by')->constrained('users')->restrictOnDelete();
|
||||
$table->timestamp('archived_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('projects');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('project_revisions', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('project_id')->constrained('projects')->cascadeOnDelete();
|
||||
$table->unsignedInteger('revision_number');
|
||||
$table->text('change_notes')->nullable();
|
||||
$table->string('status')->default('draft');
|
||||
$table->foreignId('created_by')->constrained('users')->restrictOnDelete();
|
||||
$table->foreignId('approved_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->timestamp('approved_at')->nullable();
|
||||
$table->string('fusion_project_id')->nullable();
|
||||
$table->string('fusion_document_id')->nullable();
|
||||
$table->string('fusion_version')->nullable();
|
||||
$table->timestamps();
|
||||
$table->unique(['project_id', 'revision_number']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('project_revisions');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('project_files', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('revision_id')->constrained('project_revisions')->cascadeOnDelete();
|
||||
$table->string('role');
|
||||
$table->string('original_filename');
|
||||
$table->string('storage_key')->unique();
|
||||
$table->string('extension', 16);
|
||||
$table->string('media_type');
|
||||
$table->unsignedBigInteger('byte_size');
|
||||
$table->string('sha256', 64);
|
||||
$table->foreignId('uploaded_by')->constrained('users')->restrictOnDelete();
|
||||
$table->string('printer_profile')->nullable();
|
||||
$table->string('material')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('project_files');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('project_cost_snapshots', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('project_id')->constrained('projects')->cascadeOnDelete();
|
||||
$table->foreignId('revision_id')->constrained('project_revisions')->cascadeOnDelete();
|
||||
$table->string('currency', 3);
|
||||
$table->string('basis');
|
||||
$table->unsignedInteger('batch_units');
|
||||
$table->decimal('filament_total', 14, 4)->nullable();
|
||||
$table->decimal('packaging_total', 14, 4)->nullable();
|
||||
$table->decimal('shipping_total', 14, 4)->nullable();
|
||||
$table->decimal('grand_total', 14, 4)->nullable();
|
||||
$table->string('source_reference')->nullable();
|
||||
$table->text('notes')->nullable();
|
||||
$table->foreignId('recorded_by')->constrained('users')->restrictOnDelete();
|
||||
$table->timestamp('recorded_at');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('project_cost_snapshots');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('project_cost_lines', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('cost_snapshot_id')->constrained('project_cost_snapshots')->cascadeOnDelete();
|
||||
$table->string('category');
|
||||
$table->string('description');
|
||||
$table->decimal('quantity', 12, 3);
|
||||
$table->string('quantity_unit', 16);
|
||||
$table->decimal('unit_rate', 14, 6);
|
||||
$table->decimal('waste_quantity', 12, 3)->default(0);
|
||||
$table->decimal('line_total', 14, 4);
|
||||
$table->date('source_price_date')->nullable();
|
||||
$table->string('source_reference')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('project_cost_lines');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('project_releases', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('project_id')->constrained('projects')->cascadeOnDelete();
|
||||
$table->foreignId('revision_id')->constrained('project_revisions')->restrictOnDelete();
|
||||
$table->foreignId('cost_snapshot_id')->nullable()->constrained('project_cost_snapshots')->restrictOnDelete();
|
||||
$table->string('action')->default('release');
|
||||
$table->foreignId('released_by')->constrained('users')->restrictOnDelete();
|
||||
$table->timestamp('released_at');
|
||||
$table->text('notes')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('project_releases');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('project_listing_links', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('project_id')->constrained('projects')->cascadeOnDelete();
|
||||
$table->unsignedBigInteger('shop_id');
|
||||
$table->unsignedBigInteger('listing_id');
|
||||
$table->string('variation_key')->default('all');
|
||||
$table->string('title_snapshot')->nullable();
|
||||
$table->string('state_snapshot')->nullable();
|
||||
$table->timestamp('remote_updated_at')->nullable();
|
||||
$table->timestamp('synced_at')->nullable();
|
||||
$table->foreignId('live_release_id')->nullable()->constrained('project_releases')->nullOnDelete();
|
||||
$table->unsignedInteger('lock_version')->default(0);
|
||||
$table->foreignId('created_by')->constrained('users')->restrictOnDelete();
|
||||
$table->timestamp('detached_at')->nullable();
|
||||
$table->timestamps();
|
||||
$table->unique(['shop_id', 'listing_id', 'variation_key']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('project_listing_links');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('project_events', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('project_id')->constrained('projects')->cascadeOnDelete();
|
||||
$table->string('event_type');
|
||||
$table->foreignId('actor_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->string('subject_type')->nullable();
|
||||
$table->unsignedBigInteger('subject_id')->nullable();
|
||||
$table->json('metadata')->nullable();
|
||||
$table->timestamp('created_at');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('project_events');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('project_cost_lines', function (Blueprint $table): void {
|
||||
$table->unsignedInteger('units_per_parcel')->nullable()->after('quantity_unit');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('project_cost_lines', function (Blueprint $table): void {
|
||||
$table->dropColumn('units_per_parcel');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 558.154 122.67">
|
||||
<title>HL3D refined lockup for dark backgrounds</title>
|
||||
<g transform="scale(.174) translate(-90 -10)"><path fill="#A7ADB5" d="M90 650 L90 240 L260 135 L260 290 L195 330 L195 425 L365 320 L365 155 L505 70 L505 520 L680 625 L545 710 L370 605.972222222 L370 450 L200 555 L200 715 Z"/><path fill="#E7FF00" d="M545 90 L680 10 L680 420 L910 560 L775 645 L545 505 Z"/></g>
|
||||
<rect x="165" y="14" width="3.5" height="95" fill="#A7ADB5"/>
|
||||
<path transform="translate(192 106) scale(5.35)" fill="#F4F6F8" d="M12.480 -0.000L12.480 -7.104L5.856 -7.104L5.856 -0.000L1.752 -0.000L1.752 -16.992L5.856 -16.992L5.856 -10.560L12.480 -10.560L12.480 -16.992L16.584 -16.992L16.584 -0.000ZM20.938 -0.000L20.938 -16.992L25.066 -16.992L25.066 -3.576L31.642 -3.576L31.642 -0.000ZM46.652 -4.944Q46.652 -3.552 46.088 -2.532Q45.524 -1.512 44.612 -0.852Q43.700 -0.192 42.560 0.132Q41.420 0.456 40.244 0.456Q39.092 0.456 38.000 0.192Q36.908 -0.072 36.008 -0.612Q35.108 -1.152 34.436 -2.004Q33.764 -2.856 33.428 -4.056L37.172 -5.040Q37.412 -4.200 38.156 -3.564Q38.900 -2.928 40.052 -2.928Q40.484 -2.928 40.928 -3.048Q41.372 -3.168 41.720 -3.420Q42.068 -3.672 42.296 -4.080Q42.524 -4.488 42.524 -5.088Q42.524 -5.736 42.224 -6.168Q41.924 -6.600 41.432 -6.852Q40.940 -7.104 40.316 -7.212Q39.692 -7.320 39.068 -7.320L37.988 -7.320L37.988 -10.272L39.164 -10.272Q39.740 -10.272 40.268 -10.356Q40.796 -10.440 41.216 -10.668Q41.636 -10.896 41.888 -11.280Q42.140 -11.664 42.140 -12.288Q42.140 -13.200 41.492 -13.680Q40.844 -14.160 40.004 -14.160Q39.140 -14.160 38.504 -13.644Q37.868 -13.128 37.676 -12.264L33.932 -13.128Q34.244 -14.208 34.868 -15.012Q35.492 -15.816 36.344 -16.356Q37.196 -16.896 38.204 -17.172Q39.212 -17.448 40.268 -17.448Q41.396 -17.448 42.464 -17.160Q43.532 -16.872 44.372 -16.272Q45.212 -15.672 45.728 -14.772Q46.244 -13.872 46.244 -12.648Q46.244 -11.232 45.440 -10.260Q44.636 -9.288 43.388 -8.952L43.388 -8.880Q44.108 -8.712 44.708 -8.340Q45.308 -7.968 45.740 -7.452Q46.172 -6.936 46.412 -6.300Q46.652 -5.664 46.652 -4.944ZM66.630 -8.544Q66.630 -6.312 65.802 -4.692Q64.974 -3.072 63.618 -2.028Q62.262 -0.984 60.558 -0.492Q58.854 -0.000 57.102 -0.000L50.766 -0.000L50.766 -16.992L56.910 -16.992Q58.710 -16.992 60.462 -16.572Q62.214 -16.152 63.582 -15.180Q64.950 -14.208 65.790 -12.576Q66.630 -10.944 66.630 -8.544ZM62.358 -8.544Q62.358 -9.984 61.890 -10.932Q61.422 -11.880 60.642 -12.444Q59.862 -13.008 58.866 -13.248Q57.870 -13.488 56.814 -13.488L54.774 -13.488L54.774 -3.552L56.718 -3.552Q57.822 -3.552 58.842 -3.804Q59.862 -4.056 60.642 -4.632Q61.422 -5.208 61.890 -6.168Q62.358 -7.128 62.358 -8.544Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 558.154 122.67">
|
||||
<title>HL3D refined lockup for light backgrounds</title>
|
||||
<g transform="scale(.174) translate(-90 -10)"><path fill="#111316" d="M90 650 L90 240 L260 135 L260 290 L195 330 L195 425 L365 320 L365 155 L505 70 L505 520 L680 625 L545 710 L370 605.972222222 L370 450 L200 555 L200 715 Z"/><path fill="#515963" d="M545 90 L680 10 L680 420 L910 560 L775 645 L545 505 Z"/></g>
|
||||
<rect x="165" y="14" width="3.5" height="95" fill="#111316"/>
|
||||
<path transform="translate(192 106) scale(5.35)" fill="#111316" d="M12.480 -0.000L12.480 -7.104L5.856 -7.104L5.856 -0.000L1.752 -0.000L1.752 -16.992L5.856 -16.992L5.856 -10.560L12.480 -10.560L12.480 -16.992L16.584 -16.992L16.584 -0.000ZM20.938 -0.000L20.938 -16.992L25.066 -16.992L25.066 -3.576L31.642 -3.576L31.642 -0.000ZM46.652 -4.944Q46.652 -3.552 46.088 -2.532Q45.524 -1.512 44.612 -0.852Q43.700 -0.192 42.560 0.132Q41.420 0.456 40.244 0.456Q39.092 0.456 38.000 0.192Q36.908 -0.072 36.008 -0.612Q35.108 -1.152 34.436 -2.004Q33.764 -2.856 33.428 -4.056L37.172 -5.040Q37.412 -4.200 38.156 -3.564Q38.900 -2.928 40.052 -2.928Q40.484 -2.928 40.928 -3.048Q41.372 -3.168 41.720 -3.420Q42.068 -3.672 42.296 -4.080Q42.524 -4.488 42.524 -5.088Q42.524 -5.736 42.224 -6.168Q41.924 -6.600 41.432 -6.852Q40.940 -7.104 40.316 -7.212Q39.692 -7.320 39.068 -7.320L37.988 -7.320L37.988 -10.272L39.164 -10.272Q39.740 -10.272 40.268 -10.356Q40.796 -10.440 41.216 -10.668Q41.636 -10.896 41.888 -11.280Q42.140 -11.664 42.140 -12.288Q42.140 -13.200 41.492 -13.680Q40.844 -14.160 40.004 -14.160Q39.140 -14.160 38.504 -13.644Q37.868 -13.128 37.676 -12.264L33.932 -13.128Q34.244 -14.208 34.868 -15.012Q35.492 -15.816 36.344 -16.356Q37.196 -16.896 38.204 -17.172Q39.212 -17.448 40.268 -17.448Q41.396 -17.448 42.464 -17.160Q43.532 -16.872 44.372 -16.272Q45.212 -15.672 45.728 -14.772Q46.244 -13.872 46.244 -12.648Q46.244 -11.232 45.440 -10.260Q44.636 -9.288 43.388 -8.952L43.388 -8.880Q44.108 -8.712 44.708 -8.340Q45.308 -7.968 45.740 -7.452Q46.172 -6.936 46.412 -6.300Q46.652 -5.664 46.652 -4.944ZM66.630 -8.544Q66.630 -6.312 65.802 -4.692Q64.974 -3.072 63.618 -2.028Q62.262 -0.984 60.558 -0.492Q58.854 -0.000 57.102 -0.000L50.766 -0.000L50.766 -16.992L56.910 -16.992Q58.710 -16.992 60.462 -16.572Q62.214 -16.152 63.582 -15.180Q64.950 -14.208 65.790 -12.576Q66.630 -10.944 66.630 -8.544ZM62.358 -8.544Q62.358 -9.984 61.890 -10.932Q61.422 -11.880 60.642 -12.444Q59.862 -13.008 58.866 -13.248Q57.870 -13.488 56.814 -13.488L54.774 -13.488L54.774 -3.552L56.718 -3.552Q57.822 -3.552 58.842 -3.804Q59.862 -4.056 60.642 -4.632Q61.422 -5.208 61.890 -6.168Q62.358 -7.128 62.358 -8.544Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 558.154 122.67">
|
||||
<title>HL3D refined lockup for light backgrounds</title>
|
||||
<g transform="scale(.174) translate(-90 -10)"><path fill="#111316" d="M90 650 L90 240 L260 135 L260 290 L195 330 L195 425 L365 320 L365 155 L505 70 L505 520 L680 625 L545 710 L370 605.972222222 L370 450 L200 555 L200 715 Z"/><path fill="#111316" d="M545 90 L680 10 L680 420 L910 560 L775 645 L545 505 Z"/></g>
|
||||
<rect x="165" y="14" width="3.5" height="95" fill="#111316"/>
|
||||
<path transform="translate(192 106) scale(5.35)" fill="#111316" d="M12.480 -0.000L12.480 -7.104L5.856 -7.104L5.856 -0.000L1.752 -0.000L1.752 -16.992L5.856 -16.992L5.856 -10.560L12.480 -10.560L12.480 -16.992L16.584 -16.992L16.584 -0.000ZM20.938 -0.000L20.938 -16.992L25.066 -16.992L25.066 -3.576L31.642 -3.576L31.642 -0.000ZM46.652 -4.944Q46.652 -3.552 46.088 -2.532Q45.524 -1.512 44.612 -0.852Q43.700 -0.192 42.560 0.132Q41.420 0.456 40.244 0.456Q39.092 0.456 38.000 0.192Q36.908 -0.072 36.008 -0.612Q35.108 -1.152 34.436 -2.004Q33.764 -2.856 33.428 -4.056L37.172 -5.040Q37.412 -4.200 38.156 -3.564Q38.900 -2.928 40.052 -2.928Q40.484 -2.928 40.928 -3.048Q41.372 -3.168 41.720 -3.420Q42.068 -3.672 42.296 -4.080Q42.524 -4.488 42.524 -5.088Q42.524 -5.736 42.224 -6.168Q41.924 -6.600 41.432 -6.852Q40.940 -7.104 40.316 -7.212Q39.692 -7.320 39.068 -7.320L37.988 -7.320L37.988 -10.272L39.164 -10.272Q39.740 -10.272 40.268 -10.356Q40.796 -10.440 41.216 -10.668Q41.636 -10.896 41.888 -11.280Q42.140 -11.664 42.140 -12.288Q42.140 -13.200 41.492 -13.680Q40.844 -14.160 40.004 -14.160Q39.140 -14.160 38.504 -13.644Q37.868 -13.128 37.676 -12.264L33.932 -13.128Q34.244 -14.208 34.868 -15.012Q35.492 -15.816 36.344 -16.356Q37.196 -16.896 38.204 -17.172Q39.212 -17.448 40.268 -17.448Q41.396 -17.448 42.464 -17.160Q43.532 -16.872 44.372 -16.272Q45.212 -15.672 45.728 -14.772Q46.244 -13.872 46.244 -12.648Q46.244 -11.232 45.440 -10.260Q44.636 -9.288 43.388 -8.952L43.388 -8.880Q44.108 -8.712 44.708 -8.340Q45.308 -7.968 45.740 -7.452Q46.172 -6.936 46.412 -6.300Q46.652 -5.664 46.652 -4.944ZM66.630 -8.544Q66.630 -6.312 65.802 -4.692Q64.974 -3.072 63.618 -2.028Q62.262 -0.984 60.558 -0.492Q58.854 -0.000 57.102 -0.000L50.766 -0.000L50.766 -16.992L56.910 -16.992Q58.710 -16.992 60.462 -16.572Q62.214 -16.152 63.582 -15.180Q64.950 -14.208 65.790 -12.576Q66.630 -10.944 66.630 -8.544ZM62.358 -8.544Q62.358 -9.984 61.890 -10.932Q61.422 -11.880 60.642 -12.444Q59.862 -13.008 58.866 -13.248Q57.870 -13.488 56.814 -13.488L54.774 -13.488L54.774 -3.552L56.718 -3.552Q57.822 -3.552 58.842 -3.804Q59.862 -4.056 60.642 -4.632Q61.422 -5.208 61.890 -6.168Q62.358 -7.128 62.358 -8.544Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 558.154 122.67">
|
||||
<title>HL3D refined lockup for light backgrounds</title>
|
||||
<g transform="scale(.174) translate(-90 -10)"><path fill="#FFFFFF" d="M90 650 L90 240 L260 135 L260 290 L195 330 L195 425 L365 320 L365 155 L505 70 L505 520 L680 625 L545 710 L370 605.972222222 L370 450 L200 555 L200 715 Z"/><path fill="#FFFFFF" d="M545 90 L680 10 L680 420 L910 560 L775 645 L545 505 Z"/></g>
|
||||
<rect x="165" y="14" width="3.5" height="95" fill="#FFFFFF"/>
|
||||
<path transform="translate(192 106) scale(5.35)" fill="#FFFFFF" d="M12.480 -0.000L12.480 -7.104L5.856 -7.104L5.856 -0.000L1.752 -0.000L1.752 -16.992L5.856 -16.992L5.856 -10.560L12.480 -10.560L12.480 -16.992L16.584 -16.992L16.584 -0.000ZM20.938 -0.000L20.938 -16.992L25.066 -16.992L25.066 -3.576L31.642 -3.576L31.642 -0.000ZM46.652 -4.944Q46.652 -3.552 46.088 -2.532Q45.524 -1.512 44.612 -0.852Q43.700 -0.192 42.560 0.132Q41.420 0.456 40.244 0.456Q39.092 0.456 38.000 0.192Q36.908 -0.072 36.008 -0.612Q35.108 -1.152 34.436 -2.004Q33.764 -2.856 33.428 -4.056L37.172 -5.040Q37.412 -4.200 38.156 -3.564Q38.900 -2.928 40.052 -2.928Q40.484 -2.928 40.928 -3.048Q41.372 -3.168 41.720 -3.420Q42.068 -3.672 42.296 -4.080Q42.524 -4.488 42.524 -5.088Q42.524 -5.736 42.224 -6.168Q41.924 -6.600 41.432 -6.852Q40.940 -7.104 40.316 -7.212Q39.692 -7.320 39.068 -7.320L37.988 -7.320L37.988 -10.272L39.164 -10.272Q39.740 -10.272 40.268 -10.356Q40.796 -10.440 41.216 -10.668Q41.636 -10.896 41.888 -11.280Q42.140 -11.664 42.140 -12.288Q42.140 -13.200 41.492 -13.680Q40.844 -14.160 40.004 -14.160Q39.140 -14.160 38.504 -13.644Q37.868 -13.128 37.676 -12.264L33.932 -13.128Q34.244 -14.208 34.868 -15.012Q35.492 -15.816 36.344 -16.356Q37.196 -16.896 38.204 -17.172Q39.212 -17.448 40.268 -17.448Q41.396 -17.448 42.464 -17.160Q43.532 -16.872 44.372 -16.272Q45.212 -15.672 45.728 -14.772Q46.244 -13.872 46.244 -12.648Q46.244 -11.232 45.440 -10.260Q44.636 -9.288 43.388 -8.952L43.388 -8.880Q44.108 -8.712 44.708 -8.340Q45.308 -7.968 45.740 -7.452Q46.172 -6.936 46.412 -6.300Q46.652 -5.664 46.652 -4.944ZM66.630 -8.544Q66.630 -6.312 65.802 -4.692Q64.974 -3.072 63.618 -2.028Q62.262 -0.984 60.558 -0.492Q58.854 -0.000 57.102 -0.000L50.766 -0.000L50.766 -16.992L56.910 -16.992Q58.710 -16.992 60.462 -16.572Q62.214 -16.152 63.582 -15.180Q64.950 -14.208 65.790 -12.576Q66.630 -10.944 66.630 -8.544ZM62.358 -8.544Q62.358 -9.984 61.890 -10.932Q61.422 -11.880 60.642 -12.444Q59.862 -13.008 58.866 -13.248Q57.870 -13.488 56.814 -13.488L54.774 -13.488L54.774 -3.552L56.718 -3.552Q57.822 -3.552 58.842 -3.804Q59.862 -4.056 60.642 -4.632Q61.422 -5.208 61.890 -6.168Q62.358 -7.128 62.358 -8.544Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="90 10 820 705">
|
||||
<title>HL3D monogram for dark backgrounds</title>
|
||||
<path fill="#A7ADB5" d="M90 650 L90 240 L260 135 L260 290 L195 330 L195 425 L365 320 L365 155 L505 70 L505 520 L680 625 L545 710 L370 605.972222222 L370 450 L200 555 L200 715 Z"/><path fill="#E7FF00" d="M545 90 L680 10 L680 420 L910 560 L775 645 L545 505 Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 382 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="90 10 820 705">
|
||||
<title>HL3D monogram for light backgrounds</title>
|
||||
<path fill="#111316" d="M90 650 L90 240 L260 135 L260 290 L195 330 L195 425 L365 320 L365 155 L505 70 L505 520 L680 625 L545 710 L370 605.972222222 L370 450 L200 555 L200 715 Z"/><path fill="#515963" d="M545 90 L680 10 L680 420 L910 560 L775 645 L545 505 Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 383 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 820">
|
||||
<title>HL3D production touchmark</title>
|
||||
<path fill="#FFFFFF" d="M90 650 L90 240 L260 135 L260 290 L195 330 L195 425 L365 320 L365 155 L505 70 L505 520 L680 625 L545 710 L370 605.972222222 L370 450 L200 555 L200 715 Z"/><path fill="#FFFFFF" d="M545 90 L680 10 L680 420 L910 560 L775 645 L545 505 Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 372 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 820">
|
||||
<title>HL3D production touchmark</title>
|
||||
<path fill="#111111" d="M90 650 L90 240 L260 135 L260 290 L195 330 L195 425 L365 320 L365 155 L505 70 L505 520 L680 625 L545 710 L370 605.972222222 L370 450 L200 555 L200 715 Z"/><path fill="#111111" d="M545 90 L680 10 L680 420 L910 560 L775 645 L545 505 Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 372 B |
@@ -1,9 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 820">
|
||||
<title>HickLab3D</title>
|
||||
<rect width="1000" height="820" rx="180" fill="#e3f504"/>
|
||||
<g fill="#111111">
|
||||
<path d="M90 650 L90 240 L260 135 L260 290 L195 330 L195 425 L365 320 L365 155 L505 70 L505 520 L370 603 L370 450 L200 555 L200 715 Z"/>
|
||||
<path d="M545 90 L680 10 L680 420 L910 560 L775 645 L545 505 Z"/>
|
||||
<path d="M365 603 L505 520 L680 625 L545 710 Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 451 B |
|
Before Width: | Height: | Size: 703 KiB |
|
Before Width: | Height: | Size: 405 KiB |
@@ -1,12 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 820">
|
||||
<title>HL3D HL Touchmark</title>
|
||||
<desc>Single-colour simplified HL monogram for 3D-print embossing, debossing and touchmarking.</desc>
|
||||
<g fill="#111111">
|
||||
<!-- H: heavy angular form -->
|
||||
<path d="M90 650 L90 240 L260 135 L260 290 L195 330 L195 425 L365 320 L365 155 L505 70 L505 520 L370 603 L370 450 L200 555 L200 715 Z"/>
|
||||
<!-- L: interlocking lower-right form -->
|
||||
<path d="M545 90 L680 10 L680 420 L910 560 L775 645 L545 505 Z"/>
|
||||
<!-- lower bridge / foot to echo the dimensional mark while remaining a simple solid touchmark -->
|
||||
<path d="M365 603 L505 520 L680 625 L545 710 Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 687 B |
@@ -3,15 +3,45 @@
|
||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||
@source '../../storage/framework/views/*.php';
|
||||
|
||||
/*
|
||||
* HL3D brand tokens (Brand Guidelines, HL3D_Brand_Identity.pdf): carbon #111316,
|
||||
* alloy #A7ADB5, fluorescent yellow #E7FF00, ice #F4F6F8, slate #515963.
|
||||
* No proprietary font files are bundled; "Avenir Next" falls back to the local
|
||||
* system sans stack when the licensed font isn't installed, and Menlo is used
|
||||
* for revision/reference labels with a monospace fallback.
|
||||
*/
|
||||
@theme {
|
||||
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif;
|
||||
--color-brand: #e3f504;
|
||||
--color-slate-950: #0b0c0d;
|
||||
--color-slate-900: #17191b;
|
||||
--font-sans: 'Avenir Next', 'Avenir', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
--font-mono: Menlo, ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
--color-brand: #E7FF00;
|
||||
--color-carbon: #111316;
|
||||
--color-alloy: #A7ADB5;
|
||||
--color-ice: #F4F6F8;
|
||||
--color-hl-slate: #515963;
|
||||
--color-slate-950: #111316;
|
||||
--color-slate-900: #1a1d21;
|
||||
--color-slate-800: #292c30;
|
||||
--color-slate-700: #3c4146;
|
||||
--color-slate-500: #9a9fa3;
|
||||
--color-slate-400: #b8bec4;
|
||||
--color-slate-400: #A7ADB5;
|
||||
--color-slate-300: #d6dade;
|
||||
--color-slate-100: #f4f5f5;
|
||||
--color-slate-100: #F4F6F8;
|
||||
}
|
||||
|
||||
.nav-group summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-group[open] > summary .nav-group-chevron {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
.nav-group:not([open]) > summary .nav-group-chevron {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.nav-group-chevron {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
const chartBase = {
|
||||
chart: {
|
||||
background: 'transparent',
|
||||
foreColor: '#b8bec4',
|
||||
fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif',
|
||||
foreColor: '#A7ADB5',
|
||||
fontFamily: "'Avenir Next', 'Avenir', ui-sans-serif, system-ui, sans-serif",
|
||||
toolbar: { show: false },
|
||||
zoom: { enabled: false },
|
||||
},
|
||||
@@ -31,7 +31,7 @@ if (document.querySelector('[data-dashboard-chart]')) {
|
||||
options = {
|
||||
...chartBase,
|
||||
chart: { ...chartBase.chart, type: 'area', height },
|
||||
colors: ['#e3f504'],
|
||||
colors: ['#E7FF00'],
|
||||
series: [{ name: 'Awaiting dispatch', data: data.series }],
|
||||
xaxis: { categories: data.labels, labels: { rotate: -45, hideOverlappingLabels: true } },
|
||||
yaxis: { min: 0, forceNiceScale: true, labels: { formatter: (value) => Number.isInteger(value) ? value.toString() : '' } },
|
||||
@@ -47,11 +47,11 @@ if (document.querySelector('[data-dashboard-chart]')) {
|
||||
chart: { ...chartBase.chart, type: 'donut', height },
|
||||
labels: data.map((item) => item.label),
|
||||
series: data.map((item) => item.count),
|
||||
colors: ['#e3f504', '#7dd3a3', '#f0a46b', '#f17fa5', '#8b9df0'],
|
||||
legend: { position: 'bottom', fontSize: '12px', labels: { colors: '#b8bec4' } },
|
||||
colors: ['#E7FF00', '#7dd3a3', '#f0a46b', '#f17fa5', '#8b9df0'],
|
||||
legend: { position: 'bottom', fontSize: '12px', labels: { colors: '#A7ADB5' } },
|
||||
plotOptions: { pie: { donut: { size: '68%', labels: { show: true, total: { show: true, label: 'Printers' } } } } },
|
||||
dataLabels: { enabled: false },
|
||||
stroke: { colors: ['#17191b'], width: 2 },
|
||||
stroke: { colors: ['#1a1d21'], width: 2 },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -63,3 +63,4 @@ if (document.querySelector('[data-dashboard-chart]')) {
|
||||
}
|
||||
|
||||
import './notifications';
|
||||
import './navigation';
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
const costLines = document.querySelector('[data-cost-lines]');
|
||||
if (costLines) {
|
||||
const template = costLines.firstElementChild.cloneNode(true);
|
||||
let nextIndex = costLines.children.length;
|
||||
document.querySelector('[data-add-cost-line]')?.addEventListener('click', () => {
|
||||
if (costLines.children.length >= 50) { return; }
|
||||
const line = template.cloneNode(true);
|
||||
line.querySelector('legend').textContent = `Cost line ${nextIndex + 1}`;
|
||||
line.querySelectorAll('[name]').forEach((input) => {
|
||||
input.name = input.name.replace(/lines\[\d+\]/, `lines[${nextIndex}]`);
|
||||
if (input.tagName === 'INPUT') { input.value = input.name.includes('units_per_parcel') ? '1' : input.name.includes('waste_quantity') ? '0' : ''; }
|
||||
});
|
||||
nextIndex += 1;
|
||||
costLines.append(line);
|
||||
line.querySelector('select').focus();
|
||||
});
|
||||
costLines.addEventListener('click', (event) => {
|
||||
if (event.target.matches('[data-remove-cost-line]') && costLines.children.length > 1) { event.target.closest('[data-cost-line]').remove(); }
|
||||
});
|
||||
}
|
||||
|
||||
const sidebar = document.querySelector('[data-sidebar]');
|
||||
const sidebarToggle = document.querySelector('[data-sidebar-toggle]');
|
||||
|
||||
if (sidebar && sidebarToggle) {
|
||||
const collapsedKey = 'hl3d-sidebar-collapsed';
|
||||
|
||||
const applyCollapsed = (collapsed) => {
|
||||
sidebar.classList.toggle('lg:w-20', collapsed);
|
||||
sidebar.querySelectorAll('[data-sidebar-label]').forEach((label) => {
|
||||
label.classList.toggle('hidden', collapsed);
|
||||
});
|
||||
sidebar.querySelectorAll('[data-sidebar-expanded-mark]').forEach((mark) => {
|
||||
mark.classList.toggle('hidden', collapsed);
|
||||
});
|
||||
sidebar.querySelectorAll('[data-sidebar-collapsed-mark]').forEach((mark) => {
|
||||
mark.classList.toggle('hidden', !collapsed);
|
||||
});
|
||||
sidebarToggle.setAttribute('aria-pressed', collapsed ? 'true' : 'false');
|
||||
sidebarToggle.textContent = collapsed ? 'Expand' : 'Collapse';
|
||||
};
|
||||
|
||||
applyCollapsed(localStorage.getItem(collapsedKey) === '1');
|
||||
|
||||
sidebarToggle.addEventListener('click', () => {
|
||||
const collapsed = !sidebar.classList.contains('lg:w-20');
|
||||
localStorage.setItem(collapsedKey, collapsed ? '1' : '0');
|
||||
applyCollapsed(collapsed);
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('[data-nav-group]').forEach((details) => {
|
||||
const storageKey = `hl3d-nav-group-${details.dataset.navGroup}`;
|
||||
const stored = localStorage.getItem(storageKey);
|
||||
|
||||
if (stored !== null) {
|
||||
details.open = stored === '1';
|
||||
}
|
||||
|
||||
details.addEventListener('toggle', () => {
|
||||
localStorage.setItem(storageKey, details.open ? '1' : '0');
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="theme-color" content="#0b0c0d"><link rel="icon" type="image/svg+xml" href="{{ asset('favicon.svg') }}"><title>Sign in · HickLab3D</title>@vite(['resources/css/app.css', 'resources/js/app.js'])</head>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="theme-color" content="#111316"><link rel="icon" type="image/svg+xml" href="{{ asset('branding/hl3d-monogram-dark-background.svg') }}"><title>Sign in · HL3D</title>@vite(['resources/css/app.css', 'resources/js/app.js'])</head>
|
||||
<body class="grid min-h-screen place-items-center bg-slate-950 px-5 font-sans text-slate-100">
|
||||
<main class="w-full max-w-lg rounded-3xl border border-slate-800 bg-slate-900 p-6 shadow-2xl shadow-black/30 sm:p-8">
|
||||
<div class="mb-8 rounded-2xl bg-[#d8dad8] p-4 ring-1 ring-white/15 sm:p-6"><img src="{{ asset('hicklab3d-full.png') }}" alt="HickLab3D" class="mx-auto h-auto w-full max-w-[390px]"></div>
|
||||
<div class="mb-8 rounded-2xl bg-ice p-4 ring-1 ring-white/15 sm:p-6"><img src="{{ asset('branding/hl3d-lockup-light-background.svg') }}" alt="HickLab3D · Engineered Excellence" class="mx-auto h-auto w-full max-w-[390px]"></div>
|
||||
<h2 class="text-xl font-semibold">Welcome back</h2><p class="mt-1 text-sm text-slate-400">Sign in to your dashboard.</p>
|
||||
<form method="post" action="{{ route('login.store') }}" class="mt-7 flex flex-col gap-5">@csrf
|
||||
<label class="flex flex-col gap-2 text-sm font-medium">Username<input name="username" value="{{ old('username') }}" required autocomplete="username" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 outline-none focus:border-brand"></label>
|
||||
|
||||
@@ -1,12 +1,38 @@
|
||||
@extends('layouts.app')
|
||||
@section('title', 'Etsy shop')
|
||||
@section('content')
|
||||
<div class="flex flex-wrap items-end justify-between gap-4"><div><p class="text-xs font-bold uppercase tracking-[.2em] text-brand">Sales</p><h2 class="mt-2 text-2xl font-bold">{{ $connection?->shop_name ?? 'Connect your Etsy shop' }}</h2><p class="mt-1 text-slate-400">Paid orders awaiting dispatch and active listings.</p></div>@if($configured)<form method="post" action="{{ route('etsy.connect') }}">@csrf<button class="rounded-xl bg-brand px-4 py-3 text-sm font-semibold text-slate-950 hover:bg-brand/90">{{ $connection ? 'Reconnect Etsy' : 'Connect Etsy' }}</button></form>@else<a href="{{ route('settings.index') }}" class="rounded-xl bg-brand px-4 py-3 text-sm font-semibold text-slate-950 hover:bg-brand/90">Set up Etsy</a>@endif</div>
|
||||
<div class="flex flex-wrap items-end justify-between gap-4"><div><p class="text-xs font-bold uppercase tracking-[.2em] text-brand">Sales</p><h2 class="mt-2 text-2xl font-bold">{{ $connection?->shop_name ?? 'Connect your Etsy shop' }}</h2><p class="mt-1 text-slate-400">Paid orders awaiting dispatch and listings by state.</p></div>@if($configured)<form method="post" action="{{ route('etsy.connect') }}">@csrf<button class="rounded-xl bg-brand px-4 py-3 text-sm font-semibold text-slate-950 hover:bg-brand/90">{{ $connection ? 'Reconnect Etsy' : 'Connect Etsy' }}</button></form>@else<a href="{{ route('settings.index') }}" class="rounded-xl bg-brand px-4 py-3 text-sm font-semibold text-slate-950 hover:bg-brand/90">Set up Etsy</a>@endif</div>
|
||||
@if(! $configured)<div role="alert" class="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-200">Add your Etsy app key and shared secret in <a class="underline" href="{{ route('settings.index') }}">Settings</a>, then connect your shop.</div>@endif
|
||||
@if($integrationError)<div role="alert" class="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-200">{{ $integrationError }}</div>@endif
|
||||
@if($stateError)<div role="alert" class="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-200">{{ $stateError }}</div>@endif
|
||||
@if($pageError)<div role="alert" class="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-200">{{ $pageError }}</div>@endif
|
||||
@if($receiptsError)<div role="alert" class="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-200">{{ $receiptsError }}</div>@endif
|
||||
@if($listingsError)<div role="alert" class="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-200">{{ $listingsError }}</div>@endif
|
||||
@if($connection)
|
||||
<div class="grid gap-4 sm:grid-cols-2"><div class="rounded-2xl border border-slate-800 bg-slate-900 p-6"><p class="text-sm text-slate-400">Awaiting dispatch</p><p class="mt-3 text-4xl font-bold">{{ count($receipts) }}</p></div><div class="rounded-2xl border border-slate-800 bg-slate-900 p-6"><p class="text-sm text-slate-400">Active listings shown</p><p class="mt-3 text-4xl font-bold">{{ count($listings) }}</p></div></div>
|
||||
<div class="grid gap-4 sm:grid-cols-2"><div class="rounded-2xl border border-slate-800 bg-slate-900 p-6"><p class="text-sm text-slate-400">Awaiting dispatch</p><p class="mt-3 text-4xl font-bold">{{ count($receipts) }}</p></div><div class="rounded-2xl border border-slate-800 bg-slate-900 p-6"><p class="text-sm text-slate-400">{{ ucfirst($state === 'active' ? 'Live' : 'Draft') }} listings total</p><p class="mt-3 text-4xl font-bold">{{ $listingCount }}</p></div></div>
|
||||
<section class="overflow-x-auto rounded-2xl border border-slate-800 bg-slate-900"><h3 class="p-6 text-lg font-semibold">Orders to dispatch</h3><table class="w-full min-w-[650px] text-left text-sm"><thead class="border-y border-slate-800 text-slate-400"><tr><th class="p-4">Receipt</th><th class="p-4">Buyer</th><th class="p-4">Placed</th><th class="p-4">Total</th></tr></thead><tbody class="divide-y divide-slate-800">@forelse($receipts as $receipt)<tr><td class="p-4 font-medium">#{{ $receipt['receipt_id'] ?? '—' }}</td><td class="p-4">{{ $receipt['name'] ?? $receipt['buyer_email'] ?? 'Buyer' }}</td><td class="p-4">{{ isset($receipt['create_timestamp']) ? \Illuminate\Support\Carbon::createFromTimestamp($receipt['create_timestamp'])->format('d M Y') : '—' }}</td><td class="p-4">{{ isset($receipt['grandtotal']['amount']) ? ($receipt['grandtotal']['currency_code'] ?? 'GBP').' '.number_format((float) $receipt['grandtotal']['amount'] / max(1, (int) ($receipt['grandtotal']['divisor'] ?? 100)), 2) : '—' }}</td></tr>@empty<tr><td colspan="4" class="p-6 text-slate-400">No paid orders awaiting dispatch.</td></tr>@endforelse</tbody></table></section>
|
||||
<section class="rounded-2xl border border-slate-800 bg-slate-900 p-6"><h3 class="text-lg font-semibold">Active listings</h3><div class="mt-4 divide-y divide-slate-800">@forelse($listings as $listing)<div class="flex items-center justify-between gap-4 py-3"><span>{{ $listing['title'] ?? 'Untitled listing' }}</span><span class="shrink-0 text-sm text-slate-400">{{ $listing['quantity'] ?? 0 }} available</span></div>@empty<p class="py-3 text-sm text-slate-400">No listings returned.</p>@endforelse</div></section>
|
||||
<section class="rounded-2xl border border-slate-800 bg-slate-900 p-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<h3 class="text-lg font-semibold">Listings</h3>
|
||||
<div class="flex gap-2 text-sm" role="tablist" aria-label="Listing state">
|
||||
<a href="{{ route('etsy.index', ['state' => 'active']) }}" role="tab" aria-selected="{{ $state === 'active' ? 'true' : 'false' }}" @class(['rounded-lg px-3 py-2', 'bg-brand/10 text-brand' => $state === 'active', 'text-slate-400 hover:text-white' => $state !== 'active'])>Live</a>
|
||||
<a href="{{ route('etsy.index', ['state' => 'draft']) }}" role="tab" aria-selected="{{ $state === 'draft' ? 'true' : 'false' }}" @class(['rounded-lg px-3 py-2', 'bg-brand/10 text-brand' => $state === 'draft', 'text-slate-400 hover:text-white' => $state !== 'draft'])>Drafts</a>
|
||||
</div>
|
||||
</div>
|
||||
@php
|
||||
$snapshotAge = is_array($listingSnapshot ?? null) && ! empty($listingSnapshot['fetched_at'])
|
||||
? \Illuminate\Support\Carbon::createFromTimestamp($listingSnapshot['fetched_at'])->diffForHumans()
|
||||
: null;
|
||||
$snapshotStale = is_array($listingSnapshot ?? null) && ! empty($listingSnapshot['stale']);
|
||||
@endphp
|
||||
<p class="mt-3 text-xs text-slate-500">Showing {{ count($listings) }} of {{ $listingCount }} {{ $state === 'active' ? 'live' : 'draft' }} listings.{{ $snapshotAge ? ' Snapshot '.$snapshotAge.($snapshotStale ? ' · cached after a failed refresh, not asserted live' : '').'.' : '' }}</p>
|
||||
<div class="mt-4 divide-y divide-slate-800">@forelse($listings as $listing)<div class="flex items-center justify-between gap-4 py-3"><span>{{ $listing['title'] ?? 'Untitled listing' }}</span><span class="shrink-0 text-sm text-slate-400">{{ $listing['quantity'] ?? 0 }} available</span></div>@empty<p class="py-3 text-sm text-slate-400">No {{ $state === 'active' ? 'live' : 'draft' }} listings returned.</p>@endforelse</div>
|
||||
@if($listingCount > $perPage || $page > 1)
|
||||
<nav class="mt-4 flex items-center justify-between text-sm text-slate-400" aria-label="Listings pagination">
|
||||
@if($page > 1)<a href="{{ route('etsy.index', ['page' => $page - 1, 'state' => $state]) }}" class="hover:text-white">← Previous</a>@else<span></span>@endif
|
||||
<span>Page {{ $page }}</span>
|
||||
@if($listingCount > $page * $perPage)<a href="{{ route('etsy.index', ['page' => $page + 1, 'state' => $state]) }}" class="hover:text-white">Next →</a>@else<span></span>@endif
|
||||
</nav>
|
||||
@endif
|
||||
</section>
|
||||
@endif
|
||||
@endsection
|
||||
|
||||
@@ -3,39 +3,48 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="theme-color" content="#0b0c0d">
|
||||
<meta name="theme-color" content="#111316">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<link rel="icon" type="image/svg+xml" href="{{ asset('favicon.svg') }}">
|
||||
<title>@yield('title', 'Dashboard') · HickLab3D</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="icon" type="image/svg+xml" href="{{ asset('branding/hl3d-monogram-dark-background.svg') }}">
|
||||
<title>@yield('title', 'Dashboard') · HL3D</title>
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
</head>
|
||||
<body class="min-h-screen bg-slate-950 font-sans text-slate-100 antialiased">
|
||||
<div class="min-h-screen lg:flex">
|
||||
<aside class="border-b border-slate-800 bg-slate-900/80 lg:fixed lg:inset-y-0 lg:w-64 lg:border-r lg:border-b-0">
|
||||
<div class="flex h-20 items-center gap-3 px-6">
|
||||
<a href="{{ route('dashboard') }}" class="grid size-10 shrink-0 place-items-center rounded-xl bg-brand p-1.5 transition-transform hover:scale-105" aria-label="HickLab3D overview"><img src="{{ asset('hicklab3d-touchmark.svg') }}" alt="" class="size-full object-contain"></a>
|
||||
<div><strong class="block text-lg tracking-tight">HickLab3D</strong><span class="text-xs text-slate-400">Operations studio</span></div>
|
||||
<aside data-sidebar class="hidden border-b border-slate-800 bg-slate-900/80 lg:fixed lg:inset-y-0 lg:block lg:w-64 lg:border-r lg:border-b-0">
|
||||
<div class="flex h-20 items-center px-6">
|
||||
<a href="{{ route('dashboard') }}" class="flex shrink-0 items-center gap-3 transition-transform hover:scale-105 motion-reduce:transition-none" aria-label="HL3D overview">
|
||||
<img src="{{ asset('branding/hl3d-lockup-dark-background.svg') }}" alt="HickLab3D" class="h-9 w-auto" data-sidebar-expanded-mark>
|
||||
<img src="{{ asset('branding/hl3d-monogram-dark-background.svg') }}" alt="HickLab3D" class="hidden size-8 object-contain" data-sidebar-collapsed-mark>
|
||||
</a>
|
||||
</div>
|
||||
<nav class="flex gap-1 overflow-x-auto px-4 pb-4 lg:flex-col lg:overflow-visible" aria-label="Main navigation">
|
||||
@php $links = [['dashboard', 'Overview'], ['printers.index', 'Print farm'], ['etsy.index', 'Etsy'], ['shipping', 'Shipping'], ['dryer', 'Dryer']]; @endphp
|
||||
@foreach($links as [$routeName, $label])
|
||||
@if(! in_array($routeName, ['shipping', 'etsy.index']) || auth()->user()->role === 'admin')
|
||||
<a href="{{ route($routeName) }}" @class(['shrink-0 rounded-xl px-4 py-3 text-sm font-medium transition-colors hover:bg-slate-800 hover:text-white', 'bg-brand/10 text-brand' => request()->routeIs($routeName) || request()->routeIs(str_replace('.index', '.*', $routeName)), 'text-slate-400' => !request()->routeIs($routeName)])>{{ $label }}</a>
|
||||
@endif
|
||||
<nav class="flex flex-col gap-1 px-4 pb-4" aria-label="Main navigation">
|
||||
@foreach($navigationGroups as $group)
|
||||
@continue($group['items'] === [])
|
||||
<details class="nav-group mb-1" data-nav-group="{{ $group['key'] }}" open>
|
||||
<summary class="flex cursor-pointer list-none items-center justify-between rounded-lg px-2 py-2 text-xs font-semibold uppercase tracking-[.15em] text-slate-500 hover:text-slate-300">
|
||||
<span data-sidebar-label>{{ $group['label'] }}</span>
|
||||
<svg class="nav-group-chevron size-3 shrink-0 transition-transform motion-reduce:transition-none" viewBox="0 0 12 12" fill="none" aria-hidden="true"><path d="M3 4.5 6 7.5 9 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</summary>
|
||||
<div class="flex flex-col gap-1 pt-1">
|
||||
@foreach($group['items'] as $item)
|
||||
<a href="{{ route($item['route']) }}" @if(request()->routeIs($item['activePatterns'])) aria-current="page" @endif @class(['block shrink-0 truncate rounded-xl px-4 py-3 text-sm font-medium transition-colors hover:bg-slate-800 hover:text-white motion-reduce:transition-none', 'bg-brand/10 text-brand' => request()->routeIs($item['activePatterns']), 'text-slate-400' => ! request()->routeIs($item['activePatterns'])])>{{ $item['label'] }}</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</details>
|
||||
@endforeach
|
||||
@if(auth()->user()->role === 'admin')
|
||||
<a href="{{ route('settings.index') }}" @class(['shrink-0 rounded-xl px-4 py-3 text-sm font-medium hover:bg-slate-800', 'bg-brand/10 text-brand' => request()->routeIs('settings.*'), 'text-slate-400' => !request()->routeIs('settings.*')])>Settings</a>
|
||||
<a href="{{ route('users.index') }}" @class(['shrink-0 rounded-xl px-4 py-3 text-sm font-medium hover:bg-slate-800', 'bg-brand/10 text-brand' => request()->routeIs('users.*'), 'text-slate-400' => !request()->routeIs('users.*')])>Users</a>
|
||||
@endif
|
||||
</nav>
|
||||
<div class="px-6 pb-5 text-xs text-slate-500 lg:absolute lg:bottom-0">Made for the HickLab3D workshop</div>
|
||||
<div class="px-6 pb-5 text-xs text-slate-500 lg:absolute lg:bottom-0" data-sidebar-label>Made for the HL3D workshop</div>
|
||||
<button type="button" data-sidebar-toggle class="mx-4 mb-4 hidden w-auto rounded-lg border border-slate-700 px-3 py-2 text-xs text-slate-400 hover:border-brand hover:text-white lg:block" aria-pressed="false">Collapse</button>
|
||||
</aside>
|
||||
<div class="min-w-0 flex-1 lg:pl-64">
|
||||
<div class="min-w-0 flex-1 lg:pl-64" data-content>
|
||||
<header class="flex min-h-20 items-center justify-between gap-4 border-b border-slate-800 px-6 lg:px-10">
|
||||
<div><p class="text-xs font-semibold uppercase tracking-[.2em] text-brand">Workshop dashboard</p><h1 class="text-xl font-bold">@yield('title', 'Overview')</h1></div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button type="button" popovertarget="mobile-nav" popovertargetaction="show" class="rounded-lg border border-slate-700 p-2 text-slate-300 hover:border-brand hover:text-white lg:hidden" aria-label="Open navigation menu">
|
||||
<svg class="size-5" viewBox="0 0 20 20" fill="none" aria-hidden="true"><path d="M3 5h14M3 10h14M3 15h14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||
</button>
|
||||
<div><p class="text-xs font-semibold uppercase tracking-[.2em] text-brand">Operate</p><h1 class="text-xl font-bold">@yield('title', 'Overview')</h1></div>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">@include('partials.notifications')<span class="hidden text-sm text-slate-400 sm:inline">{{ auth()->user()->username }}</span><form method="post" action="{{ route('logout') }}">@csrf<button class="rounded-lg border border-slate-700 px-3 py-2 text-sm text-slate-300 hover:border-brand hover:text-white">Sign out</button></form></div>
|
||||
</header>
|
||||
<main class="flex w-full flex-col gap-7 px-6 py-8 lg:px-10">
|
||||
@@ -45,6 +54,29 @@
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mobile-nav" popover="auto" class="fixed inset-0 m-0 h-screen w-full max-w-none border-none bg-slate-950 p-0 lg:hidden" aria-label="Navigation menu">
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="flex items-center justify-between border-b border-slate-800 px-6 py-5">
|
||||
<strong class="text-lg">HL3D navigation</strong>
|
||||
<button type="button" popovertarget="mobile-nav" popovertargetaction="hide" class="rounded-lg border border-slate-700 p-2 text-slate-300" aria-label="Close navigation menu">
|
||||
<svg class="size-5" viewBox="0 0 20 20" fill="none" aria-hidden="true"><path d="M5 5l10 10M15 5L5 15" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<nav class="flex flex-1 flex-col gap-4 overflow-y-auto px-6 py-6" aria-label="Main navigation">
|
||||
@foreach($navigationGroups as $group)
|
||||
@continue($group['items'] === [])
|
||||
<div>
|
||||
<p class="px-2 pb-2 text-xs font-semibold uppercase tracking-[.15em] text-slate-500">{{ $group['label'] }}</p>
|
||||
<div class="flex flex-col gap-1">
|
||||
@foreach($group['items'] as $item)
|
||||
<a href="{{ route($item['route']) }}" @if(request()->routeIs($item['activePatterns'])) aria-current="page" @endif @class(['rounded-xl px-4 py-3 text-sm font-medium', 'bg-brand/10 text-brand' => request()->routeIs($item['activePatterns']), 'text-slate-300' => ! request()->routeIs($item['activePatterns'])])>{{ $item['label'] }}</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
@stack('scripts')
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
@extends('layouts.app')
|
||||
@section('title', 'New project')
|
||||
@section('content')
|
||||
<div><h2 class="text-2xl font-bold">New project</h2><p class="mt-1 text-slate-400">Create the product aggregate. Revisions, files and listings are added afterwards.</p></div>
|
||||
<form method="post" action="{{ route('projects.store') }}" class="grid gap-4 rounded-2xl border border-slate-800 bg-slate-900 p-6 md:grid-cols-2">
|
||||
@csrf
|
||||
<label class="grid gap-1 text-sm text-slate-400">Reference<input name="reference" value="{{ old('reference') }}" required maxlength="64" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100"></label>
|
||||
<label class="grid gap-1 text-sm text-slate-400">Title<input name="title" value="{{ old('title') }}" required maxlength="255" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100"></label>
|
||||
<label class="grid gap-1 text-sm text-slate-400 md:col-span-2">Notes<textarea name="notes" rows="3" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100">{{ old('notes') }}</textarea></label>
|
||||
<label class="grid gap-1 text-sm text-slate-400">Currency<input name="currency" value="{{ old('currency', 'GBP') }}" maxlength="3" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100 uppercase"></label>
|
||||
<div class="md:col-span-2"><button class="rounded-xl bg-brand px-5 py-3 font-semibold text-slate-950">Create project</button></div>
|
||||
</form>
|
||||
@endsection
|
||||
@@ -0,0 +1,16 @@
|
||||
@extends('layouts.app')
|
||||
@section('title', 'Edit project')
|
||||
@section('content')
|
||||
<div><h2 class="text-2xl font-bold">Edit {{ $project->reference }}</h2></div>
|
||||
<form method="post" action="{{ route('projects.update', $project) }}" class="grid gap-4 rounded-2xl border border-slate-800 bg-slate-900 p-6 md:grid-cols-2">
|
||||
@csrf @method('PATCH')
|
||||
<label class="grid gap-1 text-sm text-slate-400">Title<input name="title" value="{{ old('title', $project->title) }}" required maxlength="255" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100"></label>
|
||||
<label class="grid gap-1 text-sm text-slate-400">Stage
|
||||
<select name="stage" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100">
|
||||
@foreach(\App\Models\Project::STAGES as $stage)<option value="{{ $stage }}" @selected($project->stage === $stage)>{{ ucfirst($stage) }}</option>@endforeach
|
||||
</select>
|
||||
</label>
|
||||
<label class="grid gap-1 text-sm text-slate-400 md:col-span-2">Notes<textarea name="notes" rows="3" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100">{{ old('notes', $project->notes) }}</textarea></label>
|
||||
<div class="md:col-span-2"><button class="rounded-xl bg-brand px-5 py-3 font-semibold text-slate-950">Save changes</button></div>
|
||||
</form>
|
||||
@endsection
|
||||
@@ -0,0 +1,35 @@
|
||||
@extends('layouts.app')
|
||||
@section('title', 'Projects')
|
||||
@section('content')
|
||||
<div class="flex flex-wrap items-end justify-between gap-4">
|
||||
<div><h2 class="text-2xl font-bold">Projects</h2><p class="mt-1 text-slate-400">Product aggregates: revisions, files, listing mappings and costing.</p></div>
|
||||
<a href="{{ route('projects.create') }}" class="rounded-xl bg-brand px-4 py-3 text-sm font-semibold text-slate-950 hover:bg-brand/90">New project</a>
|
||||
</div>
|
||||
<form method="get" action="{{ route('projects.index') }}" class="flex flex-wrap items-center gap-3 rounded-2xl border border-slate-800 bg-slate-900 p-4">
|
||||
<input type="search" name="search" value="{{ request('search') }}" placeholder="Search title or reference" class="flex-1 rounded-xl border border-slate-700 bg-slate-950 px-4 py-2 text-sm">
|
||||
<select name="stage" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-2 text-sm">
|
||||
<option value="">All stages</option>
|
||||
@foreach($stages as $stage)<option value="{{ $stage }}" @selected(request('stage') === $stage)>{{ ucfirst($stage) }}</option>@endforeach
|
||||
</select>
|
||||
<button class="rounded-xl border border-slate-700 px-4 py-2 text-sm text-slate-300 hover:border-brand hover:text-white">Filter</button>
|
||||
</form>
|
||||
<section class="overflow-x-auto rounded-2xl border border-slate-800 bg-slate-900">
|
||||
<table class="w-full min-w-[720px] text-left text-sm">
|
||||
<thead class="border-y border-slate-800 text-slate-400"><tr><th class="p-4">Reference</th><th class="p-4">Title</th><th class="p-4">Stage</th><th class="p-4">Revisions</th><th class="p-4">Listings</th></tr></thead>
|
||||
<tbody class="divide-y divide-slate-800">
|
||||
@forelse($projects as $project)
|
||||
<tr class="hover:bg-slate-800/40">
|
||||
<td class="p-4 font-medium"><a class="text-brand hover:underline" href="{{ route('projects.show', $project) }}">{{ $project->reference }}</a></td>
|
||||
<td class="p-4">{{ $project->title }}</td>
|
||||
<td class="p-4"><span class="rounded-full bg-slate-800 px-2 py-1 text-xs capitalize">{{ $project->stage }}</span>@if($project->isArchived())<span class="ml-2 rounded-full bg-rose-500/10 px-2 py-1 text-xs text-rose-300">Archived</span>@endif</td>
|
||||
<td class="p-4">{{ $project->revisions_count }}</td>
|
||||
<td class="p-4">{{ $project->listing_links_count }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="5" class="p-6 text-slate-400">No projects yet.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{{ $projects->links() }}
|
||||
@endsection
|
||||
@@ -0,0 +1,177 @@
|
||||
@extends('layouts.app')
|
||||
@section('title', $project->reference)
|
||||
@section('content')
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-bold uppercase tracking-[.2em] text-brand">{{ $project->reference }}</p>
|
||||
<h2 class="mt-2 text-2xl font-bold">{{ $project->title }}</h2>
|
||||
<p class="mt-1 text-slate-400">Stage: <span class="capitalize">{{ $project->stage }}</span> · Currency {{ $project->currency }} @if($project->isArchived())· <span class="text-rose-300">Archived</span>@endif</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ route('projects.edit', $project) }}" class="rounded-xl border border-slate-700 px-4 py-3 text-sm text-slate-300 hover:border-brand hover:text-white">Edit</a>
|
||||
@unless($project->isArchived())
|
||||
<form method="post" action="{{ route('projects.archive', $project) }}" onsubmit="return confirm('Archive this project? History is preserved.')">@csrf<button class="rounded-xl border border-rose-500/40 px-4 py-3 text-sm text-rose-300">Archive</button></form>
|
||||
@endunless
|
||||
</div>
|
||||
</div>
|
||||
@if($project->notes)<p class="rounded-2xl border border-slate-800 bg-slate-900 p-4 text-sm text-slate-300">{{ $project->notes }}</p>@endif
|
||||
|
||||
<section>
|
||||
<h3 class="text-lg font-semibold">Revisions & files</h3>
|
||||
<div class="mt-3 grid gap-3">
|
||||
@forelse($project->revisions as $revision)
|
||||
<article class="rounded-2xl border border-slate-800 bg-slate-900 p-5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<p class="font-semibold">Revision {{ $revision->revision_number }} <span class="ml-2 rounded-full bg-slate-800 px-2 py-1 text-xs capitalize">{{ $revision->status }}</span></p>
|
||||
@if(! $revision->isApproved())
|
||||
<form method="post" action="{{ route('projects.revisions.approve', [$project, $revision]) }}"><input type="hidden">@csrf<button class="rounded-lg border border-brand/40 px-3 py-2 text-xs text-brand">Approve</button></form>
|
||||
@else
|
||||
<span class="text-xs text-slate-400">Approved by {{ $revision->approver?->username }} on {{ $revision->approved_at?->format('d M Y') }}</span>
|
||||
@endif
|
||||
</div>
|
||||
@if($revision->change_notes)<p class="mt-2 text-sm text-slate-400">{{ $revision->change_notes }}</p>@endif
|
||||
<ul class="mt-3 divide-y divide-slate-800 text-sm">
|
||||
@foreach($revision->files as $file)
|
||||
<li class="flex items-center justify-between gap-3 py-2">
|
||||
<span><span class="rounded-full bg-slate-800 px-2 py-0.5 text-xs uppercase">{{ $file->extension }}</span> {{ $file->original_filename }} <span class="font-mono text-xs text-slate-500">({{ number_format($file->byte_size / 1024, 1) }} KB)</span></span>
|
||||
<a class="text-brand hover:underline" href="{{ route('projects.files.download', [$project, $file]) }}">Download</a>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</article>
|
||||
@empty
|
||||
<p class="text-sm text-slate-400">No revisions uploaded yet.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
<details class="mt-4 rounded-2xl border border-slate-800 bg-slate-900 p-5">
|
||||
<summary class="cursor-pointer text-sm font-semibold text-brand">Upload a new revision</summary>
|
||||
<form method="post" action="{{ route('projects.revisions.store', $project) }}" enctype="multipart/form-data" class="mt-4 grid gap-3">
|
||||
@csrf
|
||||
<label class="grid gap-1 text-sm text-slate-400">Change notes<textarea name="change_notes" rows="2" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100"></textarea></label>
|
||||
<label class="grid gap-1 text-sm text-slate-400">Files (STL, 3MF or G-code; up to 10, 100 MiB each)<input type="file" name="files[]" multiple class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100"></label>
|
||||
<label class="grid gap-1 text-sm">Fusion project (optional)<input name="fusion_project_id" maxlength="255" class="rounded-lg bg-slate-950 p-3"></label>
|
||||
<label class="grid gap-1 text-sm">Fusion document (optional)<input name="fusion_document_id" maxlength="255" class="rounded-lg bg-slate-950 p-3"></label>
|
||||
<label class="grid gap-1 text-sm">Fusion native version (optional)<input name="fusion_version" maxlength="255" class="rounded-lg bg-slate-950 p-3"></label>
|
||||
<div><button class="rounded-lg bg-brand px-4 py-2 text-sm font-semibold text-slate-950">Upload revision</button></div>
|
||||
</form>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 class="text-lg font-semibold">Listings</h3>
|
||||
<div class="mt-3 grid gap-3">
|
||||
@forelse($project->listingLinks as $link)
|
||||
<article class="rounded-2xl border border-slate-800 bg-slate-900 p-5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<p class="font-semibold">Listing #{{ $link->listing_id }} <span class="ml-2 rounded-full bg-slate-800 px-2 py-1 text-xs">{{ $link->variation_key }}</span>@if($link->isDetached())<span class="ml-2 rounded-full bg-rose-500/10 px-2 py-1 text-xs text-rose-300">Detached</span>@endif</p>
|
||||
@unless($link->isDetached())
|
||||
<form method="post" action="{{ route('projects.listings.destroy', [$project, $link]) }}">@csrf @method('DELETE')<button class="rounded-lg border border-rose-500/40 px-3 py-2 text-xs text-rose-300">Detach</button></form>
|
||||
@endunless
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-slate-400">{{ $link->title_snapshot }} · State: {{ $link->state_snapshot ?? 'unknown' }} · Synced {{ $link->synced_at?->diffForHumans() ?? 'never' }}</p>
|
||||
@if($link->liveRelease)
|
||||
<p class="mt-2 text-sm text-brand">Live: revision {{ $link->liveRelease->revision->revision_number }} ({{ $link->liveRelease->action }} on {{ $link->liveRelease->released_at->format('d M Y') }})</p>
|
||||
@else
|
||||
<p class="mt-2 text-sm text-slate-500">No release pointer set yet.</p>
|
||||
@endif
|
||||
<details class="mt-3"><summary class="cursor-pointer text-xs text-brand">Release / revert</summary>
|
||||
<form method="post" action="{{ route('projects.listings.release', [$project, $link]) }}" class="mt-3 grid gap-2 md:grid-cols-4">
|
||||
@csrf
|
||||
<select name="revision_id" class="rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-sm text-slate-100"><option value="">Revision…</option>@foreach($project->revisions->where('status', 'approved') as $rev)<option value="{{ $rev->id }}">Rev {{ $rev->revision_number }}</option>@endforeach</select>
|
||||
<select name="cost_snapshot_id" class="rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-sm text-slate-100"><option value="">Cost snapshot…</option>@foreach($project->costSnapshots as $snap)<option value="{{ $snap->id }}">#{{ $snap->id }} {{ $snap->basis }} {{ $snap->grand_total ?? 'n/a' }}</option>@endforeach</select>
|
||||
<input type="hidden" name="lock_version" value="{{ $link->lock_version }}">
|
||||
<button class="rounded-lg bg-brand px-3 py-2 text-sm font-semibold text-slate-950">Release</button>
|
||||
</form>
|
||||
@if($project->releases->isNotEmpty())
|
||||
<form method="post" action="{{ route('projects.listings.revert', [$project, $link]) }}" class="mt-3 flex flex-wrap gap-3">
|
||||
@csrf
|
||||
<label class="grid gap-1 text-sm">Restore historical release<select name="release_id" required class="rounded-lg bg-slate-950 p-2">@foreach($project->releases as $release)<option value="{{ $release->id }}">#{{ $release->id }} · revision {{ $release->revision->revision_number }}</option>@endforeach</select></label>
|
||||
<input type="hidden" name="lock_version" value="{{ $link->lock_version }}">
|
||||
<button class="rounded-lg border border-brand px-3 py-2 text-sm text-brand">Revert with new history entry</button>
|
||||
</form>
|
||||
@endif
|
||||
</details>
|
||||
</article>
|
||||
@empty
|
||||
<p class="text-sm text-slate-400">No listings linked yet.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
<details class="mt-4 rounded-2xl border border-slate-800 bg-slate-900 p-5">
|
||||
<summary class="cursor-pointer text-sm font-semibold text-brand">Link an Etsy listing</summary>
|
||||
<form method="post" action="{{ route('projects.listings.store', $project) }}" class="mt-4 flex flex-wrap gap-3">
|
||||
@csrf
|
||||
<input type="number" name="listing_id" placeholder="Etsy listing ID" required class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100">
|
||||
<button class="rounded-lg bg-brand px-4 py-2 text-sm font-semibold text-slate-950">Verify & link</button>
|
||||
</form>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 class="text-lg font-semibold">Costing</h3>
|
||||
<div class="mt-3 overflow-x-auto rounded-2xl border border-slate-800 bg-slate-900">
|
||||
<table class="w-full min-w-[720px] text-left text-sm">
|
||||
<thead class="border-y border-slate-800 text-slate-400"><tr><th class="p-4">Revision</th><th class="p-4">Basis</th><th class="p-4">Filament</th><th class="p-4">Packaging</th><th class="p-4">Shipping</th><th class="p-4">Total</th><th class="p-4">Per unit</th></tr></thead>
|
||||
<tbody class="divide-y divide-slate-800">
|
||||
@forelse($project->costSnapshots as $snapshot)
|
||||
<tr>
|
||||
<td class="p-4">Rev {{ $snapshot->revision->revision_number }}</td>
|
||||
<td class="p-4 capitalize">{{ $snapshot->basis }}</td>
|
||||
<td class="p-4">{{ $snapshot->filament_total ?? 'not costed' }}</td>
|
||||
<td class="p-4">{{ $snapshot->packaging_total ?? 'not costed' }}</td>
|
||||
<td class="p-4">{{ $snapshot->shipping_total ?? 'not costed' }}</td>
|
||||
<td class="p-4 font-semibold">{{ $snapshot->grand_total ?? 'not costed' }}</td>
|
||||
<td class="p-4">{{ $snapshot->perUnitGrandTotal() ?? '—' }} / {{ $snapshot->batch_units }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="7" class="p-6 text-slate-400">No cost snapshots recorded.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<details class="mt-4 rounded-2xl border border-slate-800 bg-slate-900 p-5">
|
||||
<summary class="cursor-pointer text-sm font-semibold text-brand">Record a cost snapshot</summary>
|
||||
<form method="post" action="{{ route('projects.cost-snapshots.store', $project) }}" class="mt-4 grid gap-3">
|
||||
@csrf
|
||||
<p class="text-sm text-slate-400">{{ $project->currency }} only. Filament and packaging quantities cover the batch. Shipping quantities describe parcels in one shipment; cost is allocated by units per parcel to the saleable yield. Enter zero intentionally; omit uncosted categories. Excludes fees, tax, labour and energy — not net profit.</p>
|
||||
<div class="grid gap-3 md:grid-cols-4">
|
||||
<label class="grid gap-1 text-sm">Revision<select name="revision_id" required class="rounded-lg bg-slate-950 p-3">@foreach($project->revisions as $rev)<option value="{{ $rev->id }}">Rev {{ $rev->revision_number }}</option>@endforeach</select></label>
|
||||
<label class="grid gap-1 text-sm">Basis<select name="basis" class="rounded-lg bg-slate-950 p-3"><option value="estimate">Estimate</option><option value="actual">Actual</option></select></label>
|
||||
<label class="grid gap-1 text-sm">Currency<input name="currency" value="{{ $project->currency }}" readonly class="rounded-lg bg-slate-950 p-3"></label>
|
||||
<label class="grid gap-1 text-sm">Saleable batch yield<input type="number" name="batch_units" value="1" min="1" max="100000" required class="rounded-lg bg-slate-950 p-3"></label>
|
||||
</div>
|
||||
<div data-cost-lines class="grid gap-3">
|
||||
@for($i = 0; $i < 3; $i++)
|
||||
<fieldset class="grid gap-3 rounded-xl border border-slate-700 p-3 sm:grid-cols-2 lg:grid-cols-3" data-cost-line>
|
||||
<legend class="px-1 text-sm">Cost line {{ $i + 1 }}</legend>
|
||||
<label class="grid gap-1 text-sm">Category<select name="lines[{{ $i }}][category]" class="rounded-lg bg-slate-950 p-2"><option value="filament" @selected($i === 0)>Filament</option><option value="packaging" @selected($i === 1)>Packaging</option><option value="shipping" @selected($i === 2)>Shipping</option></select></label>
|
||||
<label class="grid gap-1 text-sm">Material / component / service<input name="lines[{{ $i }}][description]" required class="rounded-lg bg-slate-950 p-2"></label>
|
||||
<label class="grid gap-1 text-sm">Quantity<input name="lines[{{ $i }}][quantity]" inputmode="decimal" required class="rounded-lg bg-slate-950 p-2"></label>
|
||||
<label class="grid gap-1 text-sm">Unit<select name="lines[{{ $i }}][quantity_unit]" class="rounded-lg bg-slate-950 p-2"><option value="g" @selected($i === 0)>g (filament)</option><option value="unit" @selected($i === 1)>unit (packaging)</option><option value="parcel" @selected($i === 2)>parcel (shipping)</option></select></label>
|
||||
<label class="grid gap-1 text-sm">Rate per kg / component / parcel<input name="lines[{{ $i }}][unit_rate]" inputmode="decimal" required class="rounded-lg bg-slate-950 p-2"></label>
|
||||
<label class="grid gap-1 text-sm">Additional waste / support (not already included)<input name="lines[{{ $i }}][waste_quantity]" value="0" inputmode="decimal" class="rounded-lg bg-slate-950 p-2"></label>
|
||||
<label class="grid gap-1 text-sm">Units per parcel (shipping)<input name="lines[{{ $i }}][units_per_parcel]" type="number" min="1" value="1" class="rounded-lg bg-slate-950 p-2"></label>
|
||||
<label class="grid gap-1 text-sm">Source / confirmed invoice reference<input name="lines[{{ $i }}][source_reference]" maxlength="255" class="rounded-lg bg-slate-950 p-2"></label>
|
||||
<label class="grid gap-1 text-sm">Source price date<input name="lines[{{ $i }}][source_price_date]" type="date" class="rounded-lg bg-slate-950 p-2"></label>
|
||||
<button type="button" data-remove-cost-line class="text-left text-sm text-rose-300">Remove line</button>
|
||||
</fieldset>
|
||||
@endfor
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3"><button type="button" data-add-cost-line class="rounded-lg border border-slate-600 px-4 py-2 text-sm">Add cost line</button><button class="rounded-lg bg-brand px-4 py-2 text-sm font-semibold text-slate-950">Record snapshot</button></div>
|
||||
</form>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 class="text-lg font-semibold">History</h3>
|
||||
<ul class="mt-3 divide-y divide-slate-800 rounded-2xl border border-slate-800 bg-slate-900 text-sm">
|
||||
@forelse($project->events as $event)
|
||||
<li class="flex items-center justify-between gap-3 p-4">
|
||||
<span>{{ str($event->event_type)->replace('_', ' ')->ucfirst() }} @if($event->actor)by {{ $event->actor->username }}@endif</span>
|
||||
<span class="text-xs text-slate-500">{{ $event->created_at->format('d M Y H:i') }}</span>
|
||||
</li>
|
||||
@empty
|
||||
<li class="p-6 text-slate-400">No events recorded yet.</li>
|
||||
@endforelse
|
||||
</ul>
|
||||
</section>
|
||||
@endsection
|
||||
@@ -10,6 +10,12 @@ use App\Http\Controllers\IntegrationSettingsController;
|
||||
use App\Http\Controllers\NotificationController;
|
||||
use App\Http\Controllers\Parcel2GoConnectionController;
|
||||
use App\Http\Controllers\PrinterController;
|
||||
use App\Http\Controllers\ProjectController;
|
||||
use App\Http\Controllers\ProjectCostSnapshotController;
|
||||
use App\Http\Controllers\ProjectFileController;
|
||||
use App\Http\Controllers\ProjectListingLinkController;
|
||||
use App\Http\Controllers\ProjectReleaseController;
|
||||
use App\Http\Controllers\ProjectRevisionController;
|
||||
use App\Http\Controllers\ScheduleSettingsController;
|
||||
use App\Http\Controllers\ShippingController;
|
||||
use App\Http\Controllers\UserController;
|
||||
@@ -49,6 +55,21 @@ Route::middleware(['auth', EnsureAccountActive::class])->group(function (): void
|
||||
Route::get('/dryer/status', [DryerController::class, 'status'])->name('dryer.status');
|
||||
Route::post('/dryer/control', [DryerController::class, 'control'])->name('dryer.control');
|
||||
Route::post('/dryer/faults/acknowledge', [DryerController::class, 'acknowledge'])->name('dryer.acknowledge');
|
||||
Route::get('/projects', [ProjectController::class, 'index'])->name('projects.index');
|
||||
Route::get('/projects/create', [ProjectController::class, 'create'])->name('projects.create');
|
||||
Route::post('/projects', [ProjectController::class, 'store'])->name('projects.store');
|
||||
Route::get('/projects/{project}', [ProjectController::class, 'show'])->name('projects.show');
|
||||
Route::get('/projects/{project}/edit', [ProjectController::class, 'edit'])->name('projects.edit');
|
||||
Route::patch('/projects/{project}', [ProjectController::class, 'update'])->name('projects.update');
|
||||
Route::post('/projects/{project}/archive', [ProjectController::class, 'archive'])->name('projects.archive');
|
||||
Route::post('/projects/{project}/revisions', [ProjectRevisionController::class, 'store'])->name('projects.revisions.store');
|
||||
Route::post('/projects/{project}/revisions/{revision}/approve', [ProjectRevisionController::class, 'approve'])->name('projects.revisions.approve');
|
||||
Route::get('/projects/{project}/files/{file}/download', [ProjectFileController::class, 'download'])->name('projects.files.download');
|
||||
Route::post('/projects/{project}/listings', [ProjectListingLinkController::class, 'store'])->name('projects.listings.store');
|
||||
Route::delete('/projects/{project}/listings/{listingLink}', [ProjectListingLinkController::class, 'destroy'])->name('projects.listings.destroy');
|
||||
Route::post('/projects/{project}/cost-snapshots', [ProjectCostSnapshotController::class, 'store'])->name('projects.cost-snapshots.store');
|
||||
Route::post('/projects/{project}/listings/{listingLink}/release', [ProjectReleaseController::class, 'store'])->name('projects.listings.release');
|
||||
Route::post('/projects/{project}/listings/{listingLink}/revert', [ProjectReleaseController::class, 'revert'])->name('projects.listings.revert');
|
||||
Route::get('/users', [UserController::class, 'index'])->name('users.index');
|
||||
Route::post('/users', [UserController::class, 'store'])->name('users.store');
|
||||
Route::patch('/users/{user}', [UserController::class, 'update'])->name('users.update');
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class BrandAssetsTest extends TestCase
|
||||
{
|
||||
use LazilyRefreshDatabase;
|
||||
|
||||
/** @var array<string, string> */
|
||||
private const EXPECTED_SHA256 = [
|
||||
'hl3d-lockup-dark-background.svg' => '609328a1f100fa5ed12fc5c59116c6e00e79faf9c22dfce745b0a8c05b3d0fa2',
|
||||
'hl3d-lockup-light-background.svg' => '9561e36ece9abb8596921cdb530cb2bebc4f475dffc7150aed3c526811fe3af6',
|
||||
'hl3d-lockup-mono-black.svg' => '4776441a3aba2b4c28768edb50717e4f6ac4e92e9fa8398c4b8b954170d2ecdd',
|
||||
'hl3d-lockup-mono-white.svg' => 'cd55fb8d459f145bd0c9ace5f3cec0f2be875cdb6d5700f32dccd47e64af623a',
|
||||
'hl3d-monogram-dark-background.svg' => '3ec17182fa2a408cfcaf8a1f6fa04cfd2d23ce661ebe8b94c8a1f4023224d3f8',
|
||||
'hl3d-monogram-light-background.svg' => '134493cbe99c171bf6b10fa93c3910fc9ea6a52806f17455c06762b3fedeca13',
|
||||
'hl3d-touchmark.svg' => 'add1001f0344d3a577bcd37dd66568a2c63a7e8b81ba1dc723e8aebc00cf81bc',
|
||||
'hl3d-touchmark-white.svg' => '364c63cc996fe96b7ca6ae96dfed59fc6b2924c000438f16ee130931971739b1',
|
||||
];
|
||||
|
||||
public function test_copied_brand_assets_match_supplied_checksums_exactly(): void
|
||||
{
|
||||
foreach (self::EXPECTED_SHA256 as $filename => $sha256) {
|
||||
$path = public_path('branding/'.$filename);
|
||||
$this->assertFileExists($path, "Missing supplied brand asset: {$filename}");
|
||||
$this->assertSame($sha256, hash_file('sha256', $path), "Checksum mismatch for {$filename}");
|
||||
}
|
||||
}
|
||||
|
||||
public function test_dashboard_does_not_fetch_external_fonts(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)->get(route('dashboard'))->assertOk()
|
||||
->assertDontSee('fonts.googleapis.com', false)
|
||||
->assertDontSee('fonts.gstatic.com', false)
|
||||
->assertDontSee('fonts.bunny.net', false);
|
||||
}
|
||||
|
||||
public function test_login_page_uses_approved_light_lockup_and_no_external_fonts(): void
|
||||
{
|
||||
$this->get(route('login'))->assertOk()
|
||||
->assertSee('branding/hl3d-lockup-light-background.svg', false)
|
||||
->assertDontSee('fonts.googleapis.com', false)
|
||||
->assertDontSee('fonts.bunny.net', false);
|
||||
}
|
||||
|
||||
public function test_layout_uses_monogram_for_sidebar_and_favicon(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)->get(route('dashboard'))->assertOk()
|
||||
->assertSee('branding/hl3d-monogram-dark-background.svg', false)
|
||||
->assertDontSee('hicklab3d-touchmark.svg', false)
|
||||
->assertDontSee('e3f504', false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\EtsyConnection;
|
||||
use App\Models\User;
|
||||
use App\Services\Etsy;
|
||||
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
class EtsyListingFilterTest extends TestCase
|
||||
{
|
||||
use LazilyRefreshDatabase;
|
||||
|
||||
private function connectEtsy(): void
|
||||
{
|
||||
config()->set('services.etsy', ['keystring' => 'key', 'shared_secret' => 'secret', 'shop_name' => 'HickLab3D']);
|
||||
EtsyConnection::create([
|
||||
'shop_id' => 42, 'shop_name' => 'HickLab3D', 'access_token' => 'token', 'refresh_token' => 'refresh',
|
||||
'expires_at' => now()->addHour(), 'scopes' => 'listings_r listings_w transactions_r transactions_w shops_r',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_default_state_is_active_listings(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
$this->connectEtsy();
|
||||
Http::fake([
|
||||
'https://api.etsy.com/v3/application/shops/42/receipts*' => Http::response(['results' => []]),
|
||||
'https://api.etsy.com/v3/application/shops/42/listings*' => Http::response(['count' => 1, 'results' => [['listing_id' => 1, 'shop_id' => 42, 'title' => 'Active item', 'state' => 'active']]]),
|
||||
]);
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
|
||||
$this->actingAs($admin)->get(route('etsy.index'))->assertOk()->assertSee('Active item')->assertSee('Live');
|
||||
Http::assertSent(fn ($request) => str_contains($request->url(), '/listings') && ($request['state'] ?? null) === 'active');
|
||||
}
|
||||
|
||||
public function test_draft_state_can_be_requested(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
$this->connectEtsy();
|
||||
Http::fake([
|
||||
'https://api.etsy.com/v3/application/shops/42/receipts*' => Http::response(['results' => []]),
|
||||
'https://api.etsy.com/v3/application/shops/42/listings*' => Http::response(['count' => 1, 'results' => [['listing_id' => 2, 'shop_id' => 42, 'title' => 'Draft item', 'state' => 'draft']]]),
|
||||
]);
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
|
||||
$this->actingAs($admin)->get(route('etsy.index', ['state' => 'draft']))->assertOk()->assertSee('Draft item');
|
||||
Http::assertSent(fn ($request) => str_contains($request->url(), '/listings') && ($request['state'] ?? null) === 'draft');
|
||||
}
|
||||
|
||||
public function test_invalid_state_is_rejected(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
$this->connectEtsy();
|
||||
Http::fake(['https://api.etsy.com/v3/application/shops/42/receipts*' => Http::response(['results' => []])]);
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
|
||||
$this->actingAs($admin)->get(route('etsy.index', ['state' => 'inactive']))->assertOk()->assertSee('is not a supported listing state');
|
||||
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/listings'));
|
||||
}
|
||||
|
||||
public function test_invalid_page_is_rejected(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
$this->connectEtsy();
|
||||
Http::fake(['https://api.etsy.com/v3/application/shops/42/receipts*' => Http::response(['results' => []])]);
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
|
||||
$this->actingAs($admin)->get(route('etsy.index', ['page' => 0]))->assertOk()->assertSee('is not a valid page number');
|
||||
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/listings'));
|
||||
}
|
||||
|
||||
public function test_pagination_preserves_state_and_advances_offset(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
$this->connectEtsy();
|
||||
Http::fake([
|
||||
'https://api.etsy.com/v3/application/shops/42/receipts*' => Http::response(['results' => []]),
|
||||
'https://api.etsy.com/v3/application/shops/42/listings*' => Http::response(['count' => 60, 'results' => [['listing_id' => 3, 'shop_id' => 42, 'title' => 'Page two item', 'state' => 'draft']]]),
|
||||
]);
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
|
||||
$this->actingAs($admin)->get(route('etsy.index', ['state' => 'draft', 'page' => 2]))->assertOk()
|
||||
->assertSee('Page two item')->assertSee('page=1&state=draft', false)->assertSee('page=3&state=draft', false);
|
||||
Http::assertSent(fn ($request) => ($request['offset'] ?? null) === 20 && ($request['limit'] ?? null) === 20 && ($request['state'] ?? null) === 'draft');
|
||||
}
|
||||
|
||||
public function test_changing_state_resets_to_first_page(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
$this->connectEtsy();
|
||||
Http::fake([
|
||||
'https://api.etsy.com/v3/application/shops/42/receipts*' => Http::response(['results' => []]),
|
||||
'https://api.etsy.com/v3/application/shops/42/listings*' => Http::response(['count' => 1, 'results' => []]),
|
||||
]);
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
|
||||
// No explicit page param when switching state: offset must be 0, not carried over from a prior page.
|
||||
$this->actingAs($admin)->get(route('etsy.index', ['state' => 'draft']))->assertOk();
|
||||
Http::assertSent(fn ($request) => ($request['offset'] ?? null) === 0);
|
||||
}
|
||||
|
||||
public function test_listing_cache_is_separated_by_state(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
$this->connectEtsy();
|
||||
Http::fake([
|
||||
'https://api.etsy.com/v3/application/shops/42/receipts*' => Http::response(['results' => []]),
|
||||
'https://api.etsy.com/v3/application/shops/42/listings*' => Http::sequence()
|
||||
->push(['count' => 1, 'results' => [['listing_id' => 1, 'shop_id' => 42, 'title' => 'Active item', 'state' => 'active']]])
|
||||
->push(['count' => 1, 'results' => [['listing_id' => 2, 'shop_id' => 42, 'title' => 'Draft item', 'state' => 'draft']]]),
|
||||
]);
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
|
||||
$this->actingAs($admin)->get(route('etsy.index', ['state' => 'active']))->assertOk()->assertSee('Active item');
|
||||
$this->actingAs($admin)->get(route('etsy.index', ['state' => 'draft']))->assertOk()->assertSee('Draft item');
|
||||
// Revisiting active state should still show the active item from cache, not the draft one.
|
||||
$this->actingAs($admin)->get(route('etsy.index', ['state' => 'active']))->assertOk()->assertSee('Active item')->assertDontSee('Draft item');
|
||||
// Receipts share one cache key regardless of state, so only the first call hits the network;
|
||||
// listings are keyed per state, so active and draft each fetch once and then serve from cache.
|
||||
Http::assertSentCount(3);
|
||||
}
|
||||
|
||||
public function test_zero_results_shows_empty_state_not_an_error(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
$this->connectEtsy();
|
||||
Http::fake([
|
||||
'https://api.etsy.com/v3/application/shops/42/receipts*' => Http::response(['results' => []]),
|
||||
'https://api.etsy.com/v3/application/shops/42/listings*' => Http::response(['count' => 0, 'results' => []]),
|
||||
]);
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
|
||||
$this->actingAs($admin)->get(route('etsy.index', ['state' => 'draft']))->assertOk()
|
||||
->assertSee('No draft listings returned.')
|
||||
->assertDontSee('temporarily unavailable');
|
||||
}
|
||||
|
||||
public function test_receipts_remain_visible_when_listings_fail(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
$this->connectEtsy();
|
||||
Http::fake([
|
||||
'https://api.etsy.com/v3/application/shops/42/receipts*' => Http::response(['results' => [
|
||||
['receipt_id' => 1, 'name' => 'Buyer', 'create_timestamp' => now()->timestamp],
|
||||
]]),
|
||||
'https://api.etsy.com/v3/application/shops/42/listings*' => Http::response([], 503),
|
||||
]);
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
|
||||
$this->actingAs($admin)->get(route('etsy.index'))->assertOk()
|
||||
->assertSee('#1')->assertSee('temporarily unavailable');
|
||||
}
|
||||
|
||||
public function test_extreme_and_array_query_parameters_never_reach_listings_api(): void
|
||||
{
|
||||
$this->connectEtsy();
|
||||
Http::fake(['*receipts*' => Http::response(['results' => []])]);
|
||||
$this->actingAs(User::factory()->create(['role' => 'admin']));
|
||||
foreach ([['page' => '99999999999999999999999'], ['state' => ['draft']], ['page' => ['1']]] as $query) {
|
||||
$this->get(route('etsy.index', $query))->assertOk()->assertSee('not a');
|
||||
}
|
||||
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/listings'));
|
||||
}
|
||||
|
||||
public function test_listing_results_include_snapshot_age(): void
|
||||
{
|
||||
$this->connectEtsy();
|
||||
Http::fake(['*listings*' => Http::response(['count' => 0, 'results' => []])]);
|
||||
$result = app(Etsy::class)->listListings(EtsyConnection::firstOrFail(), 'draft', 25, 25);
|
||||
$this->assertIsInt($result['fetched_at']);
|
||||
$this->assertFalse($result['stale']);
|
||||
}
|
||||
|
||||
public function test_guest_is_redirected(): void
|
||||
{
|
||||
$this->get(route('etsy.index'))->assertRedirect(route('login'));
|
||||
}
|
||||
|
||||
public function test_non_admin_forbidden(): void
|
||||
{
|
||||
$this->actingAs(User::factory()->create())->get(route('etsy.index'))->assertForbidden();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Tests\TestCase;
|
||||
|
||||
class LegacyRetentionTest extends TestCase
|
||||
{
|
||||
use LazilyRefreshDatabase;
|
||||
|
||||
public function test_additive_project_migrations_do_not_disturb_legacy_filament_rows(): void
|
||||
{
|
||||
$userId = DB::table('users')->insertGetId([
|
||||
'username' => 'legacy-user', 'email' => 'legacy@example.test', 'password' => 'x', 'role' => 'user',
|
||||
]);
|
||||
$filamentId = DB::table('filamentTracker')->insertGetId([
|
||||
'userId' => $userId, 'filamentName' => 'Legacy PLA', 'amazonUrl' => 'https://example.test/x',
|
||||
'filamentWeight' => 1000, 'brand' => 'LegacyBrand', 'material' => 'PLA', 'color' => 'Black', 'itemDiameter' => 1.75,
|
||||
]);
|
||||
DB::table('filamentPriceHistory')->insert([
|
||||
'filamentId' => $filamentId, 'price' => 19.99, 'recordedAt' => now(),
|
||||
]);
|
||||
|
||||
// Re-running migrate must be a no-op for already-applied migrations and
|
||||
// must not touch legacy rows that predate the project/costing tables.
|
||||
Artisan::call('migrate', ['--force' => true]);
|
||||
|
||||
$this->assertDatabaseHas('filamentTracker', ['id' => $filamentId, 'filamentName' => 'Legacy PLA', 'userId' => $userId]);
|
||||
$this->assertDatabaseHas('filamentPriceHistory', ['filamentId' => $filamentId, 'price' => 19.99]);
|
||||
$this->assertDatabaseCount('filamentTracker', 1);
|
||||
}
|
||||
|
||||
public function test_project_tables_are_additive_and_coexist_with_legacy_tables(): void
|
||||
{
|
||||
$this->assertTrue(Schema::hasTable('filamentTracker'));
|
||||
$this->assertTrue(Schema::hasTable('filamentPriceHistory'));
|
||||
$this->assertTrue(Schema::hasTable('bambuPrinters'));
|
||||
$this->assertTrue(Schema::hasTable('projects'));
|
||||
$this->assertTrue(Schema::hasTable('project_cost_snapshots'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class NavigationTest extends TestCase
|
||||
{
|
||||
use LazilyRefreshDatabase;
|
||||
|
||||
public function test_regular_user_only_sees_non_admin_groups(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)->get(route('dashboard'));
|
||||
|
||||
$response->assertOk()->assertSee('Workspace')->assertSee('Workshop')
|
||||
->assertDontSee('Projects')->assertDontSee('Commerce')->assertDontSee('Administration');
|
||||
}
|
||||
|
||||
public function test_admin_sees_every_navigation_group_with_no_empty_group(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
|
||||
$response = $this->actingAs($admin)->get(route('dashboard'));
|
||||
|
||||
$response->assertOk()
|
||||
->assertSee('Workspace')->assertSee('Projects')
|
||||
->assertSee('Workshop')->assertSee('Print farm')->assertSee('Dryer')
|
||||
->assertSee('Commerce')->assertSee('Etsy')->assertSee('Shipping')
|
||||
->assertSee('Administration')->assertSee('Settings')->assertSee('Users');
|
||||
}
|
||||
|
||||
public function test_active_route_gets_aria_current(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
|
||||
$this->actingAs($admin)->get(route('projects.index'))
|
||||
->assertOk()
|
||||
->assertSee('aria-current="page"', false);
|
||||
}
|
||||
|
||||
public function test_mobile_drawer_markup_is_present_and_accessible(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)->get(route('dashboard'))->assertOk()
|
||||
->assertSee('id="mobile-nav"', false)
|
||||
->assertSee('popover="auto"', false)
|
||||
->assertSee('popovertarget="mobile-nav"', false)
|
||||
->assertSee('aria-label="Navigation menu"', false);
|
||||
}
|
||||
|
||||
public function test_desktop_sidebar_is_hidden_below_the_lg_breakpoint(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$html = $this->actingAs($user)->get(route('dashboard'))->assertOk()->getContent();
|
||||
|
||||
// The bug: on a 390px viewport the desktop <aside> rendered as a full
|
||||
// visible block (no `hidden`), pushing the real page header down to
|
||||
// ~y673. It must not render at all below the `lg` breakpoint; the
|
||||
// separate #mobile-nav popover is the only mobile navigation surface.
|
||||
$this->assertMatchesRegularExpression(
|
||||
'/<aside[^>]*data-sidebar[^>]*class="[^"]*\bhidden\b[^"]*\blg:block\b[^"]*"/',
|
||||
$html,
|
||||
);
|
||||
}
|
||||
|
||||
public function test_sidebar_header_uses_the_supplied_lockup_when_expanded_and_monogram_when_collapsed(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$html = $this->actingAs($user)->get(route('dashboard'))->assertOk()->getContent();
|
||||
|
||||
$this->assertMatchesRegularExpression(
|
||||
'/<img[^>]*src="[^"]*hl3d-lockup-dark-background\.svg"[^>]*data-sidebar-expanded-mark/',
|
||||
$html,
|
||||
);
|
||||
$this->assertMatchesRegularExpression(
|
||||
'/<img[^>]*src="[^"]*hl3d-monogram-dark-background\.svg"[^>]*class="[^"]*\bhidden\b[^"]*"[^>]*data-sidebar-collapsed-mark/',
|
||||
$html,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectCostSnapshot;
|
||||
use App\Models\ProjectRevision;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProjectCompletionTest extends TestCase
|
||||
{
|
||||
use LazilyRefreshDatabase;
|
||||
|
||||
public function test_shipping_allocation_is_applied_to_batch_not_just_recorded(): void
|
||||
{
|
||||
$project = Project::factory()->create(['currency' => 'GBP']);
|
||||
$revision = ProjectRevision::factory()->for($project)->create();
|
||||
$this->actingAs(User::factory()->create(['role' => 'admin']))->post(route('projects.cost-snapshots.store', $project), [
|
||||
'revision_id' => $revision->id, 'basis' => 'estimate', 'currency' => 'GBP', 'batch_units' => 4,
|
||||
'lines' => [['category' => 'shipping', 'description' => 'Two units per parcel', 'quantity' => '1', 'quantity_unit' => 'parcel', 'unit_rate' => '5', 'units_per_parcel' => 2]],
|
||||
])->assertRedirect();
|
||||
$this->assertSame('10.0000', ProjectCostSnapshot::firstOrFail()->shipping_total);
|
||||
$this->assertSame('2.5000', ProjectCostSnapshot::firstOrFail()->perUnitGrandTotal());
|
||||
}
|
||||
|
||||
public function test_cost_currency_cannot_diverge_from_project(): void
|
||||
{
|
||||
$project = Project::factory()->create(['currency' => 'GBP']);
|
||||
$revision = ProjectRevision::factory()->for($project)->create();
|
||||
$this->actingAs(User::factory()->create(['role' => 'admin']))->post(route('projects.cost-snapshots.store', $project), [
|
||||
'revision_id' => $revision->id, 'basis' => 'estimate', 'currency' => 'USD', 'batch_units' => 1,
|
||||
'lines' => [['category' => 'filament', 'description' => 'PLA', 'quantity' => '10', 'quantity_unit' => 'g', 'unit_rate' => '20']],
|
||||
])->assertSessionHasErrors('currency');
|
||||
$this->assertDatabaseCount('project_cost_snapshots', 0);
|
||||
}
|
||||
|
||||
public function test_costing_and_revert_have_usable_labeled_forms(): void
|
||||
{
|
||||
$project = Project::factory()->create();
|
||||
$this->actingAs(User::factory()->create(['role' => 'admin']))->get(route('projects.show', $project))->assertOk()
|
||||
->assertSee('Add cost line')->assertSee('Saleable batch yield')->assertSee('Units per parcel')->assertSee('Fusion document');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectCostSnapshot;
|
||||
use App\Models\ProjectRevision;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProjectCostSnapshotTest extends TestCase
|
||||
{
|
||||
use LazilyRefreshDatabase;
|
||||
|
||||
public function test_admin_can_record_a_cost_snapshot_with_multiple_material_lines(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
$revision = ProjectRevision::factory()->for($project)->create();
|
||||
|
||||
$response = $this->actingAs($admin)->post(route('projects.cost-snapshots.store', $project), [
|
||||
'revision_id' => $revision->id,
|
||||
'basis' => 'estimate',
|
||||
'currency' => 'GBP',
|
||||
'batch_units' => 4,
|
||||
'lines' => [
|
||||
['category' => 'filament', 'description' => 'PLA black', 'quantity' => '200', 'quantity_unit' => 'g', 'unit_rate' => '18.000000', 'waste_quantity' => '10'],
|
||||
['category' => 'filament', 'description' => 'PETG clear', 'quantity' => '50', 'quantity_unit' => 'g', 'unit_rate' => '22.000000'],
|
||||
['category' => 'packaging', 'description' => 'Box', 'quantity' => '1', 'quantity_unit' => 'unit', 'unit_rate' => '0.650000'],
|
||||
['category' => 'shipping', 'description' => 'Parcel', 'quantity' => '1', 'quantity_unit' => 'parcel', 'unit_rate' => '3.990000', 'units_per_parcel' => '4'],
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
$snapshot = ProjectCostSnapshot::first();
|
||||
$this->assertNotNull($snapshot);
|
||||
// (200g + 10g waste) * £18.00/kg = £3.78, plus 50g * £22.00/kg = £1.10.
|
||||
$this->assertSame('4.8800', (string) $snapshot->filament_total);
|
||||
$this->assertSame(4, $snapshot->lines()->count());
|
||||
}
|
||||
|
||||
public function test_missing_category_is_not_costed_rather_than_zero(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
$revision = ProjectRevision::factory()->for($project)->create();
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.cost-snapshots.store', $project), [
|
||||
'revision_id' => $revision->id, 'basis' => 'estimate', 'currency' => 'GBP', 'batch_units' => 1,
|
||||
'lines' => [['category' => 'filament', 'description' => 'PLA', 'quantity' => '0', 'quantity_unit' => 'g', 'unit_rate' => '18.000000']],
|
||||
]);
|
||||
|
||||
$snapshot = ProjectCostSnapshot::first();
|
||||
$this->assertSame('0.0000', (string) $snapshot->filament_total);
|
||||
$this->assertNull($snapshot->packaging_total);
|
||||
$this->assertNull($snapshot->shipping_total);
|
||||
}
|
||||
|
||||
public function test_negative_quantity_is_rejected(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
$revision = ProjectRevision::factory()->for($project)->create();
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.cost-snapshots.store', $project), [
|
||||
'revision_id' => $revision->id, 'basis' => 'estimate', 'currency' => 'GBP', 'batch_units' => 1,
|
||||
'lines' => [['category' => 'filament', 'description' => 'PLA', 'quantity' => '-5', 'quantity_unit' => 'g', 'unit_rate' => '18.000000']],
|
||||
])->assertSessionHasErrors('lines');
|
||||
$this->assertDatabaseCount('project_cost_snapshots', 0);
|
||||
}
|
||||
|
||||
public function test_zero_or_negative_batch_yield_is_rejected(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
$revision = ProjectRevision::factory()->for($project)->create();
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.cost-snapshots.store', $project), [
|
||||
'revision_id' => $revision->id, 'basis' => 'estimate', 'currency' => 'GBP', 'batch_units' => 0,
|
||||
'lines' => [['category' => 'filament', 'description' => 'PLA', 'quantity' => '5', 'quantity_unit' => 'g', 'unit_rate' => '18.000000']],
|
||||
])->assertSessionHasErrors('batch_units');
|
||||
}
|
||||
|
||||
public function test_invalid_currency_is_rejected(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
$revision = ProjectRevision::factory()->for($project)->create();
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.cost-snapshots.store', $project), [
|
||||
'revision_id' => $revision->id, 'basis' => 'estimate', 'currency' => 'ZZZ', 'batch_units' => 1,
|
||||
'lines' => [['category' => 'filament', 'description' => 'PLA', 'quantity' => '5', 'quantity_unit' => 'g', 'unit_rate' => '18.000000']],
|
||||
])->assertSessionHasErrors('currency');
|
||||
}
|
||||
|
||||
public function test_actual_snapshot_never_overwrites_the_estimate(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
$revision = ProjectRevision::factory()->for($project)->create();
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.cost-snapshots.store', $project), [
|
||||
'revision_id' => $revision->id, 'basis' => 'estimate', 'currency' => 'GBP', 'batch_units' => 1,
|
||||
'lines' => [['category' => 'filament', 'description' => 'PLA', 'quantity' => '100', 'quantity_unit' => 'g', 'unit_rate' => '20.000000']],
|
||||
]);
|
||||
$this->actingAs($admin)->post(route('projects.cost-snapshots.store', $project), [
|
||||
'revision_id' => $revision->id, 'basis' => 'actual', 'currency' => 'GBP', 'batch_units' => 1,
|
||||
'lines' => [['category' => 'filament', 'description' => 'PLA', 'quantity' => '110', 'quantity_unit' => 'g', 'unit_rate' => '21.000000']],
|
||||
]);
|
||||
|
||||
$this->assertDatabaseCount('project_cost_snapshots', 2);
|
||||
$this->assertDatabaseHas('project_cost_snapshots', ['basis' => 'estimate', 'filament_total' => '2.0000']);
|
||||
$this->assertDatabaseHas('project_cost_snapshots', ['basis' => 'actual', 'filament_total' => '2.3100']);
|
||||
}
|
||||
|
||||
public function test_non_admin_cannot_record_cost_snapshots(): void
|
||||
{
|
||||
$project = Project::factory()->create();
|
||||
$revision = ProjectRevision::factory()->for($project)->create();
|
||||
|
||||
$this->actingAs(User::factory()->create())->post(route('projects.cost-snapshots.store', $project), [
|
||||
'revision_id' => $revision->id, 'basis' => 'estimate', 'currency' => 'GBP', 'batch_units' => 1,
|
||||
'lines' => [['category' => 'filament', 'description' => 'PLA', 'quantity' => '5', 'quantity_unit' => 'g', 'unit_rate' => '18.000000']],
|
||||
])->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_forged_line_total_in_the_request_is_ignored_in_favour_of_the_computed_value(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
$revision = ProjectRevision::factory()->for($project)->create();
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.cost-snapshots.store', $project), [
|
||||
'revision_id' => $revision->id, 'basis' => 'estimate', 'currency' => 'GBP', 'batch_units' => 1,
|
||||
'lines' => [[
|
||||
'category' => 'filament', 'description' => 'PLA', 'quantity' => '100', 'quantity_unit' => 'g',
|
||||
'unit_rate' => '20.000000', 'line_total' => '0.0001',
|
||||
]],
|
||||
]);
|
||||
|
||||
$snapshot = ProjectCostSnapshot::first();
|
||||
// 100g * £20.00/kg = £2.00, regardless of the forged line_total in the payload.
|
||||
$this->assertSame('2.0000', (string) $snapshot->filament_total);
|
||||
$this->assertSame('2.0000', (string) $snapshot->lines()->first()->line_total);
|
||||
}
|
||||
|
||||
public function test_quantity_unit_must_match_the_category_allowlist(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
$revision = ProjectRevision::factory()->for($project)->create();
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.cost-snapshots.store', $project), [
|
||||
'revision_id' => $revision->id, 'basis' => 'estimate', 'currency' => 'GBP', 'batch_units' => 1,
|
||||
'lines' => [['category' => 'filament', 'description' => 'PLA', 'quantity' => '100', 'quantity_unit' => 'kg', 'unit_rate' => '20.000000']],
|
||||
])->assertSessionHasErrors('lines');
|
||||
$this->assertDatabaseCount('project_cost_snapshots', 0);
|
||||
}
|
||||
|
||||
public function test_shipping_line_requires_a_positive_units_per_parcel(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
$revision = ProjectRevision::factory()->for($project)->create();
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.cost-snapshots.store', $project), [
|
||||
'revision_id' => $revision->id, 'basis' => 'estimate', 'currency' => 'GBP', 'batch_units' => 1,
|
||||
'lines' => [['category' => 'shipping', 'description' => 'Parcel', 'quantity' => '1', 'quantity_unit' => 'parcel', 'unit_rate' => '4.990000']],
|
||||
])->assertSessionHasErrors('lines.0.units_per_parcel');
|
||||
$this->assertDatabaseCount('project_cost_snapshots', 0);
|
||||
}
|
||||
|
||||
public function test_combined_lines_exceeding_storable_precision_are_rejected_as_validation_not_a_server_error(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
$revision = ProjectRevision::factory()->for($project)->create();
|
||||
|
||||
$response = $this->actingAs($admin)->post(route('projects.cost-snapshots.store', $project), [
|
||||
'revision_id' => $revision->id, 'basis' => 'estimate', 'currency' => 'GBP', 'batch_units' => 1,
|
||||
'lines' => [
|
||||
['category' => 'packaging', 'description' => 'Box A', 'quantity' => '1000000', 'quantity_unit' => 'unit', 'unit_rate' => '6000.000000'],
|
||||
['category' => 'packaging', 'description' => 'Box B', 'quantity' => '1000000', 'quantity_unit' => 'unit', 'unit_rate' => '6000.000000'],
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors('lines');
|
||||
$response->assertStatus(302);
|
||||
$this->assertDatabaseCount('project_cost_snapshots', 0);
|
||||
}
|
||||
|
||||
public function test_cannot_cost_a_revision_from_a_different_project(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$projectA = Project::factory()->create();
|
||||
$projectB = Project::factory()->create();
|
||||
$revisionOfB = ProjectRevision::factory()->for($projectB)->create();
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.cost-snapshots.store', $projectA), [
|
||||
'revision_id' => $revisionOfB->id, 'basis' => 'estimate', 'currency' => 'GBP', 'batch_units' => 1,
|
||||
'lines' => [['category' => 'filament', 'description' => 'PLA', 'quantity' => '5', 'quantity_unit' => 'g', 'unit_rate' => '18.000000']],
|
||||
])->assertNotFound();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectListingLink;
|
||||
use App\Models\ProjectRelease;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProjectLifecycleTest extends TestCase
|
||||
{
|
||||
use LazilyRefreshDatabase;
|
||||
|
||||
public function test_guest_is_redirected_to_login(): void
|
||||
{
|
||||
$this->get(route('projects.index'))->assertRedirect(route('login'));
|
||||
}
|
||||
|
||||
public function test_regular_user_cannot_view_projects(): void
|
||||
{
|
||||
$this->actingAs(User::factory()->create())->get(route('projects.index'))->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_admin_can_view_empty_project_index(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
|
||||
$this->actingAs($admin)->get(route('projects.index'))->assertOk()->assertSee('Projects');
|
||||
}
|
||||
|
||||
public function test_admin_can_create_a_project(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
|
||||
$response = $this->actingAs($admin)->post(route('projects.store'), [
|
||||
'reference' => 'PRJ-0001',
|
||||
'title' => 'Articulated dragon',
|
||||
'notes' => 'Test product',
|
||||
'currency' => 'GBP',
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
$this->assertDatabaseHas('projects', ['reference' => 'PRJ-0001', 'title' => 'Articulated dragon', 'stage' => 'design']);
|
||||
}
|
||||
|
||||
public function test_regular_user_cannot_create_a_project(): void
|
||||
{
|
||||
$this->actingAs(User::factory()->create())->post(route('projects.store'), [
|
||||
'reference' => 'PRJ-0002', 'title' => 'Nope', 'currency' => 'GBP',
|
||||
])->assertForbidden();
|
||||
$this->assertDatabaseMissing('projects', ['reference' => 'PRJ-0002']);
|
||||
}
|
||||
|
||||
public function test_reference_must_be_unique(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
Project::factory()->create(['reference' => 'PRJ-DUP']);
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.store'), [
|
||||
'reference' => 'PRJ-DUP', 'title' => 'Dup', 'currency' => 'GBP',
|
||||
])->assertSessionHasErrors('reference');
|
||||
}
|
||||
|
||||
public function test_currency_must_be_a_valid_iso_code(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.store'), [
|
||||
'reference' => 'PRJ-FX', 'title' => 'Bad currency', 'currency' => 'XYZ',
|
||||
])->assertSessionHasErrors('currency');
|
||||
}
|
||||
|
||||
public function test_admin_can_view_project_detail(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create(['title' => 'Detail view project']);
|
||||
|
||||
$this->actingAs($admin)->get(route('projects.show', $project))->assertOk()->assertSee('Detail view project');
|
||||
}
|
||||
|
||||
public function test_admin_can_edit_project_details(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create(['title' => 'Old title']);
|
||||
|
||||
$this->actingAs($admin)->patch(route('projects.update', $project), ['title' => 'New title'])->assertRedirect();
|
||||
$this->assertSame('New title', $project->fresh()->title);
|
||||
}
|
||||
|
||||
public function test_stage_can_move_forward_and_backward_reversibly(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create(['stage' => 'design']);
|
||||
|
||||
$this->actingAs($admin)->patch(route('projects.update', $project), ['stage' => 'ready'])->assertRedirect();
|
||||
$this->assertSame('ready', $project->fresh()->stage);
|
||||
|
||||
$this->actingAs($admin)->patch(route('projects.update', $project), ['stage' => 'design'])->assertRedirect();
|
||||
$this->assertSame('design', $project->fresh()->stage);
|
||||
}
|
||||
|
||||
public function test_moving_to_live_requires_an_approved_released_listing_mapping(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create(['stage' => 'ready']);
|
||||
|
||||
$this->actingAs($admin)->patch(route('projects.update', $project), ['stage' => 'live'])
|
||||
->assertSessionHasErrors('stage');
|
||||
$this->assertSame('ready', $project->fresh()->stage);
|
||||
}
|
||||
|
||||
public function test_moving_to_live_succeeds_once_a_listing_has_a_live_release(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create(['stage' => 'ready']);
|
||||
ProjectListingLink::factory()->for($project)->create(['live_release_id' => null]);
|
||||
$release = ProjectRelease::factory()->for($project)->create();
|
||||
$project->listingLinks()->first()->update(['live_release_id' => $release->id]);
|
||||
|
||||
$this->actingAs($admin)->patch(route('projects.update', $project), ['stage' => 'live'])->assertRedirect();
|
||||
$this->assertSame('live', $project->fresh()->stage);
|
||||
}
|
||||
|
||||
public function test_moving_to_live_is_rejected_unless_every_non_detached_mapping_is_released(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create(['stage' => 'ready']);
|
||||
$releasedLink = ProjectListingLink::factory()->for($project)->create(['live_release_id' => null]);
|
||||
$release = ProjectRelease::factory()->for($project)->create();
|
||||
$releasedLink->update(['live_release_id' => $release->id]);
|
||||
ProjectListingLink::factory()->for($project)->create(['live_release_id' => null]);
|
||||
|
||||
$this->actingAs($admin)->patch(route('projects.update', $project), ['stage' => 'live'])
|
||||
->assertSessionHasErrors('stage');
|
||||
$this->assertSame('ready', $project->fresh()->stage);
|
||||
}
|
||||
|
||||
public function test_moving_to_live_ignores_detached_mappings_without_a_release(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create(['stage' => 'ready']);
|
||||
$releasedLink = ProjectListingLink::factory()->for($project)->create(['live_release_id' => null]);
|
||||
$release = ProjectRelease::factory()->for($project)->create();
|
||||
$releasedLink->update(['live_release_id' => $release->id]);
|
||||
ProjectListingLink::factory()->for($project)->create(['live_release_id' => null, 'detached_at' => now()]);
|
||||
|
||||
$this->actingAs($admin)->patch(route('projects.update', $project), ['stage' => 'live'])->assertRedirect();
|
||||
$this->assertSame('live', $project->fresh()->stage);
|
||||
}
|
||||
|
||||
public function test_rejects_unknown_stage(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$this->actingAs($admin)->patch(route('projects.update', $project), ['stage' => 'launched'])
|
||||
->assertSessionHasErrors('stage');
|
||||
}
|
||||
|
||||
public function test_admin_can_archive_a_project_without_deleting_it(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.archive', $project))->assertRedirect();
|
||||
$project->refresh();
|
||||
$this->assertNotNull($project->archived_at);
|
||||
$this->assertModelExists($project);
|
||||
}
|
||||
|
||||
public function test_archiving_records_a_project_event(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.archive', $project));
|
||||
|
||||
$this->assertDatabaseHas('project_events', ['project_id' => $project->id, 'event_type' => 'archived']);
|
||||
}
|
||||
|
||||
public function test_project_creation_records_an_event(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.store'), [
|
||||
'reference' => 'PRJ-EVT', 'title' => 'Event test', 'currency' => 'GBP',
|
||||
]);
|
||||
|
||||
$project = Project::where('reference', 'PRJ-EVT')->first();
|
||||
$this->assertDatabaseHas('project_events', ['project_id' => $project->id, 'event_type' => 'created']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\EtsyConnection;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectListingLink;
|
||||
use App\Models\ProjectRelease;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProjectListingLinkTest extends TestCase
|
||||
{
|
||||
use LazilyRefreshDatabase;
|
||||
|
||||
private function connectEtsy(): EtsyConnection
|
||||
{
|
||||
config()->set('services.etsy', ['keystring' => 'key', 'shared_secret' => 'secret', 'shop_name' => 'HickLab3D']);
|
||||
|
||||
return EtsyConnection::create([
|
||||
'shop_id' => 42, 'shop_name' => 'HickLab3D', 'access_token' => 'token', 'refresh_token' => 'refresh',
|
||||
'expires_at' => now()->addHour(), 'scopes' => 'listings_r listings_w transactions_r transactions_w shops_r',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_admin_can_attach_a_verified_listing_by_reading_it_from_etsy(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
$this->connectEtsy();
|
||||
Http::fake(['https://api.etsy.com/v3/application/listings/999*' => Http::response([
|
||||
'listing_id' => 999, 'shop_id' => 42, 'title' => 'Dragon figurine', 'state' => 'active',
|
||||
'last_modified_timestamp' => now()->timestamp,
|
||||
])]);
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.listings.store', $project), ['listing_id' => 999])
|
||||
->assertRedirect();
|
||||
|
||||
$this->assertDatabaseHas('project_listing_links', [
|
||||
'project_id' => $project->id, 'shop_id' => 42, 'listing_id' => 999, 'title_snapshot' => 'Dragon figurine', 'state_snapshot' => 'active',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_rejects_a_listing_that_belongs_to_a_different_shop(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
$this->connectEtsy();
|
||||
Http::fake(['https://api.etsy.com/v3/application/listings/555*' => Http::response([
|
||||
'listing_id' => 555, 'shop_id' => 999999, 'title' => 'Not ours', 'state' => 'active',
|
||||
])]);
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.listings.store', $project), ['listing_id' => 555])
|
||||
->assertSessionHasErrors('listing_id');
|
||||
$this->assertDatabaseCount('project_listing_links', 0);
|
||||
}
|
||||
|
||||
public function test_rejects_a_nonexistent_listing(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
$this->connectEtsy();
|
||||
Http::fake(['https://api.etsy.com/v3/application/listings/404*' => Http::response(['error' => 'not found'], 404)]);
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.listings.store', $project), ['listing_id' => 404])
|
||||
->assertSessionHasErrors('listing_id');
|
||||
}
|
||||
|
||||
public function test_cannot_attach_the_same_listing_to_a_different_project(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
$this->connectEtsy();
|
||||
Http::fake(['https://api.etsy.com/v3/application/listings/999*' => Http::response([
|
||||
'listing_id' => 999, 'shop_id' => 42, 'title' => 'Dragon figurine', 'state' => 'active',
|
||||
])]);
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$projectA = Project::factory()->create();
|
||||
$projectB = Project::factory()->create();
|
||||
ProjectListingLink::factory()->for($projectA)->create(['shop_id' => 42, 'listing_id' => 999, 'variation_key' => 'all']);
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.listings.store', $projectB), ['listing_id' => 999])
|
||||
->assertSessionHasErrors('listing_id');
|
||||
}
|
||||
|
||||
public function test_reattaching_same_listing_to_same_project_does_not_duplicate(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
$this->connectEtsy();
|
||||
Http::fake(['https://api.etsy.com/v3/application/listings/999*' => Http::response([
|
||||
'listing_id' => 999, 'shop_id' => 42, 'title' => 'Dragon figurine v2', 'state' => 'active',
|
||||
])]);
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
ProjectListingLink::factory()->for($project)->create(['shop_id' => 42, 'listing_id' => 999, 'variation_key' => 'all', 'title_snapshot' => 'old title']);
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.listings.store', $project), ['listing_id' => 999])->assertRedirect();
|
||||
|
||||
$this->assertDatabaseCount('project_listing_links', 1);
|
||||
$this->assertDatabaseHas('project_listing_links', ['listing_id' => 999, 'title_snapshot' => 'Dragon figurine v2']);
|
||||
}
|
||||
|
||||
public function test_detaching_a_listing_preserves_its_release_history(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
$link = ProjectListingLink::factory()->for($project)->create();
|
||||
|
||||
$this->actingAs($admin)->delete(route('projects.listings.destroy', [$project, $link]))->assertRedirect();
|
||||
|
||||
$link->refresh();
|
||||
$this->assertNotNull($link->detached_at);
|
||||
$this->assertModelExists($link);
|
||||
}
|
||||
|
||||
public function test_attaching_a_new_unready_mapping_to_an_already_live_project_drops_it_back_to_design(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
$this->connectEtsy();
|
||||
Http::fake(['https://api.etsy.com/v3/application/listings/999*' => Http::response([
|
||||
'listing_id' => 999, 'shop_id' => 42, 'title' => 'Dragon figurine', 'state' => 'active',
|
||||
])]);
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create(['stage' => 'live']);
|
||||
$existingLink = ProjectListingLink::factory()->for($project)->create(['live_release_id' => null]);
|
||||
$release = ProjectRelease::factory()->for($project)->create();
|
||||
$existingLink->update(['live_release_id' => $release->id]);
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.listings.store', $project), ['listing_id' => 999])
|
||||
->assertRedirect();
|
||||
|
||||
$this->assertSame('design', $project->fresh()->stage);
|
||||
$this->assertDatabaseHas('project_events', ['project_id' => $project->id, 'event_type' => 'stage_changed']);
|
||||
}
|
||||
|
||||
public function test_rejects_an_arbitrary_variation_key_since_per_variant_mapping_is_not_implemented(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
$this->connectEtsy();
|
||||
Http::fake(['https://api.etsy.com/v3/application/listings/999*' => Http::response([
|
||||
'listing_id' => 999, 'shop_id' => 42, 'title' => 'Dragon figurine', 'state' => 'active',
|
||||
])]);
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.listings.store', $project), [
|
||||
'listing_id' => 999, 'variation_key' => 'color:red',
|
||||
])->assertSessionHasErrors('variation_key');
|
||||
$this->assertDatabaseCount('project_listing_links', 0);
|
||||
}
|
||||
|
||||
public function test_non_admin_cannot_attach_listings(): void
|
||||
{
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$this->actingAs(User::factory()->create())->post(route('projects.listings.store', $project), ['listing_id' => 1])
|
||||
->assertForbidden();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectCostSnapshot;
|
||||
use App\Models\ProjectFile;
|
||||
use App\Models\ProjectListingLink;
|
||||
use App\Models\ProjectRelease;
|
||||
use App\Models\ProjectRevision;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProjectReleaseTest extends TestCase
|
||||
{
|
||||
use LazilyRefreshDatabase;
|
||||
|
||||
private function approvedRevisionWithCost(Project $project, int $revisionNumber = 1): array
|
||||
{
|
||||
$revision = ProjectRevision::factory()->approved()->for($project)->create(['revision_number' => $revisionNumber]);
|
||||
ProjectFile::factory()->for($revision, 'revision')->create();
|
||||
$snapshot = ProjectCostSnapshot::factory()->for($project)->for($revision, 'revision')->create([
|
||||
'grand_total' => '10.0000', 'filament_total' => '10.0000',
|
||||
'packaging_total' => '0.0000', 'shipping_total' => '0.0000',
|
||||
]);
|
||||
|
||||
return [$revision, $snapshot];
|
||||
}
|
||||
|
||||
public function test_admin_can_release_an_approved_revision_to_a_listing(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
[$revision, $snapshot] = $this->approvedRevisionWithCost($project);
|
||||
$link = ProjectListingLink::factory()->for($project)->create(['lock_version' => 0, 'live_release_id' => null]);
|
||||
|
||||
$response = $this->actingAs($admin)->post(route('projects.listings.release', [$project, $link]), [
|
||||
'revision_id' => $revision->id, 'cost_snapshot_id' => $snapshot->id, 'lock_version' => 0,
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
$link->refresh();
|
||||
$this->assertNotNull($link->live_release_id);
|
||||
$this->assertSame(1, $link->lock_version);
|
||||
$this->assertDatabaseHas('project_releases', ['project_id' => $project->id, 'revision_id' => $revision->id, 'action' => 'release']);
|
||||
}
|
||||
|
||||
public function test_uploading_v2_does_not_move_the_live_pointer(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
[$v1, $snapshot1] = $this->approvedRevisionWithCost($project, 1);
|
||||
$link = ProjectListingLink::factory()->for($project)->create(['lock_version' => 0, 'live_release_id' => null]);
|
||||
$this->actingAs($admin)->post(route('projects.listings.release', [$project, $link]), [
|
||||
'revision_id' => $v1->id, 'cost_snapshot_id' => $snapshot1->id, 'lock_version' => 0,
|
||||
]);
|
||||
$link->refresh();
|
||||
$firstReleaseId = $link->live_release_id;
|
||||
|
||||
[$v2, $snapshot2] = $this->approvedRevisionWithCost($project, 2);
|
||||
|
||||
$link->refresh();
|
||||
$this->assertSame($firstReleaseId, $link->live_release_id);
|
||||
}
|
||||
|
||||
public function test_releasing_v2_then_reverting_restores_v1_with_new_history_entry(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
[$v1, $snapshot1] = $this->approvedRevisionWithCost($project, 1);
|
||||
$link = ProjectListingLink::factory()->for($project)->create(['lock_version' => 0, 'live_release_id' => null]);
|
||||
$this->actingAs($admin)->post(route('projects.listings.release', [$project, $link]), [
|
||||
'revision_id' => $v1->id, 'cost_snapshot_id' => $snapshot1->id, 'lock_version' => 0,
|
||||
]);
|
||||
$link->refresh();
|
||||
$firstRelease = $link->live_release_id;
|
||||
|
||||
[$v2, $snapshot2] = $this->approvedRevisionWithCost($project, 2);
|
||||
$this->actingAs($admin)->post(route('projects.listings.release', [$project, $link]), [
|
||||
'revision_id' => $v2->id, 'cost_snapshot_id' => $snapshot2->id, 'lock_version' => $link->lock_version,
|
||||
]);
|
||||
$link->refresh();
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.listings.revert', [$project, $link]), [
|
||||
'release_id' => $firstRelease, 'lock_version' => $link->lock_version,
|
||||
])->assertRedirect();
|
||||
|
||||
$link->refresh();
|
||||
$revertRelease = ProjectRelease::find($link->live_release_id);
|
||||
$this->assertSame('revert', $revertRelease->action);
|
||||
$this->assertSame($v1->id, $revertRelease->revision_id);
|
||||
$this->assertDatabaseCount('project_releases', 3);
|
||||
}
|
||||
|
||||
public function test_optimistic_concurrency_rejects_stale_lock_version(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
[$v1, $snapshot1] = $this->approvedRevisionWithCost($project, 1);
|
||||
$link = ProjectListingLink::factory()->for($project)->create(['lock_version' => 0, 'live_release_id' => null]);
|
||||
$this->actingAs($admin)->post(route('projects.listings.release', [$project, $link]), [
|
||||
'revision_id' => $v1->id, 'cost_snapshot_id' => $snapshot1->id, 'lock_version' => 0,
|
||||
]);
|
||||
|
||||
[$v2, $snapshot2] = $this->approvedRevisionWithCost($project, 2);
|
||||
$response = $this->actingAs($admin)->post(route('projects.listings.release', [$project, $link]), [
|
||||
'revision_id' => $v2->id, 'cost_snapshot_id' => $snapshot2->id, 'lock_version' => 0,
|
||||
]);
|
||||
|
||||
$response->assertStatus(409);
|
||||
}
|
||||
|
||||
public function test_cannot_release_a_revision_from_a_different_project(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$projectA = Project::factory()->create();
|
||||
$projectB = Project::factory()->create();
|
||||
[$revisionOfB] = $this->approvedRevisionWithCost($projectB);
|
||||
$link = ProjectListingLink::factory()->for($projectA)->create(['lock_version' => 0]);
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.listings.release', [$projectA, $link]), [
|
||||
'revision_id' => $revisionOfB->id, 'cost_snapshot_id' => 999999, 'lock_version' => 0,
|
||||
])->assertStatus(422);
|
||||
}
|
||||
|
||||
public function test_cannot_release_an_unapproved_revision(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
$draft = ProjectRevision::factory()->for($project)->create(['status' => 'draft']);
|
||||
$link = ProjectListingLink::factory()->for($project)->create(['lock_version' => 0]);
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.listings.release', [$project, $link]), [
|
||||
'revision_id' => $draft->id, 'cost_snapshot_id' => 999999, 'lock_version' => 0,
|
||||
])->assertStatus(422);
|
||||
}
|
||||
|
||||
public function test_cannot_release_to_a_listing_link_from_another_project(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$projectA = Project::factory()->create();
|
||||
$projectB = Project::factory()->create();
|
||||
[$revisionOfA] = $this->approvedRevisionWithCost($projectA);
|
||||
$linkOfB = ProjectListingLink::factory()->for($projectB)->create(['lock_version' => 0]);
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.listings.release', [$projectA, $linkOfB]), [
|
||||
'revision_id' => $revisionOfA->id, 'lock_version' => 0,
|
||||
])->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_non_admin_cannot_release(): void
|
||||
{
|
||||
$project = Project::factory()->create();
|
||||
$link = ProjectListingLink::factory()->for($project)->create(['lock_version' => 0]);
|
||||
|
||||
$this->actingAs(User::factory()->create())->post(route('projects.listings.release', [$project, $link]), [
|
||||
'revision_id' => 1, 'lock_version' => 0,
|
||||
])->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_release_requires_a_cost_snapshot(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
$revision = ProjectRevision::factory()->approved()->for($project)->create(['revision_number' => 1]);
|
||||
ProjectFile::factory()->for($revision, 'revision')->create();
|
||||
$link = ProjectListingLink::factory()->for($project)->create(['lock_version' => 0]);
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.listings.release', [$project, $link]), [
|
||||
'revision_id' => $revision->id, 'lock_version' => 0,
|
||||
])->assertSessionHasErrors('cost_snapshot_id');
|
||||
$this->assertDatabaseCount('project_releases', 0);
|
||||
}
|
||||
|
||||
public function test_release_rejects_an_incomplete_cost_snapshot(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
$revision = ProjectRevision::factory()->approved()->for($project)->create(['revision_number' => 1]);
|
||||
ProjectFile::factory()->for($revision, 'revision')->create();
|
||||
$incomplete = ProjectCostSnapshot::factory()->for($project)->for($revision, 'revision')->create([
|
||||
'grand_total' => '10.0000', 'filament_total' => '10.0000',
|
||||
'packaging_total' => null, 'shipping_total' => null,
|
||||
]);
|
||||
$link = ProjectListingLink::factory()->for($project)->create(['lock_version' => 0]);
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.listings.release', [$project, $link]), [
|
||||
'revision_id' => $revision->id, 'cost_snapshot_id' => $incomplete->id, 'lock_version' => 0,
|
||||
])->assertSessionHasErrors('cost_snapshot_id');
|
||||
$this->assertDatabaseCount('project_releases', 0);
|
||||
}
|
||||
|
||||
public function test_release_rejects_a_detached_listing_link(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
[$revision, $snapshot] = $this->approvedRevisionWithCost($project);
|
||||
$link = ProjectListingLink::factory()->for($project)->create(['lock_version' => 0, 'detached_at' => now()]);
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.listings.release', [$project, $link]), [
|
||||
'revision_id' => $revision->id, 'cost_snapshot_id' => $snapshot->id, 'lock_version' => 0,
|
||||
])->assertSessionHasErrors('listing_link');
|
||||
$this->assertDatabaseCount('project_releases', 0);
|
||||
}
|
||||
|
||||
public function test_revert_rejects_a_detached_listing_link(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
[$v1, $snapshot1] = $this->approvedRevisionWithCost($project);
|
||||
$link = ProjectListingLink::factory()->for($project)->create(['lock_version' => 0, 'live_release_id' => null]);
|
||||
$this->actingAs($admin)->post(route('projects.listings.release', [$project, $link]), [
|
||||
'revision_id' => $v1->id, 'cost_snapshot_id' => $snapshot1->id, 'lock_version' => 0,
|
||||
]);
|
||||
$link->refresh();
|
||||
$firstRelease = $link->live_release_id;
|
||||
$link->update(['detached_at' => now()]);
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.listings.revert', [$project, $link]), [
|
||||
'release_id' => $firstRelease, 'lock_version' => $link->lock_version,
|
||||
])->assertSessionHasErrors('listing_link');
|
||||
}
|
||||
|
||||
public function test_release_is_recorded_atomically_with_its_audit_event(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
[$revision, $snapshot] = $this->approvedRevisionWithCost($project);
|
||||
$link = ProjectListingLink::factory()->for($project)->create(['lock_version' => 0, 'live_release_id' => null]);
|
||||
Schema::drop('project_events');
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.listings.release', [$project, $link]), [
|
||||
'revision_id' => $revision->id, 'cost_snapshot_id' => $snapshot->id, 'lock_version' => 0,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseCount('project_releases', 0);
|
||||
$link->refresh();
|
||||
$this->assertNull($link->live_release_id);
|
||||
$this->assertSame(0, $link->lock_version);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectFile;
|
||||
use App\Models\ProjectRevision;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProjectRevisionFileTest extends TestCase
|
||||
{
|
||||
use LazilyRefreshDatabase;
|
||||
|
||||
private function stl(string $name = 'part.stl'): UploadedFile
|
||||
{
|
||||
$content = "solid cube\nfacet normal 0 0 0\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\nendsolid cube\n";
|
||||
|
||||
return UploadedFile::fake()->createWithContent($name, $content);
|
||||
}
|
||||
|
||||
public function test_admin_can_upload_a_new_revision_with_files(): void
|
||||
{
|
||||
Storage::fake('project_files');
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$response = $this->actingAs($admin)->post(route('projects.revisions.store', $project), [
|
||||
'change_notes' => 'First cut',
|
||||
'files' => [$this->stl('body.stl')],
|
||||
'roles' => ['body'],
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
$this->assertDatabaseHas('project_revisions', ['project_id' => $project->id, 'revision_number' => 1, 'status' => 'draft']);
|
||||
$revision = ProjectRevision::first();
|
||||
$file = ProjectFile::where('revision_id', $revision->id)->first();
|
||||
$this->assertNotNull($file);
|
||||
$this->assertSame('body.stl', $file->original_filename);
|
||||
$this->assertNotSame('body.stl', $file->storage_key);
|
||||
Storage::disk('project_files')->assertExists($file->storage_key);
|
||||
}
|
||||
|
||||
public function test_revision_numbers_increment_per_project(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
Storage::fake('project_files');
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.revisions.store', $project), [
|
||||
'files' => [$this->stl()], 'roles' => ['body'],
|
||||
]);
|
||||
$this->actingAs($admin)->post(route('projects.revisions.store', $project), [
|
||||
'files' => [$this->stl()], 'roles' => ['body'],
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('project_revisions', ['project_id' => $project->id, 'revision_number' => 1]);
|
||||
$this->assertDatabaseHas('project_revisions', ['project_id' => $project->id, 'revision_number' => 2]);
|
||||
}
|
||||
|
||||
public function test_rejects_upload_exceeding_file_count_limit(): void
|
||||
{
|
||||
Storage::fake('project_files');
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$files = [];
|
||||
$roles = [];
|
||||
for ($i = 0; $i < 11; $i++) {
|
||||
$files[] = $this->stl("part{$i}.stl");
|
||||
$roles[] = 'body';
|
||||
}
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.revisions.store', $project), [
|
||||
'files' => $files, 'roles' => $roles,
|
||||
])->assertSessionHasErrors('files');
|
||||
}
|
||||
|
||||
public function test_rejects_malicious_zip_disguised_as_3mf(): void
|
||||
{
|
||||
Storage::fake('project_files');
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
$path = tempnam(sys_get_temp_dir(), 'zip');
|
||||
file_put_contents($path, random_bytes(64));
|
||||
$file = new UploadedFile($path, 'evil.3mf', null, null, true);
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.revisions.store', $project), [
|
||||
'files' => [$file], 'roles' => ['body'],
|
||||
])->assertSessionHasErrors('files.0');
|
||||
|
||||
$this->assertDatabaseCount('project_files', 0);
|
||||
unlink($path);
|
||||
}
|
||||
|
||||
public function test_failed_validation_leaves_no_orphaned_stored_bytes(): void
|
||||
{
|
||||
Storage::fake('project_files');
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
$path = tempnam(sys_get_temp_dir(), 'zip');
|
||||
file_put_contents($path, random_bytes(64));
|
||||
$badFile = new UploadedFile($path, 'evil.3mf', null, null, true);
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.revisions.store', $project), [
|
||||
'files' => [$this->stl('good.stl'), $badFile], 'roles' => ['body', 'body'],
|
||||
]);
|
||||
|
||||
$this->assertDatabaseCount('project_files', 0);
|
||||
$this->assertCount(0, Storage::disk('project_files')->allFiles());
|
||||
unlink($path);
|
||||
}
|
||||
|
||||
public function test_non_validation_failure_mid_upload_cleans_up_stored_bytes(): void
|
||||
{
|
||||
Storage::fake('project_files');
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$calls = 0;
|
||||
ProjectFile::creating(function () use (&$calls) {
|
||||
$calls++;
|
||||
if ($calls === 2) {
|
||||
throw new \RuntimeException('Simulated DB failure mid-upload.');
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
$this->actingAs($admin)->post(route('projects.revisions.store', $project), [
|
||||
'files' => [$this->stl('a.stl'), $this->stl('b.stl')],
|
||||
'roles' => ['body', 'body'],
|
||||
]);
|
||||
} finally {
|
||||
ProjectFile::flushEventListeners();
|
||||
}
|
||||
|
||||
$this->assertDatabaseCount('project_files', 0);
|
||||
$this->assertCount(0, Storage::disk('project_files')->allFiles());
|
||||
}
|
||||
|
||||
public function test_rejects_a_batch_whose_combined_size_is_excessive(): void
|
||||
{
|
||||
Storage::fake('project_files');
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$files = [];
|
||||
$roles = [];
|
||||
for ($i = 0; $i < 4; $i++) {
|
||||
$files[] = UploadedFile::fake()->create("big{$i}.stl", 90 * 1024, 'model/stl');
|
||||
$roles[] = 'body';
|
||||
}
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.revisions.store', $project), [
|
||||
'files' => $files, 'roles' => $roles,
|
||||
])->assertSessionHasErrors('files');
|
||||
$this->assertDatabaseCount('project_files', 0);
|
||||
}
|
||||
|
||||
public function test_approving_a_revision_locks_in_approver_and_timestamp(): void
|
||||
{
|
||||
Storage::fake('project_files');
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
$this->actingAs($admin)->post(route('projects.revisions.store', $project), [
|
||||
'files' => [$this->stl()], 'roles' => ['body'],
|
||||
]);
|
||||
$revision = ProjectRevision::first();
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.revisions.approve', [$project, $revision]))->assertRedirect();
|
||||
|
||||
$revision->refresh();
|
||||
$this->assertSame('approved', $revision->status);
|
||||
$this->assertSame($admin->id, $revision->approved_by);
|
||||
$this->assertNotNull($revision->approved_at);
|
||||
}
|
||||
|
||||
public function test_approved_revision_cannot_be_modified_by_adding_more_files(): void
|
||||
{
|
||||
Storage::fake('project_files');
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
$revision = ProjectRevision::factory()->approved()->for($project)->create(['revision_number' => 1]);
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.revisions.store', $project), [
|
||||
'files' => [$this->stl()], 'roles' => ['body'], 'revision_id' => $revision->id,
|
||||
]);
|
||||
|
||||
// A correction always creates a new revision rather than mutating the approved one.
|
||||
$this->assertDatabaseHas('project_revisions', ['project_id' => $project->id, 'revision_number' => 2]);
|
||||
$this->assertSame(0, ProjectFile::where('revision_id', $revision->id)->count());
|
||||
}
|
||||
|
||||
public function test_guest_cannot_download_project_file(): void
|
||||
{
|
||||
Storage::fake('project_files');
|
||||
$project = Project::factory()->create();
|
||||
$revision = ProjectRevision::factory()->for($project)->create();
|
||||
$file = ProjectFile::factory()->for($revision, 'revision')->create();
|
||||
|
||||
$this->get(route('projects.files.download', [$project, $file]))->assertRedirect(route('login'));
|
||||
}
|
||||
|
||||
public function test_cannot_download_a_file_through_a_different_projects_url(): void
|
||||
{
|
||||
Storage::fake('project_files');
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$projectA = Project::factory()->create();
|
||||
$projectB = Project::factory()->create();
|
||||
$revision = ProjectRevision::factory()->for($projectA)->create();
|
||||
$file = ProjectFile::factory()->for($revision, 'revision')->create();
|
||||
|
||||
$this->actingAs($admin)->get(route('projects.files.download', [$projectB, $file]))->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_admin_can_download_an_owned_project_file(): void
|
||||
{
|
||||
Storage::fake('project_files');
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
$this->actingAs($admin)->post(route('projects.revisions.store', $project), [
|
||||
'files' => [$this->stl('mypart.stl')], 'roles' => ['body'],
|
||||
]);
|
||||
$file = ProjectFile::first();
|
||||
|
||||
$this->actingAs($admin)->get(route('projects.files.download', [$project, $file]))
|
||||
->assertOk()
|
||||
->assertHeader('content-disposition', 'attachment; filename=mypart.stl');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectFile;
|
||||
use App\Models\ProjectListingLink;
|
||||
use App\Models\ProjectRelease;
|
||||
use App\Models\ProjectRevision;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProjectSecurityTest extends TestCase
|
||||
{
|
||||
use LazilyRefreshDatabase;
|
||||
|
||||
public function test_disabled_admin_is_logged_out_before_reaching_project_routes(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin', 'disabled' => true]);
|
||||
|
||||
$this->actingAs($admin)->get(route('projects.index'))
|
||||
->assertRedirect(route('login'));
|
||||
$this->assertGuest();
|
||||
}
|
||||
|
||||
public function test_project_notes_with_script_tags_are_rendered_escaped_not_executed(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create(['notes' => '<script>alert(1)</script>']);
|
||||
|
||||
$response = $this->actingAs($admin)->get(route('projects.show', $project));
|
||||
|
||||
$response->assertOk()
|
||||
->assertDontSee('<script>alert(1)</script>', false)
|
||||
->assertSee('<script>alert(1)</script>', false);
|
||||
}
|
||||
|
||||
public function test_revision_change_notes_with_html_are_escaped_in_the_project_view(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
ProjectRevision::factory()->for($project)->create(['change_notes' => '<img src=x onerror=alert(1)>']);
|
||||
|
||||
$this->actingAs($admin)->get(route('projects.show', $project))->assertOk()
|
||||
->assertDontSee('<img src=x onerror=alert(1)>', false);
|
||||
}
|
||||
|
||||
public function test_every_mutating_project_endpoint_rejects_guests(): void
|
||||
{
|
||||
$project = Project::factory()->create();
|
||||
$revision = ProjectRevision::factory()->for($project)->create();
|
||||
$file = ProjectFile::factory()->for($revision, 'revision')->create();
|
||||
$listingLink = ProjectListingLink::factory()->for($project)->create();
|
||||
$release = ProjectRelease::factory()->for($project)->create();
|
||||
|
||||
$this->post(route('projects.store'), [])->assertRedirect(route('login'));
|
||||
$this->patch(route('projects.update', $project), [])->assertRedirect(route('login'));
|
||||
$this->post(route('projects.archive', $project))->assertRedirect(route('login'));
|
||||
$this->post(route('projects.revisions.store', $project), [])->assertRedirect(route('login'));
|
||||
$this->post(route('projects.revisions.approve', [$project, $revision]))->assertRedirect(route('login'));
|
||||
$this->get(route('projects.files.download', [$project, $file]))->assertRedirect(route('login'));
|
||||
$this->post(route('projects.listings.store', $project), [])->assertRedirect(route('login'));
|
||||
$this->delete(route('projects.listings.destroy', [$project, $listingLink]))->assertRedirect(route('login'));
|
||||
$this->post(route('projects.cost-snapshots.store', $project), [])->assertRedirect(route('login'));
|
||||
$this->post(route('projects.listings.release', [$project, $listingLink]), [])->assertRedirect(route('login'));
|
||||
$this->post(route('projects.listings.revert', [$project, $listingLink]), ['release_id' => $release->id])->assertRedirect(route('login'));
|
||||
}
|
||||
|
||||
public function test_every_mutating_project_endpoint_rejects_non_admin_users(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$project = Project::factory()->create();
|
||||
$revision = ProjectRevision::factory()->for($project)->create();
|
||||
$file = ProjectFile::factory()->for($revision, 'revision')->create();
|
||||
$listingLink = ProjectListingLink::factory()->for($project)->create();
|
||||
$release = ProjectRelease::factory()->for($project)->create();
|
||||
|
||||
$this->actingAs($user)->post(route('projects.store'), [])->assertForbidden();
|
||||
$this->actingAs($user)->patch(route('projects.update', $project), [])->assertForbidden();
|
||||
$this->actingAs($user)->post(route('projects.archive', $project))->assertForbidden();
|
||||
$this->actingAs($user)->post(route('projects.revisions.store', $project), [])->assertForbidden();
|
||||
$this->actingAs($user)->post(route('projects.revisions.approve', [$project, $revision]))->assertForbidden();
|
||||
$this->actingAs($user)->get(route('projects.files.download', [$project, $file]))->assertForbidden();
|
||||
$this->actingAs($user)->post(route('projects.listings.store', $project), [])->assertForbidden();
|
||||
$this->actingAs($user)->delete(route('projects.listings.destroy', [$project, $listingLink]))->assertForbidden();
|
||||
$this->actingAs($user)->post(route('projects.cost-snapshots.store', $project), [])->assertForbidden();
|
||||
$this->actingAs($user)->post(route('projects.listings.release', [$project, $listingLink]), [])->assertForbidden();
|
||||
$this->actingAs($user)->post(route('projects.listings.revert', [$project, $listingLink]), ['release_id' => $release->id])->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_disabled_admin_cannot_use_any_mutating_project_endpoint(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin', 'disabled' => true]);
|
||||
$project = Project::factory()->create();
|
||||
$listingLink = ProjectListingLink::factory()->for($project)->create();
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.store'), [])->assertRedirect(route('login'));
|
||||
$this->actingAs($admin)->post(route('projects.listings.release', [$project, $listingLink]), [])->assertRedirect(route('login'));
|
||||
}
|
||||
|
||||
public function test_cross_project_revision_id_is_rejected_rather_than_silently_creating_a_new_revision(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$projectA = Project::factory()->create();
|
||||
$projectB = Project::factory()->create();
|
||||
$foreignRevision = ProjectRevision::factory()->for($projectB)->create(['revision_number' => 1]);
|
||||
Storage::fake('project_files');
|
||||
$stl = UploadedFile::fake()->createWithContent(
|
||||
'part.stl',
|
||||
"solid cube\nfacet normal 0 0 0\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\nendsolid cube\n",
|
||||
);
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.revisions.store', $projectA), [
|
||||
'files' => [$stl], 'roles' => ['body'], 'revision_id' => $foreignRevision->id,
|
||||
])->assertRedirect();
|
||||
|
||||
// A revision_id that doesn't belong to this project must never be written to;
|
||||
// it must fall through to creating a fresh revision scoped to this project.
|
||||
$this->assertDatabaseHas('project_revisions', ['project_id' => $projectA->id, 'revision_number' => 1]);
|
||||
$this->assertSame(0, $foreignRevision->fresh()->files()->count());
|
||||
}
|
||||
|
||||
public function test_missing_csrf_token_is_rejected_when_verification_actually_runs(): void
|
||||
{
|
||||
$this->app['env'] = 'local';
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$response = $this->actingAs($admin)->post(route('projects.archive', $project));
|
||||
|
||||
$response->assertStatus(419);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Support\FixedPoint;
|
||||
use InvalidArgumentException;
|
||||
use OverflowException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class FixedPointTest extends TestCase
|
||||
{
|
||||
public function test_parses_decimal_string_to_scaled_integer(): void
|
||||
{
|
||||
$this->assertSame(1500, FixedPoint::toScaled('1.5', 3));
|
||||
$this->assertSame(-1500, FixedPoint::toScaled('-1.5', 3));
|
||||
$this->assertSame(1000000, FixedPoint::toScaled('1', 6));
|
||||
$this->assertSame(0, FixedPoint::toScaled('0', 4));
|
||||
}
|
||||
|
||||
public function test_rejects_malformed_decimal_strings(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
FixedPoint::toScaled('1.5.3', 3);
|
||||
}
|
||||
|
||||
public function test_rejects_non_numeric_strings(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
FixedPoint::toScaled('abc', 3);
|
||||
}
|
||||
|
||||
public function test_rejects_values_exceeding_bound(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
FixedPoint::toScaled('100000000', 6, maxAbs: 1_000_000);
|
||||
}
|
||||
|
||||
public function test_rejects_negative_values_when_disallowed(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
FixedPoint::toScaled('-5', 3, allowNegative: false);
|
||||
}
|
||||
|
||||
public function test_multiplies_two_scaled_values_and_rescales_with_round_half_up(): void
|
||||
{
|
||||
// 2.5 grams (scale 3) * 4.0 rate per kg (scale 6) => product scale 9, then /1000 for g->kg => rescale to scale 4.
|
||||
$grams = FixedPoint::toScaled('2.500', 3);
|
||||
$rate = FixedPoint::toScaled('4.000000', 6);
|
||||
$productScale9 = FixedPoint::multiply($grams, $rate);
|
||||
// raw cost = 2.5g * (4.0/1000 per g) = 0.01 -> scale 4 => 100
|
||||
$result = FixedPoint::rescale($productScale9, 9 + 3, 4);
|
||||
$this->assertSame(100, $result);
|
||||
$this->assertSame('0.0100', FixedPoint::format($result, 4));
|
||||
}
|
||||
|
||||
public function test_rescale_rounds_half_up(): void
|
||||
{
|
||||
// 0.00005 at scale 5 rescaled to scale 4 should round up to 0.0001 (not banker's rounding).
|
||||
$this->assertSame(1, FixedPoint::rescale(5, 5, 4));
|
||||
$this->assertSame(0, FixedPoint::rescale(4, 5, 4));
|
||||
$this->assertSame(-1, FixedPoint::rescale(-5, 5, 4));
|
||||
}
|
||||
|
||||
public function test_add_and_subtract_scaled_values(): void
|
||||
{
|
||||
$a = FixedPoint::toScaled('10.1234', 4);
|
||||
$b = FixedPoint::toScaled('0.0001', 4);
|
||||
$this->assertSame(101235, FixedPoint::add($a, $b));
|
||||
$this->assertSame(101233, FixedPoint::subtract($a, $b));
|
||||
}
|
||||
|
||||
public function test_add_detects_overflow(): void
|
||||
{
|
||||
$this->expectException(OverflowException::class);
|
||||
FixedPoint::add(PHP_INT_MAX, 1);
|
||||
}
|
||||
|
||||
public function test_multiply_detects_overflow(): void
|
||||
{
|
||||
$this->expectException(OverflowException::class);
|
||||
FixedPoint::multiply(PHP_INT_MAX, 2);
|
||||
}
|
||||
|
||||
public function test_divide_scaled_value_by_positive_integer_with_round_half_up(): void
|
||||
{
|
||||
$amount = FixedPoint::toScaled('10.0000', 4);
|
||||
$this->assertSame(FixedPoint::toScaled('3.3333', 4), FixedPoint::divideByInt($amount, 3));
|
||||
}
|
||||
|
||||
public function test_divide_by_zero_is_rejected(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
FixedPoint::divideByInt(FixedPoint::toScaled('1', 4), 0);
|
||||
}
|
||||
|
||||
public function test_format_renders_fixed_decimal_places_without_floats(): void
|
||||
{
|
||||
$this->assertSame('1234.5000', FixedPoint::format(FixedPoint::toScaled('1234.5', 4), 4));
|
||||
$this->assertSame('-0.0100', FixedPoint::format(FixedPoint::toScaled('-0.01', 4), 4));
|
||||
$this->assertSame('0.0000', FixedPoint::format(0, 4));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,592 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Services\ManufacturingFileValidator;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
use ZipArchive;
|
||||
|
||||
class ManufacturingFileValidatorTest extends TestCase
|
||||
{
|
||||
private ManufacturingFileValidator $validator;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->validator = new ManufacturingFileValidator;
|
||||
}
|
||||
|
||||
private function uploadedFile(string $path, string $clientName): UploadedFile
|
||||
{
|
||||
return new UploadedFile($path, $clientName, null, null, true);
|
||||
}
|
||||
|
||||
public function test_accepts_valid_ascii_stl(): void
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'stl');
|
||||
file_put_contents($path, "solid cube\nfacet normal 0 0 0\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\nendsolid cube\n");
|
||||
|
||||
$result = $this->validator->validate($this->uploadedFile($path, 'cube.stl'));
|
||||
|
||||
$this->assertSame('stl', $result['extension']);
|
||||
unlink($path);
|
||||
}
|
||||
|
||||
public function test_accepts_valid_binary_stl(): void
|
||||
{
|
||||
$path = $this->makeBinaryStl(triangles: 2);
|
||||
|
||||
$result = $this->validator->validate($this->uploadedFile($path, 'part.stl'));
|
||||
|
||||
$this->assertSame('stl', $result['extension']);
|
||||
unlink($path);
|
||||
}
|
||||
|
||||
public function test_rejects_binary_stl_with_triangle_count_mismatch(): void
|
||||
{
|
||||
$path = $this->makeBinaryStl(triangles: 2, declareExtraTriangle: true);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'part.stl'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_ascii_stl_with_zero_facets(): void
|
||||
{
|
||||
// The exact review example: a single line with "solid" and "endsolid"
|
||||
// tokens but no actual facet/vertex data must not be accepted.
|
||||
$path = tempnam(sys_get_temp_dir(), 'stl');
|
||||
file_put_contents($path, "solid garbage endsolid\n");
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'garbage.stl'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_accepts_ascii_stl_with_multiple_facets(): void
|
||||
{
|
||||
$facet = "facet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\n";
|
||||
$path = tempnam(sys_get_temp_dir(), 'stl');
|
||||
file_put_contents($path, "solid cube\n".$facet.$facet."endsolid cube\n");
|
||||
|
||||
$result = $this->validator->validate($this->uploadedFile($path, 'cube.stl'));
|
||||
|
||||
$this->assertSame('stl', $result['extension']);
|
||||
unlink($path);
|
||||
}
|
||||
|
||||
public function test_rejects_ascii_stl_missing_outer_loop(): void
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'stl');
|
||||
file_put_contents($path, "solid cube\nfacet normal 0 0 1\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\nendsolid cube\n");
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'cube.stl'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_ascii_stl_with_non_finite_vertex_value(): void
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'stl');
|
||||
file_put_contents($path, "solid cube\nfacet normal 0 0 1\nouter loop\nvertex nan 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\nendsolid cube\n");
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'cube.stl'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_ascii_stl_missing_a_vertex(): void
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'stl');
|
||||
file_put_contents($path, "solid cube\nfacet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nendloop\nendfacet\nendsolid cube\n");
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'cube.stl'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_binary_stl_with_zero_triangles(): void
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'stl');
|
||||
file_put_contents($path, str_repeat('A', 80).pack('V', 0));
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'empty.stl'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_binary_stl_with_non_finite_values(): void
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'stl');
|
||||
$handle = fopen($path, 'wb');
|
||||
fwrite($handle, str_repeat('A', 80));
|
||||
fwrite($handle, pack('V', 1));
|
||||
fwrite($handle, str_repeat(pack('f', NAN), 12));
|
||||
fwrite($handle, pack('v', 0));
|
||||
fclose($handle);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'nan.stl'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_binary_stl_with_absurd_triangle_count(): void
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'stl');
|
||||
$header = str_repeat('A', 80);
|
||||
file_put_contents($path, $header.pack('V', 100_000_000));
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'huge.stl'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_file_with_mismatched_extension_and_content(): void
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'fake');
|
||||
file_put_contents($path, "G1 X10 Y10\nG1 X20 Y20\n");
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'fake.stl'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_unsupported_extension(): void
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'exe');
|
||||
file_put_contents($path, 'MZ'.str_repeat("\x00", 100));
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'payload.exe'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_accepts_plain_text_gcode(): void
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'gcode');
|
||||
file_put_contents($path, "; generated by slicer\nG28\nG1 X0 Y0 Z0.2 F3000\nM104 S200\n");
|
||||
|
||||
$result = $this->validator->validate($this->uploadedFile($path, 'part.gcode'));
|
||||
|
||||
$this->assertSame('gcode', $result['extension']);
|
||||
unlink($path);
|
||||
}
|
||||
|
||||
public function test_rejects_gcode_containing_binary_bytes(): void
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'gcode');
|
||||
file_put_contents($path, "G1 X0\0\x01\x02 binary disguised as text");
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'part.gcode'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_accepts_valid_3mf_archive(): void
|
||||
{
|
||||
$path = $this->make3mf([
|
||||
'[Content_Types].xml' => '<?xml version="1.0"?><Types xmlns="x"/>',
|
||||
'3D/3dmodel.model' => '<?xml version="1.0"?><model xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02"><resources></resources><build></build></model>',
|
||||
'_rels/.rels' => '<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Target="/3D/3dmodel.model" Id="rel0" Type="y"/></Relationships>',
|
||||
]);
|
||||
|
||||
$result = $this->validator->validate($this->uploadedFile($path, 'part.3mf'));
|
||||
|
||||
$this->assertSame('3mf', $result['extension']);
|
||||
unlink($path);
|
||||
}
|
||||
|
||||
public function test_rejects_3mf_model_missing_required_resources_or_build_elements(): void
|
||||
{
|
||||
$path = $this->make3mf([
|
||||
'[Content_Types].xml' => '<?xml version="1.0"?><Types xmlns="x"/>',
|
||||
'3D/3dmodel.model' => '<?xml version="1.0"?><model xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02"></model>',
|
||||
'_rels/.rels' => '<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Target="/3D/3dmodel.model" Id="rel0" Type="y"/></Relationships>',
|
||||
]);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'incomplete.3mf'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_3mf_model_with_malformed_not_well_formed_xml(): void
|
||||
{
|
||||
$path = $this->make3mf([
|
||||
'[Content_Types].xml' => '<?xml version="1.0"?><Types xmlns="x"/>',
|
||||
'3D/3dmodel.model' => '<?xml version="1.0"?><model xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02"><resources></resources><build></model>',
|
||||
'_rels/.rels' => '<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Target="/3D/3dmodel.model" Id="rel0" Type="y"/></Relationships>',
|
||||
]);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'malformed.3mf'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_3mf_relationship_with_single_quoted_external_target(): void
|
||||
{
|
||||
// The old validator only regex-matched double-quoted Target="..." attributes,
|
||||
// so a single-quoted external target slipped straight through unparsed.
|
||||
$path = $this->make3mf([
|
||||
'[Content_Types].xml' => '<?xml version="1.0"?><Types xmlns="x"/>',
|
||||
'3D/3dmodel.model' => '<?xml version="1.0"?><model xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02"><resources></resources><build></build></model>',
|
||||
'_rels/.rels' => "<?xml version='1.0'?><Relationships xmlns='http://schemas.openxmlformats.org/package/2006/relationships'><Relationship Target='https://evil.example.test/payload' Id='rel0' Type='y'/></Relationships>",
|
||||
]);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'evil.3mf'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_3mf_relationship_with_external_target_mode_regardless_of_target_value(): void
|
||||
{
|
||||
$path = $this->make3mf([
|
||||
'[Content_Types].xml' => '<?xml version="1.0"?><Types xmlns="x"/>',
|
||||
'3D/3dmodel.model' => '<?xml version="1.0"?><model xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02"><resources></resources><build></build></model>',
|
||||
'_rels/.rels' => '<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Target="safe/internal/part.xml" TargetMode="External" Id="rel0" Type="y"/></Relationships>',
|
||||
]);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'evil.3mf'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_3mf_nested_rels_part_with_external_target(): void
|
||||
{
|
||||
// Per-part relationship files (not just the root _rels/.rels) must be
|
||||
// inspected too; a nested one pointing outside the package must still
|
||||
// be rejected.
|
||||
$path = $this->make3mf([
|
||||
'[Content_Types].xml' => '<?xml version="1.0"?><Types xmlns="x"/>',
|
||||
'3D/3dmodel.model' => '<?xml version="1.0"?><model xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02"><resources></resources><build></build></model>',
|
||||
'_rels/.rels' => '<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Target="/3D/3dmodel.model" Id="rel0" Type="y"/></Relationships>',
|
||||
'3D/_rels/3dmodel.model.rels' => '<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Target="http://evil.example.test/texture.png" Id="rel1" Type="y"/></Relationships>',
|
||||
]);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'evil.3mf'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_3mf_with_an_encrypted_entry(): void
|
||||
{
|
||||
$zip = new ZipArchive;
|
||||
$path = tempnam(sys_get_temp_dir(), 'tmf').'.3mf';
|
||||
$zip->open($path, ZipArchive::CREATE);
|
||||
$zip->addFromString('[Content_Types].xml', '<?xml version="1.0"?><Types xmlns="x"/>');
|
||||
$zip->addFromString('3D/3dmodel.model', '<?xml version="1.0"?><model xmlns="x"><resources></resources><build></build></model>');
|
||||
$zip->addFromString('secret.bin', 'payload');
|
||||
$zip->setEncryptionName('secret.bin', ZipArchive::EM_AES_256, 'password');
|
||||
$zip->close();
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'evil.3mf'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_3mf_entry_exceeding_the_per_entry_xml_size_cap(): void
|
||||
{
|
||||
// Low-compressibility padding keeps this under the ratio-bomb threshold
|
||||
// while still being an individually oversized XML-ish part.
|
||||
$zip = new ZipArchive;
|
||||
$path = tempnam(sys_get_temp_dir(), 'tmf').'.3mf';
|
||||
$zip->open($path, ZipArchive::CREATE);
|
||||
$zip->addFromString('[Content_Types].xml', '<?xml version="1.0"?><Types xmlns="x"/>');
|
||||
$zip->addFromString('3D/3dmodel.model', '<?xml version="1.0"?><model xmlns="x"><resources></resources><build></build></model>');
|
||||
$zip->addFromString('_rels/.rels', '<?xml version="1.0"?><Relationships xmlns="x"><Relationship Target="/3D/3dmodel.model" Id="rel0" Type="y"/></Relationships>');
|
||||
$zip->addFromString('huge.xml', '<x>'.base64_encode(random_bytes(18 * 1024 * 1024)).'</x>');
|
||||
$zip->setCompressionName('huge.xml', ZipArchive::CM_STORE);
|
||||
$zip->close();
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'huge.3mf'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_3mf_with_a_duplicate_entry_path(): void
|
||||
{
|
||||
// ZipArchive's own API dedupes entries by name, so a genuine duplicate
|
||||
// (two local file headers + two central directory records sharing one
|
||||
// name — a known zip-parser confusion vector) has to be hand-built.
|
||||
$path = tempnam(sys_get_temp_dir(), 'tmf').'.3mf';
|
||||
file_put_contents($path, $this->rawZipWithDuplicateEntryName());
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'dup.3mf'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
private function rawZipWithDuplicateEntryName(): string
|
||||
{
|
||||
// An otherwise entirely valid, acceptable 3MF archive, plus a duplicate
|
||||
// pair of an unrelated entry name — so this only fails if duplicate
|
||||
// detection itself exists, not for any other unrelated reason.
|
||||
$entries = [
|
||||
['[Content_Types].xml', '<?xml version="1.0"?><Types xmlns="x"/>'],
|
||||
['3D/3dmodel.model', '<?xml version="1.0"?><model xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02"><resources></resources><build></build></model>'],
|
||||
['_rels/.rels', '<?xml version="1.0"?><Relationships xmlns="x"><Relationship Target="/3D/3dmodel.model" Id="rel0" Type="y"/></Relationships>'],
|
||||
['same.txt', 'first'],
|
||||
['same.txt', 'second'],
|
||||
];
|
||||
|
||||
$local = '';
|
||||
$central = '';
|
||||
$offset = 0;
|
||||
foreach ($entries as [$name, $content]) {
|
||||
$crc = crc32($content);
|
||||
$len = strlen($content);
|
||||
$nameLen = strlen($name);
|
||||
$lfh = pack('VvvvvvVVVvv', 0x04034B50, 20, 0, 0, 0, 0, $crc, $len, $len, $nameLen, 0).$name.$content;
|
||||
$cdfh = pack('VvvvvvvVVVvvvvvVV', 0x02014B50, 20, 20, 0, 0, 0, 0, $crc, $len, $len, $nameLen, 0, 0, 0, 0, 0, $offset).$name;
|
||||
$local .= $lfh;
|
||||
$central .= $cdfh;
|
||||
$offset += strlen($lfh);
|
||||
}
|
||||
|
||||
$eocd = pack('VvvvvVVv', 0x06054B50, 0, 0, count($entries), count($entries), strlen($central), $offset, 0);
|
||||
|
||||
return $local.$central.$eocd;
|
||||
}
|
||||
|
||||
public function test_rejects_3mf_missing_required_model_part(): void
|
||||
{
|
||||
$path = $this->make3mf([
|
||||
'[Content_Types].xml' => '<?xml version="1.0"?><Types xmlns="x"/>',
|
||||
]);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'broken.3mf'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_3mf_with_path_traversal_entry(): void
|
||||
{
|
||||
$path = $this->make3mf([
|
||||
'[Content_Types].xml' => '<?xml version="1.0"?><Types xmlns="x"/>',
|
||||
'3D/3dmodel.model' => '<?xml version="1.0"?><model xmlns="x"></model>',
|
||||
'../../../etc/evil.txt' => 'pwned',
|
||||
]);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'evil.3mf'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_3mf_with_absolute_path_entry(): void
|
||||
{
|
||||
$zip = new ZipArchive;
|
||||
$path = tempnam(sys_get_temp_dir(), 'tmf').'.3mf';
|
||||
$zip->open($path, ZipArchive::CREATE);
|
||||
$zip->addFromString('[Content_Types].xml', '<?xml version="1.0"?><Types xmlns="x"/>');
|
||||
$zip->addFromString('3D/3dmodel.model', '<?xml version="1.0"?><model xmlns="x"></model>');
|
||||
// ZipArchive normalises leading slashes away, so write the entry via a raw local file header hack:
|
||||
// fall back to asserting the validator rejects entries containing backslashes / drive letters instead.
|
||||
$zip->addFromString('C:\\Windows\\evil.dll', 'pwned');
|
||||
$zip->close();
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'evil.3mf'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_3mf_model_part_with_doctype_declaration(): void
|
||||
{
|
||||
$path = $this->make3mf([
|
||||
'[Content_Types].xml' => '<?xml version="1.0"?><Types xmlns="x"/>',
|
||||
'3D/3dmodel.model' => '<?xml version="1.0"?><!DOCTYPE model [<!ENTITY xxe SYSTEM "file:///etc/passwd">]><model xmlns="x">&xxe;</model>',
|
||||
]);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'xxe.3mf'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_3mf_relationship_with_external_target(): void
|
||||
{
|
||||
$path = $this->make3mf([
|
||||
'[Content_Types].xml' => '<?xml version="1.0"?><Types xmlns="x"/>',
|
||||
'3D/3dmodel.model' => '<?xml version="1.0"?><model xmlns="x"></model>',
|
||||
'_rels/.rels' => '<?xml version="1.0"?><Relationships xmlns="x"><Relationship Target="https://evil.example.test/payload" Id="rel0" Type="y"/></Relationships>',
|
||||
]);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'evil.3mf'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_3mf_with_symlink_entry(): void
|
||||
{
|
||||
$zip = new ZipArchive;
|
||||
$path = tempnam(sys_get_temp_dir(), 'tmf').'.3mf';
|
||||
$zip->open($path, ZipArchive::CREATE);
|
||||
$zip->addFromString('[Content_Types].xml', '<?xml version="1.0"?><Types xmlns="x"/>');
|
||||
$zip->addFromString('3D/3dmodel.model', '<?xml version="1.0"?><model xmlns="x"></model>');
|
||||
$zip->addFromString('evil-link', '/etc/passwd');
|
||||
$zip->setExternalAttributesName('evil-link', ZipArchive::OPSYS_UNIX, 0120777 << 16);
|
||||
$zip->close();
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'evil.3mf'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_3mf_with_too_many_entries(): void
|
||||
{
|
||||
$entries = ['[Content_Types].xml' => '<?xml version="1.0"?><Types xmlns="x"/>', '3D/3dmodel.model' => '<?xml version="1.0"?><model xmlns="x"></model>'];
|
||||
for ($i = 0; $i < 5000; $i++) {
|
||||
$entries["junk/{$i}.txt"] = 'x';
|
||||
}
|
||||
$path = $this->make3mf($entries);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'bomb.3mf'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_3mf_entry_with_extreme_compression_ratio(): void
|
||||
{
|
||||
$zip = new ZipArchive;
|
||||
$path = tempnam(sys_get_temp_dir(), 'tmf').'.3mf';
|
||||
$zip->open($path, ZipArchive::CREATE);
|
||||
$zip->addFromString('[Content_Types].xml', '<?xml version="1.0"?><Types xmlns="x"/>');
|
||||
$zip->addFromString('3D/3dmodel.model', '<?xml version="1.0"?><model xmlns="x"></model>');
|
||||
$zip->addFromString('bomb.bin', str_repeat("\0", 20 * 1024 * 1024));
|
||||
$zip->setCompressionName('bomb.bin', ZipArchive::CM_DEFLATE, 9);
|
||||
$zip->close();
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'bomb.3mf'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_file_exceeding_maximum_size(): void
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'stl');
|
||||
$handle = fopen($path, 'wb');
|
||||
fwrite($handle, str_repeat('A', 80));
|
||||
fwrite($handle, pack('V', 1));
|
||||
fseek($handle, (100 * 1024 * 1024) + 200);
|
||||
fwrite($handle, 'x');
|
||||
fclose($handle);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
$this->validator->validate($this->uploadedFile($path, 'huge.stl'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
private function makeBinaryStl(int $triangles, bool $declareExtraTriangle = false): string
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'stl');
|
||||
$handle = fopen($path, 'wb');
|
||||
fwrite($handle, str_repeat('A', 80));
|
||||
fwrite($handle, pack('V', $declareExtraTriangle ? $triangles + 1 : $triangles));
|
||||
for ($i = 0; $i < $triangles; $i++) {
|
||||
fwrite($handle, str_repeat(pack('f', 0.0), 12));
|
||||
fwrite($handle, pack('v', 0));
|
||||
}
|
||||
fclose($handle);
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $entries
|
||||
*/
|
||||
private function make3mf(array $entries): string
|
||||
{
|
||||
$zip = new ZipArchive;
|
||||
$path = tempnam(sys_get_temp_dir(), 'tmf').'.3mf';
|
||||
$zip->open($path, ZipArchive::CREATE);
|
||||
foreach ($entries as $name => $contents) {
|
||||
$zip->addFromString($name, $contents);
|
||||
}
|
||||
$zip->close();
|
||||
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Services\ProjectCosting;
|
||||
use InvalidArgumentException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProjectCostingTest extends TestCase
|
||||
{
|
||||
public function test_filament_line_converts_grams_to_kilograms_before_pricing(): void
|
||||
{
|
||||
// 250g at £20.00/kg = £5.00
|
||||
$line = ProjectCosting::lineTotal('filament', '250', 'g', '20.000000');
|
||||
$this->assertSame('5.0000', $line);
|
||||
}
|
||||
|
||||
public function test_filament_line_includes_explicit_waste_without_double_counting(): void
|
||||
{
|
||||
// 250g consumed + 10g explicit purge/support waste = 260g at £20.00/kg
|
||||
$line = ProjectCosting::lineTotal('filament', '250', 'g', '20.000000', waste: '10');
|
||||
$this->assertSame('5.2000', $line);
|
||||
}
|
||||
|
||||
public function test_packaging_line_is_quantity_times_unit_rate(): void
|
||||
{
|
||||
$line = ProjectCosting::lineTotal('packaging', '3', 'unit', '0.750000');
|
||||
$this->assertSame('2.2500', $line);
|
||||
}
|
||||
|
||||
public function test_shipping_line_total_is_quantity_times_parcel_rate(): void
|
||||
{
|
||||
$line = ProjectCosting::lineTotal('shipping', '1', 'parcel', '4.990000');
|
||||
$this->assertSame('4.9900', $line);
|
||||
}
|
||||
|
||||
public function test_shipping_unit_contribution_divides_rate_by_units_per_parcel_not_batch_yield(): void
|
||||
{
|
||||
// One £4.99 parcel covering 2 saleable units ships at £2.495 per unit,
|
||||
// independent of however many units the manufacturing batch itself yielded.
|
||||
$this->assertSame('2.4950', ProjectCosting::shippingUnitContribution('4.990000', 2));
|
||||
}
|
||||
|
||||
public function test_shipping_unit_contribution_rejects_zero_or_negative_units_per_parcel(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
ProjectCosting::shippingUnitContribution('4.990000', 0);
|
||||
}
|
||||
|
||||
public function test_filament_rejects_a_quantity_unit_not_in_the_category_allowlist(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
ProjectCosting::lineTotal('filament', '250', 'G', '20.000000');
|
||||
}
|
||||
|
||||
public function test_filament_rejects_kilograms_as_the_quantity_unit(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
ProjectCosting::lineTotal('filament', '0.25', 'kg', '20.000000');
|
||||
}
|
||||
|
||||
public function test_packaging_rejects_a_quantity_unit_not_in_the_category_allowlist(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
ProjectCosting::lineTotal('packaging', '1', 'units', '0.650000');
|
||||
}
|
||||
|
||||
public function test_shipping_rejects_a_quantity_unit_not_in_the_category_allowlist(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
ProjectCosting::lineTotal('shipping', '1', 'box', '4.990000');
|
||||
}
|
||||
|
||||
public function test_aggregate_rejects_a_category_total_exceeding_the_storable_decimal_precision(): void
|
||||
{
|
||||
// decimal(14,4) tops out around 9,999,999,999.9999; two large-but-individually-valid
|
||||
// lines can still sum past that bound, so the aggregate step must re-check it.
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
ProjectCosting::aggregate([
|
||||
['category' => 'filament', 'line_total' => '9999999999.0000'],
|
||||
['category' => 'filament', 'line_total' => '9999999999.0000'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_aggregate_rejects_a_grand_total_exceeding_the_storable_decimal_precision(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
ProjectCosting::aggregate([
|
||||
['category' => 'filament', 'line_total' => '9999999999.0000'],
|
||||
['category' => 'packaging', 'line_total' => '9999999999.0000'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_zero_quantity_is_a_valid_intentional_zero(): void
|
||||
{
|
||||
$this->assertSame('0.0000', ProjectCosting::lineTotal('packaging', '0', 'unit', '5.000000'));
|
||||
}
|
||||
|
||||
public function test_negative_quantity_is_rejected(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
ProjectCosting::lineTotal('packaging', '-1', 'unit', '5.000000');
|
||||
}
|
||||
|
||||
public function test_negative_rate_is_rejected(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
ProjectCosting::lineTotal('packaging', '1', 'unit', '-5.000000');
|
||||
}
|
||||
|
||||
public function test_absurd_quantity_overflows_and_is_rejected(): void
|
||||
{
|
||||
$this->expectException(\Throwable::class);
|
||||
ProjectCosting::lineTotal('filament', '999999999999', 'g', '999999999999.000000');
|
||||
}
|
||||
|
||||
public function test_aggregate_distinguishes_missing_category_from_intentional_zero(): void
|
||||
{
|
||||
$totals = ProjectCosting::aggregate([
|
||||
['category' => 'filament', 'line_total' => '5.0000'],
|
||||
['category' => 'packaging', 'line_total' => '0.0000'],
|
||||
]);
|
||||
|
||||
$this->assertSame('5.0000', $totals['filament_total']);
|
||||
$this->assertSame('0.0000', $totals['packaging_total']);
|
||||
$this->assertNull($totals['shipping_total']);
|
||||
$this->assertSame('5.0000', $totals['grand_total']);
|
||||
}
|
||||
|
||||
public function test_aggregate_grand_total_is_null_when_nothing_costed(): void
|
||||
{
|
||||
$totals = ProjectCosting::aggregate([]);
|
||||
|
||||
$this->assertNull($totals['filament_total']);
|
||||
$this->assertNull($totals['packaging_total']);
|
||||
$this->assertNull($totals['shipping_total']);
|
||||
$this->assertNull($totals['grand_total']);
|
||||
}
|
||||
|
||||
public function test_aggregate_sums_multiple_lines_per_category(): void
|
||||
{
|
||||
$totals = ProjectCosting::aggregate([
|
||||
['category' => 'filament', 'line_total' => '5.0000'],
|
||||
['category' => 'filament', 'line_total' => '2.5000'],
|
||||
]);
|
||||
|
||||
$this->assertSame('7.5000', $totals['filament_total']);
|
||||
}
|
||||
|
||||
public function test_per_unit_divides_grand_total_by_batch_yield_with_rounding(): void
|
||||
{
|
||||
$this->assertSame('3.3333', ProjectCosting::perUnit('10.0000', 3));
|
||||
}
|
||||
|
||||
public function test_per_unit_rejects_zero_or_negative_yield(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
ProjectCosting::perUnit('10.0000', 0);
|
||||
}
|
||||
|
||||
public function test_rejects_unknown_category(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
ProjectCosting::lineTotal('labour', '1', 'unit', '1.000000');
|
||||
}
|
||||
|
||||
public function test_rejects_mixed_currency_summation(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
ProjectCosting::assertSameCurrency(['GBP', 'USD']);
|
||||
}
|
||||
|
||||
public function test_allows_single_consistent_currency(): void
|
||||
{
|
||||
ProjectCosting::assertSameCurrency(['GBP', 'GBP', 'GBP']);
|
||||
$this->addToAssertionCount(1);
|
||||
}
|
||||
|
||||
public function test_validates_iso_currency_allowlist(): void
|
||||
{
|
||||
$this->assertTrue(ProjectCosting::isValidCurrency('GBP'));
|
||||
$this->assertFalse(ProjectCosting::isValidCurrency('XYZ'));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import laravel from 'laravel-vite-plugin';
|
||||
import { bunny } from 'laravel-vite-plugin/fonts';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
export default defineConfig({
|
||||
@@ -8,11 +7,6 @@ export default defineConfig({
|
||||
laravel({
|
||||
input: ['resources/css/app.css', 'resources/js/app.js'],
|
||||
refresh: true,
|
||||
fonts: [
|
||||
bunny('Instrument Sans', {
|
||||
weights: [400, 500, 600],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
tailwindcss(),
|
||||
],
|
||||
|
||||