chore: 完善模組化架構遷移與修復前端顯示錯誤

- 修正所有模組 Controller 的 Model 引用路徑 (App\Modules\...)
- 更新 ProductionOrder 與 ProductionOrderItem 模型結構以符合新版邏輯
- 修復 resources/js/utils/format.ts 在處理空值時導致 toLocaleString 崩潰的問題
- 清除全域路徑與 Controller 遷移殘留檔案
This commit is contained in:
2026-01-26 10:37:47 +08:00
parent db0c1ce3af
commit b0848a6bb8
70 changed files with 947 additions and 833 deletions

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Modules\Inventory\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions;
class Category extends Model
{
use HasFactory, LogsActivity;
protected $fillable = ['name', 'description'];
public function products(): HasMany
{
return $this->hasMany(Product::class);
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logAll()
->logOnlyDirty()
->dontSubmitEmptyLogs();
}
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
{
$properties = $activity->properties;
$snapshot = $properties['snapshot'] ?? [];
$snapshot['name'] = $this->name;
$properties['snapshot'] = $snapshot;
$activity->properties = $properties;
}
}

View File

@@ -0,0 +1,140 @@
<?php
namespace App\Modules\Inventory\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use App\Modules\Procurement\Models\PurchaseOrder; // Cross-module dependency
class Inventory extends Model
{
/** @use HasFactory<\Database\Factories\InventoryFactory> */
use HasFactory;
use \Illuminate\Database\Eloquent\SoftDeletes;
use \Spatie\Activitylog\Traits\LogsActivity;
protected $fillable = [
'warehouse_id',
'product_id',
'quantity',
'location',
// 批號追溯欄位
'batch_number',
'box_number',
'origin_country',
'arrival_date',
'expiry_date',
'source_purchase_order_id',
'quality_status',
'quality_remark',
];
protected $casts = [
'arrival_date' => 'date:Y-m-d',
'expiry_date' => 'date:Y-m-d',
];
/**
* Transient property to store the reason for the activity log (e.g., "Replenishment #123").
* This is not stored in the database column but used for logging context.
* @var string|null
*/
public $activityLogReason;
public function getActivitylogOptions(): \Spatie\Activitylog\LogOptions
{
return \Spatie\Activitylog\LogOptions::defaults()
->logAll()
->logOnlyDirty()
->dontSubmitEmptyLogs();
}
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
{
$properties = $activity->properties;
$attributes = $properties['attributes'] ?? [];
$snapshot = $properties['snapshot'] ?? [];
// Always snapshot names for context, even if IDs didn't change
// $this refers to the Inventory model instance
$snapshot['warehouse_name'] = $this->warehouse ? $this->warehouse->name : ($snapshot['warehouse_name'] ?? null);
$snapshot['product_name'] = $this->product ? $this->product->name : ($snapshot['product_name'] ?? null);
// Capture the reason if set
if ($this->activityLogReason) {
$attributes['_reason'] = $this->activityLogReason;
}
$properties['attributes'] = $attributes;
$properties['snapshot'] = $snapshot;
$activity->properties = $properties;
}
public function warehouse(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(Warehouse::class);
}
public function product(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(Product::class);
}
public function transactions(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(InventoryTransaction::class);
}
public function lastOutgoingTransaction()
{
return $this->hasOne(InventoryTransaction::class)->ofMany([
'actual_time' => 'max',
'id' => 'max',
], function ($query) {
$query->where('quantity', '<', 0);
});
}
public function lastIncomingTransaction()
{
return $this->hasOne(InventoryTransaction::class)->ofMany([
'actual_time' => 'max',
'id' => 'max',
], function ($query) {
$query->where('quantity', '>', 0);
});
}
/**
* 來源採購單
*/
public function sourcePurchaseOrder(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(PurchaseOrder::class, 'source_purchase_order_id');
}
/**
* 產生批號
* 格式:{商品代號}-{來源國家}-{入庫日期}-{批次流水號}
*/
public static function generateBatchNumber(string $productCode, string $originCountry, string $arrivalDate): string
{
$dateFormatted = date('Ymd', strtotime($arrivalDate));
$prefix = "{$productCode}-{$originCountry}-{$dateFormatted}-";
// 加入 withTrashed() 確保流水號不會撞到已刪除的紀錄
$lastBatch = static::withTrashed()
->where('batch_number', 'like', "{$prefix}%")
->orderByDesc('batch_number')
->first();
if ($lastBatch) {
$lastNumber = (int) substr($lastBatch->batch_number, -2);
$nextNumber = str_pad($lastNumber + 1, 2, '0', STR_PAD_LEFT);
} else {
$nextNumber = '01';
}
return $prefix . $nextNumber;
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Modules\Inventory\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use App\Modules\Core\Models\User; // Cross-module Core dependency
class InventoryTransaction extends Model
{
/** @use HasFactory<\Database\Factories\InventoryTransactionFactory> */
use HasFactory;
protected $fillable = [
'inventory_id',
'type',
'quantity',
'balance_before',
'balance_after',
'reason',
'reference_type',
'reference_id',
'user_id',
'actual_time',
];
protected $casts = [
'actual_time' => 'datetime',
];
public function inventory(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(Inventory::class);
}
public function user(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(User::class);
}
public function reference(): \Illuminate\Database\Eloquent\Relations\MorphTo
{
return $this->morphTo();
}
}

View File

@@ -0,0 +1,116 @@
<?php
namespace App\Modules\Inventory\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions;
use App\Modules\Procurement\Models\Vendor; // Cross-module dependency (Procurement)
class Product extends Model
{
use HasFactory, LogsActivity, SoftDeletes;
protected $fillable = [
'code',
'name',
'category_id',
'brand',
'specification',
'base_unit_id',
'large_unit_id',
'conversion_rate',
'purchase_unit_id',
];
protected $casts = [
'conversion_rate' => 'decimal:4',
];
/**
* Get the category that owns the product.
*/
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
public function baseUnit(): BelongsTo
{
return $this->belongsTo(Unit::class, 'base_unit_id');
}
public function largeUnit(): BelongsTo
{
return $this->belongsTo(Unit::class, 'large_unit_id');
}
public function purchaseUnit(): BelongsTo
{
return $this->belongsTo(Unit::class, 'purchase_unit_id');
}
public function vendors(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
{
return $this->belongsToMany(Vendor::class)->withPivot('last_price')->withTimestamps();
}
public function inventories(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(Inventory::class);
}
public function transactions(): HasMany
{
return $this->hasMany(InventoryTransaction::class);
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logAll()
->logOnlyDirty()
->dontSubmitEmptyLogs();
}
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
{
$properties = $activity->properties;
$attributes = $properties['attributes'] ?? [];
$snapshot = $properties['snapshot'] ?? [];
// Handle Category Name Snapshot
if (isset($attributes['category_id'])) {
$category = Category::find($attributes['category_id']);
$snapshot['category_name'] = $category ? $category->name : null;
}
// Handle Unit Name Snapshots
$unitFields = ['base_unit_id', 'large_unit_id', 'purchase_unit_id'];
foreach ($unitFields as $field) {
if (isset($attributes[$field])) {
$unit = Unit::find($attributes[$field]);
$nameKey = str_replace('_id', '_name', $field);
$snapshot[$nameKey] = $unit ? $unit->name : null;
}
}
// Always snapshot self name for context (so logs always show "Cola")
$snapshot['name'] = $this->name;
$properties['attributes'] = $attributes;
$properties['snapshot'] = $snapshot;
$activity->properties = $properties;
}
public function warehouses(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
{
return $this->belongsToMany(Warehouse::class, 'inventories')
->withPivot(['quantity', 'safety_stock', 'location'])
->withTimestamps();
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Modules\Inventory\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions;
class Unit extends Model
{
use HasFactory, LogsActivity;
protected $fillable = ['name', 'abbreviation'];
public function productsAsBase(): HasMany
{
return $this->hasMany(Product::class, 'base_unit_id');
}
public function productsAsLarge(): HasMany
{
return $this->hasMany(Product::class, 'large_unit_id');
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logAll()
->logOnlyDirty()
->dontSubmitEmptyLogs();
}
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
{
$properties = $activity->properties;
$snapshot = $properties['snapshot'] ?? [];
$snapshot['name'] = $this->name;
$properties['snapshot'] = $snapshot;
$activity->properties = $properties;
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Modules\Inventory\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use App\Modules\Procurement\Models\PurchaseOrder; // Cross-module dependency (Procurement)
class Warehouse extends Model
{
/** @use HasFactory<\Database\Factories\WarehouseFactory> */
use HasFactory;
use \Spatie\Activitylog\Traits\LogsActivity;
protected $fillable = [
'code',
'name',
'address',
'description',
];
public function getActivitylogOptions(): \Spatie\Activitylog\LogOptions
{
return \Spatie\Activitylog\LogOptions::defaults()
->logAll()
->logOnlyDirty()
->dontSubmitEmptyLogs();
}
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
{
$properties = $activity->properties;
$snapshot = $properties['snapshot'] ?? [];
$snapshot['name'] = $this->name;
$properties['snapshot'] = $snapshot;
$activity->properties = $properties;
}
public function inventories(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(Inventory::class);
}
public function purchaseOrders(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(PurchaseOrder::class);
}
public function products(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
{
return $this->belongsToMany(Product::class, 'inventories')
->withPivot(['quantity', 'safety_stock', 'location'])
->withTimestamps();
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Modules\Inventory\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 倉庫-商品安全庫存設定
* 每個倉庫-商品組合只有一筆安全庫存設定
*/
class WarehouseProductSafetyStock extends Model
{
protected $table = 'warehouse_product_safety_stocks';
protected $fillable = [
'warehouse_id',
'product_id',
'safety_stock',
];
protected $casts = [
'safety_stock' => 'decimal:2',
];
/**
* 所屬倉庫
*/
public function warehouse(): BelongsTo
{
return $this->belongsTo(Warehouse::class);
}
/**
* 所屬商品
*/
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
}