feat: [HR/기타] 캘린더/배차/설비/재고 + DB 마이그레이션
- 캘린더 CRUD API, 배차차량 관리 API (CRUD + options) - 배차정보 다중 행 시스템 (shipment_vehicle_dispatches) - 설비 다중점검주기 + 부 담당자 스키마 추가 - TodayIssue 날짜 기반 조회, Stock/Client 날짜 필터 - i18n 메시지 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ public function index(Request $request): JsonResponse
|
||||
'sort_dir',
|
||||
'per_page',
|
||||
'page',
|
||||
'start_date',
|
||||
'end_date',
|
||||
]);
|
||||
|
||||
$stocks = $this->service->index($params);
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
|
||||
|
||||
74
app/Http/Controllers/Api/V1/VehicleDispatchController.php
Normal file
74
app/Http/Controllers/Api/V1/VehicleDispatchController.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\VehicleDispatch\VehicleDispatchUpdateRequest;
|
||||
use App\Services\VehicleDispatchService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class VehicleDispatchController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly VehicleDispatchService $service
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 배차차량 목록 조회
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$params = $request->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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\VehicleDispatch;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class VehicleDispatchUpdateRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'freight_cost_type' => '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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
* 거래처 관계
|
||||
*/
|
||||
|
||||
52
app/Models/Tenants/ShipmentVehicleDispatch.php
Normal file
52
app/Models/Tenants/ShipmentVehicleDispatch.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Tenants;
|
||||
|
||||
use App\Traits\Auditable;
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class ShipmentVehicleDispatch extends Model
|
||||
{
|
||||
use Auditable, BelongsToTenant, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'shipment_id',
|
||||
'seq',
|
||||
'logistics_company',
|
||||
'arrival_datetime',
|
||||
'tonnage',
|
||||
'vehicle_no',
|
||||
'driver_contact',
|
||||
'remarks',
|
||||
'options',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'seq' => '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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 범용 일정 조회 (본사 공통 + 테넌트 일정)
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 목록)
|
||||
*/
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
|
||||
// 결과 조회
|
||||
|
||||
140
app/Services/VehicleDispatchService.php
Normal file
140
app/Services/VehicleDispatchService.php
Normal file
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Tenants\ShipmentVehicleDispatch;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
|
||||
class VehicleDispatchService extends Service
|
||||
{
|
||||
/**
|
||||
* 배차차량 목록 조회
|
||||
*/
|
||||
public function index(array $params): LengthAwarePaginator
|
||||
{
|
||||
$tenantId = $this->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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('equipments', function (Blueprint $table) {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// 컬럼 추가 (이미 존재하면 건너뜀)
|
||||
if (! Schema::hasColumn('equipment_inspection_templates', 'inspection_cycle')) {
|
||||
Schema::table('equipment_inspection_templates', function (Blueprint $table) {
|
||||
$table->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}`");
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('equipment_inspections', function (Blueprint $table) {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('shipment_vehicle_dispatches', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('shipment_vehicle_dispatches', function (Blueprint $table) {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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' => '전표에서 사용 중인 계정과목은 삭제할 수 없습니다.',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -567,6 +567,22 @@
|
||||
'downloaded' => '문서가 다운로드되었습니다.',
|
||||
],
|
||||
|
||||
// 일반전표입력
|
||||
'journal_entry' => [
|
||||
'fetched' => '전표 조회 성공',
|
||||
'created' => '전표가 등록되었습니다.',
|
||||
'updated' => '분개가 수정되었습니다.',
|
||||
'deleted' => '분개가 삭제되었습니다.',
|
||||
],
|
||||
|
||||
// 계정과목
|
||||
'account_subject' => [
|
||||
'fetched' => '계정과목 조회 성공',
|
||||
'created' => '계정과목이 등록되었습니다.',
|
||||
'toggled' => '계정과목 상태가 변경되었습니다.',
|
||||
'deleted' => '계정과목이 삭제되었습니다.',
|
||||
],
|
||||
|
||||
// CEO 대시보드 부가세 현황
|
||||
'vat' => [
|
||||
'sales_tax' => '매출세액',
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user