feat: 견적확정 밸리데이션, 작업지시 통계 공정별 카운트, 입고/재고 개선
- 견적확정 시 업체명/현장명/담당자/연락처 필수 검증 추가 (QuoteService) - 작업지시 stats API에 by_process 공정별 카운트 반환 추가 - 작업지시 목록/상세 쿼리에 수주 개소(rootNodes) 연관 로딩 - 작업지시 품목에 sourceOrderItem.node 관계 추가 - 입고관리 완료건 수정 허용 및 재고 차이 조정 - work_order_step_progress 테이블 마이그레이션 - receivings 테이블 options 컬럼 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
297
app/Console/Commands/MapItemsToProcesses.php
Normal file
297
app/Console/Commands/MapItemsToProcesses.php
Normal file
@@ -0,0 +1,297 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Items\Item;
|
||||
use App\Models\Process;
|
||||
use App\Models\ProcessItem;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 5130 기준 품목-공정 매핑 (A+B+C 전략)
|
||||
*
|
||||
* A. 품목명 키워드 기반:
|
||||
* - "스크린용", "스크린" → P-002 스크린
|
||||
* - "철재용", "철재", "슬랫" → P-001 슬랫
|
||||
*
|
||||
* B. BD 코드 기반:
|
||||
* - BD-* → P-003 절곡
|
||||
*
|
||||
* C. 재고생산(LOT) 기반 (5130 lot 테이블 분석):
|
||||
* - PT-* 코드 → P-004 재고생산
|
||||
* - 가이드레일, 케이스, 연기차단재, L-Bar → P-004 재고생산
|
||||
*/
|
||||
class MapItemsToProcesses extends Command
|
||||
{
|
||||
protected $signature = 'items:map-to-processes
|
||||
{--tenant= : 특정 테넌트 ID (기본: 모든 테넌트)}
|
||||
{--dry-run : 실제 실행 없이 미리보기만}
|
||||
{--clear : 기존 매핑 삭제 후 새로 매핑}';
|
||||
|
||||
protected $description = '5130 기준 품목-공정 자동 매핑 (A: 키워드 + B: BD코드 + C: 재고생산)';
|
||||
|
||||
/**
|
||||
* 공정별 매핑 규칙 정의
|
||||
*
|
||||
* 5130 LOT 생산 품목 분류:
|
||||
* - R: 가이드레일-벽면형, S: 가이드레일-측면형
|
||||
* - C: 케이스 (린텔부, 전면부, 점검구, 후면코너부)
|
||||
* - B: 하단마감재-스크린, T: 하단마감재-철재
|
||||
* - G: 연기차단재
|
||||
* - L: L-Bar
|
||||
*/
|
||||
private array $mappingRules = [
|
||||
'P-001' => [
|
||||
'name' => '슬랫',
|
||||
'code_patterns' => [],
|
||||
'name_keywords' => ['철재용', '철재', '슬랫'],
|
||||
'name_excludes' => ['스크린', '가이드레일', '하단마감', '연기차단', '케이스'], // 재고생산 품목 제외
|
||||
],
|
||||
'P-002' => [
|
||||
'name' => '스크린',
|
||||
'code_patterns' => [],
|
||||
'name_keywords' => ['스크린용', '스크린', '원단', '실리카', '방충', '와이어'],
|
||||
'name_excludes' => ['가이드레일', '하단마감', '연기차단', '케이스'], // 재고생산 품목 제외
|
||||
],
|
||||
'P-003' => [
|
||||
'name' => '절곡',
|
||||
'code_patterns' => ['BD-%'], // BD 코드는 절곡
|
||||
'name_keywords' => ['절곡'], // 절곡 키워드만 (나머지는 P-004로)
|
||||
'name_excludes' => [],
|
||||
],
|
||||
'P-004' => [
|
||||
'name' => '재고생산',
|
||||
'code_patterns' => ['PT-%'], // PT 코드는 재고생산 부품
|
||||
'name_keywords' => ['가이드레일', '케이스', '연기차단', 'L-Bar', 'L-BAR', 'LBar', '하단마감', '린텔', '하장바'],
|
||||
'name_excludes' => [],
|
||||
'code_excludes' => ['BD-%'], // BD 코드는 P-003으로
|
||||
],
|
||||
];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$tenantId = $this->option('tenant');
|
||||
$dryRun = $this->option('dry-run');
|
||||
$clear = $this->option('clear');
|
||||
|
||||
$this->info('=== 5130 기준 품목-공정 매핑 (A+B+C 전략) ===');
|
||||
$this->info('A. 품목명 키워드: 스크린용→P-002, 철재용→P-001');
|
||||
$this->info('B. BD 코드: BD-* → P-003 절곡');
|
||||
$this->info('C. 재고생산: PT-* 또는 가이드레일/케이스/연기차단재/L-Bar → P-004');
|
||||
$this->newLine();
|
||||
|
||||
// 공정 조회
|
||||
$processQuery = Process::query();
|
||||
if ($tenantId) {
|
||||
$processQuery->where('tenant_id', $tenantId);
|
||||
}
|
||||
$processes = $processQuery->whereIn('process_code', array_keys($this->mappingRules))->get()->keyBy('process_code');
|
||||
|
||||
if ($processes->isEmpty()) {
|
||||
$this->error('매핑 대상 공정이 없습니다. (P-001, P-002, P-003, P-004)');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info('대상 공정:');
|
||||
foreach ($processes as $code => $process) {
|
||||
$this->line(" - {$code}: {$process->process_name} (ID: {$process->id})");
|
||||
}
|
||||
$this->newLine();
|
||||
|
||||
// 기존 매핑 삭제 (--clear 옵션)
|
||||
if ($clear) {
|
||||
$processIds = $processes->pluck('id')->toArray();
|
||||
$existingCount = ProcessItem::whereIn('process_id', $processIds)->count();
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn("[DRY-RUN] 기존 매핑 {$existingCount}개 삭제 예정");
|
||||
} else {
|
||||
ProcessItem::whereIn('process_id', $processIds)->delete();
|
||||
$this->warn("기존 매핑 {$existingCount}개 삭제 완료");
|
||||
}
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
// 매핑 결과 저장
|
||||
$results = [
|
||||
'P-001' => ['items' => collect(), 'process' => $processes->get('P-001')],
|
||||
'P-002' => ['items' => collect(), 'process' => $processes->get('P-002')],
|
||||
'P-003' => ['items' => collect(), 'process' => $processes->get('P-003')],
|
||||
'P-004' => ['items' => collect(), 'process' => $processes->get('P-004')],
|
||||
];
|
||||
|
||||
// 품목 조회 및 분류
|
||||
$itemQuery = Item::query();
|
||||
if ($tenantId) {
|
||||
$itemQuery->where('tenant_id', $tenantId);
|
||||
}
|
||||
$items = $itemQuery->get();
|
||||
|
||||
$this->info("전체 품목 수: {$items->count()}개");
|
||||
$this->newLine();
|
||||
|
||||
$mappedCount = 0;
|
||||
$unmappedItems = collect();
|
||||
|
||||
foreach ($items as $item) {
|
||||
$processCode = $this->classifyItem($item);
|
||||
|
||||
if ($processCode && isset($results[$processCode])) {
|
||||
$results[$processCode]['items']->push($item);
|
||||
$mappedCount++;
|
||||
} else {
|
||||
$unmappedItems->push($item);
|
||||
}
|
||||
}
|
||||
|
||||
// 결과 출력
|
||||
$this->info('=== 분류 결과 ===');
|
||||
$this->newLine();
|
||||
|
||||
$tableData = [];
|
||||
foreach ($results as $code => $data) {
|
||||
$count = $data['items']->count();
|
||||
$processName = $data['process']?->process_name ?? '(없음)';
|
||||
$tableData[] = [$code, $processName, $count];
|
||||
}
|
||||
$tableData[] = ['-', '미분류', $unmappedItems->count()];
|
||||
$tableData[] = ['=', '합계', $items->count()];
|
||||
|
||||
$this->table(['공정코드', '공정명', '품목 수'], $tableData);
|
||||
$this->newLine();
|
||||
|
||||
// 샘플 출력
|
||||
foreach ($results as $code => $data) {
|
||||
if ($data['items']->isNotEmpty()) {
|
||||
$this->info("[{$code} {$data['process']?->process_name}] 샘플 (최대 10개):");
|
||||
foreach ($data['items']->take(10) as $item) {
|
||||
$this->line(" - {$item->code}: {$item->name}");
|
||||
}
|
||||
$this->newLine();
|
||||
}
|
||||
}
|
||||
|
||||
// 미분류 샘플
|
||||
if ($unmappedItems->isNotEmpty()) {
|
||||
$this->info("[미분류] 샘플 (최대 10개):");
|
||||
foreach ($unmappedItems->take(10) as $item) {
|
||||
$this->line(" - {$item->code}: {$item->name}");
|
||||
}
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
// 실제 매핑 실행
|
||||
if (! $dryRun) {
|
||||
$this->info('=== 매핑 실행 ===');
|
||||
|
||||
DB::transaction(function () use ($results) {
|
||||
foreach ($results as $code => $data) {
|
||||
$process = $data['process'];
|
||||
if (! $process) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$priority = 0;
|
||||
foreach ($data['items'] as $item) {
|
||||
// 중복 체크
|
||||
$exists = ProcessItem::where('process_id', $process->id)
|
||||
->where('item_id', $item->id)
|
||||
->exists();
|
||||
|
||||
if (! $exists) {
|
||||
ProcessItem::create([
|
||||
'process_id' => $process->id,
|
||||
'item_id' => $item->id,
|
||||
'priority' => $priority++,
|
||||
'is_active' => true,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->info(" {$code}: {$data['items']->count()}개 매핑 완료");
|
||||
}
|
||||
});
|
||||
|
||||
$this->newLine();
|
||||
$this->info("총 {$mappedCount}개 품목 매핑 완료!");
|
||||
} else {
|
||||
$this->newLine();
|
||||
$this->warn('[DRY-RUN] 실제 매핑은 수행되지 않았습니다.');
|
||||
$this->line('실제 실행: php artisan items:map-to-processes --clear');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 품목을 공정에 분류 (A+B+C 전략)
|
||||
*/
|
||||
private function classifyItem(Item $item): ?string
|
||||
{
|
||||
$code = $item->code ?? '';
|
||||
$name = $item->name ?? '';
|
||||
|
||||
// B. BD 코드 → P-003 절곡 (최우선)
|
||||
if (str_starts_with($code, 'BD-')) {
|
||||
return 'P-003';
|
||||
}
|
||||
|
||||
// C. PT 코드 → P-004 재고생산 (코드 기반 우선)
|
||||
if (str_starts_with($code, 'PT-')) {
|
||||
return 'P-004';
|
||||
}
|
||||
|
||||
// C. P-004 재고생산 키워드 체크 (가이드레일, 케이스, 연기차단재, L-Bar, 하단마감, 린텔)
|
||||
foreach ($this->mappingRules['P-004']['name_keywords'] as $keyword) {
|
||||
if (mb_stripos($name, $keyword) !== false) {
|
||||
return 'P-004';
|
||||
}
|
||||
}
|
||||
|
||||
// A. 품목명 키워드 기반 분류
|
||||
// P-002 스크린 먼저 체크 (스크린용, 스크린 키워드)
|
||||
foreach ($this->mappingRules['P-002']['name_keywords'] as $keyword) {
|
||||
if (mb_stripos($name, $keyword) !== false) {
|
||||
// 재고생산 품목 제외
|
||||
$excluded = false;
|
||||
foreach ($this->mappingRules['P-002']['name_excludes'] as $exclude) {
|
||||
if (mb_stripos($name, $exclude) !== false) {
|
||||
$excluded = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (! $excluded) {
|
||||
return 'P-002';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// P-001 슬랫 체크 (철재용, 철재, 슬랫 키워드)
|
||||
foreach ($this->mappingRules['P-001']['name_keywords'] as $keyword) {
|
||||
if (mb_stripos($name, $keyword) !== false) {
|
||||
// 재고생산 품목 제외
|
||||
$excluded = false;
|
||||
foreach ($this->mappingRules['P-001']['name_excludes'] as $exclude) {
|
||||
if (mb_stripos($name, $exclude) !== false) {
|
||||
$excluded = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (! $excluded) {
|
||||
return 'P-001';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// P-003 절곡 키워드 체크 (BD 코드 외에 키워드로도 분류)
|
||||
foreach ($this->mappingRules['P-003']['name_keywords'] as $keyword) {
|
||||
if (mb_stripos($name, $keyword) !== false) {
|
||||
return 'P-003';
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
use App\Exceptions\DuplicateCodeException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
class ApiResponse
|
||||
@@ -245,6 +246,15 @@ public static function handle(
|
||||
return self::success($data, $responseTitle, $debug, $statusCode);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
// 모든 예외를 로깅 (디버깅용)
|
||||
Log::error('API Exception', [
|
||||
'message' => $e->getMessage(),
|
||||
'exception' => get_class($e),
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine(),
|
||||
'url' => request()->fullUrl(),
|
||||
'method' => request()->method(),
|
||||
]);
|
||||
|
||||
// ValidationException - 422 Unprocessable Entity
|
||||
if ($e instanceof \Illuminate\Validation\ValidationException) {
|
||||
@@ -279,9 +289,12 @@ public static function handle(
|
||||
);
|
||||
}
|
||||
|
||||
// 일반 예외는 500으로 처리, debug 모드에서만 스택 트레이스 포함
|
||||
return self::error('서버 에러', 500, [
|
||||
// 일반 예외는 500으로 처리, debug 모드에서만 상세 정보 포함
|
||||
$errorMessage = config('app.debug') ? $e->getMessage() : '서버 에러';
|
||||
|
||||
return self::error($errorMessage, 500, [
|
||||
'details' => config('app.debug') ? $e->getTraceAsString() : null,
|
||||
'exception' => config('app.debug') ? get_class($e) : null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,4 +157,34 @@ public function registerMaterialInput(Request $request, int $id)
|
||||
return $this->service->registerMaterialInput($id, $request->input('material_ids', []));
|
||||
}, __('message.work_order.material_input_registered'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 공정 단계 진행 현황 조회
|
||||
*/
|
||||
public function stepProgress(int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->service->getStepProgress($id);
|
||||
}, __('message.work_order.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 공정 단계 완료 토글
|
||||
*/
|
||||
public function toggleStepProgress(Request $request, int $id, int $progressId)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id, $progressId) {
|
||||
return $this->service->toggleStepProgress($id, $progressId);
|
||||
}, __('message.work_order.updated'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 자재 투입 이력 조회
|
||||
*/
|
||||
public function materialInputHistory(int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->service->getMaterialInputHistory($id);
|
||||
}, __('message.work_order.fetched'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,9 @@ public function rules(): array
|
||||
'receiving_qty' => ['nullable', 'numeric', 'min:0'],
|
||||
'receiving_date' => ['nullable', 'date'],
|
||||
'lot_no' => ['nullable', 'string', 'max:50'],
|
||||
'inspection_status' => ['nullable', 'string', 'max:10'],
|
||||
'inspection_date' => ['nullable', 'date'],
|
||||
'inspection_result' => ['nullable', 'string', 'max:20'],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -195,6 +195,14 @@ public function issues(): HasMany
|
||||
return $this->hasMany(WorkOrderIssue::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 공정 단계 진행 추적
|
||||
*/
|
||||
public function stepProgress(): HasMany
|
||||
{
|
||||
return $this->hasMany(WorkOrderStepProgress::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 출하 목록
|
||||
*/
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Models\Production;
|
||||
|
||||
use App\Models\Items\Item;
|
||||
use App\Models\Orders\OrderItem;
|
||||
use App\Traits\Auditable;
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@@ -72,6 +73,14 @@ public function item(): BelongsTo
|
||||
return $this->belongsTo(Item::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 원본 수주 품목
|
||||
*/
|
||||
public function sourceOrderItem(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(OrderItem::class, 'source_order_item_id');
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// 스코프
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
148
app/Models/Production/WorkOrderStepProgress.php
Normal file
148
app/Models/Production/WorkOrderStepProgress.php
Normal file
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Production;
|
||||
|
||||
use App\Models\Members\User;
|
||||
use App\Models\ProcessStep;
|
||||
use App\Traits\Auditable;
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* 작업지시 공정 단계 진행 추적 모델
|
||||
*
|
||||
* 각 작업지시별 공정 단계의 완료 상태를 추적
|
||||
*/
|
||||
class WorkOrderStepProgress extends Model
|
||||
{
|
||||
use Auditable, BelongsToTenant;
|
||||
|
||||
protected $table = 'work_order_step_progress';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'work_order_id',
|
||||
'process_step_id',
|
||||
'work_order_item_id',
|
||||
'status',
|
||||
'completed_at',
|
||||
'completed_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'completed_at' => 'datetime',
|
||||
];
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// 상수
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
public const STATUS_WAITING = 'waiting';
|
||||
|
||||
public const STATUS_IN_PROGRESS = 'in_progress';
|
||||
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
|
||||
public const STATUSES = [
|
||||
self::STATUS_WAITING,
|
||||
self::STATUS_IN_PROGRESS,
|
||||
self::STATUS_COMPLETED,
|
||||
];
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// 관계
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 작업지시
|
||||
*/
|
||||
public function workOrder(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(WorkOrder::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 공정 단계
|
||||
*/
|
||||
public function processStep(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProcessStep::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업지시 품목 (선택적)
|
||||
*/
|
||||
public function workOrderItem(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(WorkOrderItem::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 완료 처리자
|
||||
*/
|
||||
public function completedByUser(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'completed_by');
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// 스코프
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
public function scopeCompleted($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_COMPLETED);
|
||||
}
|
||||
|
||||
public function scopeWaiting($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_WAITING);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// 헬퍼 메서드
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 완료 처리
|
||||
*/
|
||||
public function markCompleted(?int $userId = null): void
|
||||
{
|
||||
$this->status = self::STATUS_COMPLETED;
|
||||
$this->completed_at = now();
|
||||
$this->completed_by = $userId;
|
||||
$this->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* 대기 상태로 되돌리기
|
||||
*/
|
||||
public function markWaiting(): void
|
||||
{
|
||||
$this->status = self::STATUS_WAITING;
|
||||
$this->completed_at = null;
|
||||
$this->completed_by = null;
|
||||
$this->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* 완료 여부
|
||||
*/
|
||||
public function isCompleted(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_COMPLETED;
|
||||
}
|
||||
|
||||
/**
|
||||
* 완료 토글
|
||||
*/
|
||||
public function toggle(?int $userId = null): void
|
||||
{
|
||||
if ($this->isCompleted()) {
|
||||
$this->markWaiting();
|
||||
} else {
|
||||
$this->markCompleted($userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ class Receiving extends Model
|
||||
'receiving_manager',
|
||||
'status',
|
||||
'remark',
|
||||
'options',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'deleted_by',
|
||||
@@ -45,8 +46,33 @@ class Receiving extends Model
|
||||
'order_qty' => 'decimal:2',
|
||||
'receiving_qty' => 'decimal:2',
|
||||
'item_id' => 'integer',
|
||||
'options' => 'array',
|
||||
];
|
||||
|
||||
/**
|
||||
* JSON 직렬화 시 자동 포함되는 접근자
|
||||
*/
|
||||
protected $appends = [
|
||||
'manufacturer',
|
||||
'material_no',
|
||||
'inspection_status',
|
||||
'inspection_date',
|
||||
'inspection_result',
|
||||
];
|
||||
|
||||
/**
|
||||
* Options 키 상수 (확장 필드)
|
||||
*/
|
||||
public const OPTION_MANUFACTURER = 'manufacturer'; // 제조사
|
||||
|
||||
public const OPTION_MATERIAL_NO = 'material_no'; // 거래처 자재번호
|
||||
|
||||
public const OPTION_INSPECTION_STATUS = 'inspection_status'; // 수입검사 (적/부적/-)
|
||||
|
||||
public const OPTION_INSPECTION_DATE = 'inspection_date'; // 검사일
|
||||
|
||||
public const OPTION_INSPECTION_RESULT = 'inspection_result'; // 검사결과 (합격/불합격)
|
||||
|
||||
/**
|
||||
* 상태 목록
|
||||
*/
|
||||
@@ -82,12 +108,72 @@ public function getStatusLabelAttribute(): string
|
||||
return self::STATUSES[$this->status] ?? $this->status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options에서 값 가져오기
|
||||
*/
|
||||
public function getOption(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return $this->options[$key] ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options에 값 설정
|
||||
*/
|
||||
public function setOption(string $key, mixed $value): self
|
||||
{
|
||||
$options = $this->options ?? [];
|
||||
$options[$key] = $value;
|
||||
$this->options = $options;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 제조사 접근자
|
||||
*/
|
||||
public function getManufacturerAttribute(): ?string
|
||||
{
|
||||
return $this->getOption(self::OPTION_MANUFACTURER);
|
||||
}
|
||||
|
||||
/**
|
||||
* 거래처 자재번호 접근자
|
||||
*/
|
||||
public function getMaterialNoAttribute(): ?string
|
||||
{
|
||||
return $this->getOption(self::OPTION_MATERIAL_NO);
|
||||
}
|
||||
|
||||
/**
|
||||
* 수입검사 상태 접근자
|
||||
*/
|
||||
public function getInspectionStatusAttribute(): ?string
|
||||
{
|
||||
return $this->getOption(self::OPTION_INSPECTION_STATUS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 검사일 접근자
|
||||
*/
|
||||
public function getInspectionDateAttribute(): ?string
|
||||
{
|
||||
return $this->getOption(self::OPTION_INSPECTION_DATE);
|
||||
}
|
||||
|
||||
/**
|
||||
* 검사결과 접근자
|
||||
*/
|
||||
public function getInspectionResultAttribute(): ?string
|
||||
{
|
||||
return $this->getOption(self::OPTION_INSPECTION_RESULT);
|
||||
}
|
||||
|
||||
/**
|
||||
* 수정 가능 여부
|
||||
*/
|
||||
public function canEdit(): bool
|
||||
{
|
||||
return $this->status !== 'completed';
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -489,21 +489,12 @@ public function resolve(array $params): array
|
||||
];
|
||||
$categoryName = $categoryMapping[$category] ?? $category;
|
||||
|
||||
$template = DocumentTemplate::query()
|
||||
$baseQuery = DocumentTemplate::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('is_active', true)
|
||||
->where(function ($q) use ($category, $categoryName) {
|
||||
// category 필드가 code 또는 name과 매칭
|
||||
$q->where('category', $category)
|
||||
->orWhere('category', $categoryName)
|
||||
->orWhere('category', 'LIKE', "%{$categoryName}%");
|
||||
})
|
||||
->whereHas('links', function ($q) use ($itemId) {
|
||||
// 해당 item_id가 연결된 템플릿만
|
||||
$q->where('source_table', 'items')
|
||||
->whereHas('linkValues', function ($q2) use ($itemId) {
|
||||
$q2->where('linkable_id', $itemId);
|
||||
});
|
||||
->where(function ($q) use ($itemId) {
|
||||
$q->whereJsonContains('linked_item_ids', (int) $itemId)
|
||||
->orWhereJsonContains('linked_item_ids', (string) $itemId);
|
||||
})
|
||||
->with([
|
||||
'approvalLines',
|
||||
@@ -511,10 +502,22 @@ public function resolve(array $params): array
|
||||
'sections.items',
|
||||
'columns',
|
||||
'sectionFields',
|
||||
'links.linkValues',
|
||||
])
|
||||
]);
|
||||
|
||||
// 1차: category 매칭 + item_id
|
||||
$template = (clone $baseQuery)
|
||||
->where(function ($q) use ($category, $categoryName) {
|
||||
$q->where('category', $category)
|
||||
->orWhere('category', $categoryName)
|
||||
->orWhere('category', 'LIKE', "%{$categoryName}%");
|
||||
})
|
||||
->first();
|
||||
|
||||
// 2차: category 무관, item_id 연결만으로 fallback
|
||||
if (! $template) {
|
||||
$template = $baseQuery->first();
|
||||
}
|
||||
|
||||
if (! $template) {
|
||||
throw new NotFoundHttpException(__('error.document.template_not_found'));
|
||||
}
|
||||
@@ -607,6 +610,19 @@ public function upsert(array $data): Document
|
||||
*/
|
||||
private function formatTemplateForReact(DocumentTemplate $template): array
|
||||
{
|
||||
// common_codes에서 inspection_method 코드 목록 조회 (code → name 매핑)
|
||||
$tenantId = $this->tenantId();
|
||||
$methodCodes = DB::table('common_codes')
|
||||
->where('code_group', 'inspection_method')
|
||||
->where('is_active', true)
|
||||
->where(function ($q) use ($tenantId) {
|
||||
$q->where('tenant_id', $tenantId)
|
||||
->orWhereNull('tenant_id');
|
||||
})
|
||||
->orderByRaw('tenant_id IS NULL') // tenant 우선
|
||||
->pluck('name', 'code')
|
||||
->toArray();
|
||||
|
||||
return [
|
||||
'id' => $template->id,
|
||||
'name' => $template->name,
|
||||
@@ -620,6 +636,8 @@ private function formatTemplateForReact(DocumentTemplate $template): array
|
||||
'footer_judgement_options' => $template->footer_judgement_options,
|
||||
'approval_lines' => $template->approvalLines->map(fn ($line) => [
|
||||
'id' => $line->id,
|
||||
'name' => $line->name,
|
||||
'dept' => $line->dept,
|
||||
'role' => $line->role,
|
||||
'user_id' => $line->user_id,
|
||||
'sort_order' => $line->sort_order,
|
||||
@@ -648,23 +666,29 @@ private function formatTemplateForReact(DocumentTemplate $template): array
|
||||
'id' => $section->id,
|
||||
'name' => $section->name,
|
||||
'sort_order' => $section->sort_order,
|
||||
'items' => $section->items->map(fn ($item) => [
|
||||
'id' => $item->id,
|
||||
'field_values' => $item->field_values ?? [],
|
||||
// 레거시 필드도 포함 (하위 호환)
|
||||
'category' => $item->category,
|
||||
'item' => $item->item,
|
||||
'standard' => $item->standard,
|
||||
'standard_criteria' => $item->standard_criteria,
|
||||
'tolerance' => $item->tolerance,
|
||||
'method' => $item->method,
|
||||
'measurement_type' => $item->measurement_type,
|
||||
'frequency' => $item->frequency,
|
||||
'frequency_n' => $item->frequency_n,
|
||||
'frequency_c' => $item->frequency_c,
|
||||
'regulation' => $item->regulation,
|
||||
'sort_order' => $item->sort_order,
|
||||
])->toArray(),
|
||||
'items' => $section->items->map(function ($item) use ($methodCodes) {
|
||||
// method 코드를 한글 이름으로 변환
|
||||
$methodName = $item->method ? ($methodCodes[$item->method] ?? $item->method) : null;
|
||||
|
||||
return [
|
||||
'id' => $item->id,
|
||||
'field_values' => $item->field_values ?? [],
|
||||
// 레거시 필드도 포함 (하위 호환)
|
||||
'category' => $item->category,
|
||||
'item' => $item->item,
|
||||
'standard' => $item->standard,
|
||||
'standard_criteria' => $item->standard_criteria,
|
||||
'tolerance' => $item->tolerance,
|
||||
'method' => $item->method,
|
||||
'method_name' => $methodName, // 검사방식 한글 이름 추가
|
||||
'measurement_type' => $item->measurement_type,
|
||||
'frequency' => $item->frequency,
|
||||
'frequency_n' => $item->frequency_n,
|
||||
'frequency_c' => $item->frequency_c,
|
||||
'regulation' => $item->regulation,
|
||||
'sort_order' => $item->sort_order,
|
||||
];
|
||||
})->toArray(),
|
||||
])->toArray(),
|
||||
'columns' => $template->columns->map(fn ($col) => [
|
||||
'id' => $col->id,
|
||||
@@ -707,9 +731,11 @@ private function formatDocumentForReact(Document $document): array
|
||||
'description' => $a->description,
|
||||
'file' => $a->file ? [
|
||||
'id' => $a->file->id,
|
||||
'original_name' => $a->file->original_name,
|
||||
'original_name' => $a->file->original_name ?? $a->file->display_name ?? $a->file->stored_name,
|
||||
'display_name' => $a->file->display_name,
|
||||
'file_path' => $a->file->file_path,
|
||||
'file_size' => $a->file->file_size,
|
||||
'mime_type' => $a->file->mime_type,
|
||||
] : null,
|
||||
])->toArray(),
|
||||
'approvals' => $document->approvals->map(fn ($ap) => [
|
||||
|
||||
@@ -1029,23 +1029,34 @@ private function getItemsWithInspectionTemplate(array $itemIds): array
|
||||
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
// document_templates에서 category='incoming_inspection'이고
|
||||
// linked_item_ids JSON 배열에 품목 ID가 포함된 템플릿 조회
|
||||
// DocumentService::resolve()와 동일한 category 매칭 조건
|
||||
$categoryCode = 'incoming_inspection';
|
||||
$categoryName = '수입검사';
|
||||
|
||||
$templates = \DB::table('document_templates')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('category', 'incoming_inspection')
|
||||
->where('is_active', true)
|
||||
->whereNotNull('linked_item_ids')
|
||||
->where(function ($q) use ($categoryCode, $categoryName) {
|
||||
$q->where('category', $categoryCode)
|
||||
->orWhere('category', $categoryName)
|
||||
->orWhere('category', 'LIKE', "%{$categoryName}%");
|
||||
})
|
||||
->get(['linked_item_ids']);
|
||||
|
||||
$linkedItemIds = [];
|
||||
foreach ($templates as $template) {
|
||||
$ids = json_decode($template->linked_item_ids, true) ?? [];
|
||||
$linkedItemIds = array_merge($linkedItemIds, $ids);
|
||||
// int/string 타입 모두 매칭되도록 정수로 통일
|
||||
foreach ($ids as $id) {
|
||||
$linkedItemIds[] = (int) $id;
|
||||
}
|
||||
}
|
||||
|
||||
// 요청된 품목 ID와 연결된 품목 ID의 교집합
|
||||
return array_values(array_intersect($itemIds, array_unique($linkedItemIds)));
|
||||
// 요청된 품목 ID도 정수로 통일하여 교집합
|
||||
$intItemIds = array_map('intval', $itemIds);
|
||||
|
||||
return array_values(array_intersect($intItemIds, array_unique($linkedItemIds)));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -199,6 +199,7 @@ public static function getUserInfoForLogin(int $userId): array
|
||||
'name' => $user->name,
|
||||
'email' => $user->email,
|
||||
'phone' => $user->phone,
|
||||
'department' => null,
|
||||
];
|
||||
|
||||
// 2. 활성 테넌트 조회 (1순위: is_default=1, 2순위: is_active=1 첫 번째)
|
||||
@@ -221,6 +222,18 @@ public static function getUserInfoForLogin(int $userId): array
|
||||
$defaultUserTenant = $userTenants->first();
|
||||
$tenant = $defaultUserTenant->tenant;
|
||||
|
||||
// 2-1. 소속 부서 조회 (tenant_user_profiles → departments)
|
||||
$profile = DB::table('tenant_user_profiles')
|
||||
->where('user_id', $userId)
|
||||
->where('tenant_id', $tenant->id)
|
||||
->first();
|
||||
if ($profile && $profile->department_id) {
|
||||
$dept = DB::table('departments')->where('id', $profile->department_id)->first();
|
||||
if ($dept) {
|
||||
$userInfo['department'] = $dept->name;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 테넌트 정보 구성
|
||||
$tenantInfo = [
|
||||
'id' => $tenant->id,
|
||||
|
||||
@@ -753,9 +753,9 @@ public function createProductionOrder(int $orderId, array $data)
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
// 수주 조회
|
||||
// 수주 + 노드 조회
|
||||
$order = Order::where('tenant_id', $tenantId)
|
||||
->with('items')
|
||||
->with(['items', 'rootNodes'])
|
||||
->find($orderId);
|
||||
|
||||
if (! $order) {
|
||||
@@ -776,16 +776,31 @@ public function createProductionOrder(int $orderId, array $data)
|
||||
throw new BadRequestHttpException(__('error.order.production_order_already_exists'));
|
||||
}
|
||||
|
||||
// order_items의 item_id를 기반으로 공정별 자동 분류
|
||||
$itemIds = $order->items->pluck('item_id')->filter()->unique()->values()->toArray();
|
||||
// order_nodes의 BOM 결과를 기반으로 공정별 자동 분류
|
||||
$bomItemIds = [];
|
||||
$nodesBomMap = []; // node_id => [item_name => bom_item]
|
||||
|
||||
foreach ($order->rootNodes as $node) {
|
||||
$bomResult = $node->options['bom_result'] ?? [];
|
||||
$bomItems = $bomResult['items'] ?? [];
|
||||
|
||||
foreach ($bomItems as $bomItem) {
|
||||
if (! empty($bomItem['item_id'])) {
|
||||
$bomItemIds[] = $bomItem['item_id'];
|
||||
$nodesBomMap[$node->id][$bomItem['item_name']] = $bomItem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$bomItemIds = array_unique($bomItemIds);
|
||||
|
||||
// process_items 테이블에서 item_id → process_id 매핑 조회
|
||||
$itemProcessMap = [];
|
||||
if (! empty($itemIds)) {
|
||||
if (! empty($bomItemIds)) {
|
||||
$processItems = DB::table('process_items as pi')
|
||||
->join('processes as p', 'pi.process_id', '=', 'p.id')
|
||||
->where('p.tenant_id', $tenantId)
|
||||
->whereIn('pi.item_id', $itemIds)
|
||||
->whereIn('pi.item_id', $bomItemIds)
|
||||
->where('pi.is_active', true)
|
||||
->select('pi.item_id', 'pi.process_id')
|
||||
->get();
|
||||
@@ -795,11 +810,25 @@ public function createProductionOrder(int $orderId, array $data)
|
||||
}
|
||||
}
|
||||
|
||||
// order_items를 공정별로 그룹화
|
||||
// order_items를 공정별로 그룹화 (BOM item_id → process 매핑 활용)
|
||||
$itemsByProcess = [];
|
||||
foreach ($order->items as $orderItem) {
|
||||
$processId = $itemProcessMap[$orderItem->item_id] ?? null;
|
||||
$key = $processId ?? 'none'; // null은 'none' 키로 그룹화
|
||||
$processId = null;
|
||||
|
||||
// 1. order_item의 item_id가 있으면 직접 매핑
|
||||
if ($orderItem->item_id && isset($itemProcessMap[$orderItem->item_id])) {
|
||||
$processId = $itemProcessMap[$orderItem->item_id];
|
||||
}
|
||||
// 2. item_id가 없으면 노드의 BOM에서 item_name으로 찾기
|
||||
elseif ($orderItem->order_node_id && isset($nodesBomMap[$orderItem->order_node_id])) {
|
||||
$nodeBom = $nodesBomMap[$orderItem->order_node_id];
|
||||
$bomItem = $nodeBom[$orderItem->item_name] ?? null;
|
||||
if ($bomItem && ! empty($bomItem['item_id']) && isset($itemProcessMap[$bomItem['item_id']])) {
|
||||
$processId = $itemProcessMap[$bomItem['item_id']];
|
||||
}
|
||||
}
|
||||
|
||||
$key = $processId ?? 'none';
|
||||
|
||||
if (! isset($itemsByProcess[$key])) {
|
||||
$itemsByProcess[$key] = [
|
||||
@@ -810,7 +839,7 @@ public function createProductionOrder(int $orderId, array $data)
|
||||
$itemsByProcess[$key]['items'][] = $orderItem;
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($order, $data, $tenantId, $userId, $itemsByProcess) {
|
||||
return DB::transaction(function () use ($order, $data, $tenantId, $userId, $itemsByProcess, $nodesBomMap) {
|
||||
$workOrders = [];
|
||||
|
||||
foreach ($itemsByProcess as $key => $group) {
|
||||
@@ -840,11 +869,18 @@ public function createProductionOrder(int $orderId, array $data)
|
||||
// work_order_items에 아이템 추가
|
||||
$sortOrder = 1;
|
||||
foreach ($items as $orderItem) {
|
||||
// item_id 결정: order_item에 있으면 사용, 없으면 BOM에서 가져오기
|
||||
$itemId = $orderItem->item_id;
|
||||
if (! $itemId && $orderItem->order_node_id && isset($nodesBomMap[$orderItem->order_node_id])) {
|
||||
$bomItem = $nodesBomMap[$orderItem->order_node_id][$orderItem->item_name] ?? null;
|
||||
$itemId = $bomItem['item_id'] ?? null;
|
||||
}
|
||||
|
||||
DB::table('work_order_items')->insert([
|
||||
'tenant_id' => $tenantId,
|
||||
'work_order_id' => $workOrder->id,
|
||||
'source_order_item_id' => $orderItem->id,
|
||||
'item_id' => $orderItem->item_id,
|
||||
'item_id' => $itemId,
|
||||
'item_name' => $orderItem->item_name,
|
||||
'specification' => $orderItem->specification,
|
||||
'quantity' => $orderItem->quantity,
|
||||
|
||||
@@ -526,6 +526,26 @@ public function finalize(int $id): Quote
|
||||
throw new BadRequestHttpException(__('error.quote_not_finalizable'));
|
||||
}
|
||||
|
||||
// 확정 시 필수 필드 검증 (업체명, 현장명, 담당자, 연락처)
|
||||
$missing = [];
|
||||
if (empty($quote->client_name)) {
|
||||
$missing[] = '업체명';
|
||||
}
|
||||
if (empty($quote->site_name)) {
|
||||
$missing[] = '현장명';
|
||||
}
|
||||
if (empty($quote->manager)) {
|
||||
$missing[] = '담당자';
|
||||
}
|
||||
if (empty($quote->contact)) {
|
||||
$missing[] = '연락처';
|
||||
}
|
||||
if (! empty($missing)) {
|
||||
throw new BadRequestHttpException(
|
||||
__('error.quote_finalize_missing_fields', ['fields' => implode(', ', $missing)])
|
||||
);
|
||||
}
|
||||
|
||||
$quote->update([
|
||||
'status' => Quote::STATUS_FINALIZED,
|
||||
'is_final' => true,
|
||||
|
||||
@@ -16,7 +16,7 @@ public function index(array $params): LengthAwarePaginator
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$query = Receiving::query()
|
||||
->with('creator:id,name')
|
||||
->with(['creator:id,name', 'item:id,item_type,code,name'])
|
||||
->where('tenant_id', $tenantId);
|
||||
|
||||
// 검색어 필터
|
||||
@@ -57,8 +57,67 @@ public function index(array $params): LengthAwarePaginator
|
||||
|
||||
// 페이지네이션
|
||||
$perPage = $params['per_page'] ?? 20;
|
||||
$paginator = $query->paginate($perPage);
|
||||
|
||||
return $query->paginate($perPage);
|
||||
// 수입검사 템플릿 연결 여부 계산
|
||||
$itemIds = $paginator->pluck('item_id')->filter()->unique()->values()->toArray();
|
||||
$itemsWithInspection = $this->getItemsWithInspectionTemplate($itemIds);
|
||||
|
||||
// has_inspection_template 필드 추가
|
||||
$paginator->getCollection()->transform(function ($receiving) use ($itemsWithInspection) {
|
||||
$receiving->has_inspection_template = $receiving->item_id
|
||||
? in_array($receiving->item_id, $itemsWithInspection)
|
||||
: false;
|
||||
|
||||
return $receiving;
|
||||
});
|
||||
|
||||
return $paginator;
|
||||
}
|
||||
|
||||
/**
|
||||
* 수입검사 템플릿에 연결된 품목 ID 조회
|
||||
*
|
||||
* DocumentService::resolve()와 동일한 조건 사용:
|
||||
* - category: 영문 코드('incoming_inspection'), 한글('수입검사'), 부분 매칭 모두 지원
|
||||
* - linked_item_ids: int/string 타입 모두 매칭
|
||||
*/
|
||||
private function getItemsWithInspectionTemplate(array $itemIds): array
|
||||
{
|
||||
if (empty($itemIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
// DocumentService::resolve()와 동일한 category 매칭 조건
|
||||
$categoryCode = 'incoming_inspection';
|
||||
$categoryName = '수입검사';
|
||||
|
||||
$templates = DB::table('document_templates')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('is_active', true)
|
||||
->whereNotNull('linked_item_ids')
|
||||
->where(function ($q) use ($categoryCode, $categoryName) {
|
||||
$q->where('category', $categoryCode)
|
||||
->orWhere('category', $categoryName)
|
||||
->orWhere('category', 'LIKE', "%{$categoryName}%");
|
||||
})
|
||||
->get(['linked_item_ids']);
|
||||
|
||||
$linkedItemIds = [];
|
||||
foreach ($templates as $template) {
|
||||
$ids = json_decode($template->linked_item_ids, true) ?? [];
|
||||
// int/string 타입 모두 매칭되도록 정수로 통일
|
||||
foreach ($ids as $id) {
|
||||
$linkedItemIds[] = (int) $id;
|
||||
}
|
||||
}
|
||||
|
||||
// 요청된 품목 ID도 정수로 통일하여 교집합
|
||||
$intItemIds = array_map('intval', $itemIds);
|
||||
|
||||
return array_values(array_intersect($intItemIds, array_unique($linkedItemIds)));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -103,7 +162,7 @@ public function show(int $id): Receiving
|
||||
|
||||
return Receiving::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->with(['creator:id,name'])
|
||||
->with(['creator:id,name', 'item:id,item_type,code,name'])
|
||||
->findOrFail($id);
|
||||
}
|
||||
|
||||
@@ -119,12 +178,18 @@ public function store(array $data): Receiving
|
||||
// 입고번호 자동 생성
|
||||
$receivingNumber = $this->generateReceivingNumber($tenantId);
|
||||
|
||||
// item_id 조회 (전달되지 않은 경우 item_code로 조회)
|
||||
$itemId = $data['item_id'] ?? null;
|
||||
if (! $itemId && ! empty($data['item_code'])) {
|
||||
$itemId = $this->findItemIdByCode($tenantId, $data['item_code']);
|
||||
}
|
||||
|
||||
$receiving = new Receiving;
|
||||
$receiving->tenant_id = $tenantId;
|
||||
$receiving->receiving_number = $receivingNumber;
|
||||
$receiving->order_no = $data['order_no'] ?? null;
|
||||
$receiving->order_date = $data['order_date'] ?? null;
|
||||
$receiving->item_id = $data['item_id'] ?? null;
|
||||
$receiving->item_id = $itemId;
|
||||
$receiving->item_code = $data['item_code'];
|
||||
$receiving->item_name = $data['item_name'];
|
||||
$receiving->specification = $data['specification'] ?? null;
|
||||
@@ -134,6 +199,10 @@ public function store(array $data): Receiving
|
||||
$receiving->due_date = $data['due_date'] ?? null;
|
||||
$receiving->status = $data['status'] ?? 'order_completed';
|
||||
$receiving->remark = $data['remark'] ?? null;
|
||||
|
||||
// options 필드 처리 (제조사, 수입검사 등 확장 필드)
|
||||
$receiving->options = $this->buildOptions($data);
|
||||
|
||||
$receiving->created_by = $userId;
|
||||
$receiving->updated_by = $userId;
|
||||
$receiving->save();
|
||||
@@ -167,6 +236,13 @@ public function update(int $id, array $data): Receiving
|
||||
}
|
||||
if (isset($data['item_code'])) {
|
||||
$receiving->item_code = $data['item_code'];
|
||||
// item_code 변경 시 item_id도 업데이트
|
||||
if (! isset($data['item_id'])) {
|
||||
$receiving->item_id = $this->findItemIdByCode($tenantId, $data['item_code']);
|
||||
}
|
||||
}
|
||||
if (isset($data['item_id'])) {
|
||||
$receiving->item_id = $data['item_id'];
|
||||
}
|
||||
if (isset($data['item_name'])) {
|
||||
$receiving->item_name = $data['item_name'];
|
||||
@@ -190,10 +266,13 @@ public function update(int $id, array $data): Receiving
|
||||
$receiving->remark = $data['remark'];
|
||||
}
|
||||
|
||||
// 입고완료(completed) 상태로 변경 시 입고처리 로직 실행
|
||||
$isCompletingReceiving = isset($data['status'])
|
||||
&& $data['status'] === 'completed'
|
||||
&& $receiving->status !== 'completed';
|
||||
// 상태 변경 감지
|
||||
$oldStatus = $receiving->status;
|
||||
$newStatus = $data['status'] ?? $oldStatus;
|
||||
$wasCompleted = $oldStatus === 'completed';
|
||||
|
||||
// 입고완료(completed) 상태로 신규 전환
|
||||
$isCompletingReceiving = $newStatus === 'completed' && ! $wasCompleted;
|
||||
|
||||
if ($isCompletingReceiving) {
|
||||
// 입고수량 설정 (없으면 발주수량 사용)
|
||||
@@ -201,16 +280,44 @@ public function update(int $id, array $data): Receiving
|
||||
$receiving->receiving_date = $data['receiving_date'] ?? now()->toDateString();
|
||||
$receiving->lot_no = $data['lot_no'] ?? $this->generateLotNo();
|
||||
$receiving->status = 'completed';
|
||||
} elseif (isset($data['status'])) {
|
||||
$receiving->status = $data['status'];
|
||||
} else {
|
||||
// 일반 필드 업데이트
|
||||
if (isset($data['receiving_qty'])) {
|
||||
$receiving->receiving_qty = $data['receiving_qty'];
|
||||
}
|
||||
if (isset($data['receiving_date'])) {
|
||||
$receiving->receiving_date = $data['receiving_date'];
|
||||
}
|
||||
if (isset($data['lot_no'])) {
|
||||
$receiving->lot_no = $data['lot_no'];
|
||||
}
|
||||
if (isset($data['status'])) {
|
||||
$receiving->status = $data['status'];
|
||||
}
|
||||
}
|
||||
|
||||
// options 필드 업데이트 (제조사, 수입검사 등 확장 필드)
|
||||
$receiving->options = $this->mergeOptions($receiving->options, $data);
|
||||
|
||||
$receiving->updated_by = $userId;
|
||||
$receiving->save();
|
||||
|
||||
// 입고완료 시 재고 연동
|
||||
if ($isCompletingReceiving && $receiving->item_id) {
|
||||
app(StockService::class)->increaseFromReceiving($receiving);
|
||||
// 재고 연동
|
||||
if ($receiving->item_id) {
|
||||
$stockService = app(StockService::class);
|
||||
|
||||
if ($isCompletingReceiving) {
|
||||
// 대기 → 완료: 전량 재고 증가
|
||||
$stockService->increaseFromReceiving($receiving);
|
||||
} elseif ($wasCompleted) {
|
||||
// 기존 완료 상태에서 수정: 차이만큼 조정
|
||||
// 완료→완료(수량변경): newQty = 변경된 수량
|
||||
// 완료→대기: newQty = 0 (전량 차감)
|
||||
$newQty = $newStatus === 'completed'
|
||||
? (float) $receiving->receiving_qty
|
||||
: 0;
|
||||
$stockService->adjustFromReceiving($receiving, $newQty);
|
||||
}
|
||||
}
|
||||
|
||||
return $receiving->fresh();
|
||||
@@ -318,4 +425,110 @@ private function generateLotNo(): string
|
||||
|
||||
return "{$year}{$month}{$day}-{$seq}";
|
||||
}
|
||||
|
||||
/**
|
||||
* 품목코드로 품목 ID 조회
|
||||
*/
|
||||
private function findItemIdByCode(int $tenantId, string $itemCode): ?int
|
||||
{
|
||||
$item = DB::table('items')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('code', $itemCode)
|
||||
->whereNull('deleted_at')
|
||||
->first(['id']);
|
||||
|
||||
return $item?->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* options 필드 빌드 (등록 시)
|
||||
*/
|
||||
private function buildOptions(array $data): ?array
|
||||
{
|
||||
$options = [];
|
||||
|
||||
// 제조사
|
||||
if (isset($data['manufacturer'])) {
|
||||
$options[Receiving::OPTION_MANUFACTURER] = $data['manufacturer'];
|
||||
}
|
||||
|
||||
// 거래처 자재번호
|
||||
if (isset($data['material_no'])) {
|
||||
$options[Receiving::OPTION_MATERIAL_NO] = $data['material_no'];
|
||||
}
|
||||
|
||||
// 수입검사 상태 (적/부적/-)
|
||||
if (isset($data['inspection_status'])) {
|
||||
$options[Receiving::OPTION_INSPECTION_STATUS] = $data['inspection_status'];
|
||||
}
|
||||
|
||||
// 검사일
|
||||
if (isset($data['inspection_date'])) {
|
||||
$options[Receiving::OPTION_INSPECTION_DATE] = $data['inspection_date'];
|
||||
}
|
||||
|
||||
// 검사결과 (합격/불합격)
|
||||
if (isset($data['inspection_result'])) {
|
||||
$options[Receiving::OPTION_INSPECTION_RESULT] = $data['inspection_result'];
|
||||
}
|
||||
|
||||
// 추가 확장 필드가 있으면 여기에 계속 추가 가능
|
||||
|
||||
return ! empty($options) ? $options : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* options 필드 병합 (수정 시)
|
||||
*/
|
||||
private function mergeOptions(?array $existing, array $data): ?array
|
||||
{
|
||||
$options = $existing ?? [];
|
||||
|
||||
// 제조사
|
||||
if (array_key_exists('manufacturer', $data)) {
|
||||
if ($data['manufacturer'] === null || $data['manufacturer'] === '') {
|
||||
unset($options[Receiving::OPTION_MANUFACTURER]);
|
||||
} else {
|
||||
$options[Receiving::OPTION_MANUFACTURER] = $data['manufacturer'];
|
||||
}
|
||||
}
|
||||
|
||||
// 거래처 자재번호
|
||||
if (array_key_exists('material_no', $data)) {
|
||||
if ($data['material_no'] === null || $data['material_no'] === '') {
|
||||
unset($options[Receiving::OPTION_MATERIAL_NO]);
|
||||
} else {
|
||||
$options[Receiving::OPTION_MATERIAL_NO] = $data['material_no'];
|
||||
}
|
||||
}
|
||||
|
||||
// 수입검사 상태
|
||||
if (array_key_exists('inspection_status', $data)) {
|
||||
if ($data['inspection_status'] === null || $data['inspection_status'] === '') {
|
||||
unset($options[Receiving::OPTION_INSPECTION_STATUS]);
|
||||
} else {
|
||||
$options[Receiving::OPTION_INSPECTION_STATUS] = $data['inspection_status'];
|
||||
}
|
||||
}
|
||||
|
||||
// 검사일
|
||||
if (array_key_exists('inspection_date', $data)) {
|
||||
if ($data['inspection_date'] === null || $data['inspection_date'] === '') {
|
||||
unset($options[Receiving::OPTION_INSPECTION_DATE]);
|
||||
} else {
|
||||
$options[Receiving::OPTION_INSPECTION_DATE] = $data['inspection_date'];
|
||||
}
|
||||
}
|
||||
|
||||
// 검사결과
|
||||
if (array_key_exists('inspection_result', $data)) {
|
||||
if ($data['inspection_result'] === null || $data['inspection_result'] === '') {
|
||||
unset($options[Receiving::OPTION_INSPECTION_RESULT]);
|
||||
} else {
|
||||
$options[Receiving::OPTION_INSPECTION_RESULT] = $data['inspection_result'];
|
||||
}
|
||||
}
|
||||
|
||||
return ! empty($options) ? $options : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,4 +38,16 @@ protected function apiUserId(): int
|
||||
|
||||
return (int) $uid;
|
||||
}
|
||||
|
||||
/**
|
||||
* 서비스 컨텍스트 설정 (다른 서비스에서 호출 시 사용)
|
||||
* tenant_id, api_user를 명시적으로 설정
|
||||
*/
|
||||
public function setContext(int $tenantId, int $userId): self
|
||||
{
|
||||
app()->instance('tenant_id', $tenantId);
|
||||
app()->instance('api_user', $userId);
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -313,6 +313,107 @@ public function increaseFromReceiving(Receiving $receiving): StockLot
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 입고 수정 시 재고 조정 (차이만큼 증감)
|
||||
*
|
||||
* - completed→completed 수량변경: 차이만큼 조정 (50→60 = +10)
|
||||
* - completed→대기: 전량 차감 (newQty = 0)
|
||||
*
|
||||
* @param Receiving $receiving 입고 레코드
|
||||
* @param float $newQty 새 수량 (상태가 completed가 아니면 0)
|
||||
*/
|
||||
public function adjustFromReceiving(Receiving $receiving, float $newQty): void
|
||||
{
|
||||
if (! $receiving->item_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
DB::transaction(function () use ($receiving, $newQty, $tenantId, $userId) {
|
||||
// 1. 해당 입고로 생성된 StockLot 조회
|
||||
$stockLot = StockLot::where('tenant_id', $tenantId)
|
||||
->where('receiving_id', $receiving->id)
|
||||
->first();
|
||||
|
||||
if (! $stockLot) {
|
||||
Log::warning('StockLot not found for receiving adjustment', [
|
||||
'receiving_id' => $receiving->id,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$stock = Stock::where('id', $stockLot->stock_id)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if (! $stock) {
|
||||
return;
|
||||
}
|
||||
|
||||
$oldQty = (float) $stockLot->qty;
|
||||
$diff = $newQty - $oldQty;
|
||||
|
||||
// 차이가 없으면 스킵
|
||||
if (abs($diff) < 0.001) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. StockLot 수량 조정
|
||||
$stockLot->qty = $newQty;
|
||||
$stockLot->available_qty = max(0, $newQty - $stockLot->reserved_qty);
|
||||
$stockLot->updated_by = $userId;
|
||||
|
||||
if ($newQty <= 0) {
|
||||
$stockLot->qty = 0;
|
||||
$stockLot->available_qty = 0;
|
||||
$stockLot->reserved_qty = 0;
|
||||
$stockLot->status = 'used';
|
||||
} else {
|
||||
$stockLot->status = 'available';
|
||||
}
|
||||
|
||||
$stockLot->save();
|
||||
|
||||
// 3. Stock 정보 갱신
|
||||
$stock->refreshFromLots();
|
||||
|
||||
// 4. 거래 이력 기록
|
||||
$this->recordTransaction(
|
||||
stock: $stock,
|
||||
type: $diff > 0 ? StockTransaction::TYPE_IN : StockTransaction::TYPE_OUT,
|
||||
qty: $diff,
|
||||
reason: StockTransaction::REASON_RECEIVING,
|
||||
referenceType: 'receiving',
|
||||
referenceId: $receiving->id,
|
||||
lotNo: $receiving->lot_no,
|
||||
stockLotId: $stockLot->id
|
||||
);
|
||||
|
||||
// 5. 감사 로그 기록
|
||||
$this->logStockChange(
|
||||
stock: $stock,
|
||||
action: $diff > 0 ? 'stock_increase' : 'stock_decrease',
|
||||
reason: 'receiving_adjustment',
|
||||
referenceType: 'receiving',
|
||||
referenceId: $receiving->id,
|
||||
qtyChange: $diff,
|
||||
lotNo: $receiving->lot_no
|
||||
);
|
||||
|
||||
Log::info('Stock adjusted from receiving modification', [
|
||||
'receiving_id' => $receiving->id,
|
||||
'item_id' => $receiving->item_id,
|
||||
'stock_id' => $stock->id,
|
||||
'old_qty' => $oldQty,
|
||||
'new_qty' => $newQty,
|
||||
'diff' => $diff,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stock 조회 또는 생성
|
||||
*
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
use App\Models\Production\WorkOrderAssignee;
|
||||
use App\Models\Production\WorkOrderBendingDetail;
|
||||
use App\Models\Production\WorkOrderItem;
|
||||
use App\Models\Production\WorkOrderStepProgress;
|
||||
use App\Models\Tenants\Shipment;
|
||||
use App\Models\Tenants\ShipmentItem;
|
||||
use App\Services\Audit\AuditLogger;
|
||||
@@ -37,6 +38,7 @@ public function index(array $params)
|
||||
$processCode = $params['process_code'] ?? null;
|
||||
$assigneeId = $params['assignee_id'] ?? null;
|
||||
$assignedToMe = isset($params['assigned_to_me']) && $params['assigned_to_me'];
|
||||
$workerScreen = isset($params['worker_screen']) && $params['worker_screen'];
|
||||
$teamId = $params['team_id'] ?? null;
|
||||
$scheduledFrom = $params['scheduled_from'] ?? null;
|
||||
$scheduledTo = $params['scheduled_to'] ?? null;
|
||||
@@ -47,10 +49,12 @@ public function index(array $params)
|
||||
'assignee:id,name',
|
||||
'assignees.user:id,name',
|
||||
'team:id,name',
|
||||
'salesOrder:id,order_no,client_id,client_name',
|
||||
'salesOrder' => fn ($q) => $q->select('id', 'order_no', 'client_id', 'client_name', 'site_name', 'quantity', 'received_at', 'delivery_date')->withCount('rootNodes'),
|
||||
'salesOrder.client:id,name',
|
||||
'process:id,process_name,process_code,department',
|
||||
'items:id,work_order_id,item_name,quantity',
|
||||
'items:id,work_order_id,item_id,item_name,specification,quantity,unit,status,options,sort_order,source_order_item_id',
|
||||
'items.sourceOrderItem:id,order_node_id',
|
||||
'items.sourceOrderItem.node:id,name,code',
|
||||
]);
|
||||
|
||||
// 검색어
|
||||
@@ -96,6 +100,35 @@ public function index(array $params)
|
||||
});
|
||||
}
|
||||
|
||||
// 작업자 화면용 캐스케이드 필터: 개인 배정 → 부서 → 전체
|
||||
if ($workerScreen) {
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
// 1차: 개인 배정된 작업이 있는지 확인
|
||||
$hasPersonal = (clone $query)->where(function ($q) use ($userId) {
|
||||
$q->where('assignee_id', $userId)
|
||||
->orWhereHas('assignees', fn ($aq) => $aq->where('user_id', $userId));
|
||||
})->exists();
|
||||
|
||||
if ($hasPersonal) {
|
||||
$query->where(function ($q) use ($userId) {
|
||||
$q->where('assignee_id', $userId)
|
||||
->orWhereHas('assignees', fn ($aq) => $aq->where('user_id', $userId));
|
||||
});
|
||||
} else {
|
||||
// 2차: 사용자 소속 부서의 작업지시 필터
|
||||
$departmentIds = DB::table('department_user')
|
||||
->where('user_id', $userId)
|
||||
->where('tenant_id', $tenantId)
|
||||
->pluck('department_id');
|
||||
|
||||
if ($departmentIds->isNotEmpty()) {
|
||||
$query->whereIn('team_id', $departmentIds);
|
||||
}
|
||||
// 3차: 부서도 없으면 필터 없이 전체 노출
|
||||
}
|
||||
}
|
||||
|
||||
// 팀 필터
|
||||
if ($teamId !== null) {
|
||||
$query->where('team_id', $teamId);
|
||||
@@ -127,14 +160,34 @@ public function stats(): array
|
||||
->pluck('count', 'status')
|
||||
->toArray();
|
||||
|
||||
// 공정별 카운트 (탭 숫자 표시용)
|
||||
$byProcess = WorkOrder::where('tenant_id', $tenantId)
|
||||
->select('process_id', DB::raw('count(*) as count'))
|
||||
->groupBy('process_id')
|
||||
->pluck('count', 'process_id')
|
||||
->toArray();
|
||||
|
||||
$total = array_sum($counts);
|
||||
$noneCount = $byProcess[''] ?? $byProcess[0] ?? 0;
|
||||
// null 키는 빈 문자열로 변환되므로 별도 처리
|
||||
$processedByProcess = [];
|
||||
foreach ($byProcess as $key => $count) {
|
||||
if ($key === '' || $key === 0 || $key === null) {
|
||||
$processedByProcess['none'] = $count;
|
||||
} else {
|
||||
$processedByProcess[(string) $key] = $count;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'total' => array_sum($counts),
|
||||
'total' => $total,
|
||||
'unassigned' => $counts[WorkOrder::STATUS_UNASSIGNED] ?? 0,
|
||||
'pending' => $counts[WorkOrder::STATUS_PENDING] ?? 0,
|
||||
'waiting' => $counts[WorkOrder::STATUS_WAITING] ?? 0,
|
||||
'in_progress' => $counts[WorkOrder::STATUS_IN_PROGRESS] ?? 0,
|
||||
'completed' => $counts[WorkOrder::STATUS_COMPLETED] ?? 0,
|
||||
'shipped' => $counts[WorkOrder::STATUS_SHIPPED] ?? 0,
|
||||
'by_process' => $processedByProcess,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -150,13 +203,16 @@ public function show(int $id)
|
||||
'assignee:id,name',
|
||||
'assignees.user:id,name',
|
||||
'team:id,name',
|
||||
'salesOrder:id,order_no,site_name,client_id,client_contact,received_at,writer_id,created_at,quantity',
|
||||
'salesOrder' => fn ($q) => $q->select('id', 'order_no', 'site_name', 'client_id', 'client_contact', 'received_at', 'writer_id', 'created_at', 'quantity')->withCount('rootNodes'),
|
||||
'salesOrder.client:id,name',
|
||||
'salesOrder.writer:id,name',
|
||||
'process:id,process_name,process_code,work_steps,department',
|
||||
'items',
|
||||
'process.steps' => fn ($q) => $q->where('is_active', true)->orderBy('sort_order'),
|
||||
'items.sourceOrderItem:id,order_node_id',
|
||||
'items.sourceOrderItem.node:id,name,code',
|
||||
'bendingDetail',
|
||||
'issues' => fn ($q) => $q->orderByDesc('created_at'),
|
||||
'stepProgress.processStep:id,process_id,step_code,step_name,sort_order,needs_inspection,connection_type,completion_type',
|
||||
])
|
||||
->find($id);
|
||||
|
||||
@@ -1291,4 +1347,146 @@ private function syncWorkOrderStatusFromItems(WorkOrder $workOrder): bool
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// 공정 단계 진행 관리
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 작업지시의 공정 단계 진행 현황 조회
|
||||
*
|
||||
* process_steps 마스터 기준으로 진행 레코드를 자동 생성(없으면)하고 반환
|
||||
*/
|
||||
public function getStepProgress(int $workOrderId): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$workOrder = WorkOrder::where('tenant_id', $tenantId)
|
||||
->with(['process.steps' => fn ($q) => $q->where('is_active', true)->orderBy('sort_order')])
|
||||
->find($workOrderId);
|
||||
|
||||
if (! $workOrder) {
|
||||
throw new NotFoundHttpException(__('error.not_found'));
|
||||
}
|
||||
|
||||
$processSteps = $workOrder->process?->steps ?? collect();
|
||||
if ($processSteps->isEmpty()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 기존 진행 레코드 조회
|
||||
$existingProgress = WorkOrderStepProgress::where('work_order_id', $workOrderId)
|
||||
->whereNull('work_order_item_id')
|
||||
->get()
|
||||
->keyBy('process_step_id');
|
||||
|
||||
// 없는 단계는 자동 생성
|
||||
$result = [];
|
||||
foreach ($processSteps as $step) {
|
||||
if ($existingProgress->has($step->id)) {
|
||||
$progress = $existingProgress->get($step->id);
|
||||
} else {
|
||||
$progress = WorkOrderStepProgress::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'work_order_id' => $workOrderId,
|
||||
'process_step_id' => $step->id,
|
||||
'work_order_item_id' => null,
|
||||
'status' => WorkOrderStepProgress::STATUS_WAITING,
|
||||
]);
|
||||
}
|
||||
|
||||
$result[] = [
|
||||
'id' => $progress->id,
|
||||
'process_step_id' => $step->id,
|
||||
'step_code' => $step->step_code,
|
||||
'step_name' => $step->step_name,
|
||||
'sort_order' => $step->sort_order,
|
||||
'needs_inspection' => $step->needs_inspection,
|
||||
'connection_type' => $step->connection_type,
|
||||
'completion_type' => $step->completion_type,
|
||||
'status' => $progress->status,
|
||||
'is_completed' => $progress->isCompleted(),
|
||||
'completed_at' => $progress->completed_at?->toDateTimeString(),
|
||||
'completed_by' => $progress->completed_by,
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 공정 단계 완료 토글
|
||||
*/
|
||||
public function toggleStepProgress(int $workOrderId, int $progressId): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
$workOrder = WorkOrder::where('tenant_id', $tenantId)->find($workOrderId);
|
||||
if (! $workOrder) {
|
||||
throw new NotFoundHttpException(__('error.not_found'));
|
||||
}
|
||||
|
||||
$progress = WorkOrderStepProgress::where('id', $progressId)
|
||||
->where('work_order_id', $workOrderId)
|
||||
->first();
|
||||
|
||||
if (! $progress) {
|
||||
throw new NotFoundHttpException(__('error.not_found'));
|
||||
}
|
||||
|
||||
$before = ['status' => $progress->status];
|
||||
$progress->toggle($userId);
|
||||
$after = ['status' => $progress->status];
|
||||
|
||||
$this->auditLogger->log(
|
||||
$tenantId,
|
||||
self::AUDIT_TARGET,
|
||||
$workOrderId,
|
||||
'step_progress_toggled',
|
||||
$before,
|
||||
$after
|
||||
);
|
||||
|
||||
return [
|
||||
'id' => $progress->id,
|
||||
'status' => $progress->status,
|
||||
'is_completed' => $progress->isCompleted(),
|
||||
'completed_at' => $progress->completed_at?->toDateTimeString(),
|
||||
'completed_by' => $progress->completed_by,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 자재 투입 이력 조회
|
||||
*/
|
||||
public function getMaterialInputHistory(int $workOrderId): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$workOrder = WorkOrder::where('tenant_id', $tenantId)->find($workOrderId);
|
||||
if (! $workOrder) {
|
||||
throw new NotFoundHttpException(__('error.not_found'));
|
||||
}
|
||||
|
||||
// audit_logs에서 material_input 액션 이력 조회
|
||||
$logs = DB::table('audit_logs')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('target_type', self::AUDIT_TARGET)
|
||||
->where('target_id', $workOrderId)
|
||||
->where('action', 'material_input')
|
||||
->orderByDesc('created_at')
|
||||
->get();
|
||||
|
||||
return $logs->map(function ($log) {
|
||||
$after = json_decode($log->after_data ?? '{}', true);
|
||||
|
||||
return [
|
||||
'id' => $log->id,
|
||||
'materials' => $after['materials'] ?? [],
|
||||
'created_at' => $log->created_at,
|
||||
'actor_id' => $log->actor_id,
|
||||
];
|
||||
})->toArray();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user