feat: 整合門市領料日誌、API 文件存取、修改庫存與併發編號問題、供應商商品內聯編輯及日誌 UI 優化
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 1m0s
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 1m0s
This commit is contained in:
@@ -32,6 +32,7 @@ class ActivityLogController extends Controller
|
|||||||
'App\Modules\Inventory\Models\InventoryCountDoc' => '庫存盤點單',
|
'App\Modules\Inventory\Models\InventoryCountDoc' => '庫存盤點單',
|
||||||
'App\Modules\Inventory\Models\InventoryAdjustDoc' => '庫存盤調單',
|
'App\Modules\Inventory\Models\InventoryAdjustDoc' => '庫存盤調單',
|
||||||
'App\Modules\Inventory\Models\InventoryTransferOrder' => '庫存調撥單',
|
'App\Modules\Inventory\Models\InventoryTransferOrder' => '庫存調撥單',
|
||||||
|
'App\Modules\Inventory\Models\StoreRequisition' => '門市叫貨單',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -186,8 +186,14 @@ class InventoryController extends Controller
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return DB::transaction(function () use ($validated, $warehouse) {
|
return DB::transaction(function () use ($validated, $warehouse) {
|
||||||
// 修正時間精度:手動入庫亦補上當下時分秒
|
// 修正時間精度:使用 Carbon 解析,若含時間則保留並補上秒數,若只有日期則補上當前時間
|
||||||
$inboundDateTime = $validated['inboundDate'] . ' ' . date('H:i:s');
|
$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'], [
|
$this->inventoryService->processIncomingInventory($warehouse, $validated['items'], [
|
||||||
'inboundDate' => $inboundDateTime,
|
'inboundDate' => $inboundDateTime,
|
||||||
|
|||||||
@@ -284,7 +284,7 @@ class ProductController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function template()
|
public function template()
|
||||||
{
|
{
|
||||||
return Excel::download(new ProductTemplateExport, 'products_template.xlsx');
|
return Excel::download(new ProductTemplateExport, '商品匯入範本.xlsx');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -143,15 +143,16 @@ class StoreRequisitionController extends Controller
|
|||||||
'items.*.requested_qty.min' => '需求數量必須大於 0',
|
'items.*.requested_qty.min' => '需求數量必須大於 0',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$submitImmediately = $request->boolean('submit_immediately');
|
||||||
|
|
||||||
$requisition = $this->service->create(
|
$requisition = $this->service->create(
|
||||||
$request->only(['store_warehouse_id', 'remark']),
|
$request->only(['store_warehouse_id', 'remark']),
|
||||||
$request->items,
|
$request->items,
|
||||||
auth()->id()
|
auth()->id(),
|
||||||
|
$submitImmediately
|
||||||
);
|
);
|
||||||
|
|
||||||
// 如果需要直接提交
|
if ($submitImmediately) {
|
||||||
if ($request->boolean('submit_immediately')) {
|
|
||||||
$this->service->submit($requisition, auth()->id());
|
|
||||||
return redirect()->route('store-requisitions.index')
|
return redirect()->route('store-requisitions.index')
|
||||||
->with('success', '叫貨單已提交審核');
|
->with('success', '叫貨單已提交審核');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,6 +100,24 @@ class TransferOrderController extends Controller
|
|||||||
$transitWarehouseId
|
$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) {
|
if ($request->input('instant_post') === true) {
|
||||||
try {
|
try {
|
||||||
$this->transferService->dispatch($order, auth()->id());
|
$this->transferService->dispatch($order, auth()->id());
|
||||||
|
|||||||
@@ -26,8 +26,7 @@ class InventoryImport implements ToModel, WithHeadingRow, WithValidation, WithMa
|
|||||||
|
|
||||||
// 修正時間精度:將選定的日期與「現在的時分秒」結合
|
// 修正時間精度:將選定的日期與「現在的時分秒」結合
|
||||||
// 這樣既能保留使用者選的日期,又能提供精確的紀錄時點排順序
|
// 這樣既能保留使用者選的日期,又能提供精確的紀錄時點排順序
|
||||||
$currentTime = date('H:i:s');
|
$this->inboundDate = \Illuminate\Support\Carbon::parse($inboundDate)->setTimeFrom(now())->toDateTimeString();
|
||||||
$this->inboundDate = $inboundDate . ' ' . $currentTime;
|
|
||||||
|
|
||||||
$this->notes = $notes;
|
$this->notes = $notes;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,43 @@ use Illuminate\Validation\Rule;
|
|||||||
use Maatwebsite\Excel\Concerns\ToModel;
|
use Maatwebsite\Excel\Concerns\ToModel;
|
||||||
use Maatwebsite\Excel\Concerns\WithHeadingRow;
|
use Maatwebsite\Excel\Concerns\WithHeadingRow;
|
||||||
use Maatwebsite\Excel\Concerns\WithMapping;
|
use Maatwebsite\Excel\Concerns\WithMapping;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithMultipleSheets;
|
||||||
use Maatwebsite\Excel\Concerns\WithValidation;
|
use Maatwebsite\Excel\Concerns\WithValidation;
|
||||||
|
use Maatwebsite\Excel\Concerns\SkipsEmptyRows;
|
||||||
use Maatwebsite\Excel\Imports\HeadingRowFormatter;
|
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 $categories;
|
||||||
private $units;
|
private $units;
|
||||||
@@ -20,9 +53,6 @@ class ProductImport implements ToModel, WithHeadingRow, WithValidation, WithMapp
|
|||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
// 禁用標題格式化,保留中文標題
|
|
||||||
HeadingRowFormatter::default('none');
|
|
||||||
|
|
||||||
// 快取所有類別與單位,避免 N+1 查詢
|
// 快取所有類別與單位,避免 N+1 查詢
|
||||||
$this->categories = Category::pluck('id', 'name');
|
$this->categories = Category::pluck('id', 'name');
|
||||||
$this->units = Unit::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
|
public function map($row): array
|
||||||
{
|
{
|
||||||
// 強制將代號與條碼轉為字串,避免純數字被當作整數處理導致 max:5 驗證錯誤
|
$code = $row['商品代號(選填)'] ?? $row['商品代號'] ?? null;
|
||||||
if (isset($row['商品代號'])) {
|
$barcode = $row['條碼(選填)'] ?? $row['條碼'] ?? null;
|
||||||
$row['商品代號'] = (string) $row['商品代號'];
|
|
||||||
}
|
|
||||||
if (isset($row['條碼'])) {
|
|
||||||
$row['條碼'] = (string) $row['條碼'];
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
* @param array $row (map() 回傳的乾淨鍵名陣列)
|
||||||
*
|
|
||||||
* @return \Illuminate\Database\Eloquent\Model|null
|
|
||||||
*/
|
*/
|
||||||
public function model(array $row)
|
public function model(array $row)
|
||||||
{
|
{
|
||||||
@@ -96,13 +135,17 @@ class ProductImport implements ToModel, WithHeadingRow, WithValidation, WithMapp
|
|||||||
return null; // 返回 null,因為 Service 已經處理完儲存
|
return null; // 返回 null,因為 Service 已經處理完儲存
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
/**
|
||||||
|
* 驗證規則
|
||||||
|
*
|
||||||
|
* 鍵名必須匹配 Excel 原始標題(含「(選填)」後綴),
|
||||||
|
* 因為 WithValidation 驗證的是 map() 之前的原始資料。
|
||||||
|
*/
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'商品代號' => ['nullable', 'string', 'min:2', 'max:8'],
|
'商品代號(選填)' => ['nullable', 'string', 'min:2', 'max:8'],
|
||||||
'條碼' => ['nullable', 'string'],
|
'條碼(選填)' => ['nullable', 'string'],
|
||||||
'商品名稱' => ['required', 'string'],
|
'商品名稱' => ['required', 'string'],
|
||||||
'類別名稱' => ['required', function($attribute, $value, $fail) {
|
'類別名稱' => ['required', function($attribute, $value, $fail) {
|
||||||
if (!isset($this->categories[$value])) {
|
if (!isset($this->categories[$value])) {
|
||||||
@@ -127,4 +170,16 @@ class ProductImport implements ToModel, WithHeadingRow, WithValidation, WithMapp
|
|||||||
'批發價' => ['nullable', 'numeric', 'min:0'],
|
'批發價' => ['nullable', 'numeric', 'min:0'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自訂驗證錯誤訊息的欄位名稱
|
||||||
|
* 把含 "(選填)" 後綴的欄位顯示為友善名稱
|
||||||
|
*/
|
||||||
|
public function customValidationAttributes(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'商品代號(選填)' => '商品代號',
|
||||||
|
'條碼(選填)' => '條碼',
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,12 +29,27 @@ class Category extends Model
|
|||||||
|
|
||||||
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
|
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 = $properties['snapshot'] ?? [];
|
||||||
$snapshot['name'] = $this->name;
|
$snapshot['name'] = $this->name;
|
||||||
$properties['snapshot'] = $snapshot;
|
$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;
|
$activity->properties = $properties;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,47 @@ class GoodsReceipt extends Model
|
|||||||
->dontSubmitEmptyLogs();
|
->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()
|
public function items()
|
||||||
{
|
{
|
||||||
return $this->hasMany(GoodsReceiptItem::class);
|
return $this->hasMany(GoodsReceiptItem::class);
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ class Inventory extends Model
|
|||||||
{
|
{
|
||||||
if ($amount <= 0) return;
|
if ($amount <= 0) return;
|
||||||
$this->reserved_quantity += $amount;
|
$this->reserved_quantity += $amount;
|
||||||
$this->save();
|
$this->saveQuietly();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -157,7 +157,7 @@ class Inventory extends Model
|
|||||||
{
|
{
|
||||||
if ($amount <= 0) return;
|
if ($amount <= 0) return;
|
||||||
$this->reserved_quantity = max(0, $this->reserved_quantity - $amount);
|
$this->reserved_quantity = max(0, $this->reserved_quantity - $amount);
|
||||||
$this->save();
|
$this->saveQuietly();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -36,21 +36,23 @@ class InventoryTransferOrder extends Model
|
|||||||
if ($eventName === 'created') {
|
if ($eventName === 'created') {
|
||||||
$activity->description = 'created';
|
$activity->description = 'created';
|
||||||
} elseif ($eventName === 'updated') {
|
} elseif ($eventName === 'updated') {
|
||||||
// 如果屬性中有 status 且變更為 completed,將描述改為 posted
|
|
||||||
if (isset($properties['attributes']['status']) && $properties['attributes']['status'] === 'completed') {
|
if (isset($properties['attributes']['status']) && $properties['attributes']['status'] === 'completed') {
|
||||||
$activity->description = 'posted';
|
$activity->description = 'posted';
|
||||||
$eventName = 'posted'; // 供後續快照邏輯判定
|
$eventName = 'posted';
|
||||||
} else {
|
} else {
|
||||||
$activity->description = 'updated';
|
$activity->description = 'updated';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 處理倉庫 ID 轉名稱
|
// 處理 ID 轉名稱 (核心:支援 attributes 與 old 的自動轉換)
|
||||||
$idToNameFields = [
|
$idToNameFields = [
|
||||||
'from_warehouse_id' => 'fromWarehouse',
|
'from_warehouse_id' => 'fromWarehouse',
|
||||||
'to_warehouse_id' => 'toWarehouse',
|
'to_warehouse_id' => 'toWarehouse',
|
||||||
|
'transit_warehouse_id' => 'transitWarehouse',
|
||||||
'created_by' => 'createdBy',
|
'created_by' => 'createdBy',
|
||||||
'posted_by' => 'postedBy',
|
'posted_by' => 'postedBy',
|
||||||
|
'dispatched_by' => 'dispatchedBy',
|
||||||
|
'received_by' => 'receivedBy',
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach (['attributes', 'old'] as $part) {
|
foreach (['attributes', 'old'] as $part) {
|
||||||
@@ -58,14 +60,20 @@ class InventoryTransferOrder extends Model
|
|||||||
foreach ($idToNameFields as $idField => $relation) {
|
foreach ($idToNameFields as $idField => $relation) {
|
||||||
if (isset($properties[$part][$idField])) {
|
if (isset($properties[$part][$idField])) {
|
||||||
$id = $properties[$part][$idField];
|
$id = $properties[$part][$idField];
|
||||||
$nameField = str_replace('_id', '_name', $idField);
|
if (!$id) continue;
|
||||||
|
|
||||||
|
$nameField = str_replace('_id', '_name', $idField);
|
||||||
$name = null;
|
$name = null;
|
||||||
|
try {
|
||||||
if ($this->relationLoaded($relation) && $this->$relation && $this->$relation->id == $id) {
|
if ($this->relationLoaded($relation) && $this->$relation && $this->$relation->id == $id) {
|
||||||
$name = $this->$relation->name;
|
$name = $this->$relation->name;
|
||||||
} else {
|
} else {
|
||||||
$model = $this->$relation()->getRelated()->find($id);
|
$relatedModel = $this->$relation()->getRelated();
|
||||||
$name = $model ? $model->name : "ID: $id";
|
$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;
|
$properties[$part][$nameField] = $name;
|
||||||
}
|
}
|
||||||
@@ -73,7 +81,7 @@ class InventoryTransferOrder extends Model
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 基本單據資訊快照 (包含單號、來源、目的地)
|
// 基本單據資訊快照
|
||||||
if (in_array($eventName, ['created', 'updated', 'posted', 'deleted'])) {
|
if (in_array($eventName, ['created', 'updated', 'posted', 'deleted'])) {
|
||||||
$properties['snapshot'] = [
|
$properties['snapshot'] = [
|
||||||
'doc_no' => $this->doc_no,
|
'doc_no' => $this->doc_no,
|
||||||
@@ -85,8 +93,6 @@ class InventoryTransferOrder extends Model
|
|||||||
|
|
||||||
// 移除輔助欄位與雜訊
|
// 移除輔助欄位與雜訊
|
||||||
if (isset($properties['attributes'])) {
|
if (isset($properties['attributes'])) {
|
||||||
unset($properties['attributes']['from_warehouse_name']);
|
|
||||||
unset($properties['attributes']['to_warehouse_name']);
|
|
||||||
unset($properties['attributes']['activityProperties']);
|
unset($properties['attributes']['activityProperties']);
|
||||||
unset($properties['attributes']['updated_at']);
|
unset($properties['attributes']['updated_at']);
|
||||||
}
|
}
|
||||||
@@ -94,7 +100,7 @@ class InventoryTransferOrder extends Model
|
|||||||
unset($properties['old']['updated_at']);
|
unset($properties['old']['updated_at']);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 合併暫存屬性 (例如 items_diff)
|
// 合併暫存屬性 (重要:例如 items_diff)
|
||||||
if (!empty($this->activityProperties)) {
|
if (!empty($this->activityProperties)) {
|
||||||
$properties = array_merge($properties, $this->activityProperties);
|
$properties = array_merge($properties, $this->activityProperties);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,11 @@ class StoreRequisition extends Model
|
|||||||
->dontSubmitEmptyLogs();
|
->dontSubmitEmptyLogs();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array 暫存的活動紀錄屬性 (不會存入資料庫)
|
||||||
|
*/
|
||||||
|
public $activityProperties = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 自定義日誌屬性,解析 ID 為名稱
|
* 自定義日誌屬性,解析 ID 為名稱
|
||||||
*/
|
*/
|
||||||
@@ -48,22 +53,90 @@ class StoreRequisition extends Model
|
|||||||
{
|
{
|
||||||
$properties = $activity->properties->toArray();
|
$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'] = [
|
$properties['snapshot'] = [
|
||||||
'doc_no' => $this->doc_no,
|
'doc_no' => $this->doc_no,
|
||||||
'store_warehouse_name' => $this->storeWarehouse?->name,
|
'store_warehouse_name' => $this->storeWarehouse?->name,
|
||||||
'supply_warehouse_name' => $this->supplyWarehouse?->name,
|
'supply_warehouse_name' => $this->supplyWarehouse?->name,
|
||||||
'status' => $this->status,
|
'status' => $statusMap[$this->status] ?? $this->status,
|
||||||
];
|
];
|
||||||
|
|
||||||
// 移除雜訊欄位
|
// 移除雜訊與重複欄位
|
||||||
if (isset($properties['attributes'])) {
|
if (isset($properties['attributes'])) {
|
||||||
unset($properties['attributes']['updated_at']);
|
unset($properties['attributes']['updated_at']);
|
||||||
|
unset($properties['attributes']['activityProperties']);
|
||||||
}
|
}
|
||||||
if (isset($properties['old'])) {
|
if (isset($properties['old'])) {
|
||||||
unset($properties['old']['updated_at']);
|
unset($properties['old']['updated_at']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 合併暫存屬性 (例如 items_diff)
|
||||||
|
if (!empty($this->activityProperties)) {
|
||||||
|
$properties = array_merge($properties, $this->activityProperties);
|
||||||
|
}
|
||||||
|
|
||||||
$activity->properties = collect($properties);
|
$activity->properties = collect($properties);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,12 +34,27 @@ class Unit extends Model
|
|||||||
|
|
||||||
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
|
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 = $properties['snapshot'] ?? [];
|
||||||
$snapshot['name'] = $this->name;
|
$snapshot['name'] = $this->name;
|
||||||
$properties['snapshot'] = $snapshot;
|
$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;
|
$activity->properties = $properties;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,12 +37,31 @@ class Warehouse extends Model
|
|||||||
|
|
||||||
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
|
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 = $properties['snapshot'] ?? [];
|
||||||
$snapshot['name'] = $this->name;
|
$snapshot['name'] = $this->name;
|
||||||
$properties['snapshot'] = $snapshot;
|
$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;
|
$activity->properties = $properties;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,17 @@ use App\Modules\Inventory\Models\Inventory;
|
|||||||
use App\Modules\Inventory\Models\InventoryCountDoc;
|
use App\Modules\Inventory\Models\InventoryCountDoc;
|
||||||
use App\Modules\Inventory\Models\InventoryAdjustDoc;
|
use App\Modules\Inventory\Models\InventoryAdjustDoc;
|
||||||
use App\Modules\Inventory\Models\InventoryAdjustItem;
|
use App\Modules\Inventory\Models\InventoryAdjustItem;
|
||||||
|
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class AdjustService
|
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
|
public function createDoc(string $warehouseId, string $reason, ?string $remarks = null, int $userId, ?int $countDocId = null): InventoryAdjustDoc
|
||||||
{
|
{
|
||||||
return InventoryAdjustDoc::create([
|
return InventoryAdjustDoc::create([
|
||||||
@@ -161,29 +168,20 @@ class AdjustService
|
|||||||
'batch_number' => $item->batch_number,
|
'batch_number' => $item->batch_number,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 如果是新建立的 object (id 為空),需要初始化 default
|
// 如果是新建立的 object (id 為空),需要初始化 default 並先行儲存
|
||||||
if (!$inventory->exists) {
|
if (!$inventory->exists) {
|
||||||
$inventory->unit_cost = $item->product->cost ?? 0;
|
$inventory->unit_cost = $item->product->cost ?? 0;
|
||||||
$inventory->quantity = 0;
|
$inventory->quantity = 0;
|
||||||
|
$inventory->total_value = 0;
|
||||||
|
$inventory->saveQuietly();
|
||||||
}
|
}
|
||||||
|
|
||||||
$oldQty = $inventory->quantity;
|
$this->inventoryService->adjustInventory($inventory, [
|
||||||
$newQty = $oldQty + $item->adjust_qty;
|
'operation' => 'add',
|
||||||
|
|
||||||
$inventory->quantity = $newQty;
|
|
||||||
$inventory->total_value = $newQty * $inventory->unit_cost;
|
|
||||||
$inventory->save();
|
|
||||||
|
|
||||||
// 建立 Transaction
|
|
||||||
$inventory->transactions()->create([
|
|
||||||
'type' => '庫存調整',
|
|
||||||
'quantity' => $item->adjust_qty,
|
'quantity' => $item->adjust_qty,
|
||||||
'unit_cost' => $inventory->unit_cost,
|
'type' => 'adjustment',
|
||||||
'balance_before' => $oldQty,
|
|
||||||
'balance_after' => $newQty,
|
|
||||||
'reason' => "盤調單 {$doc->doc_no}: " . ($doc->reason ?? '手動調整'),
|
'reason' => "盤調單 {$doc->doc_no}: " . ($doc->reason ?? '手動調整'),
|
||||||
'actual_time' => now(),
|
'notes' => $item->notes,
|
||||||
'user_id' => $userId,
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
public function update(GoodsReceipt $goodsReceipt, array $data)
|
||||||
{
|
{
|
||||||
if (!in_array($goodsReceipt->status, [GoodsReceipt::STATUS_DRAFT, GoodsReceipt::STATUS_REJECTED])) {
|
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) {
|
return DB::transaction(function () use ($goodsReceipt, $data) {
|
||||||
$goodsReceipt->update([
|
$goodsReceipt->fill([
|
||||||
'vendor_id' => $data['vendor_id'] ?? $goodsReceipt->vendor_id,
|
'vendor_id' => $data['vendor_id'] ?? $goodsReceipt->vendor_id,
|
||||||
'received_date' => $data['received_date'] ?? $goodsReceipt->received_date,
|
'received_date' => $data['received_date'] ?? $goodsReceipt->received_date,
|
||||||
'remarks' => $data['remarks'] ?? $goodsReceipt->remarks,
|
'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'])) {
|
if (isset($data['items'])) {
|
||||||
// Simple strategy: delete existing items and recreate
|
|
||||||
$goodsReceipt->items()->delete();
|
$goodsReceipt->items()->delete();
|
||||||
|
|
||||||
foreach ($data['items'] as $itemData) {
|
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');
|
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
|
// Format: GR-YYYYMMDD-NN
|
||||||
$prefix = 'GR-' . date('Ymd', strtotime($date)) . '-';
|
$prefix = 'GR-' . date('Ymd', strtotime($date)) . '-';
|
||||||
|
|
||||||
$last = GoodsReceipt::where('code', 'like', $prefix . '%')
|
$last = GoodsReceipt::where('code', 'like', $prefix . '%')
|
||||||
->orderBy('id', 'desc')
|
->orderBy('id', 'desc')
|
||||||
->lockForUpdate()
|
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if ($last) {
|
if ($last) {
|
||||||
@@ -178,7 +274,12 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei
|
|||||||
$seq = 1;
|
$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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -181,11 +181,11 @@ class InventoryService implements InventoryServiceInterface
|
|||||||
|
|
||||||
// 更新其他可能變更的欄位 (如最後入庫日)
|
// 更新其他可能變更的欄位 (如最後入庫日)
|
||||||
$inventory->arrival_date = $data['arrival_date'] ?? $inventory->arrival_date;
|
$inventory->arrival_date = $data['arrival_date'] ?? $inventory->arrival_date;
|
||||||
$inventory->save();
|
$inventory->saveQuietly();
|
||||||
} else {
|
} else {
|
||||||
// 若不存在,則建立新紀錄
|
// 若不存在,則建立新紀錄
|
||||||
$unitCost = $data['unit_cost'] ?? 0;
|
$unitCost = $data['unit_cost'] ?? 0;
|
||||||
$inventory = Inventory::create([
|
$inventory = new Inventory([
|
||||||
'warehouse_id' => $data['warehouse_id'],
|
'warehouse_id' => $data['warehouse_id'],
|
||||||
'product_id' => $data['product_id'],
|
'product_id' => $data['product_id'],
|
||||||
'quantity' => $data['quantity'],
|
'quantity' => $data['quantity'],
|
||||||
@@ -199,9 +199,10 @@ class InventoryService implements InventoryServiceInterface
|
|||||||
'quality_status' => $data['quality_status'] ?? 'normal',
|
'quality_status' => $data['quality_status'] ?? 'normal',
|
||||||
'source_purchase_order_id' => $data['source_purchase_order_id'] ?? null,
|
'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,
|
'inventory_id' => $inventory->id,
|
||||||
'type' => '入庫',
|
'type' => '入庫',
|
||||||
'quantity' => $data['quantity'],
|
'quantity' => $data['quantity'],
|
||||||
@@ -214,6 +215,7 @@ class InventoryService implements InventoryServiceInterface
|
|||||||
'user_id' => auth()->id(),
|
'user_id' => auth()->id(),
|
||||||
'actual_time' => now(),
|
'actual_time' => now(),
|
||||||
]);
|
]);
|
||||||
|
$transaction->saveQuietly();
|
||||||
|
|
||||||
return $inventory;
|
return $inventory;
|
||||||
});
|
});
|
||||||
@@ -225,13 +227,12 @@ class InventoryService implements InventoryServiceInterface
|
|||||||
$inventory = Inventory::lockForUpdate()->findOrFail($inventoryId);
|
$inventory = Inventory::lockForUpdate()->findOrFail($inventoryId);
|
||||||
$balanceBefore = $inventory->quantity;
|
$balanceBefore = $inventory->quantity;
|
||||||
|
|
||||||
$inventory->decrement('quantity', $quantity); // decrement 不會自動觸發 total_value 更新
|
// 手動更新以配合 saveQuietly 消除日誌
|
||||||
// 需要手動更新總價值
|
$inventory->quantity -= $quantity;
|
||||||
$inventory->refresh();
|
|
||||||
$inventory->total_value = $inventory->quantity * $inventory->unit_cost;
|
$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,
|
'inventory_id' => $inventory->id,
|
||||||
'type' => '出庫',
|
'type' => '出庫',
|
||||||
'quantity' => -$quantity,
|
'quantity' => -$quantity,
|
||||||
@@ -244,6 +245,7 @@ class InventoryService implements InventoryServiceInterface
|
|||||||
'user_id' => auth()->id(),
|
'user_id' => auth()->id(),
|
||||||
'actual_time' => now(),
|
'actual_time' => now(),
|
||||||
]);
|
]);
|
||||||
|
$transaction->saveQuietly();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -825,7 +827,8 @@ class InventoryService implements InventoryServiceInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (abs($changeQty) > 0.0001) {
|
if (abs($changeQty) > 0.0001) {
|
||||||
$inventory->transactions()->create([
|
$transaction = new \App\Modules\Inventory\Models\InventoryTransaction([
|
||||||
|
'inventory_id' => $inventory->id,
|
||||||
'type' => $chineseType,
|
'type' => $chineseType,
|
||||||
'quantity' => $changeQty,
|
'quantity' => $changeQty,
|
||||||
'unit_cost' => $inventory->unit_cost,
|
'unit_cost' => $inventory->unit_cost,
|
||||||
@@ -835,6 +838,7 @@ class InventoryService implements InventoryServiceInterface
|
|||||||
'actual_time' => now(),
|
'actual_time' => now(),
|
||||||
'user_id' => auth()->id(),
|
'user_id' => auth()->id(),
|
||||||
]);
|
]);
|
||||||
|
$transaction->saveQuietly();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
return DB::transaction(function () use ($data, $items, $userId, $submitImmediately) {
|
||||||
$requisition = StoreRequisition::create([
|
$requisition = new StoreRequisition([
|
||||||
'store_warehouse_id' => $data['store_warehouse_id'],
|
'store_warehouse_id' => $data['store_warehouse_id'],
|
||||||
'status' => 'draft',
|
'status' => $submitImmediately ? 'pending' : 'draft',
|
||||||
|
'submitted_at' => $submitImmediately ? now() : null,
|
||||||
'remark' => $data['remark'] ?? null,
|
'remark' => $data['remark'] ?? null,
|
||||||
'created_by' => $userId,
|
'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) {
|
foreach ($items as $item) {
|
||||||
$requisition->items()->create([
|
$requisition->items()->create([
|
||||||
'product_id' => $item['product_id'],
|
'product_id' => $item['product_id'],
|
||||||
'requested_qty' => $item['requested_qty'],
|
'requested_qty' => $item['requested_qty'],
|
||||||
'remark' => $item['remark'] ?? null,
|
'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');
|
return $requisition->load('items');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -57,13 +110,74 @@ class StoreRequisitionService
|
|||||||
}
|
}
|
||||||
|
|
||||||
return DB::transaction(function () use ($requisition, $data, $items) {
|
return DB::transaction(function () use ($requisition, $data, $items) {
|
||||||
$requisition->update([
|
// 擷取舊狀態供日誌對照
|
||||||
'store_warehouse_id' => $data['store_warehouse_id'],
|
$oldAttributes = [
|
||||||
'remark' => $data['remark'] ?? null,
|
'store_warehouse_id' => $requisition->store_warehouse_id,
|
||||||
'reject_reason' => null, // 清除駁回原因
|
'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();
|
$requisition->items()->delete();
|
||||||
foreach ($items as $item) {
|
foreach ($items as $item) {
|
||||||
$requisition->items()->create([
|
$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');
|
return $requisition->load('items');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -241,6 +381,27 @@ class StoreRequisitionService
|
|||||||
|
|
||||||
if (!empty($transferItems)) {
|
if (!empty($transferItems)) {
|
||||||
$this->transferService->updateItems($transferOrder, $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');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新叫貨單狀態
|
// 更新叫貨單狀態
|
||||||
|
|||||||
@@ -9,8 +9,17 @@ use App\Modules\Inventory\Models\Warehouse;
|
|||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
||||||
|
|
||||||
class TransferService
|
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,
|
'from_warehouse_id' => $fromWarehouseId,
|
||||||
'to_warehouse_id' => $toWarehouseId,
|
'to_warehouse_id' => $toWarehouseId,
|
||||||
'transit_warehouse_id' => $transitWarehouseId,
|
'transit_warehouse_id' => $transitWarehouseId,
|
||||||
@@ -32,6 +41,26 @@ class TransferService
|
|||||||
'remarks' => $remarks,
|
'remarks' => $remarks,
|
||||||
'created_by' => $userId,
|
'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'][] = [
|
$diff['updated'][] = [
|
||||||
'product_name' => $item->product->name,
|
'product_name' => $item->product->name,
|
||||||
|
'unit_name' => $item->product->baseUnit?->name,
|
||||||
'old' => [
|
'old' => [
|
||||||
'quantity' => (float)$oldItem->quantity,
|
'quantity' => (float)$oldItem->quantity,
|
||||||
'position' => $oldItem->position,
|
'position' => $oldItem->position,
|
||||||
@@ -114,12 +144,9 @@ class TransferService
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$diff['updated'][] = [
|
$diff['added'][] = [
|
||||||
'product_name' => $item->product->name,
|
'product_name' => $item->product->name,
|
||||||
'old' => [
|
'unit_name' => $item->product->baseUnit?->name,
|
||||||
'quantity' => 0,
|
|
||||||
'notes' => null,
|
|
||||||
],
|
|
||||||
'new' => [
|
'new' => [
|
||||||
'quantity' => (float)$item->quantity,
|
'quantity' => (float)$item->quantity,
|
||||||
'notes' => $item->notes,
|
'notes' => $item->notes,
|
||||||
@@ -132,6 +159,7 @@ class TransferService
|
|||||||
if (!in_array($key, $newItemsKeys)) {
|
if (!in_array($key, $newItemsKeys)) {
|
||||||
$diff['removed'][] = [
|
$diff['removed'][] = [
|
||||||
'product_name' => $oldItem->product->name,
|
'product_name' => $oldItem->product->name,
|
||||||
|
'unit_name' => $oldItem->product->baseUnit?->name,
|
||||||
'old' => [
|
'old' => [
|
||||||
'quantity' => (float)$oldItem->quantity,
|
'quantity' => (float)$oldItem->quantity,
|
||||||
'notes' => $oldItem->notes,
|
'notes' => $oldItem->notes,
|
||||||
@@ -169,6 +197,8 @@ class TransferService
|
|||||||
$outType = '調撥出庫';
|
$outType = '調撥出庫';
|
||||||
$inType = $hasTransit ? '在途入庫' : '調撥入庫';
|
$inType = $hasTransit ? '在途入庫' : '調撥入庫';
|
||||||
|
|
||||||
|
$itemsDiff = [];
|
||||||
|
|
||||||
foreach ($order->items as $item) {
|
foreach ($order->items as $item) {
|
||||||
if ($item->quantity <= 0) continue;
|
if ($item->quantity <= 0) continue;
|
||||||
|
|
||||||
@@ -186,70 +216,65 @@ class TransferService
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$oldSourceQty = $sourceInventory->quantity;
|
$sourceBefore = (float) $sourceInventory->quantity;
|
||||||
$newSourceQty = $oldSourceQty - $item->quantity;
|
|
||||||
|
|
||||||
// 釋放草稿階段預扣的庫存
|
// 釋放草稿階段預扣的庫存
|
||||||
$sourceInventory->reserved_quantity = max(0, $sourceInventory->reserved_quantity - $item->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;
|
// 委託 InventoryService 處理扣庫與 Transaction
|
||||||
$sourceInventory->total_value = $sourceInventory->quantity * $sourceInventory->unit_cost;
|
$this->inventoryService->decreaseInventoryQuantity(
|
||||||
$sourceInventory->save();
|
$sourceInventory->id,
|
||||||
|
$item->quantity,
|
||||||
$sourceInventory->transactions()->create([
|
"調撥單 {$order->doc_no} 至 {$targetWarehouse->name}",
|
||||||
'type' => $outType,
|
InventoryTransferOrder::class,
|
||||||
'quantity' => -$item->quantity,
|
$order->id
|
||||||
'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,
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($targetInventory->wasRecentlyCreated && $targetInventory->unit_cost == 0) {
|
$sourceAfter = $sourceBefore - (float) $item->quantity;
|
||||||
$targetInventory->unit_cost = $sourceInventory->unit_cost;
|
|
||||||
}
|
|
||||||
|
|
||||||
$oldTargetQty = $targetInventory->quantity;
|
// 2. 處理目的倉/在途倉 (增加)
|
||||||
$newTargetQty = $oldTargetQty + $item->quantity;
|
// 獲取目的倉異動前的庫存數(若無則為 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;
|
$this->inventoryService->createInventoryRecord([
|
||||||
$targetInventory->total_value = $targetInventory->quantity * $targetInventory->unit_cost;
|
'warehouse_id' => $targetWarehouseId,
|
||||||
$targetInventory->save();
|
'product_id' => $item->product_id,
|
||||||
|
|
||||||
$targetInventory->transactions()->create([
|
|
||||||
'type' => $inType,
|
|
||||||
'quantity' => $item->quantity,
|
'quantity' => $item->quantity,
|
||||||
'unit_cost' => $targetInventory->unit_cost,
|
'unit_cost' => $sourceInventory->unit_cost,
|
||||||
'balance_before' => $oldTargetQty,
|
'batch_number' => $item->batch_number,
|
||||||
'balance_after' => $newTargetQty,
|
'expiry_date' => $sourceInventory->expiry_date,
|
||||||
'reason' => "調撥單 {$order->doc_no} 來自 {$fromWarehouse->name}",
|
'reason' => "調撥單 {$order->doc_no} 來自 {$fromWarehouse->name}",
|
||||||
'actual_time' => now(),
|
'reference_type' => InventoryTransferOrder::class,
|
||||||
'user_id' => $userId,
|
'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) {
|
if ($hasTransit) {
|
||||||
$order->status = 'dispatched';
|
$order->status = 'dispatched';
|
||||||
$order->dispatched_at = now();
|
$order->dispatched_at = now();
|
||||||
@@ -259,7 +284,27 @@ class TransferService
|
|||||||
$order->posted_at = now();
|
$order->posted_at = now();
|
||||||
$order->posted_by = $userId;
|
$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;
|
$transitWarehouse = $order->transitWarehouse;
|
||||||
$toWarehouse = $order->toWarehouse;
|
$toWarehouse = $order->toWarehouse;
|
||||||
|
|
||||||
|
$itemsDiff = [];
|
||||||
|
|
||||||
foreach ($order->items as $item) {
|
foreach ($order->items as $item) {
|
||||||
if ($item->quantity <= 0) continue;
|
if ($item->quantity <= 0) continue;
|
||||||
|
|
||||||
@@ -299,71 +346,83 @@ class TransferService
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$oldTransitQty = $transitInventory->quantity;
|
$transitBefore = (float) $transitInventory->quantity;
|
||||||
$newTransitQty = $oldTransitQty - $item->quantity;
|
|
||||||
|
|
||||||
$transitInventory->quantity = $newTransitQty;
|
// 委託 InventoryService 處理扣庫與 Transaction
|
||||||
$transitInventory->total_value = $transitInventory->quantity * $transitInventory->unit_cost;
|
$this->inventoryService->decreaseInventoryQuantity(
|
||||||
$transitInventory->save();
|
$transitInventory->id,
|
||||||
|
$item->quantity,
|
||||||
$transitInventory->transactions()->create([
|
"調撥單 {$order->doc_no} 配送至 {$toWarehouse->name}",
|
||||||
'type' => '在途出庫',
|
InventoryTransferOrder::class,
|
||||||
'quantity' => -$item->quantity,
|
$order->id
|
||||||
'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,
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($targetInventory->wasRecentlyCreated && $targetInventory->unit_cost == 0) {
|
$transitAfter = $transitBefore - (float) $item->quantity;
|
||||||
$targetInventory->unit_cost = $transitInventory->unit_cost;
|
|
||||||
}
|
|
||||||
|
|
||||||
$oldTargetQty = $targetInventory->quantity;
|
// 2. 目的倉增加
|
||||||
$newTargetQty = $oldTargetQty + $item->quantity;
|
$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;
|
$this->inventoryService->createInventoryRecord([
|
||||||
$targetInventory->total_value = $targetInventory->quantity * $targetInventory->unit_cost;
|
'warehouse_id' => $order->to_warehouse_id,
|
||||||
$targetInventory->save();
|
'product_id' => $item->product_id,
|
||||||
|
|
||||||
$targetInventory->transactions()->create([
|
|
||||||
'type' => '調撥入庫',
|
|
||||||
'quantity' => $item->quantity,
|
'quantity' => $item->quantity,
|
||||||
'unit_cost' => $targetInventory->unit_cost,
|
'unit_cost' => $transitInventory->unit_cost,
|
||||||
'balance_before' => $oldTargetQty,
|
'batch_number' => $item->batch_number,
|
||||||
'balance_after' => $newTargetQty,
|
'expiry_date' => $transitInventory->expiry_date,
|
||||||
'reason' => "調撥單 {$order->doc_no} 來自 {$transitWarehouse->name}",
|
'reason' => "調撥單 {$order->doc_no} 來自 {$transitWarehouse->name}",
|
||||||
'actual_time' => now(),
|
'reference_type' => InventoryTransferOrder::class,
|
||||||
'user_id' => $userId,
|
'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->status = 'completed';
|
||||||
$order->posted_at = now();
|
$order->posted_at = now();
|
||||||
$order->posted_by = $userId;
|
$order->posted_by = $userId;
|
||||||
$order->received_at = now();
|
$order->received_at = now();
|
||||||
$order->received_by = $userId;
|
$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',
|
'status' => 'voided',
|
||||||
'updated_by' => $userId
|
],
|
||||||
]);
|
'old' => [
|
||||||
|
'status' => $oldStatus,
|
||||||
|
]
|
||||||
|
])
|
||||||
|
->log('voided');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,4 +104,12 @@ interface ProcurementServiceInterface
|
|||||||
* 移除供貨商品關聯
|
* 移除供貨商品關聯
|
||||||
*/
|
*/
|
||||||
public function detachProductFromVendor(int $vendorId, int $productId): void;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,6 +188,14 @@ class PurchaseOrderController extends Controller
|
|||||||
'tax_amount' => 'nullable|numeric|min:0',
|
'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 {
|
try {
|
||||||
DB::beginTransaction();
|
DB::beginTransaction();
|
||||||
|
|
||||||
@@ -195,12 +203,10 @@ class PurchaseOrderController extends Controller
|
|||||||
$today = now()->format('Ymd');
|
$today = now()->format('Ymd');
|
||||||
$prefix = 'PO-' . $today . '-';
|
$prefix = 'PO-' . $today . '-';
|
||||||
$lastOrder = PurchaseOrder::where('code', 'like', $prefix . '%')
|
$lastOrder = PurchaseOrder::where('code', 'like', $prefix . '%')
|
||||||
->lockForUpdate() // 鎖定以避免並發衝突
|
|
||||||
->orderBy('code', 'desc')
|
->orderBy('code', 'desc')
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if ($lastOrder) {
|
if ($lastOrder) {
|
||||||
// 取得最後 2 碼序號並加 1
|
|
||||||
$lastSequence = intval(substr($lastOrder->code, -2));
|
$lastSequence = intval(substr($lastOrder->code, -2));
|
||||||
$sequence = str_pad($lastSequence + 1, 2, '0', STR_PAD_LEFT);
|
$sequence = str_pad($lastSequence + 1, 2, '0', STR_PAD_LEFT);
|
||||||
} else {
|
} else {
|
||||||
@@ -220,7 +226,8 @@ class PurchaseOrderController extends Controller
|
|||||||
// 確保有一個有效的使用者 ID
|
// 確保有一個有效的使用者 ID
|
||||||
$userId = auth()->id();
|
$userId = auth()->id();
|
||||||
if (!$userId) {
|
if (!$userId) {
|
||||||
$user = $this->coreService->ensureSystemUserExists(); $userId = $user->id;
|
$user = $this->coreService->ensureSystemUserExists();
|
||||||
|
$userId = $user->id;
|
||||||
}
|
}
|
||||||
|
|
||||||
$order = PurchaseOrder::create([
|
$order = PurchaseOrder::create([
|
||||||
@@ -229,7 +236,7 @@ class PurchaseOrderController extends Controller
|
|||||||
'warehouse_id' => $validated['warehouse_id'],
|
'warehouse_id' => $validated['warehouse_id'],
|
||||||
'user_id' => $userId,
|
'user_id' => $userId,
|
||||||
'status' => 'draft',
|
'status' => 'draft',
|
||||||
'order_date' => $validated['order_date'], // 新增
|
'order_date' => $validated['order_date'],
|
||||||
'expected_delivery_date' => $validated['expected_delivery_date'],
|
'expected_delivery_date' => $validated['expected_delivery_date'],
|
||||||
'total_amount' => $totalAmount,
|
'total_amount' => $totalAmount,
|
||||||
'tax_amount' => $taxAmount,
|
'tax_amount' => $taxAmount,
|
||||||
@@ -254,6 +261,9 @@ class PurchaseOrderController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
DB::commit();
|
DB::commit();
|
||||||
|
} finally {
|
||||||
|
$lock->release();
|
||||||
|
}
|
||||||
|
|
||||||
return redirect()->route('purchase-orders.index')->with('success', '採購單已成功建立');
|
return redirect()->route('purchase-orders.index')->with('success', '採購單已成功建立');
|
||||||
|
|
||||||
|
|||||||
@@ -133,4 +133,35 @@ class VendorProductController extends Controller
|
|||||||
|
|
||||||
return redirect()->back()->with('success', '供貨商品已移除');
|
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', '供貨商品已更新');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ class PurchaseOrder extends Model
|
|||||||
'tax_amount',
|
'tax_amount',
|
||||||
'grand_total',
|
'grand_total',
|
||||||
'remark',
|
'remark',
|
||||||
|
'invoice_number',
|
||||||
|
'invoice_date',
|
||||||
|
'invoice_amount',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
|
|||||||
@@ -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::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::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');
|
Route::delete('/vendors/{vendor}/products/{product}', [VendorProductController::class, 'destroy'])->middleware('permission:vendors.edit')->name('vendors.products.destroy');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -147,4 +147,48 @@ class ProcurementService implements ProcurementServiceInterface
|
|||||||
->where('product_id', $productId)
|
->where('product_id', $productId)
|
||||||
->delete();
|
->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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,6 +168,28 @@ const fieldLabels: Record<string, string> = {
|
|||||||
posted_by: '過帳者',
|
posted_by: '過帳者',
|
||||||
counted_qty: '盤點數量',
|
counted_qty: '盤點數量',
|
||||||
adjust_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: '生產中',
|
in_progress: '生產中',
|
||||||
// 調撥單狀態
|
// 調撥單狀態
|
||||||
voided: '已作廢',
|
voided: '已作廢',
|
||||||
|
dispatched: '已出貨',
|
||||||
};
|
};
|
||||||
|
|
||||||
// 主體類型解析 (Model 類名轉中文)
|
// 主體類型解析 (Model 類名轉中文)
|
||||||
@@ -206,17 +229,7 @@ const subjectTypeMap: Record<string, string> = {
|
|||||||
'App\\Modules\\Inventory\\Models\\GoodsReceipt': '進貨單',
|
'App\\Modules\\Inventory\\Models\\GoodsReceipt': '進貨單',
|
||||||
'App\\Modules\\Inventory\\Models\\InventoryCountDoc': '庫存盤點單',
|
'App\\Modules\\Inventory\\Models\\InventoryCountDoc': '庫存盤點單',
|
||||||
'App\\Modules\\Inventory\\Models\\InventoryAdjustDoc': '庫存盤調單',
|
'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': '採購單',
|
|
||||||
'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': '角色權限',
|
|
||||||
// 簡寫映射 (應對後端回傳 class_basename 的情況)
|
// 簡寫映射 (應對後端回傳 class_basename 的情況)
|
||||||
'Product': '商品資料',
|
'Product': '商品資料',
|
||||||
'Warehouse': '倉庫資料',
|
'Warehouse': '倉庫資料',
|
||||||
@@ -231,7 +244,8 @@ const subjectTypeMap: Record<string, string> = {
|
|||||||
'Recipe': '生產配方',
|
'Recipe': '生產配方',
|
||||||
'InventoryCountDoc': '庫存盤點單',
|
'InventoryCountDoc': '庫存盤點單',
|
||||||
'InventoryAdjustDoc': '庫存盤調單',
|
'InventoryAdjustDoc': '庫存盤調單',
|
||||||
'InventoryTransferOrder': '庫存調撥單',
|
'InventoryTransferOrder': '庫庫調撥單',
|
||||||
|
'StoreRequisition': '門市叫貨單',
|
||||||
'StockMovementDoc': '庫存單據',
|
'StockMovementDoc': '庫存單據',
|
||||||
'User': '使用者帳號',
|
'User': '使用者帳號',
|
||||||
'Role': '角色權限',
|
'Role': '角色權限',
|
||||||
@@ -264,17 +278,31 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
|
|||||||
|
|
||||||
// 自訂欄位排序順序
|
// 自訂欄位排序順序
|
||||||
const sortOrder = [
|
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',
|
'vendor_name', 'warehouse_name', 'order_date', 'expected_delivery_date', 'status', 'remark',
|
||||||
'invoice_number', 'invoice_date', 'invoice_amount',
|
'invoice_number', 'invoice_date', 'invoice_amount',
|
||||||
'total_amount', 'tax_amount', 'grand_total'
|
'total_amount', 'tax_amount', 'grand_total'
|
||||||
];
|
];
|
||||||
|
|
||||||
// 過濾掉通常會記錄但對使用者無用的內部鍵
|
// 過濾掉通常會記錄但對使用者無用的內部鍵,以及已被解析為名稱的原始 ID 欄位
|
||||||
const filteredKeys = allKeys
|
const filteredKeys = allKeys
|
||||||
.filter(key =>
|
.filter(key => {
|
||||||
!['created_at', 'updated_at', 'deleted_at', 'id', 'remember_token'].includes(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) => {
|
.sort((a, b) => {
|
||||||
const indexA = sortOrder.indexOf(a);
|
const indexA = sortOrder.indexOf(a);
|
||||||
const indexB = sortOrder.indexOf(b);
|
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') {
|
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];
|
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 {
|
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', {
|
return date.toLocaleString('zh-TW', {
|
||||||
timeZone: 'Asia/Taipei',
|
timeZone: 'Asia/Taipei',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
@@ -537,65 +569,89 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<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">
|
<TableRow key={`upd-${idx}`} className="bg-blue-50/10 hover:bg-blue-50/20">
|
||||||
<TableCell className="font-medium">{item.product_name}</TableCell>
|
<TableCell className="font-medium">{item.product_name}</TableCell>
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
<Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200">更新</Badge>
|
<Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200">更新</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm">
|
<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 && (
|
{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>
|
<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 && (
|
{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>
|
<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>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)) || null}
|
)) || 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">
|
<TableRow key={`add-${idx}`} className="bg-green-50/10 hover:bg-green-50/20">
|
||||||
<TableCell className="font-medium">{item.product_name}</TableCell>
|
<TableCell className="font-medium">{item.product_name}</TableCell>
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-200">新增</Badge>
|
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-200">新增</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm">
|
<TableCell className="text-sm">
|
||||||
{item.quantity !== undefined ? `數量: ${item.quantity} ${item.unit_name || ''} / ` : ''}
|
數量: {item.new?.quantity ?? item.quantity} {item.unit_name || item.new?.unit_name || ''}
|
||||||
{item.adjust_qty !== undefined ? `調整量: ${item.adjust_qty} / ` : ''}
|
|
||||||
{item.subtotal !== undefined ? `小計: $${item.subtotal}` : ''}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)) || null}
|
)) || 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">
|
<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="font-medium text-gray-400 line-through">{item.product_name}</TableCell>
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
<Badge variant="outline" className="bg-red-50 text-red-700 border-red-200">移除</Badge>
|
<Badge variant="outline" className="bg-red-50 text-red-700 border-red-200">移除</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm text-gray-400">
|
<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>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)) || null}
|
)) || 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>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ const subjectTypeMap: Record<string, string> = {
|
|||||||
'App\\Modules\\Inventory\\Models\\InventoryCountDoc': '庫存盤點單',
|
'App\\Modules\\Inventory\\Models\\InventoryCountDoc': '庫存盤點單',
|
||||||
'App\\Modules\\Inventory\\Models\\InventoryAdjustDoc': '庫存盤調單',
|
'App\\Modules\\Inventory\\Models\\InventoryAdjustDoc': '庫存盤調單',
|
||||||
'App\\Modules\\Inventory\\Models\\InventoryTransferOrder': '庫存調撥單',
|
'App\\Modules\\Inventory\\Models\\InventoryTransferOrder': '庫存調撥單',
|
||||||
|
'App\\Modules\\Inventory\\Models\\StoreRequisition': '門市叫貨單',
|
||||||
'App\\Modules\\Inventory\\Models\\StockMovementDoc': '庫存單據',
|
'App\\Modules\\Inventory\\Models\\StockMovementDoc': '庫存單據',
|
||||||
'App\\Modules\\Procurement\\Models\\Vendor': '廠商資料',
|
'App\\Modules\\Procurement\\Models\\Vendor': '廠商資料',
|
||||||
'App\\Modules\\Procurement\\Models\\PurchaseOrder': '採購單',
|
'App\\Modules\\Procurement\\Models\\PurchaseOrder': '採購單',
|
||||||
@@ -67,6 +68,7 @@ const subjectTypeMap: Record<string, string> = {
|
|||||||
'InventoryCountDoc': '庫存盤點單',
|
'InventoryCountDoc': '庫存盤點單',
|
||||||
'InventoryAdjustDoc': '庫存盤調單',
|
'InventoryAdjustDoc': '庫存盤調單',
|
||||||
'InventoryTransferOrder': '庫存調撥單',
|
'InventoryTransferOrder': '庫存調撥單',
|
||||||
|
'StoreRequisition': '門市叫貨單',
|
||||||
'StockMovementDoc': '庫存單據',
|
'StockMovementDoc': '庫存單據',
|
||||||
'User': '使用者帳號',
|
'User': '使用者帳號',
|
||||||
'Role': '角色權限',
|
'Role': '角色權限',
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
139
resources/js/Components/Vendor/SupplyProductList.tsx
vendored
139
resources/js/Components/Vendor/SupplyProductList.tsx
vendored
@@ -1,5 +1,7 @@
|
|||||||
import { Pencil, Trash2 } from "lucide-react";
|
import { Trash2 } from "lucide-react";
|
||||||
import { Button } from "@/Components/ui/button";
|
import { Button } from "@/Components/ui/button";
|
||||||
|
import { Input } from "@/Components/ui/input";
|
||||||
|
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -8,89 +10,128 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/Components/ui/table";
|
} 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";
|
import type { SupplyProduct } from "@/types/vendor";
|
||||||
|
|
||||||
interface SupplyProductListProps {
|
interface SupplyProductListProps {
|
||||||
products: SupplyProduct[];
|
items: SupplyProduct[];
|
||||||
onEdit: (product: SupplyProduct) => void;
|
allProducts: any[];
|
||||||
onRemove: (product: SupplyProduct) => void;
|
onRemoveItem: (index: number) => void;
|
||||||
|
onItemChange: (index: number, field: keyof SupplyProduct, value: string | number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SupplyProductList({
|
export default function SupplyProductList({
|
||||||
products,
|
items,
|
||||||
onEdit,
|
allProducts,
|
||||||
onRemove,
|
onRemoveItem,
|
||||||
|
onItemChange,
|
||||||
}: SupplyProductListProps) {
|
}: SupplyProductListProps) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-lg border shadow-sm">
|
<div className="bg-white rounded-lg border shadow-sm overflow-hidden">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow className="bg-gray-50 hover:bg-gray-50 text-grey-0">
|
||||||
<TableHead className="w-[50px] text-center">#</TableHead>
|
<TableHead className="w-[50px] text-center">#</TableHead>
|
||||||
<TableHead>商品名稱</TableHead>
|
<TableHead className="w-[40%] text-left">商品名稱</TableHead>
|
||||||
<TableHead>基本單位</TableHead>
|
<TableHead className="w-[15%] text-left">基本單位</TableHead>
|
||||||
<TableHead>轉換率</TableHead>
|
<TableHead className="w-[20%] text-left">
|
||||||
<TableHead className="text-right">
|
|
||||||
上次採購單價
|
上次採購單價
|
||||||
<div className="text-xs font-normal text-muted-foreground">(以基本單位計算)</div>
|
<span className="text-[10px] font-normal text-muted-foreground block">(以基本單位計)</span>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-center w-[150px]">操作</TableHead>
|
<TableHead className="text-center w-[80px]">操作</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{products.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<TableRow>
|
<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>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
products.map((product, index) => (
|
items.map((item, index) => (
|
||||||
<TableRow key={product.id}>
|
<TableRow key={item.id}>
|
||||||
<TableCell className="text-gray-500 font-medium text-center">
|
<TableCell className="text-gray-500 font-medium text-center">
|
||||||
{index + 1}
|
{index + 1}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{product.productName}</TableCell>
|
|
||||||
<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>
|
||||||
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{product.largeUnit && product.conversionRate ? (
|
<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">
|
||||||
<span className="text-sm text-gray-500">
|
{item.baseUnit || "-"}
|
||||||
1 {product.largeUnit} = {Number(product.conversionRate)} {product.baseUnit || product.unit}
|
</div>
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
"-"
|
|
||||||
)}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
|
||||||
{product.lastPrice ? (
|
<TableCell>
|
||||||
<span>
|
<div className="flex items-center gap-2">
|
||||||
${product.lastPrice.toLocaleString()} / {product.baseUnit || product.unit || "單位"}
|
<div className="relative flex-1">
|
||||||
</span>
|
<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>
|
||||||
|
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
<div className="flex items-center justify-center gap-2">
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => onEdit(product)}
|
className="button-outlined-error h-10 w-10 p-0"
|
||||||
className="button-outlined-primary"
|
title="移除項目"
|
||||||
>
|
|
||||||
<Pencil className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onRemove(product)}
|
|
||||||
className="button-outlined-error"
|
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</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>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
|
|||||||
213
resources/js/Pages/Vendor/Show.tsx
vendored
213
resources/js/Pages/Vendor/Show.tsx
vendored
@@ -1,29 +1,14 @@
|
|||||||
/**
|
import { useState, useEffect } from "react";
|
||||||
* 廠商詳細資訊頁面
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Head, Link, router } from "@inertiajs/react";
|
import { Head, Link, router } from "@inertiajs/react";
|
||||||
import { Phone, Mail, Plus, ArrowLeft, Contact2 } from "lucide-react";
|
import { Phone, Mail, Plus, ArrowLeft, Contact2 } from "lucide-react";
|
||||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||||
import { Label } from "@/Components/ui/label";
|
import { Label } from "@/Components/ui/label";
|
||||||
import { Button } from "@/Components/ui/button";
|
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 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 { Vendor } from "@/Pages/Vendor/Index";
|
||||||
import type { SupplyProduct } from "@/types/vendor";
|
import type { SupplyProduct } from "@/types/vendor";
|
||||||
import { getShowBreadcrumbs } from "@/utils/breadcrumb";
|
import { getShowBreadcrumbs } from "@/utils/breadcrumb";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
interface Pivot {
|
interface Pivot {
|
||||||
last_price: number | null;
|
last_price: number | null;
|
||||||
@@ -33,12 +18,11 @@ interface VendorProduct {
|
|||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
unit?: string;
|
unit?: string;
|
||||||
// Relations might be camelCase or snake_case depending on serialization settings
|
|
||||||
baseUnit?: { name: string };
|
baseUnit?: { name: string };
|
||||||
base_unit?: { name: string };
|
base_unit?: { name: string };
|
||||||
largeUnit?: { name: string };
|
largeUnit?: { name: string };
|
||||||
large_unit?: { 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;
|
purchase_unit?: string;
|
||||||
conversion_rate?: number;
|
conversion_rate?: number;
|
||||||
pivot: Pivot;
|
pivot: Pivot;
|
||||||
@@ -53,27 +37,17 @@ interface ShowProps {
|
|||||||
products: any[];
|
products: any[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function VendorShow({ vendor, products }: ShowProps) {
|
export default function VendorShow({ vendor, products: allProducts }: ShowProps) {
|
||||||
const [showAddDialog, setShowAddDialog] = useState(false);
|
const [items, setItems] = useState<SupplyProduct[]>([]);
|
||||||
const [showEditDialog, setShowEditDialog] = useState(false);
|
|
||||||
const [showRemoveDialog, setShowRemoveDialog] = useState(false);
|
|
||||||
const [selectedProduct, setSelectedProduct] = useState<SupplyProduct | null>(null);
|
|
||||||
|
|
||||||
// 轉換後端資料格式為前端組件需要的格式
|
// 初始化資料
|
||||||
const supplyProducts: SupplyProduct[] = vendor.products.map(p => {
|
useEffect(() => {
|
||||||
// Laravel load('relationName') usually results in camelCase key in JSON if method is camelCase
|
const initialItems: SupplyProduct[] = vendor.products.map(p => {
|
||||||
const baseUnitName = p.baseUnit?.name || p.base_unit?.name;
|
const baseUnitName = p.baseUnit?.name || p.base_unit?.name;
|
||||||
const largeUnitName = p.largeUnit?.name || p.large_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 {
|
return {
|
||||||
id: String(p.id),
|
id: String(p.id) + "_" + Math.random().toString(36).substr(2, 9), // 加上隨機碼以確保 key 唯一
|
||||||
productId: String(p.id),
|
productId: String(p.id),
|
||||||
productName: p.name,
|
productName: p.name,
|
||||||
unit: p.purchase_unit || baseUnitName || "個",
|
unit: p.purchase_unit || baseUnitName || "個",
|
||||||
@@ -83,48 +57,82 @@ export default function VendorShow({ vendor, products }: ShowProps) {
|
|||||||
lastPrice: p.pivot.last_price || undefined,
|
lastPrice: p.pivot.last_price || undefined,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
setItems(initialItems);
|
||||||
|
}, [vendor.products]);
|
||||||
|
|
||||||
const handleAddProduct = (productId: string, lastPrice?: number) => {
|
const handleAddItem = () => {
|
||||||
router.post(route('vendors.products.store', vendor.id), {
|
const newItem: SupplyProduct = {
|
||||||
product_id: productId,
|
id: "new_" + Math.random().toString(36).substr(2, 9),
|
||||||
last_price: lastPrice,
|
productId: "",
|
||||||
}, {
|
productName: "",
|
||||||
onSuccess: () => setShowAddDialog(false),
|
unit: "個",
|
||||||
});
|
lastPrice: undefined,
|
||||||
|
};
|
||||||
|
setItems([...items, newItem]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditProduct = (product: SupplyProduct) => {
|
const handleRemoveItem = (index: number) => {
|
||||||
setSelectedProduct(product);
|
const newItems = [...items];
|
||||||
setShowEditDialog(true);
|
newItems.splice(index, 1);
|
||||||
|
setItems(newItems);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateProduct = (productId: string, lastPrice?: number) => {
|
const handleItemChange = (index: number, field: keyof SupplyProduct, value: any) => {
|
||||||
router.put(route('vendors.products.update', [vendor.id, productId]), {
|
const newItems = [...items];
|
||||||
last_price: lastPrice,
|
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: () => {
|
onSuccess: () => {
|
||||||
setShowEditDialog(false);
|
toast.success("供貨商品已更新");
|
||||||
setSelectedProduct(null);
|
},
|
||||||
|
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 (
|
return (
|
||||||
<AuthenticatedLayout breadcrumbs={getShowBreadcrumbs("vendors", `廠商詳情 (${vendor.name})`)}>
|
<AuthenticatedLayout breadcrumbs={getShowBreadcrumbs("vendors", `廠商詳情 (${vendor.name})`)}>
|
||||||
<Head title={`廠商詳情 - ${vendor.name}`} />
|
<Head title={`廠商詳情 - ${vendor.name}`} />
|
||||||
@@ -164,11 +172,11 @@ export default function VendorShow({ vendor, products }: ShowProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-muted-foreground text-xs">廠商簡稱</Label>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-muted-foreground text-xs">統一編號</Label>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-muted-foreground text-xs">負責人</Label>
|
<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 className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-muted-foreground text-xs">聯絡人</Label>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-muted-foreground text-xs">聯絡電話</Label>
|
<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="bg-white rounded-lg border border-border p-6 shadow-sm">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3>供貨商品</h3>
|
<h3 className="font-bold">供貨商品</h3>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setShowAddDialog(true)}
|
onClick={handleAddItem}
|
||||||
className="gap-2 button-filled-primary"
|
className="gap-2 button-filled-primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
@@ -222,57 +230,30 @@ export default function VendorShow({ vendor, products }: ShowProps) {
|
|||||||
新增供貨商品
|
新增供貨商品
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SupplyProductList
|
<SupplyProductList
|
||||||
products={supplyProducts}
|
items={items}
|
||||||
onEdit={handleEditProduct}
|
allProducts={allProducts}
|
||||||
onRemove={handleRemoveProduct}
|
onRemoveItem={handleRemoveItem}
|
||||||
|
onItemChange={handleItemChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 新增供貨商品對話框 */}
|
{/* 底部按鈕 - 移至容器外 */}
|
||||||
<AddSupplyProductDialog
|
<div className="mt-6 flex items-center justify-end gap-4 py-6 border-t border-gray-100">
|
||||||
open={showAddDialog}
|
<Link href="/vendors">
|
||||||
products={products}
|
<Button variant="ghost" className="h-11 px-6 text-gray-500 hover:text-gray-700">
|
||||||
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"
|
|
||||||
>
|
|
||||||
取消
|
取消
|
||||||
</AlertDialogCancel>
|
</Button>
|
||||||
<AlertDialogAction
|
</Link>
|
||||||
className="gap-2 button-filled-error"
|
<Button
|
||||||
onClick={handleConfirmRemove}
|
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>
|
</Button>
|
||||||
</AlertDialogFooter>
|
</div>
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
</div>
|
||||||
</AuthenticatedLayout>
|
</AuthenticatedLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
83
resources/views/docs/api.blade.php
Normal file
83
resources/views/docs/api.blade.php
Normal 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>
|
||||||
@@ -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 () {
|
Route::middleware('auth')->group(function () {
|
||||||
// 儀表板 - 所有登入使用者皆可存取
|
// 儀表板 - 所有登入使用者皆可存取
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user