Add HL3D branding, Etsy Live/Draft paging and Projects lifecycle
Deploy HickLab3D Dashboard / Deploy to production (push) Failing after 23s
Deploy HickLab3D Dashboard / Deploy to production (push) Failing after 23s
Replace the old touchmark/PNG identity with the supplied lockup and monogram, segmented role-aware navigation, Live/Draft listing filters with generation-based cache invalidation, and admin Projects covering immutable file revisions, listing live pointers, and exact-decimal filament/packaging/shipping costing. Additive schema only; no Etsy publication. Isolated clone tests: 301 tests, 1162 assertions.
This commit is contained in:
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user