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:
2026-03-07 02:59:30 +09:00
parent 95371fd841
commit 4208ca3010
27 changed files with 799 additions and 15 deletions

View File

@@ -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'));
}
}

View File

@@ -29,6 +29,8 @@ public function index(Request $request): JsonResponse
'sort_dir',
'per_page',
'page',
'start_date',
'end_date',
]);
$stocks = $this->service->index($params);

View File

@@ -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'));
}

View 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);
}
}
}

View File

@@ -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',

View File

@@ -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',

View File

@@ -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',
];
}
}

View File

@@ -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');
}
/**
* 거래처 관계
*/

View 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;
}
}

View File

@@ -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,
];
}
/**
* 범용 일정 조회 (본사 공통 + 테넌트 일정)
*/

View File

@@ -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);

View File

@@ -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 목록)
*/

View File

@@ -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 [

View File

@@ -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';

View File

@@ -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();
// 결과 조회

View 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');
}
}

View File

@@ -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');
});
}
};

View File

@@ -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}`");
}
}
};

View File

@@ -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');
});
}
};

View File

@@ -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');
}
};

View File

@@ -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');
});
}
};

View File

@@ -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
{

View File

@@ -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' => '전표에서 사용 중인 계정과목은 삭제할 수 없습니다.',
],
];

View File

@@ -567,6 +567,22 @@
'downloaded' => '문서가 다운로드되었습니다.',
],
// 일반전표입력
'journal_entry' => [
'fetched' => '전표 조회 성공',
'created' => '전표가 등록되었습니다.',
'updated' => '분개가 수정되었습니다.',
'deleted' => '분개가 삭제되었습니다.',
],
// 계정과목
'account_subject' => [
'fetched' => '계정과목 조회 성공',
'created' => '계정과목이 등록되었습니다.',
'toggled' => '계정과목 상태가 변경되었습니다.',
'deleted' => '계정과목이 삭제되었습니다.',
],
// CEO 대시보드 부가세 현황
'vat' => [
'sales_tax' => '매출세액',

View File

@@ -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');

View File

@@ -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');
});

View File

@@ -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');
});