feat: 整合門市領料日誌、API 文件存取、修改庫存與併發編號問題、供應商商品內聯編輯及日誌 UI 優化
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 1m0s

This commit is contained in:
2026-03-02 16:42:12 +08:00
parent 7dac2d1f77
commit 0a955fb993
33 changed files with 1424 additions and 853 deletions

View File

@@ -32,6 +32,7 @@ class ActivityLogController extends Controller
'App\Modules\Inventory\Models\InventoryCountDoc' => '庫存盤點單',
'App\Modules\Inventory\Models\InventoryAdjustDoc' => '庫存盤調單',
'App\Modules\Inventory\Models\InventoryTransferOrder' => '庫存調撥單',
'App\Modules\Inventory\Models\StoreRequisition' => '門市叫貨單',
];
}

View File

@@ -186,8 +186,14 @@ class InventoryController extends Controller
]);
return DB::transaction(function () use ($validated, $warehouse) {
// 修正時間精度:手動入庫亦補上當下時分秒
$inboundDateTime = $validated['inboundDate'] . ' ' . date('H:i:s');
// 修正時間精度:使用 Carbon 解析,若含時間則保留並補上秒數,若只有日期則補上當前時間
$dt = \Illuminate\Support\Carbon::parse($validated['inboundDate']);
if ($dt->hour === 0 && $dt->minute === 0 && $dt->second === 0) {
$dt->setTimeFrom(now());
} else {
$dt->setSecond(now()->second);
}
$inboundDateTime = $dt->toDateTimeString();
$this->inventoryService->processIncomingInventory($warehouse, $validated['items'], [
'inboundDate' => $inboundDateTime,

View File

@@ -284,7 +284,7 @@ class ProductController extends Controller
*/
public function template()
{
return Excel::download(new ProductTemplateExport, 'products_template.xlsx');
return Excel::download(new ProductTemplateExport, '商品匯入範本.xlsx');
}
/**

View File

@@ -143,15 +143,16 @@ class StoreRequisitionController extends Controller
'items.*.requested_qty.min' => '需求數量必須大於 0',
]);
$submitImmediately = $request->boolean('submit_immediately');
$requisition = $this->service->create(
$request->only(['store_warehouse_id', 'remark']),
$request->items,
auth()->id()
auth()->id(),
$submitImmediately
);
// 如果需要直接提交
if ($request->boolean('submit_immediately')) {
$this->service->submit($requisition, auth()->id());
if ($submitImmediately) {
return redirect()->route('store-requisitions.index')
->with('success', '叫貨單已提交審核');
}

View File

@@ -100,6 +100,24 @@ class TransferOrderController extends Controller
$transitWarehouseId
);
// 手動發送「已建立」日誌,因為服務層使用了 saveQuietly 抑制自動日誌
activity()
->performedOn($order)
->causedBy(auth()->id())
->event('created')
->withProperties([
'attributes' => [
'doc_no' => $order->doc_no,
'from_warehouse_id' => $order->from_warehouse_id,
'to_warehouse_id' => $order->to_warehouse_id,
'transit_warehouse_id' => $order->transit_warehouse_id,
'remarks' => $order->remarks,
'status' => $order->status,
'created_by' => $order->created_by,
]
])
->log('created');
if ($request->input('instant_post') === true) {
try {
$this->transferService->dispatch($order, auth()->id());

View File

@@ -26,8 +26,7 @@ class InventoryImport implements ToModel, WithHeadingRow, WithValidation, WithMa
// 修正時間精度:將選定的日期與「現在的時分秒」結合
// 這樣既能保留使用者選的日期,又能提供精確的紀錄時點排順序
$currentTime = date('H:i:s');
$this->inboundDate = $inboundDate . ' ' . $currentTime;
$this->inboundDate = \Illuminate\Support\Carbon::parse($inboundDate)->setTimeFrom(now())->toDateTimeString();
$this->notes = $notes;
}

View File

@@ -9,10 +9,43 @@ use Illuminate\Validation\Rule;
use Maatwebsite\Excel\Concerns\ToModel;
use Maatwebsite\Excel\Concerns\WithHeadingRow;
use Maatwebsite\Excel\Concerns\WithMapping;
use Maatwebsite\Excel\Concerns\WithMultipleSheets;
use Maatwebsite\Excel\Concerns\WithValidation;
use Maatwebsite\Excel\Concerns\SkipsEmptyRows;
use Maatwebsite\Excel\Imports\HeadingRowFormatter;
class ProductImport implements ToModel, WithHeadingRow, WithValidation, WithMapping
/**
* 商品匯入主類別
*
* 實作 WithMultipleSheets 以限定只讀取第一個工作表(資料頁),
* 跳過第二個工作表(填寫說明頁),避免說明頁的資料被誤匯入並觸發驗證錯誤。
*/
class ProductImport implements WithMultipleSheets
{
public function __construct()
{
// 禁用標題格式化,保留中文標題
HeadingRowFormatter::default('none');
}
/**
* 指定只處理第一個工作表 (index 0)
*/
public function sheets(): array
{
return [
0 => new ProductDataSheetImport(),
];
}
}
/**
* 商品匯入 - 資料工作表處理類別
*
* 負責實際的資料解析、驗證與儲存邏輯。
* 只會被套用到 Excel 的第一個工作表(資料頁)。
*/
class ProductDataSheetImport implements ToModel, WithHeadingRow, WithValidation, WithMapping, SkipsEmptyRows
{
private $categories;
private $units;
@@ -20,9 +53,6 @@ class ProductImport implements ToModel, WithHeadingRow, WithValidation, WithMapp
public function __construct()
{
// 禁用標題格式化,保留中文標題
HeadingRowFormatter::default('none');
// 快取所有類別與單位,避免 N+1 查詢
$this->categories = Category::pluck('id', 'name');
$this->units = Unit::pluck('id', 'name');
@@ -30,27 +60,36 @@ class ProductImport implements ToModel, WithHeadingRow, WithValidation, WithMapp
}
/**
* @param mixed $row
* 資料映射:將 Excel 原始標題(含「(選填)」)對應到乾淨的鍵名
*
* @return array
* 注意WithValidation 驗證的是 map() 之前的原始資料,
* 因此 rules() 中的鍵名必須匹配 Excel 的原始標題。
* map() 的返回值只影響 model() 接收到的資料。
*/
public function map($row): array
{
// 強制將代號與條碼轉為字串,避免純數字被當作整數處理導致 max:5 驗證錯誤
if (isset($row['商品代號'])) {
$row['商品代號'] = (string) $row['商品代號'];
}
if (isset($row['條碼'])) {
$row['條碼'] = (string) $row['條碼'];
}
$code = $row['商品代號(選填)'] ?? $row['商品代號'] ?? null;
$barcode = $row['條碼(選填)'] ?? $row['條碼'] ?? null;
return $row;
return [
'商品代號' => $code !== null ? (string)$code : null,
'條碼' => $barcode !== null ? (string)$barcode : null,
'商品名稱' => $row['商品名稱'] ?? null,
'類別名稱' => $row['類別名稱'] ?? null,
'品牌' => $row['品牌'] ?? null,
'規格' => $row['規格'] ?? null,
'基本單位' => $row['基本單位'] ?? null,
'大單位' => $row['大單位'] ?? null,
'換算率' => isset($row['換算率']) ? (float)$row['換算率'] : null,
'成本價' => isset($row['成本價']) ? (float)$row['成本價'] : null,
'售價' => isset($row['售價']) ? (float)$row['售價'] : null,
'會員價' => isset($row['會員價']) ? (float)$row['會員價'] : null,
'批發價' => isset($row['批發價']) ? (float)$row['批發價'] : null,
];
}
/**
* @param array $row
*
* @return \Illuminate\Database\Eloquent\Model|null
* @param array $row (map() 回傳的乾淨鍵名陣列)
*/
public function model(array $row)
{
@@ -96,13 +135,17 @@ class ProductImport implements ToModel, WithHeadingRow, WithValidation, WithMapp
return null; // 返回 null因為 Service 已經處理完儲存
}
}
/**
* 驗證規則
*
* 鍵名必須匹配 Excel 原始標題(含「(選填)」後綴),
* 因為 WithValidation 驗證的是 map() 之前的原始資料。
*/
public function rules(): array
{
return [
'商品代號' => ['nullable', 'string', 'min:2', 'max:8'],
'條碼' => ['nullable', 'string'],
'商品代號(選填)' => ['nullable', 'string', 'min:2', 'max:8'],
'條碼(選填)' => ['nullable', 'string'],
'商品名稱' => ['required', 'string'],
'類別名稱' => ['required', function($attribute, $value, $fail) {
if (!isset($this->categories[$value])) {
@@ -127,4 +170,16 @@ class ProductImport implements ToModel, WithHeadingRow, WithValidation, WithMapp
'批發價' => ['nullable', 'numeric', 'min:0'],
];
}
/**
* 自訂驗證錯誤訊息的欄位名稱
* 把含 "(選填)" 後綴的欄位顯示為友善名稱
*/
public function customValidationAttributes(): array
{
return [
'商品代號(選填)' => '商品代號',
'條碼(選填)' => '條碼',
];
}
}

View File

@@ -29,12 +29,27 @@ class Category extends Model
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
{
$properties = $activity->properties;
$properties = $activity->properties instanceof \Illuminate\Support\Collection
? $activity->properties->toArray()
: $activity->properties;
$snapshot = $properties['snapshot'] ?? [];
$snapshot['name'] = $this->name;
$properties['snapshot'] = $snapshot;
$resolver = function (&$data) {
if (empty($data) || !is_array($data)) return;
foreach (['created_by', 'updated_by'] as $f) {
if (isset($data[$f]) && is_numeric($data[$f])) {
$data[$f] = \App\Modules\Core\Models\User::find($data[$f])?->name;
}
}
};
if (isset($properties['attributes'])) $resolver($properties['attributes']);
if (isset($properties['old'])) $resolver($properties['old']);
$activity->properties = $properties;
}
}

View File

@@ -40,6 +40,47 @@ class GoodsReceipt extends Model
->dontSubmitEmptyLogs();
}
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
{
$properties = $activity->properties instanceof \Illuminate\Support\Collection
? $activity->properties->toArray()
: $activity->properties;
$snapshot = $properties['snapshot'] ?? [];
$snapshot['doc_no'] = $this->code;
$snapshot['warehouse_name'] = $this->warehouse?->name;
if (!isset($snapshot['vendor_name']) && $this->vendor_id) {
$vendor = app(\App\Modules\Procurement\Contracts\ProcurementServiceInterface::class)
->getVendorsByIds([$this->vendor_id])->first();
$snapshot['vendor_name'] = $vendor?->name;
}
$properties['snapshot'] = $snapshot;
$resolver = function (&$data) {
if (empty($data) || !is_array($data)) return;
foreach (['user_id', 'created_by', 'updated_by'] as $f) {
if (isset($data[$f]) && is_numeric($data[$f])) {
$data[$f] = app(\App\Modules\Core\Contracts\CoreServiceInterface::class)->getUser($data[$f])?->name;
}
}
if (isset($data['warehouse_id']) && is_numeric($data['warehouse_id'])) {
$data['warehouse_id'] = \App\Modules\Inventory\Models\Warehouse::find($data['warehouse_id'])?->name;
}
if (isset($data['vendor_id']) && is_numeric($data['vendor_id'])) {
$vendor = app(\App\Modules\Procurement\Contracts\ProcurementServiceInterface::class)
->getVendorsByIds([$data['vendor_id']])->first();
$data['vendor_id'] = $vendor?->name;
}
};
if (isset($properties['attributes'])) $resolver($properties['attributes']);
if (isset($properties['old'])) $resolver($properties['old']);
$activity->properties = $properties;
}
public function items()
{
return $this->hasMany(GoodsReceiptItem::class);

View File

@@ -147,7 +147,7 @@ class Inventory extends Model
{
if ($amount <= 0) return;
$this->reserved_quantity += $amount;
$this->save();
$this->saveQuietly();
}
/**
@@ -157,7 +157,7 @@ class Inventory extends Model
{
if ($amount <= 0) return;
$this->reserved_quantity = max(0, $this->reserved_quantity - $amount);
$this->save();
$this->saveQuietly();
}
/**

View File

@@ -36,21 +36,23 @@ class InventoryTransferOrder extends Model
if ($eventName === 'created') {
$activity->description = 'created';
} elseif ($eventName === 'updated') {
// 如果屬性中有 status 且變更為 completed將描述改為 posted
if (isset($properties['attributes']['status']) && $properties['attributes']['status'] === 'completed') {
$activity->description = 'posted';
$eventName = 'posted'; // 供後續快照邏輯判定
$eventName = 'posted';
} else {
$activity->description = 'updated';
}
}
// 處理倉庫 ID 轉名稱
// 處理 ID 轉名稱 (核心:支援 attributes 與 old 的自動轉換)
$idToNameFields = [
'from_warehouse_id' => 'fromWarehouse',
'to_warehouse_id' => 'toWarehouse',
'transit_warehouse_id' => 'transitWarehouse',
'created_by' => 'createdBy',
'posted_by' => 'postedBy',
'dispatched_by' => 'dispatchedBy',
'received_by' => 'receivedBy',
];
foreach (['attributes', 'old'] as $part) {
@@ -58,14 +60,20 @@ class InventoryTransferOrder extends Model
foreach ($idToNameFields as $idField => $relation) {
if (isset($properties[$part][$idField])) {
$id = $properties[$part][$idField];
$nameField = str_replace('_id', '_name', $idField);
if (!$id) continue;
$nameField = str_replace('_id', '_name', $idField);
$name = null;
try {
if ($this->relationLoaded($relation) && $this->$relation && $this->$relation->id == $id) {
$name = $this->$relation->name;
} else {
$model = $this->$relation()->getRelated()->find($id);
$name = $model ? $model->name : "ID: $id";
$relatedModel = $this->$relation()->getRelated();
$model = $relatedModel->find($id);
$name = $model ? ($model->name ?? $model->display_name ?? "ID: $id") : "ID: $id";
}
} catch (\Exception $e) {
$name = "ID: $id";
}
$properties[$part][$nameField] = $name;
}
@@ -73,7 +81,7 @@ class InventoryTransferOrder extends Model
}
}
// 基本單據資訊快照 (包含單號、來源、目的地)
// 基本單據資訊快照
if (in_array($eventName, ['created', 'updated', 'posted', 'deleted'])) {
$properties['snapshot'] = [
'doc_no' => $this->doc_no,
@@ -85,8 +93,6 @@ class InventoryTransferOrder extends Model
// 移除輔助欄位與雜訊
if (isset($properties['attributes'])) {
unset($properties['attributes']['from_warehouse_name']);
unset($properties['attributes']['to_warehouse_name']);
unset($properties['attributes']['activityProperties']);
unset($properties['attributes']['updated_at']);
}
@@ -94,7 +100,7 @@ class InventoryTransferOrder extends Model
unset($properties['old']['updated_at']);
}
// 合併暫存屬性 (例如 items_diff)
// 合併暫存屬性 (重要:例如 items_diff)
if (!empty($this->activityProperties)) {
$properties = array_merge($properties, $this->activityProperties);
}

View File

@@ -41,6 +41,11 @@ class StoreRequisition extends Model
->dontSubmitEmptyLogs();
}
/**
* @var array 暫存的活動紀錄屬性 (不會存入資料庫)
*/
public $activityProperties = [];
/**
* 自定義日誌屬性,解析 ID 為名稱
*/
@@ -48,22 +53,90 @@ class StoreRequisition extends Model
{
$properties = $activity->properties->toArray();
// 處置日誌事件與狀態中文化
$statusMap = [
'draft' => '草稿',
'pending' => '待審核',
'approved' => '已核准',
'rejected' => '已駁回',
'completed' => '已完成',
];
// 處理 ID 轉名稱
$idToNameFields = [
'store_warehouse_id' => 'storeWarehouse',
'supply_warehouse_id' => 'supplyWarehouse',
'created_by' => 'createdBy',
'approved_by' => 'approvedBy',
'transfer_order_id' => 'transferOrder',
];
foreach (['attributes', 'old'] as $part) {
if (isset($properties[$part])) {
// 1. 解析狀態中文並替換原始 status 欄位
if (isset($properties[$part]['status'])) {
$statusValue = $properties[$part]['status'];
$properties[$part]['status'] = $statusMap[$statusValue] ?? $statusValue;
}
// 2. 解析關連名稱
foreach ($idToNameFields as $idField => $relation) {
if (isset($properties[$part][$idField])) {
$id = $properties[$part][$idField];
if (!$id) continue;
$nameField = str_replace('_id', '_name', $idField);
if (str_contains($idField, '_by')) {
$nameField = str_replace('_by', '_user_name', $idField);
}
$name = null;
try {
if ($this->relationLoaded($relation) && $this->$relation && $this->$relation->id == $id) {
// 特別處理調撥單號
$name = ($relation === 'transferOrder') ? $this->$relation->doc_no : $this->$relation->name;
} else {
$relatedModel = $this->$relation()->getRelated();
$model = $relatedModel->find($id);
if ($model) {
$name = ($relation === 'transferOrder') ? ($model->doc_no ?? "ID: $id") : ($model->name ?? "ID: $id");
} else {
$name = "ID: $id";
}
}
} catch (\Exception $e) {
$name = "ID: $id";
}
$properties[$part][$nameField] = $name;
// 移除原生的技術 ID 欄位,讓詳情更乾淨
unset($properties[$part][$idField]);
}
}
}
}
// 基本單據資訊快照
$properties['snapshot'] = [
'doc_no' => $this->doc_no,
'store_warehouse_name' => $this->storeWarehouse?->name,
'supply_warehouse_name' => $this->supplyWarehouse?->name,
'status' => $this->status,
'status' => $statusMap[$this->status] ?? $this->status,
];
// 移除雜訊欄位
// 移除雜訊與重複欄位
if (isset($properties['attributes'])) {
unset($properties['attributes']['updated_at']);
unset($properties['attributes']['activityProperties']);
}
if (isset($properties['old'])) {
unset($properties['old']['updated_at']);
}
// 合併暫存屬性 (例如 items_diff)
if (!empty($this->activityProperties)) {
$properties = array_merge($properties, $this->activityProperties);
}
$activity->properties = collect($properties);
}

View File

@@ -34,12 +34,27 @@ class Unit extends Model
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
{
$properties = $activity->properties;
$properties = $activity->properties instanceof \Illuminate\Support\Collection
? $activity->properties->toArray()
: $activity->properties;
$snapshot = $properties['snapshot'] ?? [];
$snapshot['name'] = $this->name;
$properties['snapshot'] = $snapshot;
$resolver = function (&$data) {
if (empty($data) || !is_array($data)) return;
foreach (['created_by', 'updated_by'] as $f) {
if (isset($data[$f]) && is_numeric($data[$f])) {
$data[$f] = \App\Modules\Core\Models\User::find($data[$f])?->name;
}
}
};
if (isset($properties['attributes'])) $resolver($properties['attributes']);
if (isset($properties['old'])) $resolver($properties['old']);
$activity->properties = $properties;
}
}

View File

@@ -37,12 +37,31 @@ class Warehouse extends Model
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
{
$properties = $activity->properties;
$properties = $activity->properties instanceof \Illuminate\Support\Collection
? $activity->properties->toArray()
: $activity->properties;
$snapshot = $properties['snapshot'] ?? [];
$snapshot['name'] = $this->name;
$properties['snapshot'] = $snapshot;
$resolver = function (&$data) {
if (empty($data) || !is_array($data)) return;
foreach (['created_by', 'updated_by'] as $f) {
if (isset($data[$f]) && is_numeric($data[$f])) {
$data[$f] = \App\Modules\Core\Models\User::find($data[$f])?->name;
}
}
if (isset($data['default_transit_warehouse_id']) && is_numeric($data['default_transit_warehouse_id'])) {
$data['default_transit_warehouse_id'] = self::find($data['default_transit_warehouse_id'])?->name;
}
};
if (isset($properties['attributes'])) $resolver($properties['attributes']);
if (isset($properties['old'])) $resolver($properties['old']);
$activity->properties = $properties;
}

View File

@@ -5,10 +5,17 @@ use App\Modules\Inventory\Models\Inventory;
use App\Modules\Inventory\Models\InventoryCountDoc;
use App\Modules\Inventory\Models\InventoryAdjustDoc;
use App\Modules\Inventory\Models\InventoryAdjustItem;
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
use Illuminate\Support\Facades\DB;
class AdjustService
{
protected InventoryServiceInterface $inventoryService;
public function __construct(InventoryServiceInterface $inventoryService)
{
$this->inventoryService = $inventoryService;
}
public function createDoc(string $warehouseId, string $reason, ?string $remarks = null, int $userId, ?int $countDocId = null): InventoryAdjustDoc
{
return InventoryAdjustDoc::create([
@@ -161,29 +168,20 @@ class AdjustService
'batch_number' => $item->batch_number,
]);
// 如果是新建立的 object (id 為空),需要初始化 default
// 如果是新建立的 object (id 為空),需要初始化 default 並先行儲存
if (!$inventory->exists) {
$inventory->unit_cost = $item->product->cost ?? 0;
$inventory->quantity = 0;
$inventory->total_value = 0;
$inventory->saveQuietly();
}
$oldQty = $inventory->quantity;
$newQty = $oldQty + $item->adjust_qty;
$inventory->quantity = $newQty;
$inventory->total_value = $newQty * $inventory->unit_cost;
$inventory->save();
// 建立 Transaction
$inventory->transactions()->create([
'type' => '庫存調整',
$this->inventoryService->adjustInventory($inventory, [
'operation' => 'add',
'quantity' => $item->adjust_qty,
'unit_cost' => $inventory->unit_cost,
'balance_before' => $oldQty,
'balance_after' => $newQty,
'type' => 'adjustment',
'reason' => "盤調單 {$doc->doc_no}: " . ($doc->reason ?? '手動調整'),
'actual_time' => now(),
'user_id' => $userId,
'notes' => $item->notes,
]);
}

View File

@@ -60,14 +60,6 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei
});
}
/**
* Update an existing Goods Receipt.
*
* @param GoodsReceipt $goodsReceipt
* @param array $data
* @return GoodsReceipt
* @throws \Exception
*/
public function update(GoodsReceipt $goodsReceipt, array $data)
{
if (!in_array($goodsReceipt->status, [GoodsReceipt::STATUS_DRAFT, GoodsReceipt::STATUS_REJECTED])) {
@@ -75,14 +67,42 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei
}
return DB::transaction(function () use ($goodsReceipt, $data) {
$goodsReceipt->update([
$goodsReceipt->fill([
'vendor_id' => $data['vendor_id'] ?? $goodsReceipt->vendor_id,
'received_date' => $data['received_date'] ?? $goodsReceipt->received_date,
'remarks' => $data['remarks'] ?? $goodsReceipt->remarks,
]);
$dirty = $goodsReceipt->getDirty();
$oldAttributes = [];
$newAttributes = [];
foreach ($dirty as $key => $value) {
$oldAttributes[$key] = $goodsReceipt->getOriginal($key);
$newAttributes[$key] = $value;
}
// 儲存但不觸發事件,以避免重複記錄
$goodsReceipt->saveQuietly();
// 捕捉包含商品名稱的舊項目以進行比對
$oldItemsCollection = $goodsReceipt->items()->get();
$oldProductIds = $oldItemsCollection->pluck('product_id')->unique()->toArray();
$oldProducts = $this->inventoryService->getProductsByIds($oldProductIds)->keyBy('id');
$oldItems = $oldItemsCollection->map(function($item) use ($oldProducts) {
$product = $oldProducts->get($item->product_id);
return [
'id' => $item->id,
'product_id' => $item->product_id,
'product_name' => $product?->name ?? 'Unknown',
'quantity_received' => (float) $item->quantity_received,
'unit_price' => (float) $item->unit_price,
'total_amount' => (float) $item->total_amount,
];
})->keyBy('product_id');
if (isset($data['items'])) {
// Simple strategy: delete existing items and recreate
$goodsReceipt->items()->delete();
foreach ($data['items'] as $itemData) {
@@ -99,6 +119,75 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei
}
}
// 計算項目差異
$itemDiffs = [
'added' => [],
'removed' => [],
'updated' => [],
];
$newItemsCollection = $goodsReceipt->items()->get();
$newProductIds = $newItemsCollection->pluck('product_id')->unique()->toArray();
$newProducts = $this->inventoryService->getProductsByIds($newProductIds)->keyBy('id');
$newItemsFormatted = $newItemsCollection->map(function($item) use ($newProducts) {
$product = $newProducts->get($item->product_id);
return [
'product_id' => $item->product_id,
'product_name' => $product?->name ?? 'Unknown',
'quantity_received' => (float) $item->quantity_received,
'unit_price' => (float) $item->unit_price,
'total_amount' => (float) $item->total_amount,
];
})->keyBy('product_id');
foreach ($oldItems as $productId => $oldItem) {
if (!$newItemsFormatted->has($productId)) {
$itemDiffs['removed'][] = $oldItem;
}
}
foreach ($newItemsFormatted as $productId => $newItem) {
if (!$oldItems->has($productId)) {
$itemDiffs['added'][] = $newItem;
} else {
$oldItem = $oldItems[$productId];
if (
$oldItem['quantity_received'] != $newItem['quantity_received'] ||
$oldItem['unit_price'] != $newItem['unit_price'] ||
$oldItem['total_amount'] != $newItem['total_amount']
) {
$itemDiffs['updated'][] = [
'product_name' => $newItem['product_name'],
'old' => [
'quantity_received' => $oldItem['quantity_received'],
'unit_price' => $oldItem['unit_price'],
'total_amount' => $oldItem['total_amount'],
],
'new' => [
'quantity_received' => $newItem['quantity_received'],
'unit_price' => $newItem['unit_price'],
'total_amount' => $newItem['total_amount'],
]
];
}
}
}
// 如果有變更,手動觸發單一合併日誌
if (!empty($newAttributes) || !empty($itemDiffs['added']) || !empty($itemDiffs['removed']) || !empty($itemDiffs['updated'])) {
activity()
->performedOn($goodsReceipt)
->causedBy(auth()->user())
->event('updated')
->withProperties([
'attributes' => $newAttributes,
'old' => $oldAttributes,
'items_diff' => $itemDiffs,
])
->log('updated');
}
return $goodsReceipt->fresh('items');
});
}
@@ -162,14 +251,21 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei
}
private function generateCode(string $date)
private function generateCode(string $date): string
{
// 使用 Cache Lock 防止併發時產生重複單號
$lock = \Illuminate\Support\Facades\Cache::lock('gr_code_generation', 10);
if (!$lock->get()) {
throw new \Exception('系統忙碌中,進貨單號生成失敗,請稍後再試');
}
try {
// Format: GR-YYYYMMDD-NN
$prefix = 'GR-' . date('Ymd', strtotime($date)) . '-';
$last = GoodsReceipt::where('code', 'like', $prefix . '%')
->orderBy('id', 'desc')
->lockForUpdate()
->first();
if ($last) {
@@ -178,7 +274,12 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei
$seq = 1;
}
return $prefix . str_pad($seq, 2, '0', STR_PAD_LEFT);
$code = $prefix . str_pad($seq, 2, '0', STR_PAD_LEFT);
return $code;
} finally {
$lock->release();
}
}
/**

View File

@@ -181,11 +181,11 @@ class InventoryService implements InventoryServiceInterface
// 更新其他可能變更的欄位 (如最後入庫日)
$inventory->arrival_date = $data['arrival_date'] ?? $inventory->arrival_date;
$inventory->save();
$inventory->saveQuietly();
} else {
// 若不存在,則建立新紀錄
$unitCost = $data['unit_cost'] ?? 0;
$inventory = Inventory::create([
$inventory = new Inventory([
'warehouse_id' => $data['warehouse_id'],
'product_id' => $data['product_id'],
'quantity' => $data['quantity'],
@@ -199,9 +199,10 @@ class InventoryService implements InventoryServiceInterface
'quality_status' => $data['quality_status'] ?? 'normal',
'source_purchase_order_id' => $data['source_purchase_order_id'] ?? null,
]);
$inventory->saveQuietly();
}
\App\Modules\Inventory\Models\InventoryTransaction::create([
$transaction = new \App\Modules\Inventory\Models\InventoryTransaction([
'inventory_id' => $inventory->id,
'type' => '入庫',
'quantity' => $data['quantity'],
@@ -214,6 +215,7 @@ class InventoryService implements InventoryServiceInterface
'user_id' => auth()->id(),
'actual_time' => now(),
]);
$transaction->saveQuietly();
return $inventory;
});
@@ -225,13 +227,12 @@ class InventoryService implements InventoryServiceInterface
$inventory = Inventory::lockForUpdate()->findOrFail($inventoryId);
$balanceBefore = $inventory->quantity;
$inventory->decrement('quantity', $quantity); // decrement 不會自動觸發 total_value 更新
// 需要手動更新總價值
$inventory->refresh();
// 手動更新以配合 saveQuietly 消除日誌
$inventory->quantity -= $quantity;
$inventory->total_value = $inventory->quantity * $inventory->unit_cost;
$inventory->save();
$inventory->saveQuietly();
\App\Modules\Inventory\Models\InventoryTransaction::create([
$transaction = new \App\Modules\Inventory\Models\InventoryTransaction([
'inventory_id' => $inventory->id,
'type' => '出庫',
'quantity' => -$quantity,
@@ -244,6 +245,7 @@ class InventoryService implements InventoryServiceInterface
'user_id' => auth()->id(),
'actual_time' => now(),
]);
$transaction->saveQuietly();
});
}
@@ -825,7 +827,8 @@ class InventoryService implements InventoryServiceInterface
}
if (abs($changeQty) > 0.0001) {
$inventory->transactions()->create([
$transaction = new \App\Modules\Inventory\Models\InventoryTransaction([
'inventory_id' => $inventory->id,
'type' => $chineseType,
'quantity' => $changeQty,
'unit_cost' => $inventory->unit_cost,
@@ -835,6 +838,7 @@ class InventoryService implements InventoryServiceInterface
'actual_time' => now(),
'user_id' => auth()->id(),
]);
$transaction->saveQuietly();
}
});
}

View File

@@ -23,24 +23,77 @@ class StoreRequisitionService
/**
* 建立叫貨單(含明細)
*/
public function create(array $data, array $items, int $userId): StoreRequisition
public function create(array $data, array $items, int $userId, bool $submitImmediately = false): StoreRequisition
{
return DB::transaction(function () use ($data, $items, $userId) {
$requisition = StoreRequisition::create([
return DB::transaction(function () use ($data, $items, $userId, $submitImmediately) {
$requisition = new StoreRequisition([
'store_warehouse_id' => $data['store_warehouse_id'],
'status' => 'draft',
'status' => $submitImmediately ? 'pending' : 'draft',
'submitted_at' => $submitImmediately ? now() : null,
'remark' => $data['remark'] ?? null,
'created_by' => $userId,
]);
// 手動產生單號,因為 saveQuietly 會繞過模型事件
if (empty($requisition->doc_no)) {
$today = date('Ymd');
$prefix = 'SR-' . $today . '-';
$lastDoc = StoreRequisition::where('doc_no', 'like', $prefix . '%')
->orderBy('doc_no', 'desc')
->first();
if ($lastDoc) {
$lastNumber = substr($lastDoc->doc_no, -2);
$nextNumber = str_pad((int)$lastNumber + 1, 2, '0', STR_PAD_LEFT);
} else {
$nextNumber = '01';
}
$requisition->doc_no = $prefix . $nextNumber;
}
// 靜默建立以抑制自動日誌
$requisition->saveQuietly();
$diff = ['added' => [], 'removed' => [], 'updated' => []];
foreach ($items as $item) {
$requisition->items()->create([
'product_id' => $item['product_id'],
'requested_qty' => $item['requested_qty'],
'remark' => $item['remark'] ?? null,
]);
$product = \App\Modules\Inventory\Models\Product::find($item['product_id']);
$diff['added'][] = [
'product_name' => $product?->name ?? '未知商品',
'new' => [
'quantity' => (float)$item['requested_qty'],
'remark' => $item['remark'] ?? null,
]
];
}
// 如果需直接提交,觸發通知
if ($submitImmediately) {
$this->notifyApprovers($requisition, 'submitted', $userId);
}
// 手動發送高品質日誌
activity()
->performedOn($requisition)
->causedBy($userId)
->event('created')
->withProperties([
'items_diff' => $diff,
'attributes' => [
'doc_no' => $requisition->doc_no,
'store_warehouse_id' => $requisition->store_warehouse_id,
'status' => $requisition->status,
'remark' => $requisition->remark,
'created_by' => $requisition->created_by,
'submitted_at' => $requisition->submitted_at,
]
])
->log('created');
return $requisition->load('items');
});
}
@@ -57,13 +110,74 @@ class StoreRequisitionService
}
return DB::transaction(function () use ($requisition, $data, $items) {
$requisition->update([
'store_warehouse_id' => $data['store_warehouse_id'],
'remark' => $data['remark'] ?? null,
'reject_reason' => null, // 清除駁回原因
]);
// 擷取舊狀態供日誌對照
$oldAttributes = [
'store_warehouse_id' => $requisition->store_warehouse_id,
'remark' => $requisition->remark,
];
// 重建明細
// 手動更新屬性
$requisition->store_warehouse_id = $data['store_warehouse_id'];
$requisition->remark = $data['remark'] ?? null;
$requisition->reject_reason = null; // 清除駁回原因
// 品項對比邏輯
$oldItems = $requisition->items()->with('product:id,name')->get();
$oldItemsMap = $oldItems->keyBy('product_id');
$newItemsMap = collect($items)->keyBy('product_id');
$diff = [
'added' => [],
'removed' => [],
'updated' => [],
];
// 1. 處理更新與新增
foreach ($items as $itemData) {
$productId = $itemData['product_id'];
$newQty = (float)$itemData['requested_qty'];
$newRemark = $itemData['remark'] ?? null;
if ($oldItemsMap->has($productId)) {
$oldItem = $oldItemsMap->get($productId);
if ((float)$oldItem->requested_qty !== $newQty || $oldItem->remark !== $newRemark) {
$diff['updated'][] = [
'product_name' => $oldItem->product?->name ?? '未知商品',
'old' => [
'quantity' => (float)$oldItem->requested_qty,
'remark' => $oldItem->remark,
],
'new' => [
'quantity' => $newQty,
'remark' => $newRemark,
]
];
}
$oldItemsMap->forget($productId);
} else {
$product = \App\Modules\Inventory\Models\Product::find($productId);
$diff['added'][] = [
'product_name' => $product?->name ?? '未知商品',
'new' => [
'quantity' => $newQty,
'remark' => $newRemark,
]
];
}
}
// 2. 處理移除
foreach ($oldItemsMap as $productId => $oldItem) {
$diff['removed'][] = [
'product_name' => $oldItem->product?->name ?? '未知商品',
'old' => [
'quantity' => (float)$oldItem->requested_qty,
'remark' => $oldItem->remark,
]
];
}
// 儲存實際變動
$requisition->items()->delete();
foreach ($items as $item) {
$requisition->items()->create([
@@ -73,6 +187,32 @@ class StoreRequisitionService
]);
}
// 檢查是否有任何變動 (主表或明細)
$isDirty = $requisition->isDirty();
$hasItemsDiff = !empty($diff['added']) || !empty($diff['removed']) || !empty($diff['updated']);
if ($isDirty || $hasItemsDiff) {
// 擷取新狀態
$newAttributes = [
'store_warehouse_id' => $requisition->store_warehouse_id,
'remark' => $requisition->remark,
];
// 靜默更新
$requisition->saveQuietly();
// 手動發送紀錄
activity()
->performedOn($requisition)
->event('updated')
->withProperties([
'items_diff' => $diff,
'attributes' => $newAttributes,
'old' => $oldAttributes
])
->log('updated');
}
return $requisition->load('items');
});
}
@@ -241,6 +381,27 @@ class StoreRequisitionService
if (!empty($transferItems)) {
$this->transferService->updateItems($transferOrder, $transferItems);
// 手動發送調撥單的「已建立」合併日誌,包含初始明細
activity()
->performedOn($transferOrder)
->causedBy($userId)
->event('created')
->withProperties(array_merge(
['items_diff' => $transferOrder->activityProperties['items_diff'] ?? []],
[
'attributes' => [
'doc_no' => $transferOrder->doc_no,
'from_warehouse_id' => $transferOrder->from_warehouse_id,
'to_warehouse_id' => $transferOrder->to_warehouse_id,
'transit_warehouse_id' => $transferOrder->transit_warehouse_id,
'remarks' => $transferOrder->remarks,
'status' => $transferOrder->status,
'created_by' => $transferOrder->created_by,
]
]
))
->log('created');
}
// 更新叫貨單狀態

View File

@@ -9,8 +9,17 @@ use App\Modules\Inventory\Models\Warehouse;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
class TransferService
{
protected InventoryServiceInterface $inventoryService;
public function __construct(InventoryServiceInterface $inventoryService)
{
$this->inventoryService = $inventoryService;
}
/**
* 建立調撥單草稿
*/
@@ -24,7 +33,7 @@ class TransferService
}
}
return InventoryTransferOrder::create([
$order = new InventoryTransferOrder([
'from_warehouse_id' => $fromWarehouseId,
'to_warehouse_id' => $toWarehouseId,
'transit_warehouse_id' => $transitWarehouseId,
@@ -32,6 +41,26 @@ class TransferService
'remarks' => $remarks,
'created_by' => $userId,
]);
// 手動觸發單號產生邏輯,因為 saveQuietly 繞過了 Model Events
if (empty($order->doc_no)) {
$today = date('Ymd');
$prefix = 'TRF-' . $today . '-';
$lastDoc = InventoryTransferOrder::where('doc_no', 'like', $prefix . '%')
->orderBy('doc_no', 'desc')
->first();
if ($lastDoc) {
$lastNumber = substr($lastDoc->doc_no, -2);
$nextNumber = str_pad((int)$lastNumber + 1, 2, '0', STR_PAD_LEFT);
} else {
$nextNumber = '01';
}
$order->doc_no = $prefix . $nextNumber;
}
$order->saveQuietly();
return $order;
}
/**
@@ -101,6 +130,7 @@ class TransferService
$diff['updated'][] = [
'product_name' => $item->product->name,
'unit_name' => $item->product->baseUnit?->name,
'old' => [
'quantity' => (float)$oldItem->quantity,
'position' => $oldItem->position,
@@ -114,12 +144,9 @@ class TransferService
];
}
} else {
$diff['updated'][] = [
$diff['added'][] = [
'product_name' => $item->product->name,
'old' => [
'quantity' => 0,
'notes' => null,
],
'unit_name' => $item->product->baseUnit?->name,
'new' => [
'quantity' => (float)$item->quantity,
'notes' => $item->notes,
@@ -132,6 +159,7 @@ class TransferService
if (!in_array($key, $newItemsKeys)) {
$diff['removed'][] = [
'product_name' => $oldItem->product->name,
'unit_name' => $oldItem->product->baseUnit?->name,
'old' => [
'quantity' => (float)$oldItem->quantity,
'notes' => $oldItem->notes,
@@ -169,6 +197,8 @@ class TransferService
$outType = '調撥出庫';
$inType = $hasTransit ? '在途入庫' : '調撥入庫';
$itemsDiff = [];
foreach ($order->items as $item) {
if ($item->quantity <= 0) continue;
@@ -186,70 +216,65 @@ class TransferService
]);
}
$oldSourceQty = $sourceInventory->quantity;
$newSourceQty = $oldSourceQty - $item->quantity;
$sourceBefore = (float) $sourceInventory->quantity;
// 釋放草稿階段預扣的庫存
$sourceInventory->reserved_quantity = max(0, $sourceInventory->reserved_quantity - $item->quantity);
$sourceInventory->saveQuietly();
$item->update(['snapshot_quantity' => $oldSourceQty]);
$item->update(['snapshot_quantity' => $sourceBefore]);
$sourceInventory->quantity = $newSourceQty;
$sourceInventory->total_value = $sourceInventory->quantity * $sourceInventory->unit_cost;
$sourceInventory->save();
$sourceInventory->transactions()->create([
'type' => $outType,
'quantity' => -$item->quantity,
'unit_cost' => $sourceInventory->unit_cost,
'balance_before' => $oldSourceQty,
'balance_after' => $newSourceQty,
'reason' => "調撥單 {$order->doc_no}{$targetWarehouse->name}",
'actual_time' => now(),
'user_id' => $userId,
]);
// 2. 處理目的倉/在途倉 (增加)
$targetInventory = Inventory::firstOrCreate(
[
'warehouse_id' => $targetWarehouseId,
'product_id' => $item->product_id,
'batch_number' => $item->batch_number,
'location' => $hasTransit ? null : ($item->position ?? null),
],
[
'quantity' => 0,
'unit_cost' => $sourceInventory->unit_cost,
'total_value' => 0,
'expiry_date' => $sourceInventory->expiry_date,
'quality_status' => $sourceInventory->quality_status,
'origin_country' => $sourceInventory->origin_country,
]
// 委託 InventoryService 處理扣庫與 Transaction
$this->inventoryService->decreaseInventoryQuantity(
$sourceInventory->id,
$item->quantity,
"調撥單 {$order->doc_no}{$targetWarehouse->name}",
InventoryTransferOrder::class,
$order->id
);
if ($targetInventory->wasRecentlyCreated && $targetInventory->unit_cost == 0) {
$targetInventory->unit_cost = $sourceInventory->unit_cost;
}
$sourceAfter = $sourceBefore - (float) $item->quantity;
$oldTargetQty = $targetInventory->quantity;
$newTargetQty = $oldTargetQty + $item->quantity;
// 2. 處理目的倉/在途倉 (增加)
// 獲取目的倉異動前的庫存數(若無則為 0
$targetInventoryBefore = Inventory::where('warehouse_id', $targetWarehouseId)
->where('product_id', $item->product_id)
->where('batch_number', $item->batch_number)
->first();
$targetBefore = $targetInventoryBefore ? (float) $targetInventoryBefore->quantity : 0;
$targetInventory->quantity = $newTargetQty;
$targetInventory->total_value = $targetInventory->quantity * $targetInventory->unit_cost;
$targetInventory->save();
$targetInventory->transactions()->create([
'type' => $inType,
$this->inventoryService->createInventoryRecord([
'warehouse_id' => $targetWarehouseId,
'product_id' => $item->product_id,
'quantity' => $item->quantity,
'unit_cost' => $targetInventory->unit_cost,
'balance_before' => $oldTargetQty,
'balance_after' => $newTargetQty,
'unit_cost' => $sourceInventory->unit_cost,
'batch_number' => $item->batch_number,
'expiry_date' => $sourceInventory->expiry_date,
'reason' => "調撥單 {$order->doc_no} 來自 {$fromWarehouse->name}",
'actual_time' => now(),
'user_id' => $userId,
'reference_type' => InventoryTransferOrder::class,
'reference_id' => $order->id,
'location' => $hasTransit ? null : ($item->position ?? null),
'origin_country' => $sourceInventory->origin_country,
'quality_status' => $sourceInventory->quality_status,
]);
$targetAfter = $targetBefore + (float) $item->quantity;
// 記錄異動明細供整合日誌使用
$itemsDiff[] = [
'product_name' => $item->product->name,
'batch_number' => $item->batch_number,
'quantity' => (float)$item->quantity,
'source_warehouse' => $fromWarehouse->name,
'source_before' => $sourceBefore,
'source_after' => $sourceAfter,
'target_warehouse' => $targetWarehouse->name,
'target_before' => $targetBefore,
'target_after' => $targetAfter,
];
}
$oldStatus = $order->status;
if ($hasTransit) {
$order->status = 'dispatched';
$order->dispatched_at = now();
@@ -259,7 +284,27 @@ class TransferService
$order->posted_at = now();
$order->posted_by = $userId;
}
$order->save();
$order->saveQuietly();
// 手動觸發單一合併日誌
activity()
->performedOn($order)
->causedBy(auth()->user())
->event('updated')
->withProperties([
'items_diff' => $itemsDiff,
'attributes' => [
'status' => $order->status,
'dispatched_at' => $order->dispatched_at ? $order->dispatched_at->format('Y-m-d H:i:s') : null,
'posted_at' => $order->posted_at ? $order->posted_at->format('Y-m-d H:i:s') : null,
'dispatched_by' => $order->dispatched_by,
'posted_by' => $order->posted_by,
],
'old' => [
'status' => $oldStatus,
]
])
->log($order->status == 'completed' ? 'posted' : 'dispatched');
});
}
@@ -283,6 +328,8 @@ class TransferService
$transitWarehouse = $order->transitWarehouse;
$toWarehouse = $order->toWarehouse;
$itemsDiff = [];
foreach ($order->items as $item) {
if ($item->quantity <= 0) continue;
@@ -299,71 +346,83 @@ class TransferService
]);
}
$oldTransitQty = $transitInventory->quantity;
$newTransitQty = $oldTransitQty - $item->quantity;
$transitBefore = (float) $transitInventory->quantity;
$transitInventory->quantity = $newTransitQty;
$transitInventory->total_value = $transitInventory->quantity * $transitInventory->unit_cost;
$transitInventory->save();
$transitInventory->transactions()->create([
'type' => '在途出庫',
'quantity' => -$item->quantity,
'unit_cost' => $transitInventory->unit_cost,
'balance_before' => $oldTransitQty,
'balance_after' => $newTransitQty,
'reason' => "調撥單 {$order->doc_no} 配送至 {$toWarehouse->name}",
'actual_time' => now(),
'user_id' => $userId,
]);
// 2. 目的倉增加
$targetInventory = Inventory::firstOrCreate(
[
'warehouse_id' => $order->to_warehouse_id,
'product_id' => $item->product_id,
'batch_number' => $item->batch_number,
'location' => $item->position,
],
[
'quantity' => 0,
'unit_cost' => $transitInventory->unit_cost,
'total_value' => 0,
'expiry_date' => $transitInventory->expiry_date,
'quality_status' => $transitInventory->quality_status,
'origin_country' => $transitInventory->origin_country,
]
// 委託 InventoryService 處理扣庫與 Transaction
$this->inventoryService->decreaseInventoryQuantity(
$transitInventory->id,
$item->quantity,
"調撥單 {$order->doc_no} 配送至 {$toWarehouse->name}",
InventoryTransferOrder::class,
$order->id
);
if ($targetInventory->wasRecentlyCreated && $targetInventory->unit_cost == 0) {
$targetInventory->unit_cost = $transitInventory->unit_cost;
}
$transitAfter = $transitBefore - (float) $item->quantity;
$oldTargetQty = $targetInventory->quantity;
$newTargetQty = $oldTargetQty + $item->quantity;
// 2. 目的倉增加
$targetInventoryBefore = Inventory::where('warehouse_id', $order->to_warehouse_id)
->where('product_id', $item->product_id)
->where('batch_number', $item->batch_number)
->first();
$targetBefore = $targetInventoryBefore ? (float) $targetInventoryBefore->quantity : 0;
$targetInventory->quantity = $newTargetQty;
$targetInventory->total_value = $targetInventory->quantity * $targetInventory->unit_cost;
$targetInventory->save();
$targetInventory->transactions()->create([
'type' => '調撥入庫',
$this->inventoryService->createInventoryRecord([
'warehouse_id' => $order->to_warehouse_id,
'product_id' => $item->product_id,
'quantity' => $item->quantity,
'unit_cost' => $targetInventory->unit_cost,
'balance_before' => $oldTargetQty,
'balance_after' => $newTargetQty,
'unit_cost' => $transitInventory->unit_cost,
'batch_number' => $item->batch_number,
'expiry_date' => $transitInventory->expiry_date,
'reason' => "調撥單 {$order->doc_no} 來自 {$transitWarehouse->name}",
'actual_time' => now(),
'user_id' => $userId,
'reference_type' => InventoryTransferOrder::class,
'reference_id' => $order->id,
'location' => $item->position,
'origin_country' => $transitInventory->origin_country,
'quality_status' => $transitInventory->quality_status,
]);
$targetAfter = $targetBefore + (float) $item->quantity;
$itemsDiff[] = [
'product_name' => $item->product->name,
'batch_number' => $item->batch_number,
'quantity' => (float)$item->quantity,
'source_warehouse' => $transitWarehouse->name,
'source_before' => $transitBefore,
'source_after' => $transitAfter,
'target_warehouse' => $toWarehouse->name,
'target_before' => $targetBefore,
'target_after' => $targetAfter,
];
}
$oldStatus = $order->status;
$order->status = 'completed';
$order->posted_at = now();
$order->posted_by = $userId;
$order->received_at = now();
$order->received_by = $userId;
$order->save();
$order->saveQuietly();
// 手動觸發單一合併日誌
activity()
->performedOn($order)
->causedBy(auth()->user())
->event('updated')
->withProperties([
'items_diff' => $itemsDiff,
'attributes' => [
'status' => 'completed',
'posted_at' => $order->posted_at->format('Y-m-d H:i:s'),
'received_at' => $order->received_at->format('Y-m-d H:i:s'),
'posted_by' => $order->posted_by,
'received_by' => $order->received_by,
],
'old' => [
'status' => $oldStatus,
]
])
->log('received');
});
}
@@ -387,10 +446,24 @@ class TransferService
}
}
$order->update([
$oldStatus = $order->status;
$order->status = 'voided';
$order->updated_by = $userId;
$order->saveQuietly();
activity()
->performedOn($order)
->causedBy(auth()->user())
->event('updated')
->withProperties([
'attributes' => [
'status' => 'voided',
'updated_by' => $userId
]);
],
'old' => [
'status' => $oldStatus,
]
])
->log('voided');
});
}
}

View File

@@ -104,4 +104,12 @@ interface ProcurementServiceInterface
* 移除供貨商品關聯
*/
public function detachProductFromVendor(int $vendorId, int $productId): void;
/**
* 整批同步供貨商品
*
* @param int $vendorId
* @param array $productsData Format: [['product_id' => 1, 'last_price' => 100], ...]
*/
public function syncVendorProducts(int $vendorId, array $productsData): void;
}

View File

@@ -188,6 +188,14 @@ class PurchaseOrderController extends Controller
'tax_amount' => 'nullable|numeric|min:0',
]);
try {
// 使用 Cache Lock 防止併發時產生重複單號
$lock = \Illuminate\Support\Facades\Cache::lock('po_code_generation', 10);
if (!$lock->get()) {
return back()->withErrors(['error' => '系統忙碌中,請稍後再試']);
}
try {
DB::beginTransaction();
@@ -195,12 +203,10 @@ class PurchaseOrderController extends Controller
$today = now()->format('Ymd');
$prefix = 'PO-' . $today . '-';
$lastOrder = PurchaseOrder::where('code', 'like', $prefix . '%')
->lockForUpdate() // 鎖定以避免並發衝突
->orderBy('code', 'desc')
->first();
if ($lastOrder) {
// 取得最後 2 碼序號並加 1
$lastSequence = intval(substr($lastOrder->code, -2));
$sequence = str_pad($lastSequence + 1, 2, '0', STR_PAD_LEFT);
} else {
@@ -220,7 +226,8 @@ class PurchaseOrderController extends Controller
// 確保有一個有效的使用者 ID
$userId = auth()->id();
if (!$userId) {
$user = $this->coreService->ensureSystemUserExists(); $userId = $user->id;
$user = $this->coreService->ensureSystemUserExists();
$userId = $user->id;
}
$order = PurchaseOrder::create([
@@ -229,7 +236,7 @@ class PurchaseOrderController extends Controller
'warehouse_id' => $validated['warehouse_id'],
'user_id' => $userId,
'status' => 'draft',
'order_date' => $validated['order_date'], // 新增
'order_date' => $validated['order_date'],
'expected_delivery_date' => $validated['expected_delivery_date'],
'total_amount' => $totalAmount,
'tax_amount' => $taxAmount,
@@ -254,6 +261,9 @@ class PurchaseOrderController extends Controller
}
DB::commit();
} finally {
$lock->release();
}
return redirect()->route('purchase-orders.index')->with('success', '採購單已成功建立');

View File

@@ -133,4 +133,35 @@ class VendorProductController extends Controller
return redirect()->back()->with('success', '供貨商品已移除');
}
/**
* 整批同步供貨商品
*/
public function sync(Request $request, Vendor $vendor)
{
$validated = $request->validate([
'products' => 'present|array',
'products.*.product_id' => 'required|exists:products,id',
'products.*.last_price' => 'nullable|numeric|min:0',
]);
$this->procurementService->syncVendorProducts($vendor->id, $validated['products']);
activity()
->performedOn($vendor)
->withProperties([
'attributes' => [
'products_count' => count($validated['products']),
],
'sub_subject' => '供貨商品',
'snapshot' => [
'name' => "{$vendor->name} 的供貨清單",
'vendor_name' => $vendor->name,
]
])
->event('updated')
->log('整批更新供貨商品');
return redirect()->back()->with('success', '供貨商品已更新');
}
}

View File

@@ -24,6 +24,9 @@ class PurchaseOrder extends Model
'tax_amount',
'grand_total',
'remark',
'invoice_number',
'invoice_date',
'invoice_amount',
];
protected $casts = [

View File

@@ -16,6 +16,7 @@ Route::middleware('auth')->group(function () {
// 供貨商品相關路由
Route::post('/vendors/{vendor}/products', [VendorProductController::class, 'store'])->middleware('permission:vendors.edit')->name('vendors.products.store');
Route::put('/vendors/{vendor}/products/sync', [VendorProductController::class, 'sync'])->middleware('permission:vendors.edit')->name('vendors.products.sync');
Route::put('/vendors/{vendor}/products/{product}', [VendorProductController::class, 'update'])->middleware('permission:vendors.edit')->name('vendors.products.update');
Route::delete('/vendors/{vendor}/products/{product}', [VendorProductController::class, 'destroy'])->middleware('permission:vendors.edit')->name('vendors.products.destroy');
});

View File

@@ -147,4 +147,48 @@ class ProcurementService implements ProcurementServiceInterface
->where('product_id', $productId)
->delete();
}
public function syncVendorProducts(int $vendorId, array $productsData): void
{
\Illuminate\Support\Facades\DB::transaction(function () use ($vendorId, $productsData) {
$existingPivots = \Illuminate\Support\Facades\DB::table('product_vendor')
->where('vendor_id', $vendorId)
->get();
$existingProductIds = $existingPivots->pluck('product_id')->toArray();
$newProductIds = array_column($productsData, 'product_id');
$toDelete = array_diff($existingProductIds, $newProductIds);
if (!empty($toDelete)) {
\Illuminate\Support\Facades\DB::table('product_vendor')
->where('vendor_id', $vendorId)
->whereIn('product_id', $toDelete)
->delete();
}
foreach ($productsData as $data) {
$exists = in_array($data['product_id'], $existingProductIds);
if ($exists) {
\Illuminate\Support\Facades\DB::table('product_vendor')
->where('vendor_id', $vendorId)
->where('product_id', $data['product_id'])
->update([
'last_price' => $data['last_price'] ?? null,
'updated_at' => now(),
]);
} else {
\Illuminate\Support\Facades\DB::table('product_vendor')
->insert([
'vendor_id' => $vendorId,
'product_id' => $data['product_id'],
'last_price' => $data['last_price'] ?? null,
'created_at' => now(),
'updated_at' => now(),
]);
}
}
});
}
}

View File

@@ -168,6 +168,28 @@ const fieldLabels: Record<string, string> = {
posted_by: '過帳者',
counted_qty: '盤點數量',
adjust_qty: '調整數量',
// 調撥單專有欄位
transit_warehouse_id: '在途倉庫',
transit_warehouse_name: '在途倉庫名稱',
dispatched_at: '出貨日期',
dispatched_by: '出貨人',
received_at: '收貨日期',
received_by: '收貨人',
reserved_quantity: '預扣數量',
snapshot_quantity: '異動前庫存 (快照)',
// 門市叫貨欄位
store_warehouse_id: '申請倉庫',
store_warehouse_name: '申請倉庫',
supply_warehouse_id: '供貨倉庫',
supply_warehouse_name: '供貨倉庫',
approved_by: '審核人',
approved_user_name: '審核人',
approved_at: '審核時間',
submitted_at: '提交時間',
reject_reason: '駁回原因',
status_label: '處理狀態',
transfer_order_id: '調撥單 ID',
transfer_order_name: '調撥單號',
};
// 狀態翻譯對照表
@@ -192,6 +214,7 @@ const statusMap: Record<string, string> = {
in_progress: '生產中',
// 調撥單狀態
voided: '已作廢',
dispatched: '已出貨',
};
// 主體類型解析 (Model 類名轉中文)
@@ -206,17 +229,7 @@ const subjectTypeMap: Record<string, string> = {
'App\\Modules\\Inventory\\Models\\GoodsReceipt': '進貨單',
'App\\Modules\\Inventory\\Models\\InventoryCountDoc': '庫存盤點單',
'App\\Modules\\Inventory\\Models\\InventoryAdjustDoc': '庫存盤調單',
'App\\Modules\\Inventory\\Models\\InventoryTransferOrder': '庫存調撥單',
'App\\Modules\\Inventory\\Models\\StockMovementDoc': '庫存單據',
'App\\Modules\\Procurement\\Models\\Vendor': '廠商資料',
'App\\Modules\\Procurement\\Models\\PurchaseOrder': '採購單',
'App\\Modules\\Production\\Models\\ProductionOrder': '生產工單',
'App\\Modules\\Production\\Models\\Recipe': '生產配方',
'App\\Modules\\Production\\Models\\RecipeItem': '配方品項',
'App\\Modules\\Production\\Models\\ProductionOrderItem': '工單品項',
'App\\Modules\\Finance\\Models\\UtilityFee': '公共事業費',
'App\\Modules\\Core\\Models\\User': '使用者帳號',
'App\\Modules\\Core\\Models\\Role': '角色權限',
'App\\Modules\\Inventory\\Models\\StoreRequisition': '門市叫貨單',
// 簡寫映射 (應對後端回傳 class_basename 的情況)
'Product': '商品資料',
'Warehouse': '倉庫資料',
@@ -231,7 +244,8 @@ const subjectTypeMap: Record<string, string> = {
'Recipe': '生產配方',
'InventoryCountDoc': '庫存盤點單',
'InventoryAdjustDoc': '庫存盤調單',
'InventoryTransferOrder': '庫調撥單',
'InventoryTransferOrder': '庫調撥單',
'StoreRequisition': '門市叫貨單',
'StockMovementDoc': '庫存單據',
'User': '使用者帳號',
'Role': '角色權限',
@@ -264,17 +278,31 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
// 自訂欄位排序順序
const sortOrder = [
'doc_no', 'po_number', 'gr_number', 'production_number',
'doc_no', 'po_number', 'gr_number', 'production_number', 'transfer_order_name',
'vendor_name', 'warehouse_name', 'order_date', 'expected_delivery_date', 'status', 'remark',
'invoice_number', 'invoice_date', 'invoice_amount',
'total_amount', 'tax_amount', 'grand_total'
];
// 過濾掉通常會記錄但對使用者無用的內部鍵
// 過濾掉通常會記錄但對使用者無用的內部鍵,以及已被解析為名稱的原始 ID 欄位
const filteredKeys = allKeys
.filter(key =>
!['created_at', 'updated_at', 'deleted_at', 'id', 'remember_token'].includes(key)
)
.filter(key => {
if (['created_at', 'updated_at', 'deleted_at', 'id', 'remember_token', 'activityProperties'].includes(key)) return false;
// 隱藏冗餘的狀態標籤 (因為後端已統一替換 status 內容)
if (key === 'status_label') return false;
// 隱藏技術用的 ID 欄位 (如果已有對應的名稱欄位)
if (key.endsWith('_id')) {
const nameKey = key.replace('_id', '_name');
const userNameKey = key.replace('_id', '_user_name');
if (allKeys.includes(nameKey) || allKeys.includes(userNameKey)) return false;
}
// 特別隱藏調撥單 ID
if (key === 'transfer_order_id' && (allKeys.includes('transfer_order_name') || allKeys.includes('transfer_order_doc_no'))) return false;
return true;
})
.sort((a, b) => {
const indexA = sortOrder.indexOf(a);
const indexB = sortOrder.indexOf(b);
@@ -336,9 +364,13 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
if ((key === 'order_date' || key === 'expected_delivery_date' || key === 'invoice_date' || key === 'arrival_date' || key === 'expiry_date' || key === 'received_date' || key === 'production_date') && typeof value === 'string') {
return value.split('T')[0].split(' ')[0];
}
if ((key === 'snapshot_date' || key === 'completed_at' || key === 'posted_at' || key === 'created_at' || key === 'updated_at' || key === 'actual_time') && typeof value === 'string') {
if ((key === 'snapshot_date' || key === 'completed_at' || key === 'posted_at' || key === 'created_at' || key === 'updated_at' || key === 'actual_time' || key === 'submitted_at' || key === 'approved_at') && typeof value === 'string') {
try {
const date = new Date(value);
// 處理部分 ISO 字串包含 T 的情況
const normalizedValue = value.replace('T', ' ');
const date = new Date(normalizedValue);
if (isNaN(date.getTime())) return value;
return date.toLocaleString('zh-TW', {
timeZone: 'Asia/Taipei',
year: 'numeric',
@@ -537,65 +569,89 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
</TableRow>
</TableHeader>
<TableBody>
{/* 1. 處理物件格式的 items_diff (現有的採購、盤點、調撥初始紀錄格式) */}
{!Array.isArray(activity.properties?.items_diff) && (
<>
{/* 更新項目 */}
{activity.properties.items_diff.updated?.map((item: any, idx: number) => (
{activity.properties?.items_diff?.updated?.map((item: any, idx: number) => (
<TableRow key={`upd-${idx}`} className="bg-blue-50/10 hover:bg-blue-50/20">
<TableCell className="font-medium">{item.product_name}</TableCell>
<TableCell className="text-center">
<Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200"></Badge>
</TableCell>
<TableCell className="text-sm">
<div className="space-y-1">
<div className="space-y-1 text-xs">
{item.old?.quantity !== item.new?.quantity && item.old?.quantity !== undefined && (
<div>: <span className="text-gray-500 line-through">{item.old.quantity}</span> <span className="text-blue-700 font-bold">{item.new.quantity}</span></div>
)}
{item.old?.counted_qty !== item.new?.counted_qty && item.old?.counted_qty !== undefined && (
<div>: <span className="text-gray-500 line-through">{item.old.counted_qty ?? '未盤'}</span> <span className="text-blue-700 font-bold">{item.new.counted_qty ?? '未盤'}</span></div>
)}
{item.old?.adjust_qty !== item.new?.adjust_qty && (
<div>調: <span className="text-gray-500 line-through">{item.old?.adjust_qty ?? '0'}</span> <span className="text-blue-700 font-bold">{item.new?.adjust_qty ?? '0'}</span></div>
)}
{item.old?.unit_name !== item.new?.unit_name && item.old?.unit_name !== undefined && (
<div>: <span className="text-gray-500 line-through">{item.old.unit_name || '-'}</span> <span className="text-blue-700 font-bold">{item.new.unit_name || '-'}</span></div>
)}
{item.old?.subtotal !== item.new?.subtotal && item.old?.subtotal !== undefined && (
<div>: <span className="text-gray-500 line-through">${item.old.subtotal}</span> <span className="text-blue-700 font-bold">${item.new.subtotal}</span></div>
)}
{item.old?.notes !== item.new?.notes && (
<div>: <span className="text-gray-500 line-through">{item.old?.notes || '-'}</span> <span className="text-blue-700 font-bold">{item.new?.notes || '-'}</span></div>
)}
</div>
</TableCell>
</TableRow>
)) || null}
{/* 新增項目 */}
{activity.properties.items_diff.added?.map((item: any, idx: number) => (
{activity.properties?.items_diff?.added?.map((item: any, idx: number) => (
<TableRow key={`add-${idx}`} className="bg-green-50/10 hover:bg-green-50/20">
<TableCell className="font-medium">{item.product_name}</TableCell>
<TableCell className="text-center">
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-200"></Badge>
</TableCell>
<TableCell className="text-sm">
{item.quantity !== undefined ? `數量: ${item.quantity} ${item.unit_name || ''} / ` : ''}
{item.adjust_qty !== undefined ? `調整量: ${item.adjust_qty} / ` : ''}
{item.subtotal !== undefined ? `小計: $${item.subtotal}` : ''}
: {item.new?.quantity ?? item.quantity} {item.unit_name || item.new?.unit_name || ''}
</TableCell>
</TableRow>
)) || null}
{/* 移除項目 */}
{activity.properties.items_diff.removed?.map((item: any, idx: number) => (
{activity.properties?.items_diff?.removed?.map((item: any, idx: number) => (
<TableRow key={`rem-${idx}`} className="bg-red-50/10 hover:bg-red-50/20">
<TableCell className="font-medium text-gray-400 line-through">{item.product_name}</TableCell>
<TableCell className="text-center">
<Badge variant="outline" className="bg-red-50 text-red-700 border-red-200"></Badge>
</TableCell>
<TableCell className="text-sm text-gray-400">
: {item.quantity} {item.unit_name}
: {item.old?.quantity ?? item.quantity} {item.unit_name || item.old?.unit_name || ''}
</TableCell>
</TableRow>
)) || null}
</>
)}
{/* 2. 處理陣列格式的 items_diff (調撥單過帳/收貨的複合紀錄格式) */}
{Array.isArray(activity.properties?.items_diff) && activity.properties.items_diff.map((item: any, idx: number) => (
<TableRow key={`trf-${idx}`} className="hover:bg-gray-50/50">
<TableCell className="font-medium">
{item.product_name}
<div className="text-[10px] text-gray-400 font-normal">: {item.batch_number}</div>
</TableCell>
<TableCell className="text-center">
<Badge variant="outline" className="bg-amber-50 text-amber-700 border-amber-200"></Badge>
</TableCell>
<TableCell className="text-xs p-2">
<div className="flex flex-col gap-2">
{item.source_warehouse && (
<div className="flex items-center gap-2">
<span className="w-16 text-gray-400 truncate"> ({item.source_warehouse}):</span>
<span className="text-gray-400 line-through">{Number(item.source_before || 0).toFixed(0)}</span>
<span className="text-rose-600 font-bold"> {Number(item.source_after || 0).toFixed(0)}</span>
<span className="text-[10px] bg-rose-50 text-rose-600 px-1 rounded">-{item.quantity}</span>
</div>
)}
{item.target_warehouse && (
<div className="flex items-center gap-2">
<span className="w-16 text-gray-400 truncate"> ({item.target_warehouse}):</span>
<span className="text-gray-400 line-through">{Number(item.target_before || 0).toFixed(0)}</span>
<span className="text-emerald-600 font-bold"> {Number(item.target_after || 0).toFixed(0)}</span>
<span className="text-[10px] bg-emerald-50 text-emerald-600 px-1 rounded">+{item.quantity}</span>
</div>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>

View File

@@ -42,6 +42,7 @@ const subjectTypeMap: Record<string, string> = {
'App\\Modules\\Inventory\\Models\\InventoryCountDoc': '庫存盤點單',
'App\\Modules\\Inventory\\Models\\InventoryAdjustDoc': '庫存盤調單',
'App\\Modules\\Inventory\\Models\\InventoryTransferOrder': '庫存調撥單',
'App\\Modules\\Inventory\\Models\\StoreRequisition': '門市叫貨單',
'App\\Modules\\Inventory\\Models\\StockMovementDoc': '庫存單據',
'App\\Modules\\Procurement\\Models\\Vendor': '廠商資料',
'App\\Modules\\Procurement\\Models\\PurchaseOrder': '採購單',
@@ -67,6 +68,7 @@ const subjectTypeMap: Record<string, string> = {
'InventoryCountDoc': '庫存盤點單',
'InventoryAdjustDoc': '庫存盤調單',
'InventoryTransferOrder': '庫存調撥單',
'StoreRequisition': '門市叫貨單',
'StockMovementDoc': '庫存單據',
'User': '使用者帳號',
'Role': '角色權限',

View File

@@ -1,193 +0,0 @@
/**
* 新增供貨商品對話框
*/
import { useState, useMemo } from "react";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { Label } from "@/Components/ui/label";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/Components/ui/dialog";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/Components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/Components/ui/popover";
import { Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import type { Product } from "@/types/product";
import type { SupplyProduct } from "@/types/vendor";
interface AddSupplyProductDialogProps {
open: boolean;
products: Product[];
existingSupplyProducts: SupplyProduct[];
onClose: () => void;
onAdd: (productId: string, lastPrice?: number) => void;
}
export default function AddSupplyProductDialog({
open,
products,
existingSupplyProducts,
onClose,
onAdd,
}: AddSupplyProductDialogProps) {
const [selectedProductId, setSelectedProductId] = useState<string>("");
const [lastPrice, setLastPrice] = useState<string>("");
const [openCombobox, setOpenCombobox] = useState(false);
// 過濾掉已經在供貨列表中的商品
const availableProducts = useMemo(() => {
const existingIds = new Set(existingSupplyProducts.map(sp => String(sp.productId)));
return products.filter(p => !existingIds.has(String(p.id)));
}, [products, existingSupplyProducts]);
const selectedProduct = availableProducts.find(p => p.id === selectedProductId);
const handleAdd = () => {
if (!selectedProductId) return;
const price = lastPrice ? parseFloat(lastPrice) : undefined;
onAdd(selectedProductId, price);
// 重置表單
setSelectedProductId("");
setLastPrice("");
};
const handleCancel = () => {
setSelectedProductId("");
setLastPrice("");
onClose();
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* 商品選擇 */}
<div className="flex flex-col gap-2">
<Label className="text-sm font-medium"></Label>
<Popover open={openCombobox} onOpenChange={setOpenCombobox}>
<PopoverTrigger asChild>
<button
type="button"
role="combobox"
aria-expanded={openCombobox}
className="flex h-9 w-full items-center justify-between rounded-md border-2 border-grey-3 !bg-grey-5 px-3 py-1 text-sm font-normal text-grey-0 text-left outline-none transition-colors hover:!bg-grey-5 hover:border-primary/50 focus-visible:border-[var(--primary-main)] focus-visible:ring-[3px] focus-visible:ring-[var(--primary-main)]/20"
onClick={() => setOpenCombobox(!openCombobox)}
>
{selectedProduct ? (
<span className="font-medium text-gray-900">{selectedProduct.name}</span>
) : (
<span className="text-gray-400">...</span>
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</button>
</PopoverTrigger>
<PopoverContent className="w-[450px] p-0 shadow-lg border-2 z-[9999]" align="start">
<Command>
<CommandInput placeholder="搜尋商品名稱..." />
<CommandList className="max-h-[300px]">
<CommandEmpty className="py-6 text-center text-sm text-gray-500">
</CommandEmpty>
<CommandGroup>
{availableProducts.map((product) => (
<CommandItem
key={product.id}
value={product.name}
onSelect={() => {
setSelectedProductId(product.id);
setOpenCombobox(false);
}}
className="cursor-pointer aria-selected:bg-primary/5 aria-selected:text-primary py-3"
>
<Check
className={cn(
"mr-2 h-4 w-4 text-primary",
selectedProductId === product.id ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex items-center justify-between flex-1">
<span className="font-medium">{product.name}</span>
<span className="text-xs text-gray-400 bg-gray-50 px-2 py-1 rounded">
{product.baseUnit?.name || (product.base_unit as any)?.name || product.base_unit || "個"}
</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 單位(自動帶入) */}
<div className="flex flex-col gap-2">
<Label className="text-sm font-medium text-gray-500"></Label>
<div className="h-10 px-3 py-2 bg-gray-50 border border-gray-200 rounded-md text-gray-600 font-medium text-sm flex items-center">
{selectedProduct ? (selectedProduct.baseUnit?.name || (selectedProduct.base_unit as any)?.name || selectedProduct.base_unit || "個") : "-"}
</div>
</div>
{/* 上次採購價格 */}
<div>
<Label className="text-muted-foreground text-xs">
/ {selectedProduct ? (selectedProduct.baseUnit?.name || (selectedProduct.base_unit as any)?.name || selectedProduct.base_unit || "個") : "單位"}
</Label>
<Input
type="number"
min="0"
step="any"
placeholder="輸入價格"
value={lastPrice}
onChange={(e) => setLastPrice(e.target.value)}
className="mt-1"
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={handleCancel}
className="gap-2 button-outlined-primary"
>
</Button>
<Button
size="sm"
onClick={handleAdd}
disabled={!selectedProductId}
className="gap-2 button-filled-primary"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,119 +0,0 @@
/**
* 編輯供貨商品對話框
*/
import { useEffect, useState } from "react";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { Label } from "@/Components/ui/label";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/Components/ui/dialog";
import type { SupplyProduct } from "@/types/vendor";
interface EditSupplyProductDialogProps {
open: boolean;
product: SupplyProduct | null;
onClose: () => void;
onSave: (productId: string, lastPrice?: number) => void;
}
export default function EditSupplyProductDialog({
open,
product,
onClose,
onSave,
}: EditSupplyProductDialogProps) {
const [lastPrice, setLastPrice] = useState<string>("");
useEffect(() => {
if (product) {
setLastPrice(product.lastPrice?.toString() || "");
}
}, [product, open]);
const handleSave = () => {
if (!product) return;
const price = lastPrice ? parseFloat(lastPrice) : undefined;
onSave(product.productId, price);
setLastPrice("");
};
const handleCancel = () => {
setLastPrice("");
onClose();
};
if (!product) return null;
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* 商品名稱(不可編輯) */}
<div>
<Label className="text-muted-foreground text-xs"></Label>
<Input
value={product.productName}
disabled
className="mt-1 bg-muted"
/>
</div>
{/* 單位(不可編輯) */}
<div>
<Label className="text-muted-foreground text-xs"></Label>
<Input
value={product.unit}
disabled
className="mt-1 bg-muted"
/>
</div>
{/* 上次採購價格 */}
<div>
<Label className="text-muted-foreground text-xs"> / {product.baseUnit || "單位"}</Label>
<Input
type="number"
min="0"
step="any"
placeholder="輸入價格"
value={lastPrice}
onChange={(e) => setLastPrice(e.target.value)}
className="mt-1"
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={handleCancel}
className="gap-2 button-outlined-primary"
>
</Button>
<Button
size="sm"
onClick={handleSave}
className="gap-2 button-filled-primary"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,5 +1,7 @@
import { Pencil, Trash2 } from "lucide-react";
import { Trash2 } from "lucide-react";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { SearchableSelect } from "@/Components/ui/searchable-select";
import {
Table,
TableBody,
@@ -8,89 +10,128 @@ import {
TableHeader,
TableRow,
} from "@/Components/ui/table";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/Components/ui/alert-dialog";
import type { SupplyProduct } from "@/types/vendor";
interface SupplyProductListProps {
products: SupplyProduct[];
onEdit: (product: SupplyProduct) => void;
onRemove: (product: SupplyProduct) => void;
items: SupplyProduct[];
allProducts: any[];
onRemoveItem: (index: number) => void;
onItemChange: (index: number, field: keyof SupplyProduct, value: string | number) => void;
}
export default function SupplyProductList({
products,
onEdit,
onRemove,
items,
allProducts,
onRemoveItem,
onItemChange,
}: SupplyProductListProps) {
return (
<div className="bg-white rounded-lg border shadow-sm">
<div className="bg-white rounded-lg border shadow-sm overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableRow className="bg-gray-50 hover:bg-gray-50 text-grey-0">
<TableHead className="w-[50px] text-center">#</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right">
<TableHead className="w-[40%] text-left"></TableHead>
<TableHead className="w-[15%] text-left"></TableHead>
<TableHead className="w-[20%] text-left">
<div className="text-xs font-normal text-muted-foreground">()</div>
<span className="text-[10px] font-normal text-muted-foreground block">()</span>
</TableHead>
<TableHead className="text-center w-[150px]"></TableHead>
<TableHead className="text-center w-[80px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{products.length === 0 ? (
{items.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
<TableCell colSpan={5} className="text-center text-muted-foreground py-12 italic text-sm">
</TableCell>
</TableRow>
) : (
products.map((product, index) => (
<TableRow key={product.id}>
items.map((item, index) => (
<TableRow key={item.id}>
<TableCell className="text-gray-500 font-medium text-center">
{index + 1}
</TableCell>
<TableCell>{product.productName}</TableCell>
<TableCell>
{product.baseUnit || product.unit || "-"}
<SearchableSelect
value={item.productId}
onValueChange={(value) => onItemChange(index, "productId", value)}
options={allProducts.map(p => ({
label: p.name,
value: String(p.id)
}))}
placeholder="選擇商品"
searchPlaceholder="搜尋商品..."
className="w-full h-10"
/>
</TableCell>
<TableCell>
{product.largeUnit && product.conversionRate ? (
<span className="text-sm text-gray-500">
1 {product.largeUnit} = {Number(product.conversionRate)} {product.baseUnit || product.unit}
</span>
) : (
"-"
)}
<div className="h-10 px-3 py-2 bg-gray-50/50 border border-border rounded-md text-gray-600 font-medium text-sm flex items-center">
{item.baseUnit || "-"}
</div>
</TableCell>
<TableCell className="text-right">
{product.lastPrice ? (
<span>
${product.lastPrice.toLocaleString()} / {product.baseUnit || product.unit || "單位"}
</span>
) : (
"-"
)}
<TableCell>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-xs">$</span>
<Input
type="number"
min="0"
step="any"
value={item.lastPrice === undefined ? "" : item.lastPrice}
onChange={(e) => onItemChange(index, "lastPrice", e.target.value === "" ? 0 : Number(e.target.value))}
placeholder="0.00"
className="pl-6 h-10 w-full text-right"
/>
</div>
</div>
</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-2">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() => onEdit(product)}
className="button-outlined-primary"
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onRemove(product)}
className="button-outlined-error"
className="button-outlined-error h-10 w-10 p-0"
title="移除項目"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{item.productName || "此商品"}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="button-outlined-primary"></AlertDialogCancel>
<AlertDialogAction
onClick={() => onRemoveItem(index)}
className="bg-red-600 hover:bg-red-700 text-white"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</TableCell>
</TableRow>
))

View File

@@ -1,29 +1,14 @@
/**
* 廠商詳細資訊頁面
*/
import { useState } from "react";
import { useState, useEffect } from "react";
import { Head, Link, router } from "@inertiajs/react";
import { Phone, Mail, Plus, ArrowLeft, Contact2 } from "lucide-react";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Label } from "@/Components/ui/label";
import { Button } from "@/Components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/Components/ui/alert-dialog";
import SupplyProductList from "@/Components/Vendor/SupplyProductList";
import AddSupplyProductDialog from "@/Components/Vendor/AddSupplyProductDialog";
import EditSupplyProductDialog from "@/Components/Vendor/EditSupplyProductDialog";
import type { Vendor } from "@/Pages/Vendor/Index";
import type { SupplyProduct } from "@/types/vendor";
import { getShowBreadcrumbs } from "@/utils/breadcrumb";
import { toast } from "sonner";
interface Pivot {
last_price: number | null;
@@ -33,12 +18,11 @@ interface VendorProduct {
id: number;
name: string;
unit?: string;
// Relations might be camelCase or snake_case depending on serialization settings
baseUnit?: { name: string };
base_unit?: { name: string };
largeUnit?: { name: string };
large_unit?: { name: string };
purchaseUnit?: string; // Note: if it's a relation it might be an object, but original code treated it as string
purchaseUnit?: string;
purchase_unit?: string;
conversion_rate?: number;
pivot: Pivot;
@@ -53,27 +37,17 @@ interface ShowProps {
products: any[];
}
export default function VendorShow({ vendor, products }: ShowProps) {
const [showAddDialog, setShowAddDialog] = useState(false);
const [showEditDialog, setShowEditDialog] = useState(false);
const [showRemoveDialog, setShowRemoveDialog] = useState(false);
const [selectedProduct, setSelectedProduct] = useState<SupplyProduct | null>(null);
export default function VendorShow({ vendor, products: allProducts }: ShowProps) {
const [items, setItems] = useState<SupplyProduct[]>([]);
// 轉換後端資料格式為前端組件需要的格式
const supplyProducts: SupplyProduct[] = vendor.products.map(p => {
// Laravel load('relationName') usually results in camelCase key in JSON if method is camelCase
// 初始化資料
useEffect(() => {
const initialItems: SupplyProduct[] = vendor.products.map(p => {
const baseUnitName = p.baseUnit?.name || p.base_unit?.name;
const largeUnitName = p.largeUnit?.name || p.large_unit?.name;
// Check purchase unit - seemingly originally a field string, but if relation, check if object
// Assuming purchase_unit is a string field on product table here based on original code usage?
// Wait, original code usage: p.purchase_unit || ...
// In Product model: purchase_unit_id exists, purchaseUnit is relation.
// If p.purchase_unit was working before, it might be an attribute (accessors).
// Let's stick to safe access.
return {
id: String(p.id),
id: String(p.id) + "_" + Math.random().toString(36).substr(2, 9), // 加上隨機碼以確保 key 唯一
productId: String(p.id),
productName: p.name,
unit: p.purchase_unit || baseUnitName || "個",
@@ -83,48 +57,82 @@ export default function VendorShow({ vendor, products }: ShowProps) {
lastPrice: p.pivot.last_price || undefined,
};
});
setItems(initialItems);
}, [vendor.products]);
const handleAddProduct = (productId: string, lastPrice?: number) => {
router.post(route('vendors.products.store', vendor.id), {
product_id: productId,
last_price: lastPrice,
}, {
onSuccess: () => setShowAddDialog(false),
});
const handleAddItem = () => {
const newItem: SupplyProduct = {
id: "new_" + Math.random().toString(36).substr(2, 9),
productId: "",
productName: "",
unit: "個",
lastPrice: undefined,
};
setItems([...items, newItem]);
};
const handleEditProduct = (product: SupplyProduct) => {
setSelectedProduct(product);
setShowEditDialog(true);
const handleRemoveItem = (index: number) => {
const newItems = [...items];
newItems.splice(index, 1);
setItems(newItems);
};
const handleUpdateProduct = (productId: string, lastPrice?: number) => {
router.put(route('vendors.products.update', [vendor.id, productId]), {
last_price: lastPrice,
const handleItemChange = (index: number, field: keyof SupplyProduct, value: any) => {
const newItems = [...items];
const item = { ...newItems[index] };
if (field === "productId") {
const product = allProducts.find(p => String(p.id) === String(value));
if (product) {
item.productId = String(product.id);
item.productName = product.name;
item.baseUnit = product.baseUnit?.name || product.base_unit?.name || product.base_unit || "個";
item.largeUnit = product.largeUnit?.name || product.large_unit?.name;
item.conversionRate = product.conversion_rate;
item.unit = item.baseUnit || "個";
} else {
item.productId = value;
item.productName = "";
}
} else {
(item as any)[field] = value;
}
newItems[index] = item;
setItems(newItems);
};
const handleSaveAll = () => {
// 過濾掉沒有選擇商品的項目
const validItems = items.filter(item => item.productId !== "");
if (validItems.length === 0 && items.length > 0) {
toast.error("請至少選擇一個有效的商品,或移除空白列");
return;
}
// 檢查重複商品
const productIds = validItems.map(i => i.productId);
if (new Set(productIds).size !== productIds.length) {
toast.error("供貨清單中有重複的商品,請檢查");
return;
}
router.put(route('vendors.products.sync', vendor.id), {
products: validItems.map(item => ({
product_id: item.productId,
last_price: item.lastPrice,
}))
}, {
onSuccess: () => {
setShowEditDialog(false);
setSelectedProduct(null);
toast.success("供貨商品已更新");
},
onError: () => {
toast.error("更新失敗,請檢查欄位格式");
}
});
};
const handleRemoveProduct = (product: SupplyProduct) => {
setSelectedProduct(product);
setShowRemoveDialog(true);
};
const handleConfirmRemove = () => {
if (selectedProduct) {
router.delete(route('vendors.products.destroy', [vendor.id, selectedProduct.productId]), {
onSuccess: () => {
setShowRemoveDialog(false);
setSelectedProduct(null);
}
});
}
};
return (
<AuthenticatedLayout breadcrumbs={getShowBreadcrumbs("vendors", `廠商詳情 (${vendor.name})`)}>
<Head title={`廠商詳情 - ${vendor.name}`} />
@@ -164,11 +172,11 @@ export default function VendorShow({ vendor, products }: ShowProps) {
</div>
<div>
<Label className="text-muted-foreground text-xs"></Label>
<p className="mt-1 font-medium">{vendor.short_name || "-"}</p>
<p className="mt-1 font-medium">{vendor.shortName || "-"}</p>
</div>
<div>
<Label className="text-muted-foreground text-xs"></Label>
<p className="mt-1">{vendor.tax_id || "-"}</p>
<p className="mt-1">{vendor.taxId || "-"}</p>
</div>
<div>
<Label className="text-muted-foreground text-xs"></Label>
@@ -187,7 +195,7 @@ export default function VendorShow({ vendor, products }: ShowProps) {
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<Label className="text-muted-foreground text-xs"></Label>
<p className="mt-1">{vendor.contact_name || "-"}</p>
<p className="mt-1">{vendor.contactName || "-"}</p>
</div>
<div>
<Label className="text-muted-foreground text-xs"></Label>
@@ -212,9 +220,9 @@ export default function VendorShow({ vendor, products }: ShowProps) {
{/* 供貨商品列表 */}
<div className="bg-white rounded-lg border border-border p-6 shadow-sm">
<div className="flex items-center justify-between mb-4">
<h3></h3>
<h3 className="font-bold"></h3>
<Button
onClick={() => setShowAddDialog(true)}
onClick={handleAddItem}
className="gap-2 button-filled-primary"
size="sm"
>
@@ -222,57 +230,30 @@ export default function VendorShow({ vendor, products }: ShowProps) {
</Button>
</div>
<SupplyProductList
products={supplyProducts}
onEdit={handleEditProduct}
onRemove={handleRemoveProduct}
items={items}
allProducts={allProducts}
onRemoveItem={handleRemoveItem}
onItemChange={handleItemChange}
/>
</div>
{/* 新增供貨商品對話框 */}
<AddSupplyProductDialog
open={showAddDialog}
products={products}
existingSupplyProducts={supplyProducts}
onClose={() => setShowAddDialog(false)}
onAdd={handleAddProduct}
/>
{/* 編輯供貨商品對話框 */}
<EditSupplyProductDialog
open={showEditDialog}
product={selectedProduct}
onClose={() => {
setShowEditDialog(false);
setSelectedProduct(null);
}}
onSave={handleUpdateProduct}
/>
{/* 取消供貨確認對話框 */}
<AlertDialog open={showRemoveDialog} onOpenChange={setShowRemoveDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{selectedProduct?.productName}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
className="gap-2 button-outlined-primary"
>
{/* 底部按鈕 - 移至容器外 */}
<div className="mt-6 flex items-center justify-end gap-4 py-6 border-t border-gray-100">
<Link href="/vendors">
<Button variant="ghost" className="h-11 px-6 text-gray-500 hover:text-gray-700">
</AlertDialogCancel>
<AlertDialogAction
className="gap-2 button-filled-error"
onClick={handleConfirmRemove}
</Button>
</Link>
<Button
onClick={handleSaveAll}
className="bg-primary hover:bg-primary/90 text-white shadow-primary/20 h-11 px-8 text-lg font-bold rounded-xl"
size="lg"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Button>
</div>
</div>
</AuthenticatedLayout>
);

View File

@@ -0,0 +1,83 @@
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ $title ?? 'API Documentation' }} - Star ERP</title>
<script src="https://cdn.tailwindcss.com?plugins=typography"></script>
<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&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
body {
font-family: 'Inter', sans-serif;
scroll-behavior: smooth;
}
code, pre {
font-family: 'JetBrains Mono', monospace;
}
.prose pre {
background-color: #1e293b;
color: #f8fafc;
border-radius: 0.75rem;
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1);
}
.sidebar-link.active {
color: #0ea5e9;
background-color: #f0f9ff;
border-right: 4px solid #0ea5e9;
}
</style>
</head>
<body class="bg-slate-50 text-slate-900">
<div class="flex min-h-screen">
<!-- Sidebar -->
<aside class="w-64 bg-white border-r border-slate-200 fixed h-full overflow-y-auto hidden md:block">
<div class="p-6">
<div class="flex items-center gap-2 mb-8">
<div class="w-8 h-8 bg-sky-500 rounded-lg flex items-center justify-center text-white font-bold">S</div>
<span class="font-bold text-xl tracking-tight">Star ERP</span>
</div>
<nav class="space-y-1">
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2 px-3">文件導覽</p>
@foreach($toc as $item)
<a href="#{{ $item['id'] }}" class="block px-3 py-2 text-sm font-medium text-slate-600 hover:text-sky-600 hover:bg-slate-50 rounded-md transition-all duration-200">
{{ $item['text'] }}
</a>
@endforeach
</nav>
</div>
</aside>
<!-- Main Content -->
<main class="flex-1 md:ml-64 p-6 md:p-12 lg:p-20">
<div class="max-w-4xl mx-auto">
<article class="prose prose-slate prose-sky max-w-none
prose-headings:scroll-mt-20
prose-h1:text-4xl prose-h1:font-extrabold prose-h1:tracking-tight
prose-pre:p-6 prose-pre:text-sm
prose-table:border prose-table:rounded-xl prose-table:overflow-hidden
prose-th:bg-slate-100 prose-th:p-4
prose-td:p-4 prose-td:border-t prose-td:border-slate-100">
{!! $content !!}
</article>
<footer class="mt-20 pt-8 border-t border-slate-200 text-slate-400 text-sm flex justify-between">
<span>© {{ date('Y') }} Star ERP System. All rights reserved.</span>
<span>整合介面版本 v1.2</span>
</footer>
</div>
</main>
</div>
<!-- Mobile Menu Button -->
<div class="md:hidden fixed bottom-6 right-6">
<button id="menu-toggle" class="w-14 h-14 bg-sky-500 text-white rounded-full shadow-lg flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16m-7 6h7" />
</svg>
</button>
</div>
</body>
</html>

View File

@@ -13,6 +13,43 @@ use Stancl\Tenancy\Middleware\InitializeTenancyByDomainOrSubdomain;
// 登入/登出路由
Route::get('/api/docs', function () {
$path = resource_path('markdown/manual/api-integration.md');
if (!file_exists($path)) {
abort(404);
}
$markdown = file_get_contents($path);
// 解析 Markdown 內容
$content = Illuminate\Support\Str::markdown($markdown, [
'html_input' => 'strip',
'allow_unsafe_links' => false,
]);
// 萃取 TOC (簡單的正則表達式抓取 ## 標題)
preg_match_all('/^##\s+(.+)$/m', $markdown, $matches);
$toc = collect($matches[1])->map(function ($text) {
return [
'id' => Illuminate\Support\Str::slug($text),
'text' => $text,
];
});
// 替換內容中的 ## 標題以加入 ID 錨點 (讓左側導覽能跳轉)
foreach ($toc as $item) {
$content = str_replace("<h2>{$item['text']}</h2>", "<h2 id=\"{$item['id']}\">{$item['text']}</h2>", $content);
}
return view('docs.api', [
'content' => $content,
'toc' => $toc,
'title' => '外部系統 API 對接手冊'
]);
});
Route::middleware('auth')->group(function () {
// 儀表板 - 所有登入使用者皆可存取