366 lines
14 KiB
PHP
366 lines
14 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Api\V1;
|
|
|
|
use App\Helpers\ApiResponse;
|
|
use App\Http\Controllers\Controller;
|
|
use App\Http\Requests\V1\Receiving\ProcessReceivingRequest;
|
|
use App\Http\Requests\V1\Receiving\StoreReceivingRequest;
|
|
use App\Http\Requests\V1\Receiving\UpdateReceivingRequest;
|
|
use App\Models\Items\Item;
|
|
use App\Models\Qualitys\Inspection;
|
|
use App\Models\Tenants\Receiving;
|
|
use App\Models\Tenants\Stock;
|
|
use App\Models\Tenants\StockLot;
|
|
use App\Services\ReceivingService;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
class ReceivingController extends Controller
|
|
{
|
|
public function __construct(
|
|
private readonly ReceivingService $service
|
|
) {}
|
|
|
|
/**
|
|
* 입고 목록
|
|
*/
|
|
public function index(Request $request)
|
|
{
|
|
$params = $request->only([
|
|
'search',
|
|
'status',
|
|
'start_date',
|
|
'end_date',
|
|
'sort_by',
|
|
'sort_dir',
|
|
'per_page',
|
|
'page',
|
|
]);
|
|
|
|
$receivings = $this->service->index($params);
|
|
|
|
return ApiResponse::success($receivings, __('message.fetched'));
|
|
}
|
|
|
|
/**
|
|
* 입고 통계
|
|
*/
|
|
public function stats()
|
|
{
|
|
$stats = $this->service->stats();
|
|
|
|
return ApiResponse::success($stats, __('message.fetched'));
|
|
}
|
|
|
|
/**
|
|
* 입고 등록
|
|
*/
|
|
public function store(StoreReceivingRequest $request)
|
|
{
|
|
$receiving = $this->service->store($request->validated());
|
|
|
|
return ApiResponse::success($receiving, __('message.created'), [], 201);
|
|
}
|
|
|
|
/**
|
|
* 입고 상세
|
|
*/
|
|
public function show(int $id)
|
|
{
|
|
$receiving = $this->service->show($id);
|
|
|
|
return ApiResponse::success($receiving, __('message.fetched'));
|
|
}
|
|
|
|
/**
|
|
* 입고 수정
|
|
*/
|
|
public function update(int $id, UpdateReceivingRequest $request)
|
|
{
|
|
$receiving = $this->service->update($id, $request->validated());
|
|
|
|
return ApiResponse::success($receiving, __('message.updated'));
|
|
}
|
|
|
|
/**
|
|
* 입고 삭제
|
|
*/
|
|
public function destroy(int $id)
|
|
{
|
|
$this->service->destroy($id);
|
|
|
|
return ApiResponse::success(null, __('message.deleted'));
|
|
}
|
|
|
|
/**
|
|
* 입고처리 (상태 변경 + 입고 정보 입력)
|
|
*/
|
|
public function process(int $id, ProcessReceivingRequest $request)
|
|
{
|
|
$receiving = $this->service->process($id, $request->validated());
|
|
|
|
return ApiResponse::success($receiving, __('message.receiving.processed'));
|
|
}
|
|
|
|
/**
|
|
* [개발전용] 입고 강제 생성 (원자재 입고 + 재공품 재고 + 수입검사 한번에)
|
|
*
|
|
* POST /api/v1/dev/force-receiving
|
|
* Body: { item_id: number, qty?: number }
|
|
*
|
|
* PT(재공품) 품목이면 → material 속성에서 대응 RM(원자재) 찾아 입고 생성
|
|
* + 원래 PT 품목의 재고도 함께 생성 (자재투입 매칭용)
|
|
*/
|
|
public function forceCreate(Request $request)
|
|
{
|
|
$request->validate([
|
|
'item_id' => 'required|integer',
|
|
'qty' => 'nullable|integer|min:1|max:10000',
|
|
]);
|
|
|
|
$tenantId = app('tenant_id');
|
|
$itemId = $request->input('item_id');
|
|
$qty = $request->input('qty', 100);
|
|
$userId = auth()->id() ?? 33;
|
|
$date = now()->toDateString();
|
|
|
|
$item = Item::withoutGlobalScopes()
|
|
->where('tenant_id', $tenantId)
|
|
->where('id', $itemId)
|
|
->first();
|
|
|
|
if (! $item) {
|
|
return ApiResponse::error(__('error.not_found'), 404);
|
|
}
|
|
|
|
$result = DB::transaction(function () use ($item, $tenantId, $qty, $userId, $date) {
|
|
$datePrefix = date('Ymd', strtotime($date));
|
|
$dateShort = date('ymd', strtotime($date));
|
|
|
|
// 채번
|
|
$receivingSeq = (Receiving::withoutGlobalScopes()->where('tenant_id', $tenantId)
|
|
->where('receiving_number', 'LIKE', "RV{$datePrefix}%")->count()) + 1;
|
|
$lotSeq = (StockLot::withoutGlobalScopes()->where('tenant_id', $tenantId)
|
|
->where('lot_no', 'LIKE', "{$dateShort}-%")->count()) + 1;
|
|
$inspSeq = (Inspection::withoutGlobalScopes()->where('tenant_id', $tenantId)
|
|
->where('inspection_no', 'LIKE', "IQC-{$dateShort}-%")->count()) + 1;
|
|
|
|
$receivingNumber = 'RV'.$datePrefix.str_pad($receivingSeq, 4, '0', STR_PAD_LEFT);
|
|
$lotNo = $dateShort.'-'.str_pad($lotSeq, 2, '0', STR_PAD_LEFT);
|
|
$orderNo = 'PO-'.$dateShort.'-DEV'.str_pad(rand(1, 999), 3, '0', STR_PAD_LEFT);
|
|
|
|
// PT(재공품)이면 material 속성에서 RM(원자재) 찾기
|
|
$rmItem = null;
|
|
$receivingItem = $item; // 기본: 원본 품목으로 입고
|
|
$itemOptions = is_array($item->options) ? $item->options : (json_decode($item->options ?? '{}', true) ?: []);
|
|
$materialSpec = $itemOptions['material'] ?? null;
|
|
|
|
if ($item->item_type === 'PT' && $materialSpec) {
|
|
// "EGI 1.55T" → type=EGI, thickness=1.55
|
|
// "SUS 1.2T" → type=SUS, thickness=1.2
|
|
$cleanMat = preg_replace('/T$/i', '', trim($materialSpec));
|
|
if (preg_match('/^([A-Za-z]+)\s*([\d.]+)/', $cleanMat, $m)) {
|
|
$matType = $m[1];
|
|
$thickness = (float) $m[2];
|
|
|
|
// 품목명에서 길이 추출: "... 2438mm" → 2438
|
|
$length = null;
|
|
if (preg_match('/(\d{3,5})\s*mm/i', $item->name, $lm)) {
|
|
$length = (int) $lm[1];
|
|
}
|
|
|
|
// RM 품목 검색: 재질+두께 일치, 길이 일치 (있으면)
|
|
$rmQuery = Item::withoutGlobalScopes()
|
|
->where('tenant_id', $tenantId)
|
|
->where('item_type', 'RM')
|
|
->whereNull('deleted_at')
|
|
->whereRaw("JSON_UNQUOTE(JSON_EXTRACT(options, '$.attributes.material_type')) = ?", [$matType]);
|
|
|
|
// 두께 정확 매칭 시도 → 근사 매칭 폴백
|
|
$rmExact = (clone $rmQuery)
|
|
->whereRaw("CAST(JSON_UNQUOTE(JSON_EXTRACT(options, '$.attributes.thickness')) AS DECIMAL(5,2)) = ?", [$thickness]);
|
|
if ($length) {
|
|
$rmExact = $rmExact->whereRaw("CAST(JSON_UNQUOTE(JSON_EXTRACT(options, '$.attributes.length')) AS UNSIGNED) = ?", [$length]);
|
|
}
|
|
$rmItem = $rmExact->first();
|
|
|
|
// 두께 근사 매칭 (±0.1)
|
|
if (! $rmItem) {
|
|
$rmApprox = (clone $rmQuery)
|
|
->whereRaw("ABS(CAST(JSON_UNQUOTE(JSON_EXTRACT(options, '$.attributes.thickness')) AS DECIMAL(5,2)) - ?) <= 0.1", [$thickness]);
|
|
if ($length) {
|
|
$rmApprox = $rmApprox->whereRaw("CAST(JSON_UNQUOTE(JSON_EXTRACT(options, '$.attributes.length')) AS UNSIGNED) = ?", [$length]);
|
|
}
|
|
$rmItem = $rmApprox->first();
|
|
}
|
|
|
|
// 길이 무관 매칭 (두께만)
|
|
if (! $rmItem) {
|
|
$rmItem = (clone $rmQuery)
|
|
->whereRaw("ABS(CAST(JSON_UNQUOTE(JSON_EXTRACT(options, '$.attributes.thickness')) AS DECIMAL(5,2)) - ?) <= 0.1", [$thickness])
|
|
->first();
|
|
}
|
|
|
|
if ($rmItem) {
|
|
$receivingItem = $rmItem;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 공급업체 결정
|
|
$supplier = str_contains($receivingItem->name, 'SUS') ? '현대제철(주)' : '(주)포스코';
|
|
|
|
// 1. Receiving 생성 (RM 원자재 기준)
|
|
$receiving = Receiving::withoutGlobalScopes()->create([
|
|
'tenant_id' => $tenantId,
|
|
'receiving_number' => $receivingNumber,
|
|
'order_no' => $orderNo,
|
|
'order_date' => $date,
|
|
'item_id' => $receivingItem->id,
|
|
'item_code' => $receivingItem->code,
|
|
'item_name' => $receivingItem->name,
|
|
'supplier' => $supplier,
|
|
'order_qty' => $qty,
|
|
'order_unit' => $receivingItem->unit ?: 'EA',
|
|
'due_date' => $date,
|
|
'receiving_qty' => $qty,
|
|
'receiving_date' => $date,
|
|
'lot_no' => $lotNo,
|
|
'supplier_lot' => 'SUP-'.rand(1000, 9999),
|
|
'receiving_location' => 'A-01-01',
|
|
'receiving_manager' => '관리자',
|
|
'status' => 'completed',
|
|
'remark' => '[개발전용] 강제 생성 입고'.($rmItem ? ' (원자재 '.$rmItem->code.' → 재공품 '.$item->code.')' : ''),
|
|
'options' => [
|
|
'manufacturer' => str_replace('(주)', '', $supplier),
|
|
'inspection_status' => '적',
|
|
'inspection_date' => $date,
|
|
'inspection_result' => '합격',
|
|
'force_created' => true,
|
|
'original_pt_item_id' => $item->id,
|
|
'original_pt_item_code' => $item->code,
|
|
],
|
|
'created_by' => $userId,
|
|
'updated_by' => $userId,
|
|
]);
|
|
|
|
// 2-A. 원자재(RM) Stock/StockLot 생성 (입고 이력용)
|
|
if ($rmItem && $rmItem->id !== $item->id) {
|
|
$this->createStockAndLot($rmItem, $tenantId, $qty, $userId, $date, $lotNo, $receiving, $orderNo, $supplier);
|
|
}
|
|
|
|
// 2-B. 재공품(PT) Stock/StockLot 생성 (자재투입 매칭용)
|
|
$ptStock = $this->createStockAndLot($item, $tenantId, $qty, $userId, $date, $lotNo, $receiving, $orderNo, $supplier);
|
|
|
|
// 3. IQC 수입검사 생성 (합격)
|
|
$inspectionNo = 'IQC-'.$dateShort.'-'.str_pad($inspSeq, 4, '0', STR_PAD_LEFT);
|
|
Inspection::withoutGlobalScopes()->create([
|
|
'tenant_id' => $tenantId,
|
|
'inspection_no' => $inspectionNo,
|
|
'inspection_type' => 'IQC',
|
|
'status' => 'completed',
|
|
'result' => 'pass',
|
|
'request_date' => $date,
|
|
'inspection_date' => $date,
|
|
'item_id' => $receivingItem->id,
|
|
'lot_no' => $lotNo,
|
|
'meta' => [
|
|
'quantity' => $qty,
|
|
'unit' => $receivingItem->unit ?: 'EA',
|
|
'supplier_name' => $supplier,
|
|
'item_code' => $receivingItem->code,
|
|
'item_name' => $receivingItem->name,
|
|
'force_created' => true,
|
|
],
|
|
'items' => [
|
|
['item' => '외관검사', 'standard' => '이상 없을 것', 'result' => '양호', 'judgment' => '적'],
|
|
['item' => '치수검사', 'standard' => '규격 일치', 'result' => '양호', 'judgment' => '적'],
|
|
],
|
|
'extra' => ['remarks' => '[개발전용] 자동 합격', 'opinion' => '양호'],
|
|
'created_by' => $userId,
|
|
'updated_by' => $userId,
|
|
]);
|
|
|
|
return [
|
|
'receiving_number' => $receivingNumber,
|
|
'lot_no' => $lotNo,
|
|
'rm_item_code' => $receivingItem->code,
|
|
'rm_item_name' => $receivingItem->name,
|
|
'pt_item_code' => $item->code,
|
|
'pt_item_name' => $item->name,
|
|
'qty' => $qty,
|
|
'available_qty' => $ptStock->available_qty,
|
|
'matched_rm' => $rmItem ? true : false,
|
|
];
|
|
});
|
|
|
|
return ApiResponse::success($result, '입고 데이터가 강제 생성되었습니다.');
|
|
}
|
|
|
|
/**
|
|
* Stock + StockLot 생성/갱신 헬퍼
|
|
*/
|
|
private function createStockAndLot(Item $item, int $tenantId, int $qty, int $userId, string $date, string $lotNo, Receiving $receiving, string $orderNo, string $supplier): Stock
|
|
{
|
|
$itemTypeMap = ['FG' => 'purchased_part', 'PT' => 'bent_part', 'SM' => 'sub_material', 'RM' => 'raw_material', 'CS' => 'consumable'];
|
|
$stockType = $itemTypeMap[$item->item_type] ?? 'raw_material';
|
|
|
|
$stock = Stock::withoutGlobalScopes()
|
|
->where('tenant_id', $tenantId)
|
|
->where('item_id', $item->id)
|
|
->first();
|
|
|
|
if ($stock) {
|
|
$stock->stock_qty += $qty;
|
|
$stock->available_qty += $qty;
|
|
$stock->lot_count += 1;
|
|
$stock->last_receipt_date = $date;
|
|
$stock->status = 'normal';
|
|
$stock->updated_by = $userId;
|
|
$stock->save();
|
|
} else {
|
|
$stock = Stock::withoutGlobalScopes()->create([
|
|
'tenant_id' => $tenantId,
|
|
'item_id' => $item->id,
|
|
'item_code' => $item->code,
|
|
'item_name' => $item->name,
|
|
'item_type' => $stockType,
|
|
'unit' => $item->unit ?: 'EA',
|
|
'stock_qty' => $qty,
|
|
'safety_stock' => 10,
|
|
'reserved_qty' => 0,
|
|
'available_qty' => $qty,
|
|
'lot_count' => 1,
|
|
'oldest_lot_date' => $date,
|
|
'location' => 'A-01-01',
|
|
'status' => 'normal',
|
|
'last_receipt_date' => $date,
|
|
'created_by' => $userId,
|
|
]);
|
|
}
|
|
|
|
$nextFifo = (StockLot::withoutGlobalScopes()->where('stock_id', $stock->id)->max('fifo_order') ?? 0) + 1;
|
|
StockLot::withoutGlobalScopes()->create([
|
|
'tenant_id' => $tenantId,
|
|
'stock_id' => $stock->id,
|
|
'lot_no' => $lotNo,
|
|
'fifo_order' => $nextFifo,
|
|
'receipt_date' => $date,
|
|
'qty' => $qty,
|
|
'reserved_qty' => 0,
|
|
'available_qty' => $qty,
|
|
'unit' => $item->unit ?: 'EA',
|
|
'supplier' => $supplier,
|
|
'supplier_lot' => $receiving->supplier_lot,
|
|
'po_number' => $orderNo,
|
|
'location' => 'A-01-01',
|
|
'status' => 'available',
|
|
'receiving_id' => $receiving->id,
|
|
'created_by' => $userId,
|
|
]);
|
|
|
|
return $stock;
|
|
}
|
|
}
|