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