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.
This commit is contained in:
2026-11-08 12:50:56 +00:00
parent c8c5822166
commit d4928789c4
83 changed files with 5808 additions and 84 deletions
+62
View File
@@ -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;
}
}
+45
View File
@@ -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');
}
}
+78
View File
@@ -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);
}
}
+44
View File
@@ -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(),
]);
}
}
+32
View File
@@ -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');
}
}
+51
View File
@@ -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;
}
}
+49
View File
@@ -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');
}
}
+63
View File
@@ -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;
}
}