feat: 實作銷售單匯入管理、貨道扣庫優化及 UI 細節調整
This commit is contained in:
152
app/Modules/Sales/Controllers/SalesImportController.php
Normal file
152
app/Modules/Sales/Controllers/SalesImportController.php
Normal 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', '已刪除匯入批次。');
|
||||
}
|
||||
}
|
||||
24
app/Modules/Sales/Imports/SalesImport.php
Normal file
24
app/Modules/Sales/Imports/SalesImport.php
Normal 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),
|
||||
];
|
||||
}
|
||||
}
|
||||
106
app/Modules/Sales/Imports/SalesImportSheet.php
Normal file
106
app/Modules/Sales/Imports/SalesImportSheet.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
43
app/Modules/Sales/Models/SalesImportBatch.php
Normal file
43
app/Modules/Sales/Models/SalesImportBatch.php
Normal 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');
|
||||
}
|
||||
}
|
||||
51
app/Modules/Sales/Models/SalesImportItem.php
Normal file
51
app/Modules/Sales/Models/SalesImportItem.php
Normal 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');
|
||||
}
|
||||
}
|
||||
13
app/Modules/Sales/Routes/web.php
Normal file
13
app/Modules/Sales/Routes/web.php
Normal 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');
|
||||
});
|
||||
Reference in New Issue
Block a user