feat: 實作銷售單匯入管理、貨道扣庫優化及 UI 細節調整
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 1m8s

This commit is contained in:
2026-02-09 14:36:47 +08:00
parent 590580e20a
commit b6fe9ad9f3
22 changed files with 1274 additions and 33 deletions

View File

@@ -15,16 +15,15 @@ interface InventoryServiceInterface
public function checkStock(int $productId, int $warehouseId, float $quantity): bool;
/**
* Decrease stock for a product (e.g., when an order is placed).
*
* @param int $productId
* @param int $warehouseId
* @param float $quantity
* @param string|null $reason
* @param bool $force
* @param string|null $slot
* @return void
*/
public function decreaseStock(int $productId, int $warehouseId, float $quantity, ?string $reason = null, bool $force = false): void;
public function decreaseStock(int $productId, int $warehouseId, float $quantity, ?string $reason = null, bool $force = false, ?string $slot = null): void;
/**
* Get all active warehouses.

View File

@@ -555,6 +555,7 @@ class InventoryController extends Controller
'userName' => $user ? $user->name : '系統',
'actualTime' => $tx->actual_time ? $tx->actual_time->format('Y-m-d H:i') : $tx->created_at->format('Y-m-d H:i'),
'batchNumber' => $tx->inventory?->batch_number ?? '-', // 補上批號資訊
'slot' => $tx->inventory?->location, // 加入貨道資訊
];
});
@@ -585,7 +586,7 @@ class InventoryController extends Controller
$userIds = $inventory->transactions->pluck('user_id')->filter()->unique()->toArray();
$users = $this->coreService->getUsersByIds($userIds)->keyBy('id');
$transactions = $inventory->transactions->map(function ($tx) use ($users) {
$transactions = $inventory->transactions->map(function ($tx) use ($users, $inventory) {
$user = $tx->user_id ? ($users[$tx->user_id] ?? null) : null;
return [
'id' => (string) $tx->id,
@@ -596,6 +597,7 @@ class InventoryController extends Controller
'reason' => $tx->reason,
'userName' => $user ? $user->name : '系統', // 手動對應
'actualTime' => $tx->actual_time ? $tx->actual_time->format('Y-m-d H:i') : $tx->created_at->format('Y-m-d H:i'),
'slot' => $inventory->location, // 加入貨道資訊
];
});

View File

@@ -59,13 +59,18 @@ class InventoryService implements InventoryServiceInterface
return $stock >= $quantity;
}
public function decreaseStock(int $productId, int $warehouseId, float $quantity, ?string $reason = null, bool $force = false): void
public function decreaseStock(int $productId, int $warehouseId, float $quantity, ?string $reason = null, bool $force = false, ?string $slot = null): void
{
DB::transaction(function () use ($productId, $warehouseId, $quantity, $reason, $force) {
$inventories = Inventory::where('product_id', $productId)
DB::transaction(function () use ($productId, $warehouseId, $quantity, $reason, $force, $slot) {
$query = Inventory::where('product_id', $productId)
->where('warehouse_id', $warehouseId)
->where('quantity', '>', 0)
->orderBy('arrival_date', 'asc')
->where('quantity', '>', 0);
if ($slot) {
$query->where('location', $slot);
}
$inventories = $query->orderBy('arrival_date', 'asc')
->get();
$remainingToDecrease = $quantity;
@@ -80,19 +85,25 @@ class InventoryService implements InventoryServiceInterface
if ($remainingToDecrease > 0) {
if ($force) {
// Find any existing inventory record in this warehouse to subtract from, or create one
$inventory = Inventory::where('product_id', $productId)
->where('warehouse_id', $warehouseId)
->first();
// Find any existing inventory record in this warehouse/slot to subtract from, or create one
$query = Inventory::where('product_id', $productId)
->where('warehouse_id', $warehouseId);
if ($slot) {
$query->where('location', $slot);
}
$inventory = $query->first();
if (!$inventory) {
$inventory = Inventory::create([
'warehouse_id' => $warehouseId,
'product_id' => $productId,
'location' => $slot,
'quantity' => 0,
'unit_cost' => 0,
'total_value' => 0,
'batch_number' => 'POS-AUTO-' . time(),
'batch_number' => 'POS-AUTO-' . ($slot ? $slot . '-' : '') . time(),
'arrival_date' => now(),
'origin_country' => 'TW',
'quality_status' => 'normal',

View File

@@ -0,0 +1,152 @@
<?php
namespace App\Modules\Sales\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Sales\Models\SalesImportBatch;
use App\Modules\Sales\Imports\SalesImport;
use App\Modules\Inventory\Services\InventoryService; // Assuming this exists or we need to use ProductService
use Illuminate\Http\Request;
use Inertia\Inertia;
use Maatwebsite\Excel\Facades\Excel;
use Illuminate\Support\Facades\DB;
class SalesImportController extends Controller
{
public function index(Request $request)
{
$perPage = $request->input('per_page', 10);
$batches = SalesImportBatch::with('importer')
->orderByDesc('created_at')
->paginate($perPage)
->withQueryString();
return Inertia::render('Sales/Import/Index', [
'batches' => $batches,
'filters' => [
'per_page' => $perPage,
],
]);
}
public function create()
{
return Inertia::render('Sales/Import/Create');
}
public function store(Request $request)
{
$request->validate([
'file' => 'required|file|mimes:xlsx,xls,csv,zip',
]);
DB::transaction(function () use ($request) {
$batch = SalesImportBatch::create([
'import_date' => now(),
'imported_by' => auth()->id(),
'status' => 'pending',
'tenant_id' => tenant('id'), // If tenant context requires it, but usually automatic
]);
Excel::import(new SalesImport($batch), $request->file('file'));
});
return redirect()->route('sales-imports.index')->with('success', '匯入成功,請確認內容。');
}
public function show(Request $request, SalesImportBatch $import)
{
$import->load(['items', 'importer']);
$perPage = $request->input('per_page', 10);
return Inertia::render('Sales/Import/Show', [
'import' => $import,
'items' => $import->items()->with(['product', 'warehouse'])->paginate($perPage)->withQueryString(),
'filters' => [
'per_page' => $perPage,
],
]);
}
public function confirm(SalesImportBatch $import, InventoryService $inventoryService)
{
if ($import->status !== 'pending') {
return back()->with('error', '此批次無法確認。');
}
DB::transaction(function () use ($import, $inventoryService) {
// 1. Prepare Aggregation
$aggregatedDeductions = []; // Key: "warehouse_id:product_id:slot"
// Pre-load necessary warehouses for matching
$machineIds = $import->items->pluck('machine_id')->filter()->unique();
$warehouses = \App\Modules\Inventory\Models\Warehouse::whereIn('code', $machineIds)->get()->keyBy('code');
foreach ($import->items as $item) {
// Only process shipped items with a valid product
if ($item->product_id && $item->original_status === '已出貨') {
// Resolve Warehouse from Machine ID
$warehouse = $warehouses->get($item->machine_id);
// Skip if machine_id is empty or warehouse not found
if (!$warehouse) {
continue;
}
// Aggregation Key includes Slot (貨道)
$slot = $item->slot ?: '';
$key = "{$warehouse->id}:{$item->product_id}:{$slot}";
if (!isset($aggregatedDeductions[$key])) {
$aggregatedDeductions[$key] = [
'warehouse_id' => $warehouse->id,
'product_id' => $item->product_id,
'slot' => $slot,
'quantity' => 0,
'details' => []
];
}
$aggregatedDeductions[$key]['quantity'] += $item->quantity;
$aggregatedDeductions[$key]['details'][] = $item->transaction_serial;
}
}
// 2. Execute Aggregated Deductions
foreach ($aggregatedDeductions as $deduction) {
// Construct a descriptive reason
$serialCount = count($deduction['details']);
$reason = "銷售出貨彙總 (批號: {$import->id}, 貨道: {$deduction['slot']}, 共 {$serialCount} 筆交易)";
$inventoryService->decreaseStock(
$deduction['product_id'],
$deduction['warehouse_id'],
$deduction['quantity'],
$reason,
true, // Force deduction
$deduction['slot'] // Location/Slot
);
}
// 3. Update Batch Status
$import->update([
'status' => 'confirmed',
'confirmed_at' => now(),
]);
});
return redirect()->route('sales-imports.index')->with('success', '已彙總(含貨道)並扣除庫存。');
}
public function destroy(SalesImportBatch $import)
{
if ($import->status !== 'pending') {
return back()->with('error', '只能刪除待確認的批次。');
}
$import->delete();
return redirect()->route('sales-imports.index')->with('success', '已刪除匯入批次。');
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Modules\Sales\Imports;
use App\Modules\Sales\Models\SalesImportBatch;
use Maatwebsite\Excel\Concerns\WithMultipleSheets;
class SalesImport implements WithMultipleSheets
{
protected $batch;
public function __construct(SalesImportBatch $batch)
{
$this->batch = $batch;
}
public function sheets(): array
{
// Only import the first sheet (index 0)
return [
0 => new SalesImportSheet($this->batch),
];
}
}

View File

@@ -0,0 +1,106 @@
<?php
namespace App\Modules\Sales\Imports;
use App\Modules\Sales\Models\SalesImportBatch;
use App\Modules\Sales\Models\SalesImportItem;
use App\Modules\Inventory\Models\Product;
use Illuminate\Support\Collection;
use Maatwebsite\Excel\Concerns\ToCollection;
use Maatwebsite\Excel\Concerns\WithStartRow;
use Carbon\Carbon;
class SalesImportSheet implements ToCollection, WithStartRow
{
protected $batch;
protected $products;
public function __construct(SalesImportBatch $batch)
{
$this->batch = $batch;
// Pre-load all products to minimize queries (keyed by code)
$this->products = Product::pluck('id', 'code'); // assumes code is unique
}
public function startRow(): int
{
return 3;
}
public function collection(Collection $rows)
{
$totalQuantity = 0;
$totalAmount = 0;
$items = [];
foreach ($rows as $row) {
// Index mapping based on analysis:
// 0: 銷貨單號 (Serial)
// 1: 機台編號 (Machine ID)
// 4: 訂單狀態 (Original Status)
// 7: 產品代號 (Product Code)
// 9: 銷貨日期 (Transaction At)
// 11: 金額 (Amount)
// 19: 貨道 (Slot)
// Quantity default to 1
$serial = $row[0];
$machineId = $row[1];
$originalStatus = $row[4];
$productCode = $row[7];
$transactionAt = $row[9];
$amount = $row[11];
$slot = $row[19] ?? null;
// Skip empty rows
if (empty($serial) && empty($productCode)) {
continue;
}
// Parse Date
try {
// Formatting might be needed depending on Excel date format
$transactionAt = Carbon::parse($transactionAt);
} catch (\Exception $e) {
$transactionAt = now();
}
$quantity = 1; // Default
// Clean amount (remove comma etc if needed)
$amount = is_numeric($amount) ? $amount : 0;
$items[] = [
'batch_id' => $this->batch->id,
'machine_id' => $machineId,
'slot' => $slot,
'product_code' => $productCode,
'product_id' => $this->products[$productCode] ?? null,
'transaction_at' => $transactionAt,
'transaction_serial' => $serial,
'quantity' => (int)$quantity,
'amount' => $amount,
'original_status' => $originalStatus,
'created_at' => now(),
'updated_at' => now(),
];
$totalQuantity += $quantity;
$totalAmount += $amount;
}
// Bulk insert items (chunk if necessary, but assuming reasonable size)
foreach (array_chunk($items, 1000) as $chunk) {
SalesImportItem::insert($chunk);
}
// Update Batch Totals
// Increment totals instead of overwriting, in case we decide to process multiple sheets later?
// But for now, since we only process sheet 0, overwriting or incrementing is fine.
// Given we strictly return [0 => ...], only one sheet runs.
$this->batch->update([
'total_quantity' => $totalQuantity,
'total_amount' => $totalAmount,
]);
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Modules\Sales\Models;
use App\Modules\Core\Models\User;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class SalesImportBatch extends Model
{
use HasFactory;
protected $table = 'sales_import_batches';
protected $fillable = [
'import_date',
'total_quantity',
'total_amount',
'status',
'imported_by',
'confirmed_at',
'note',
];
protected $casts = [
'import_date' => 'date',
'confirmed_at' => 'datetime',
'total_quantity' => 'decimal:4',
'total_amount' => 'decimal:4',
];
public function items(): HasMany
{
return $this->hasMany(SalesImportItem::class, 'batch_id');
}
public function importer(): BelongsTo
{
return $this->belongsTo(User::class, 'imported_by');
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Modules\Sales\Models;
use App\Modules\Inventory\Models\Product;
use App\Modules\Inventory\Models\Warehouse;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class SalesImportItem extends Model
{
use HasFactory;
protected $table = 'sales_import_items';
protected $fillable = [
'batch_id',
'machine_id',
'slot',
'product_code',
'product_id',
'transaction_at',
'transaction_serial',
'quantity',
'amount',
'original_status',
];
protected $casts = [
'transaction_at' => 'datetime',
'quantity' => 'integer',
'amount' => 'decimal:4',
'original_status' => 'string',
];
public function batch(): BelongsTo
{
return $this->belongsTo(SalesImportBatch::class, 'batch_id');
}
public function product(): BelongsTo
{
return $this->belongsTo(Product::class, 'product_id');
}
public function warehouse(): BelongsTo
{
return $this->belongsTo(Warehouse::class, 'machine_id', 'code');
}
}

View File

@@ -0,0 +1,13 @@
<?php
use Illuminate\Support\Facades\Route;
use App\Modules\Sales\Controllers\SalesImportController;
Route::middleware(['auth', 'verified'])->prefix('sales')->name('sales-imports.')->group(function () {
Route::get('/imports', [SalesImportController::class, 'index'])->name('index');
Route::get('/imports/create', [SalesImportController::class, 'create'])->name('create');
Route::post('/imports', [SalesImportController::class, 'store'])->name('store');
Route::get('/imports/{import}', [SalesImportController::class, 'show'])->name('show');
Route::post('/imports/{import}/confirm', [SalesImportController::class, 'confirm'])->name('confirm');
Route::delete('/imports/{import}', [SalesImportController::class, 'destroy'])->name('destroy');
});