diff --git a/app/Http/Controllers/Api/V1/CalendarController.php b/app/Http/Controllers/Api/V1/CalendarController.php index e7bd95e..fb4dfaa 100644 --- a/app/Http/Controllers/Api/V1/CalendarController.php +++ b/app/Http/Controllers/Api/V1/CalendarController.php @@ -51,4 +51,56 @@ public function summary(Request $request) ); }, __('message.fetched')); } + + /** + * 일정 등록 + */ + public function store(Request $request) + { + $validated = $request->validate([ + 'title' => 'required|string|max:200', + 'description' => 'nullable|string|max:1000', + 'start_date' => 'required|date_format:Y-m-d', + 'end_date' => 'required|date_format:Y-m-d|after_or_equal:start_date', + 'start_time' => 'nullable|date_format:H:i', + 'end_time' => 'nullable|date_format:H:i', + 'is_all_day' => 'boolean', + 'color' => 'nullable|string|max:20', + ]); + + return ApiResponse::handle(function () use ($validated) { + return $this->calendarService->createSchedule($validated); + }, __('message.created')); + } + + /** + * 일정 수정 + */ + public function update(Request $request, int $id) + { + $validated = $request->validate([ + 'title' => 'required|string|max:200', + 'description' => 'nullable|string|max:1000', + 'start_date' => 'required|date_format:Y-m-d', + 'end_date' => 'required|date_format:Y-m-d|after_or_equal:start_date', + 'start_time' => 'nullable|date_format:H:i', + 'end_time' => 'nullable|date_format:H:i', + 'is_all_day' => 'boolean', + 'color' => 'nullable|string|max:20', + ]); + + return ApiResponse::handle(function () use ($id, $validated) { + return $this->calendarService->updateSchedule($id, $validated); + }, __('message.updated')); + } + + /** + * 일정 삭제 + */ + public function destroy(int $id) + { + return ApiResponse::handle(function () use ($id) { + return $this->calendarService->deleteSchedule($id); + }, __('message.deleted')); + } } diff --git a/app/Http/Controllers/Api/V1/StockController.php b/app/Http/Controllers/Api/V1/StockController.php index fe9e03e..d2aebc8 100644 --- a/app/Http/Controllers/Api/V1/StockController.php +++ b/app/Http/Controllers/Api/V1/StockController.php @@ -29,6 +29,8 @@ public function index(Request $request): JsonResponse 'sort_dir', 'per_page', 'page', + 'start_date', + 'end_date', ]); $stocks = $this->service->index($params); diff --git a/app/Http/Controllers/Api/V1/TodayIssueController.php b/app/Http/Controllers/Api/V1/TodayIssueController.php index e25eff5..c510953 100644 --- a/app/Http/Controllers/Api/V1/TodayIssueController.php +++ b/app/Http/Controllers/Api/V1/TodayIssueController.php @@ -20,9 +20,10 @@ public function __construct( public function summary(Request $request): JsonResponse { $limit = (int) $request->input('limit', 30); + $date = $request->input('date'); // YYYY-MM-DD (이전 이슈 조회용) - return ApiResponse::handle(function () use ($limit) { - return $this->todayIssueService->summary($limit); + return ApiResponse::handle(function () use ($limit, $date) { + return $this->todayIssueService->summary($limit, null, $date); }, __('message.fetched')); } diff --git a/app/Http/Controllers/Api/V1/VehicleDispatchController.php b/app/Http/Controllers/Api/V1/VehicleDispatchController.php new file mode 100644 index 0000000..01e1337 --- /dev/null +++ b/app/Http/Controllers/Api/V1/VehicleDispatchController.php @@ -0,0 +1,74 @@ +only([ + 'search', + 'status', + 'start_date', + 'end_date', + 'per_page', + 'page', + ]); + + $dispatches = $this->service->index($params); + + return ApiResponse::success($dispatches, __('message.fetched')); + } + + /** + * 배차차량 통계 조회 + */ + public function stats(): JsonResponse + { + $stats = $this->service->stats(); + + return ApiResponse::success($stats, __('message.fetched')); + } + + /** + * 배차차량 상세 조회 + */ + public function show(int $id): JsonResponse + { + try { + $dispatch = $this->service->show($id); + + return ApiResponse::success($dispatch, __('message.fetched')); + } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { + return ApiResponse::error(__('error.not_found'), 404); + } + } + + /** + * 배차차량 수정 + */ + public function update(VehicleDispatchUpdateRequest $request, int $id): JsonResponse + { + try { + $dispatch = $this->service->update($id, $request->validated()); + + return ApiResponse::success($dispatch, __('message.updated')); + } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { + return ApiResponse::error(__('error.not_found'), 404); + } + } +} diff --git a/app/Http/Requests/Shipment/ShipmentStoreRequest.php b/app/Http/Requests/Shipment/ShipmentStoreRequest.php index a00fbc7..c384e41 100644 --- a/app/Http/Requests/Shipment/ShipmentStoreRequest.php +++ b/app/Http/Requests/Shipment/ShipmentStoreRequest.php @@ -21,7 +21,7 @@ public function rules(): array 'scheduled_date' => 'required|date', 'status' => 'nullable|in:scheduled,ready,shipping,completed', 'priority' => 'nullable|in:urgent,normal,low', - 'delivery_method' => 'nullable|in:pickup,direct,logistics', + 'delivery_method' => 'nullable|in:pickup,direct,logistics,direct_dispatch,loading,kyungdong_delivery,daesin_delivery,kyungdong_freight,daesin_freight,self_pickup', // 발주처/배송 정보 'client_id' => 'nullable|integer|exists:clients,id', @@ -55,6 +55,16 @@ public function rules(): array // 기타 'remarks' => 'nullable|string', + // 배차정보 + 'vehicle_dispatches' => 'nullable|array', + 'vehicle_dispatches.*.seq' => 'nullable|integer|min:1', + 'vehicle_dispatches.*.logistics_company' => 'nullable|string|max:100', + 'vehicle_dispatches.*.arrival_datetime' => 'nullable|date', + 'vehicle_dispatches.*.tonnage' => 'nullable|string|max:20', + 'vehicle_dispatches.*.vehicle_no' => 'nullable|string|max:20', + 'vehicle_dispatches.*.driver_contact' => 'nullable|string|max:50', + 'vehicle_dispatches.*.remarks' => 'nullable|string', + // 출하 품목 'items' => 'nullable|array', 'items.*.seq' => 'nullable|integer|min:1', diff --git a/app/Http/Requests/Shipment/ShipmentUpdateRequest.php b/app/Http/Requests/Shipment/ShipmentUpdateRequest.php index f86c4a2..43b4b14 100644 --- a/app/Http/Requests/Shipment/ShipmentUpdateRequest.php +++ b/app/Http/Requests/Shipment/ShipmentUpdateRequest.php @@ -19,7 +19,7 @@ public function rules(): array 'order_id' => 'nullable|integer|exists:orders,id', 'scheduled_date' => 'nullable|date', 'priority' => 'nullable|in:urgent,normal,low', - 'delivery_method' => 'nullable|in:pickup,direct,logistics', + 'delivery_method' => 'nullable|in:pickup,direct,logistics,direct_dispatch,loading,kyungdong_delivery,daesin_delivery,kyungdong_freight,daesin_freight,self_pickup', // 발주처/배송 정보 'client_id' => 'nullable|integer|exists:clients,id', @@ -53,6 +53,16 @@ public function rules(): array // 기타 'remarks' => 'nullable|string', + // 배차정보 + 'vehicle_dispatches' => 'nullable|array', + 'vehicle_dispatches.*.seq' => 'nullable|integer|min:1', + 'vehicle_dispatches.*.logistics_company' => 'nullable|string|max:100', + 'vehicle_dispatches.*.arrival_datetime' => 'nullable|date', + 'vehicle_dispatches.*.tonnage' => 'nullable|string|max:20', + 'vehicle_dispatches.*.vehicle_no' => 'nullable|string|max:20', + 'vehicle_dispatches.*.driver_contact' => 'nullable|string|max:50', + 'vehicle_dispatches.*.remarks' => 'nullable|string', + // 출하 품목 'items' => 'nullable|array', 'items.*.seq' => 'nullable|integer|min:1', diff --git a/app/Http/Requests/VehicleDispatch/VehicleDispatchUpdateRequest.php b/app/Http/Requests/VehicleDispatch/VehicleDispatchUpdateRequest.php new file mode 100644 index 0000000..9dba8f9 --- /dev/null +++ b/app/Http/Requests/VehicleDispatch/VehicleDispatchUpdateRequest.php @@ -0,0 +1,30 @@ + 'nullable|in:prepaid,collect', + 'logistics_company' => 'nullable|string|max:100', + 'arrival_datetime' => 'nullable|date', + 'tonnage' => 'nullable|string|max:20', + 'vehicle_no' => 'nullable|string|max:20', + 'driver_contact' => 'nullable|string|max:50', + 'remarks' => 'nullable|string', + 'supply_amount' => 'nullable|numeric|min:0', + 'vat' => 'nullable|numeric|min:0', + 'total_amount' => 'nullable|numeric|min:0', + 'status' => 'nullable|in:draft,completed', + ]; + } +} diff --git a/app/Models/Tenants/Shipment.php b/app/Models/Tenants/Shipment.php index a02db8c..df96ff5 100644 --- a/app/Models/Tenants/Shipment.php +++ b/app/Models/Tenants/Shipment.php @@ -134,6 +134,14 @@ public function items(): HasMany return $this->hasMany(ShipmentItem::class)->orderBy('seq'); } + /** + * 배차정보 관계 + */ + public function vehicleDispatches(): HasMany + { + return $this->hasMany(ShipmentVehicleDispatch::class)->orderBy('seq'); + } + /** * 거래처 관계 */ diff --git a/app/Models/Tenants/ShipmentVehicleDispatch.php b/app/Models/Tenants/ShipmentVehicleDispatch.php new file mode 100644 index 0000000..50eb385 --- /dev/null +++ b/app/Models/Tenants/ShipmentVehicleDispatch.php @@ -0,0 +1,52 @@ + 'integer', + 'shipment_id' => 'integer', + 'arrival_datetime' => 'datetime', + 'options' => 'array', + ]; + + /** + * 출하 관계 + */ + public function shipment(): BelongsTo + { + return $this->belongsTo(Shipment::class); + } + + /** + * 다음 순번 가져오기 + */ + public static function getNextSeq(int $shipmentId): int + { + $maxSeq = static::where('shipment_id', $shipmentId)->max('seq'); + + return ($maxSeq ?? 0) + 1; + } +} diff --git a/app/Services/CalendarService.php b/app/Services/CalendarService.php index 61d4150..bdd87ae 100644 --- a/app/Services/CalendarService.php +++ b/app/Services/CalendarService.php @@ -226,6 +226,78 @@ private function getLeaveSchedules( }); } + /** + * 일정 등록 + */ + public function createSchedule(array $data): array + { + $schedule = Schedule::create([ + 'tenant_id' => $this->tenantId(), + 'title' => $data['title'], + 'description' => $data['description'] ?? null, + 'start_date' => $data['start_date'], + 'end_date' => $data['end_date'], + 'start_time' => $data['start_time'] ?? null, + 'end_time' => $data['end_time'] ?? null, + 'is_all_day' => $data['is_all_day'] ?? true, + 'type' => Schedule::TYPE_EVENT, + 'color' => $data['color'] ?? null, + 'is_active' => true, + 'created_by' => $this->apiUserId(), + ]); + + return [ + 'id' => $schedule->id, + 'title' => $schedule->title, + 'start_date' => $schedule->start_date?->format('Y-m-d'), + 'end_date' => $schedule->end_date?->format('Y-m-d'), + ]; + } + + /** + * 일정 수정 + */ + public function updateSchedule(int $id, array $data): array + { + $schedule = Schedule::where('tenant_id', $this->tenantId()) + ->findOrFail($id); + + $schedule->update([ + 'title' => $data['title'], + 'description' => $data['description'] ?? null, + 'start_date' => $data['start_date'], + 'end_date' => $data['end_date'], + 'start_time' => $data['start_time'] ?? null, + 'end_time' => $data['end_time'] ?? null, + 'is_all_day' => $data['is_all_day'] ?? true, + 'color' => $data['color'] ?? null, + 'updated_by' => $this->apiUserId(), + ]); + + return [ + 'id' => $schedule->id, + 'title' => $schedule->title, + 'start_date' => $schedule->start_date?->format('Y-m-d'), + 'end_date' => $schedule->end_date?->format('Y-m-d'), + ]; + } + + /** + * 일정 삭제 (소프트 삭제) + */ + public function deleteSchedule(int $id): array + { + $schedule = Schedule::where('tenant_id', $this->tenantId()) + ->findOrFail($id); + + $schedule->update(['deleted_by' => $this->apiUserId()]); + $schedule->delete(); + + return [ + 'id' => $schedule->id, + ]; + } + /** * 범용 일정 조회 (본사 공통 + 테넌트 일정) */ diff --git a/app/Services/ClientService.php b/app/Services/ClientService.php index b3af238..197ee8a 100644 --- a/app/Services/ClientService.php +++ b/app/Services/ClientService.php @@ -22,6 +22,8 @@ public function index(array $params) $q = trim((string) ($params['q'] ?? '')); $onlyActive = $params['only_active'] ?? null; $clientType = $params['client_type'] ?? null; + $startDate = $params['start_date'] ?? null; + $endDate = $params['end_date'] ?? null; $query = Client::query()->where('tenant_id', $tenantId); @@ -43,6 +45,14 @@ public function index(array $params) $query->whereIn('client_type', $types); } + // 등록일 기간 필터 + if ($startDate) { + $query->whereDate('created_at', '>=', $startDate); + } + if ($endDate) { + $query->whereDate('created_at', '<=', $endDate); + } + $query->orderBy('client_code')->orderBy('id'); $paginator = $query->paginate($size, ['*'], 'page', $page); diff --git a/app/Services/ShipmentService.php b/app/Services/ShipmentService.php index 109b45d..df8b35a 100644 --- a/app/Services/ShipmentService.php +++ b/app/Services/ShipmentService.php @@ -5,6 +5,7 @@ use App\Models\Orders\Order; use App\Models\Tenants\Shipment; use App\Models\Tenants\ShipmentItem; +use App\Models\Tenants\ShipmentVehicleDispatch; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\DB; @@ -19,7 +20,7 @@ public function index(array $params): LengthAwarePaginator $query = Shipment::query() ->where('tenant_id', $tenantId) - ->with(['items', 'order.client', 'order.writer', 'workOrder']); + ->with(['items', 'vehicleDispatches', 'order.client', 'order.writer', 'workOrder']); // 검색어 필터 if (! empty($params['search'])) { @@ -164,6 +165,7 @@ public function show(int $id): Shipment 'items' => function ($query) { $query->orderBy('seq'); }, + 'vehicleDispatches', 'order.client', 'order.writer', 'workOrder', @@ -228,7 +230,12 @@ public function store(array $data): Shipment $this->syncItems($shipment, $data['items'], $tenantId); } - return $shipment->load('items'); + // 배차정보 추가 + if (! empty($data['vehicle_dispatches'])) { + $this->syncDispatches($shipment, $data['vehicle_dispatches'], $tenantId); + } + + return $shipment->load(['items', 'vehicleDispatches']); }); } @@ -283,7 +290,12 @@ public function update(int $id, array $data): Shipment $this->syncItems($shipment, $data['items'], $tenantId); } - return $shipment->load('items'); + // 배차정보 동기화 + if (isset($data['vehicle_dispatches'])) { + $this->syncDispatches($shipment, $data['vehicle_dispatches'], $tenantId); + } + + return $shipment->load(['items', 'vehicleDispatches']); }); } @@ -340,7 +352,7 @@ public function updateStatus(int $id, string $status, ?array $additionalData = n // 연결된 수주(Order) 상태 동기화 $this->syncOrderStatus($shipment, $tenantId); - return $shipment->load('items'); + return $shipment->load(['items', 'vehicleDispatches']); } /** @@ -439,6 +451,9 @@ public function delete(int $id): bool // 품목 삭제 $shipment->items()->delete(); + // 배차정보 삭제 + $shipment->vehicleDispatches()->delete(); + // 출하 삭제 $shipment->update(['deleted_by' => $userId]); $shipment->delete(); @@ -477,6 +492,33 @@ protected function syncItems(Shipment $shipment, array $items, int $tenantId): v } } + /** + * 배차정보 동기화 + */ + protected function syncDispatches(Shipment $shipment, array $dispatches, int $tenantId): void + { + // 기존 배차정보 삭제 + $shipment->vehicleDispatches()->forceDelete(); + + // 새 배차정보 생성 + $seq = 1; + foreach ($dispatches as $dispatch) { + ShipmentVehicleDispatch::create([ + 'tenant_id' => $tenantId, + 'shipment_id' => $shipment->id, + 'seq' => $dispatch['seq'] ?? $seq, + 'logistics_company' => $dispatch['logistics_company'] ?? null, + 'arrival_datetime' => $dispatch['arrival_datetime'] ?? null, + 'tonnage' => $dispatch['tonnage'] ?? null, + 'vehicle_no' => $dispatch['vehicle_no'] ?? null, + 'driver_contact' => $dispatch['driver_contact'] ?? null, + 'remarks' => $dispatch['remarks'] ?? null, + 'options' => $dispatch['options'] ?? null, + ]); + $seq++; + } + } + /** * LOT 옵션 조회 (출고 가능한 LOT 목록) */ diff --git a/app/Services/StatusBoardService.php b/app/Services/StatusBoardService.php index a924e34..b38dca5 100644 --- a/app/Services/StatusBoardService.php +++ b/app/Services/StatusBoardService.php @@ -70,6 +70,7 @@ private function getBadDebtStatus(int $tenantId): array $count = BadDebt::query() ->where('tenant_id', $tenantId) ->where('status', BadDebt::STATUS_COLLECTING) // 추심 진행 중 + ->where('is_active', true) // 활성 채권만 (목록 페이지와 일치) ->count(); return [ diff --git a/app/Services/StockService.php b/app/Services/StockService.php index d980a30..9cd1475 100644 --- a/app/Services/StockService.php +++ b/app/Services/StockService.php @@ -88,6 +88,20 @@ public function index(array $params): LengthAwarePaginator }); } + // 날짜 범위 필터 (해당 기간에 입출고 이력이 있는 품목만) + if (! empty($params['start_date']) || ! empty($params['end_date'])) { + $query->whereHas('stock', function ($stockQuery) use ($params) { + $stockQuery->whereHas('transactions', function ($txQuery) use ($params) { + if (! empty($params['start_date'])) { + $txQuery->whereDate('created_at', '>=', $params['start_date']); + } + if (! empty($params['end_date'])) { + $txQuery->whereDate('created_at', '<=', $params['end_date']); + } + }); + }); + } + // 정렬 $sortBy = $params['sort_by'] ?? 'code'; $sortDir = $params['sort_dir'] ?? 'asc'; diff --git a/app/Services/TodayIssueService.php b/app/Services/TodayIssueService.php index 6a801fd..93f5139 100644 --- a/app/Services/TodayIssueService.php +++ b/app/Services/TodayIssueService.php @@ -17,30 +17,42 @@ class TodayIssueService extends Service * * @param int $limit 조회할 최대 항목 수 (기본 30) * @param string|null $badge 뱃지 필터 (null이면 전체) + * @param string|null $date 조회 날짜 (YYYY-MM-DD, null이면 오늘) */ - public function summary(int $limit = 30, ?string $badge = null): array + public function summary(int $limit = 30, ?string $badge = null, ?string $date = null): array { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); + // date 파라미터가 있으면 해당 날짜, 없으면 오늘 + $targetDate = $date ? Carbon::parse($date) : today(); + $query = TodayIssue::query() ->where('tenant_id', $tenantId) ->forUser($userId) // 본인 대상 또는 전체 브로드캐스트 - ->active() // 만료되지 않은 이슈만 - ->today() // 오늘 날짜 이슈만 + ->whereDate('created_at', $targetDate) ->orderByDesc('created_at'); + // 이전 이슈 조회 시에는 만료 필터 무시 (과거 데이터도 조회 가능) + if (! $date) { + $query->active(); // 오늘 이슈만 만료 필터 적용 + } + // 뱃지 필터 if ($badge !== null && $badge !== 'all') { $query->byBadge($badge); } - // 전체 개수 (필터 적용 전, 오늘 날짜만) + // 전체 개수 (필터 적용 전) $totalQuery = TodayIssue::query() ->where('tenant_id', $tenantId) ->forUser($userId) - ->active() - ->today(); + ->whereDate('created_at', $targetDate); + + if (! $date) { + $totalQuery->active(); + } + $totalCount = $totalQuery->count(); // 결과 조회 diff --git a/app/Services/VehicleDispatchService.php b/app/Services/VehicleDispatchService.php new file mode 100644 index 0000000..cfae5fb --- /dev/null +++ b/app/Services/VehicleDispatchService.php @@ -0,0 +1,140 @@ +tenantId(); + + $query = ShipmentVehicleDispatch::query() + ->where('tenant_id', $tenantId) + ->with('shipment'); + + // 검색어 필터 + if (! empty($params['search'])) { + $search = $params['search']; + $query->where(function ($q) use ($search) { + $q->where('vehicle_no', 'like', "%{$search}%") + ->orWhere('options->dispatch_no', 'like', "%{$search}%") + ->orWhereHas('shipment', function ($q3) use ($search) { + $q3->where('lot_no', 'like', "%{$search}%") + ->orWhere('site_name', 'like', "%{$search}%") + ->orWhere('customer_name', 'like', "%{$search}%"); + }); + }); + } + + // 상태 필터 (options JSON) + if (! empty($params['status'])) { + $query->where('options->status', $params['status']); + } + + // 날짜 범위 필터 + if (! empty($params['start_date'])) { + $query->where('arrival_datetime', '>=', $params['start_date']); + } + if (! empty($params['end_date'])) { + $query->where('arrival_datetime', '<=', $params['end_date'].' 23:59:59'); + } + + // 정렬 + $query->orderBy('id', 'desc'); + + // 페이지네이션 + $perPage = $params['per_page'] ?? 20; + + return $query->paginate($perPage); + } + + /** + * 배차차량 통계 + */ + public function stats(): array + { + $tenantId = $this->tenantId(); + + $all = ShipmentVehicleDispatch::query() + ->where('tenant_id', $tenantId) + ->get(); + + $prepaid = 0; + $collect = 0; + $total = 0; + + foreach ($all as $dispatch) { + $opts = $dispatch->options ?? []; + $amount = (float) ($opts['total_amount'] ?? 0); + $total += $amount; + + if (($opts['freight_cost_type'] ?? '') === 'prepaid') { + $prepaid += $amount; + } + if (($opts['freight_cost_type'] ?? '') === 'collect') { + $collect += $amount; + } + } + + return [ + 'prepaid_amount' => $prepaid, + 'collect_amount' => $collect, + 'total_amount' => $total, + ]; + } + + /** + * 배차차량 상세 조회 + */ + public function show(int $id): ShipmentVehicleDispatch + { + $tenantId = $this->tenantId(); + + return ShipmentVehicleDispatch::query() + ->where('tenant_id', $tenantId) + ->with('shipment') + ->findOrFail($id); + } + + /** + * 배차차량 수정 + */ + public function update(int $id, array $data): ShipmentVehicleDispatch + { + $tenantId = $this->tenantId(); + + $dispatch = ShipmentVehicleDispatch::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + // options에 저장할 필드 분리 + $optionFields = ['freight_cost_type', 'supply_amount', 'vat', 'total_amount', 'status']; + $directFields = ['logistics_company', 'arrival_datetime', 'tonnage', 'vehicle_no', 'driver_contact', 'remarks']; + + // 기존 options 유지하면서 업데이트 + $options = $dispatch->options ?? []; + foreach ($optionFields as $field) { + if (array_key_exists($field, $data)) { + $options[$field] = $data[$field]; + } + } + + // 직접 컬럼 업데이트 + $updateData = ['options' => $options]; + foreach ($directFields as $field) { + if (array_key_exists($field, $data)) { + $updateData[$field] = $data[$field]; + } + } + + $dispatch->update($updateData); + + return $dispatch->load('shipment'); + } +} diff --git a/database/migrations/2026_02_28_100000_add_sub_manager_to_equipments_table.php b/database/migrations/2026_02_28_100000_add_sub_manager_to_equipments_table.php new file mode 100644 index 0000000..c1c195f --- /dev/null +++ b/database/migrations/2026_02_28_100000_add_sub_manager_to_equipments_table.php @@ -0,0 +1,23 @@ +foreignId('sub_manager_id')->nullable()->after('manager_id') + ->comment('부 담당자 ID (users.id)'); + }); + } + + public function down(): void + { + Schema::table('equipments', function (Blueprint $table) { + $table->dropColumn('sub_manager_id'); + }); + } +}; diff --git a/database/migrations/2026_02_28_100100_add_inspection_cycle_to_templates_table.php b/database/migrations/2026_02_28_100100_add_inspection_cycle_to_templates_table.php new file mode 100644 index 0000000..5750d5f --- /dev/null +++ b/database/migrations/2026_02_28_100100_add_inspection_cycle_to_templates_table.php @@ -0,0 +1,67 @@ +string('inspection_cycle', 20)->default('daily')->after('equipment_id') + ->comment('점검주기: daily/weekly/monthly/bimonthly/quarterly/semiannual'); + }); + } + + // FK 삭제 → 유니크 변경 → FK 재생성 (개별 statement) + $this->dropFkIfExists('equipment_inspection_templates', 'equipment_inspection_templates_equipment_id_foreign'); + $this->dropUniqueIfExists('equipment_inspection_templates', 'uq_equipment_item_no'); + + Schema::table('equipment_inspection_templates', function (Blueprint $table) { + $table->unique(['equipment_id', 'inspection_cycle', 'item_no'], 'uq_equipment_cycle_item_no'); + $table->index('inspection_cycle', 'idx_insp_tmpl_cycle'); + + $table->foreign('equipment_id') + ->references('id') + ->on('equipments') + ->onDelete('cascade'); + }); + } + + public function down(): void + { + $this->dropFkIfExists('equipment_inspection_templates', 'equipment_inspection_templates_equipment_id_foreign'); + + Schema::table('equipment_inspection_templates', function (Blueprint $table) { + $table->dropIndex('idx_insp_tmpl_cycle'); + $table->dropUnique('uq_equipment_cycle_item_no'); + $table->unique(['equipment_id', 'item_no'], 'uq_equipment_item_no'); + $table->dropColumn('inspection_cycle'); + + $table->foreign('equipment_id') + ->references('id') + ->on('equipments') + ->onDelete('cascade'); + }); + } + + private function dropFkIfExists(string $table, string $fkName): void + { + $fks = DB::select("SELECT CONSTRAINT_NAME FROM information_schema.TABLE_CONSTRAINTS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND CONSTRAINT_TYPE = 'FOREIGN KEY' AND CONSTRAINT_NAME = ?", [$table, $fkName]); + if (count($fks) > 0) { + DB::statement("ALTER TABLE `{$table}` DROP FOREIGN KEY `{$fkName}`"); + } + } + + private function dropUniqueIfExists(string $table, string $indexName): void + { + $indexes = DB::select("SHOW INDEX FROM `{$table}` WHERE Key_name = ?", [$indexName]); + if (count($indexes) > 0) { + DB::statement("ALTER TABLE `{$table}` DROP INDEX `{$indexName}`"); + } + } +}; diff --git a/database/migrations/2026_02_28_100200_add_inspection_cycle_to_inspections_table.php b/database/migrations/2026_02_28_100200_add_inspection_cycle_to_inspections_table.php new file mode 100644 index 0000000..0de3fe6 --- /dev/null +++ b/database/migrations/2026_02_28_100200_add_inspection_cycle_to_inspections_table.php @@ -0,0 +1,43 @@ +string('inspection_cycle', 20)->default('daily')->after('equipment_id') + ->comment('점검주기: daily/weekly/monthly/bimonthly/quarterly/semiannual'); + }); + + // 기존 레코드를 daily로 설정 + DB::statement("UPDATE equipment_inspections SET inspection_cycle = 'daily' WHERE inspection_cycle = '' OR inspection_cycle IS NULL"); + + Schema::table('equipment_inspections', function (Blueprint $table) { + // 기존 유니크/인덱스 삭제 + $table->dropUnique('uq_inspection_month'); + $table->dropIndex('idx_inspection_ym'); + + // cycle 포함 유니크/인덱스 추가 + $table->unique(['tenant_id', 'equipment_id', 'inspection_cycle', 'year_month'], 'uq_inspection_cycle_period'); + $table->index(['tenant_id', 'inspection_cycle', 'year_month'], 'idx_inspection_cycle_period'); + }); + } + + public function down(): void + { + Schema::table('equipment_inspections', function (Blueprint $table) { + $table->dropIndex('idx_inspection_cycle_period'); + $table->dropUnique('uq_inspection_cycle_period'); + + $table->unique(['tenant_id', 'equipment_id', 'year_month'], 'uq_inspection_month'); + $table->index(['tenant_id', 'year_month'], 'idx_inspection_ym'); + + $table->dropColumn('inspection_cycle'); + }); + } +}; diff --git a/database/migrations/2026_03_04_100000_create_shipment_vehicle_dispatches_table.php b/database/migrations/2026_03_04_100000_create_shipment_vehicle_dispatches_table.php new file mode 100644 index 0000000..e1393b2 --- /dev/null +++ b/database/migrations/2026_03_04_100000_create_shipment_vehicle_dispatches_table.php @@ -0,0 +1,43 @@ +id(); + $table->foreignId('tenant_id')->comment('테넌트 ID'); + $table->foreignId('shipment_id')->comment('출하 ID'); + $table->integer('seq')->default(1)->comment('순번'); + $table->string('logistics_company', 100)->nullable()->comment('물류사'); + $table->datetime('arrival_datetime')->nullable()->comment('도착일시'); + $table->string('tonnage', 20)->nullable()->comment('톤수'); + $table->string('vehicle_no', 20)->nullable()->comment('차량번호'); + $table->string('driver_contact', 50)->nullable()->comment('운전자 연락처'); + $table->text('remarks')->nullable()->comment('비고'); + $table->timestamps(); + $table->softDeletes(); + + // 인덱스 + $table->index(['shipment_id', 'seq']); + + // 외래키 + $table->foreign('shipment_id')->references('id')->on('shipments')->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('shipment_vehicle_dispatches'); + } +}; diff --git a/database/migrations/2026_03_04_150000_add_options_to_shipment_vehicle_dispatches_table.php b/database/migrations/2026_03_04_150000_add_options_to_shipment_vehicle_dispatches_table.php new file mode 100644 index 0000000..0bd8b28 --- /dev/null +++ b/database/migrations/2026_03_04_150000_add_options_to_shipment_vehicle_dispatches_table.php @@ -0,0 +1,23 @@ +json('options')->nullable()->after('remarks') + ->comment('추가 속성 (dispatch_no, status, freight_cost_type, supply_amount, vat, total_amount, writer)'); + }); + } + + public function down(): void + { + Schema::table('shipment_vehicle_dispatches', function (Blueprint $table) { + $table->dropColumn('options'); + }); + } +}; diff --git a/database/seeders/Kyungdong/KyungdongItemSeeder.php b/database/seeders/Kyungdong/KyungdongItemSeeder.php index 3c0f707..ef9de1b 100644 --- a/database/seeders/Kyungdong/KyungdongItemSeeder.php +++ b/database/seeders/Kyungdong/KyungdongItemSeeder.php @@ -17,7 +17,7 @@ * Phase 3.1: chandj.price_motor → items (SM) + prices 누락 품목 * Phase 3.2: chandj.price_raw_materials → items (RM) + prices 누락 품목 * - * @see docs/plans/kd-items-migration-plan.md + * @see docs/dev_plans/kd-items-migration-plan.md */ class KyungdongItemSeeder extends Seeder { diff --git a/lang/ko/error.php b/lang/ko/error.php index 54e11df..7684cc9 100644 --- a/lang/ko/error.php +++ b/lang/ko/error.php @@ -444,6 +444,15 @@ 'already_completed' => '이미 완료된 검사입니다.', ], + // 품질관리서 관련 + 'quality' => [ + 'cannot_delete_completed' => '완료된 품질관리서는 삭제할 수 없습니다.', + 'already_completed' => '이미 완료된 품질관리서입니다.', + 'cannot_modify_completed' => '완료된 품질관리서는 수정할 수 없습니다.', + 'pending_locations' => '미완료 개소가 :count건 있습니다.', + 'confirm_failed' => '필수정보가 누락된 건이 있어 확정할 수 없습니다.', + ], + // 입찰 관련 'bidding' => [ 'not_found' => '입찰을 찾을 수 없습니다.', @@ -481,4 +490,15 @@ 'cannot_delete' => '해당 계약은 삭제할 수 없습니다.', 'invalid_status' => '유효하지 않은 계약 상태입니다.', ], + + // 일반전표입력 + 'journal_entry' => [ + 'debit_credit_mismatch' => '차변 합계와 대변 합계가 일치해야 합니다.', + ], + + // 계정과목 + 'account_subject' => [ + 'duplicate_code' => '이미 존재하는 계정과목 코드입니다.', + 'in_use' => '전표에서 사용 중인 계정과목은 삭제할 수 없습니다.', + ], ]; diff --git a/lang/ko/message.php b/lang/ko/message.php index 086008e..5f20784 100644 --- a/lang/ko/message.php +++ b/lang/ko/message.php @@ -567,6 +567,22 @@ 'downloaded' => '문서가 다운로드되었습니다.', ], + // 일반전표입력 + 'journal_entry' => [ + 'fetched' => '전표 조회 성공', + 'created' => '전표가 등록되었습니다.', + 'updated' => '분개가 수정되었습니다.', + 'deleted' => '분개가 삭제되었습니다.', + ], + + // 계정과목 + 'account_subject' => [ + 'fetched' => '계정과목 조회 성공', + 'created' => '계정과목이 등록되었습니다.', + 'toggled' => '계정과목 상태가 변경되었습니다.', + 'deleted' => '계정과목이 삭제되었습니다.', + ], + // CEO 대시보드 부가세 현황 'vat' => [ 'sales_tax' => '매출세액', diff --git a/routes/api.php b/routes/api.php index a700278..0d329de 100644 --- a/routes/api.php +++ b/routes/api.php @@ -41,6 +41,7 @@ require __DIR__.'/api/v1/app.php'; require __DIR__.'/api/v1/audit.php'; require __DIR__.'/api/v1/esign.php'; + require __DIR__.'/api/v1/quality.php'; // 공유 링크 다운로드 (인증 불필요 - auth.apikey 그룹 밖) Route::get('/files/share/{token}', [FileStorageController::class, 'downloadShared'])->name('v1.files.share.download'); diff --git a/routes/api/v1/common.php b/routes/api/v1/common.php index ede81c7..c37c1ef 100644 --- a/routes/api/v1/common.php +++ b/routes/api/v1/common.php @@ -18,6 +18,7 @@ use App\Http\Controllers\Api\V1\CategoryTemplateController; use App\Http\Controllers\Api\V1\ClassificationController; use App\Http\Controllers\Api\V1\CommonController; +use App\Http\Controllers\Api\V1\DashboardCeoController; use App\Http\Controllers\Api\V1\DashboardController; use App\Http\Controllers\Api\V1\MenuController; use App\Http\Controllers\Api\V1\NotificationSettingController; @@ -225,4 +226,12 @@ Route::get('/summary', [DashboardController::class, 'summary'])->name('v1.dashboard.summary'); Route::get('/charts', [DashboardController::class, 'charts'])->name('v1.dashboard.charts'); Route::get('/approvals', [DashboardController::class, 'approvals'])->name('v1.dashboard.approvals'); + + // CEO 대시보드 섹션별 API + Route::get('/sales/summary', [DashboardCeoController::class, 'salesSummary'])->name('v1.dashboard.ceo.sales'); + Route::get('/purchases/summary', [DashboardCeoController::class, 'purchasesSummary'])->name('v1.dashboard.ceo.purchases'); + Route::get('/production/summary', [DashboardCeoController::class, 'productionSummary'])->name('v1.dashboard.ceo.production'); + Route::get('/unshipped/summary', [DashboardCeoController::class, 'unshippedSummary'])->name('v1.dashboard.ceo.unshipped'); + Route::get('/construction/summary', [DashboardCeoController::class, 'constructionSummary'])->name('v1.dashboard.ceo.construction'); + Route::get('/attendance/summary', [DashboardCeoController::class, 'attendanceSummary'])->name('v1.dashboard.ceo.attendance'); }); diff --git a/routes/api/v1/inventory.php b/routes/api/v1/inventory.php index 2246754..73b66fd 100644 --- a/routes/api/v1/inventory.php +++ b/routes/api/v1/inventory.php @@ -19,6 +19,7 @@ use App\Http\Controllers\Api\V1\ReceivingController; use App\Http\Controllers\Api\V1\ShipmentController; use App\Http\Controllers\Api\V1\StockController; +use App\Http\Controllers\Api\V1\VehicleDispatchController; use Illuminate\Support\Facades\Route; // Items API (품목 관리) @@ -123,3 +124,11 @@ Route::patch('/{id}/status', [ShipmentController::class, 'updateStatus'])->whereNumber('id')->name('v1.shipments.status'); Route::delete('/{id}', [ShipmentController::class, 'destroy'])->whereNumber('id')->name('v1.shipments.destroy'); }); + +// Vehicle Dispatch API (배차차량 관리) +Route::prefix('vehicle-dispatches')->group(function () { + Route::get('', [VehicleDispatchController::class, 'index'])->name('v1.vehicle-dispatches.index'); + Route::get('/stats', [VehicleDispatchController::class, 'stats'])->name('v1.vehicle-dispatches.stats'); + Route::get('/{id}', [VehicleDispatchController::class, 'show'])->whereNumber('id')->name('v1.vehicle-dispatches.show'); + Route::put('/{id}', [VehicleDispatchController::class, 'update'])->whereNumber('id')->name('v1.vehicle-dispatches.update'); +});