From 7246ac003f638945232937dc0fd05a79af16535e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Fri, 16 Jan 2026 21:59:06 +0900 Subject: [PATCH] =?UTF-8?q?fix(WEB):=20=EC=88=98=EC=A3=BC=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=ED=95=84=EB=93=9C=20=EB=A7=A4=ED=95=91=20?= =?UTF-8?q?=EB=B0=8F=20=EC=A0=9C=ED=92=88-=EB=B6=80=ED=92=88=20=ED=8A=B8?= =?UTF-8?q?=EB=A6=AC=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ApiClient 인터페이스: representative → manager_name, contact_person 변경 - transformApiToFrontend: client.representative → client.manager_name 수정 - ApiOrderItem에 floor_code, symbol_code 필드 추가 (제품-부품 매핑) - ApiOrder에 options 타입 정의 추가 - ApiQuote에 calculation_inputs 타입 정의 추가 - 수주 상세 페이지 제품-부품 트리 구조 UI 개선 --- LOGICAL_RELATIONSHIPS.md | 15 +- .../V1/Construction/ContractController.php | 11 + .../Controllers/Api/V1/ItemsController.php | 2 + .../Controllers/Api/V1/QuoteController.php | 11 + .../Api/V1/WorkOrderController.php | 20 ++ app/Http/Requests/Admin/FcmHistoryRequest.php | 2 +- .../Requests/Admin/FcmTokenListRequest.php | 2 +- .../Requests/Approval/FormIndexRequest.php | 2 +- .../Requests/Approval/InboxIndexRequest.php | 2 +- app/Http/Requests/Approval/IndexRequest.php | 2 +- .../Requests/Approval/LineIndexRequest.php | 2 +- .../Approval/ReferenceIndexRequest.php | 2 +- app/Http/Requests/Attendance/IndexRequest.php | 2 +- .../Requests/Bidding/BiddingStoreRequest.php | 2 +- app/Http/Requests/Employee/IndexRequest.php | 2 +- app/Http/Requests/Labor/LaborIndexRequest.php | 2 +- app/Http/Requests/Leave/IndexRequest.php | 2 +- app/Http/Requests/Loan/LoanIndexRequest.php | 2 +- app/Http/Requests/PositionRequest.php | 2 +- .../Requests/Pricing/PriceIndexRequest.php | 2 +- app/Http/Requests/Quote/QuoteIndexRequest.php | 2 +- .../TaxInvoice/TaxInvoiceListRequest.php | 2 +- .../UserInvitation/ListInvitationRequest.php | 2 +- .../V1/AiReport/AiReportListRequest.php | 2 +- .../V1/Company/CompanyRequestIndexRequest.php | 2 +- .../V1/Payment/PaymentIndexRequest.php | 2 +- .../Requests/V1/Plan/PlanIndexRequest.php | 2 +- .../Subscription/SubscriptionIndexRequest.php | 2 +- app/Models/Construction/Contract.php | 9 + app/Models/Labor.php | 2 +- app/Models/Orders/Order.php | 55 +++ app/Models/Production/WorkOrder.php | 9 + app/Models/Production/WorkOrderItem.php | 2 + app/Models/Tenants/Shipment.php | 128 +++++++ app/Services/Construction/ContractService.php | 94 +++++ .../Construction/HandoverReportService.php | 106 ++++-- app/Services/Quote/QuoteService.php | 80 +++++ app/Services/ShipmentService.php | 56 ++- app/Services/WorkOrderService.php | 327 +++++++++++++++++- app/Services/WorkResultService.php | 6 +- app/Swagger/v1/ContractApi.php | 61 ++++ app/Swagger/v1/WorkOrderApi.php | 65 ++++ ...add_tenant_id_to_work_order_sub_tables.php | 86 ++--- ...0600_create_work_order_assignees_table.php | 4 + ...work_orders_process_type_to_process_id.php | 9 +- ...5_195530_alter_clients_table_collation.php | 2 +- ...0000_add_priority_to_work_orders_table.php | 2 +- ...rder_item_id_to_work_order_items_table.php | 2 +- ..._estimate_option_codes_to_common_codes.php | 2 +- ..._16_202809_add_options_to_orders_table.php | 1 - lang/ko/message.php | 3 + routes/api.php | 4 + 52 files changed, 1105 insertions(+), 115 deletions(-) diff --git a/LOGICAL_RELATIONSHIPS.md b/LOGICAL_RELATIONSHIPS.md index cfce5a1..f784a48 100644 --- a/LOGICAL_RELATIONSHIPS.md +++ b/LOGICAL_RELATIONSHIPS.md @@ -1,6 +1,6 @@ # 논리적 데이터베이스 관계 문서 -> **자동 생성**: 2026-01-16 20:48:14 +> **자동 생성**: 2026-01-19 20:29:00 > **소스**: Eloquent 모델 관계 분석 ## 📊 모델별 관계 현황 @@ -26,6 +26,15 @@ ### bad_debt_memos - **badDebt()**: belongsTo → `bad_debts` - **creator()**: belongsTo → `users` +### biddings +**모델**: `App\Models\Bidding\Bidding` + +- **quote()**: belongsTo → `quotes` +- **client()**: belongsTo → `clients` +- **bidder()**: belongsTo → `users` +- **creator()**: belongsTo → `users` +- **updater()**: belongsTo → `users` + ### boards **모델**: `App\Models\Boards\Board` @@ -344,6 +353,7 @@ ### orders - **histories()**: hasMany → `order_histories` - **versions()**: hasMany → `order_versions` - **workOrders()**: hasMany → `work_orders` +- **shipments()**: hasMany → `shipments` ### order_historys **모델**: `App\Models\Orders\OrderHistory` @@ -431,6 +441,7 @@ ### work_orders - **primaryAssignee()**: hasMany → `work_order_assignees` - **items()**: hasMany → `work_order_items` - **issues()**: hasMany → `work_order_issues` +- **shipments()**: hasMany → `shipments` - **bendingDetail()**: hasOne → `work_order_bending_details` ### work_order_assignees @@ -758,6 +769,8 @@ ### setting_field_defs ### shipments **모델**: `App\Models\Tenants\Shipment` +- **order()**: belongsTo → `orders` +- **workOrder()**: belongsTo → `work_orders` - **creator()**: belongsTo → `users` - **updater()**: belongsTo → `users` - **items()**: hasMany → `shipment_items` diff --git a/app/Http/Controllers/Api/V1/Construction/ContractController.php b/app/Http/Controllers/Api/V1/Construction/ContractController.php index eb0cf74..52276ab 100644 --- a/app/Http/Controllers/Api/V1/Construction/ContractController.php +++ b/app/Http/Controllers/Api/V1/Construction/ContractController.php @@ -4,6 +4,7 @@ use App\Helpers\ApiResponse; use App\Http\Controllers\Controller; +use App\Http\Requests\Construction\ContractFromBiddingRequest; use App\Http\Requests\Construction\ContractStoreRequest; use App\Http\Requests\Construction\ContractUpdateRequest; use App\Services\Construction\ContractService; @@ -96,4 +97,14 @@ public function stageCounts(Request $request) return $this->service->stageCounts($request->all()); }, __('message.contract.fetched')); } + + /** + * 입찰에서 계약 생성 (낙찰 → 계약 전환) + */ + public function storeFromBidding(ContractFromBiddingRequest $request, int $biddingId) + { + return ApiResponse::handle(function () use ($request, $biddingId) { + return $this->service->storeFromBidding($biddingId, $request->validated()); + }, __('message.contract.created')); + } } diff --git a/app/Http/Controllers/Api/V1/ItemsController.php b/app/Http/Controllers/Api/V1/ItemsController.php index 2e82d3d..b35cfd8 100644 --- a/app/Http/Controllers/Api/V1/ItemsController.php +++ b/app/Http/Controllers/Api/V1/ItemsController.php @@ -46,6 +46,7 @@ public function index(Request $request) public function show(Request $request, $id) { $id = (int) $id; + return ApiResponse::handle(function () use ($request, $id) { // item_type 선택적 (없으면 ID만으로 items 테이블에서 조회) $itemType = $request->input('type') ?? $request->input('item_type'); @@ -146,6 +147,7 @@ public function stats(Request $request) public function destroy(Request $request, $id) { $id = (int) $id; + return ApiResponse::handle(function () use ($request, $id) { // item_type 필수 (동적 테이블 라우팅에 사용) $itemType = strtoupper($request->input('type') ?? $request->input('item_type') ?? ''); diff --git a/app/Http/Controllers/Api/V1/QuoteController.php b/app/Http/Controllers/Api/V1/QuoteController.php index cd76dd6..1527f76 100644 --- a/app/Http/Controllers/Api/V1/QuoteController.php +++ b/app/Http/Controllers/Api/V1/QuoteController.php @@ -151,6 +151,17 @@ public function convertToOrder(int $id) }, __('message.quote.converted')); } + /** + * 입찰 전환 + * 시공 견적을 입찰로 변환합니다. + */ + public function convertToBidding(int $id) + { + return ApiResponse::handle(function () use ($id) { + return $this->quoteService->convertToBidding($id); + }, __('message.bidding.converted')); + } + /** * 견적번호 미리보기 */ diff --git a/app/Http/Controllers/Api/V1/WorkOrderController.php b/app/Http/Controllers/Api/V1/WorkOrderController.php index ed982ad..e752496 100644 --- a/app/Http/Controllers/Api/V1/WorkOrderController.php +++ b/app/Http/Controllers/Api/V1/WorkOrderController.php @@ -137,4 +137,24 @@ public function updateItemStatus(Request $request, int $workOrderId, int $itemId return $this->service->updateItemStatus($workOrderId, $itemId, $request->input('status')); }, __('message.work_order.item_status_updated')); } + + /** + * 자재 목록 조회 + */ + public function materials(int $id) + { + return ApiResponse::handle(function () use ($id) { + return $this->service->getMaterials($id); + }, __('message.work_order.materials_fetched')); + } + + /** + * 자재 투입 등록 + */ + public function registerMaterialInput(Request $request, int $id) + { + return ApiResponse::handle(function () use ($request, $id) { + return $this->service->registerMaterialInput($id, $request->input('material_ids', [])); + }, __('message.work_order.material_input_registered')); + } } diff --git a/app/Http/Requests/Admin/FcmHistoryRequest.php b/app/Http/Requests/Admin/FcmHistoryRequest.php index e49717b..68a75f4 100644 --- a/app/Http/Requests/Admin/FcmHistoryRequest.php +++ b/app/Http/Requests/Admin/FcmHistoryRequest.php @@ -24,4 +24,4 @@ public function rules(): array 'per_page' => 'nullable|integer|min:1', ]; } -} \ No newline at end of file +} diff --git a/app/Http/Requests/Admin/FcmTokenListRequest.php b/app/Http/Requests/Admin/FcmTokenListRequest.php index 436f2a1..0396730 100644 --- a/app/Http/Requests/Admin/FcmTokenListRequest.php +++ b/app/Http/Requests/Admin/FcmTokenListRequest.php @@ -25,4 +25,4 @@ public function rules(): array 'per_page' => 'nullable|integer|min:1', ]; } -} \ No newline at end of file +} diff --git a/app/Http/Requests/Approval/FormIndexRequest.php b/app/Http/Requests/Approval/FormIndexRequest.php index 068c440..b4eaa72 100644 --- a/app/Http/Requests/Approval/FormIndexRequest.php +++ b/app/Http/Requests/Approval/FormIndexRequest.php @@ -29,4 +29,4 @@ public function rules(): array 'page' => 'nullable|integer|min:1', ]; } -} \ No newline at end of file +} diff --git a/app/Http/Requests/Approval/InboxIndexRequest.php b/app/Http/Requests/Approval/InboxIndexRequest.php index 6c56d39..b6d7ac0 100644 --- a/app/Http/Requests/Approval/InboxIndexRequest.php +++ b/app/Http/Requests/Approval/InboxIndexRequest.php @@ -24,4 +24,4 @@ public function rules(): array 'page' => 'nullable|integer|min:1', ]; } -} \ No newline at end of file +} diff --git a/app/Http/Requests/Approval/IndexRequest.php b/app/Http/Requests/Approval/IndexRequest.php index 92fef44..635a193 100644 --- a/app/Http/Requests/Approval/IndexRequest.php +++ b/app/Http/Requests/Approval/IndexRequest.php @@ -28,4 +28,4 @@ public function rules(): array 'page' => 'nullable|integer|min:1', ]; } -} \ No newline at end of file +} diff --git a/app/Http/Requests/Approval/LineIndexRequest.php b/app/Http/Requests/Approval/LineIndexRequest.php index fe80de0..87b37a6 100644 --- a/app/Http/Requests/Approval/LineIndexRequest.php +++ b/app/Http/Requests/Approval/LineIndexRequest.php @@ -24,4 +24,4 @@ public function rules(): array 'page' => 'nullable|integer|min:1', ]; } -} \ No newline at end of file +} diff --git a/app/Http/Requests/Approval/ReferenceIndexRequest.php b/app/Http/Requests/Approval/ReferenceIndexRequest.php index d104e18..598ff87 100644 --- a/app/Http/Requests/Approval/ReferenceIndexRequest.php +++ b/app/Http/Requests/Approval/ReferenceIndexRequest.php @@ -24,4 +24,4 @@ public function rules(): array 'page' => 'nullable|integer|min:1', ]; } -} \ No newline at end of file +} diff --git a/app/Http/Requests/Attendance/IndexRequest.php b/app/Http/Requests/Attendance/IndexRequest.php index 82dc824..e93c9f2 100644 --- a/app/Http/Requests/Attendance/IndexRequest.php +++ b/app/Http/Requests/Attendance/IndexRequest.php @@ -29,4 +29,4 @@ public function rules(): array 'per_page' => 'nullable|integer|min:1', ]; } -} \ No newline at end of file +} diff --git a/app/Http/Requests/Bidding/BiddingStoreRequest.php b/app/Http/Requests/Bidding/BiddingStoreRequest.php index 4de7f62..8022130 100644 --- a/app/Http/Requests/Bidding/BiddingStoreRequest.php +++ b/app/Http/Requests/Bidding/BiddingStoreRequest.php @@ -53,4 +53,4 @@ public function messages(): array 'quote_id.exists' => __('validation.exists', ['attribute' => '견적']), ]; } -} \ No newline at end of file +} diff --git a/app/Http/Requests/Employee/IndexRequest.php b/app/Http/Requests/Employee/IndexRequest.php index 7af4fa9..b9cd4aa 100644 --- a/app/Http/Requests/Employee/IndexRequest.php +++ b/app/Http/Requests/Employee/IndexRequest.php @@ -32,4 +32,4 @@ public function rules(): array 'per_page' => 'nullable|integer|min:1', ]; } -} \ No newline at end of file +} diff --git a/app/Http/Requests/Labor/LaborIndexRequest.php b/app/Http/Requests/Labor/LaborIndexRequest.php index 45690c6..c429aaf 100644 --- a/app/Http/Requests/Labor/LaborIndexRequest.php +++ b/app/Http/Requests/Labor/LaborIndexRequest.php @@ -37,4 +37,4 @@ public function messages(): array 'end_date.after_or_equal' => __('validation.after_or_equal', ['attribute' => '종료일', 'date' => '시작일']), ]; } -} \ No newline at end of file +} diff --git a/app/Http/Requests/Leave/IndexRequest.php b/app/Http/Requests/Leave/IndexRequest.php index 4fb08a6..39425f6 100644 --- a/app/Http/Requests/Leave/IndexRequest.php +++ b/app/Http/Requests/Leave/IndexRequest.php @@ -30,4 +30,4 @@ public function rules(): array 'page' => 'nullable|integer|min:1', ]; } -} \ No newline at end of file +} diff --git a/app/Http/Requests/Loan/LoanIndexRequest.php b/app/Http/Requests/Loan/LoanIndexRequest.php index c9198ea..c493b1b 100644 --- a/app/Http/Requests/Loan/LoanIndexRequest.php +++ b/app/Http/Requests/Loan/LoanIndexRequest.php @@ -56,4 +56,4 @@ public function attributes(): array 'per_page' => __('validation.attributes.per_page'), ]; } -} \ No newline at end of file +} diff --git a/app/Http/Requests/PositionRequest.php b/app/Http/Requests/PositionRequest.php index 8e42f63..6fd0875 100644 --- a/app/Http/Requests/PositionRequest.php +++ b/app/Http/Requests/PositionRequest.php @@ -59,4 +59,4 @@ public function messages(): array 'name.max' => __('validation.max.string', ['attribute' => '명칭', 'max' => 50]), ]; } -} \ No newline at end of file +} diff --git a/app/Http/Requests/Pricing/PriceIndexRequest.php b/app/Http/Requests/Pricing/PriceIndexRequest.php index f3fec9d..f2ea401 100644 --- a/app/Http/Requests/Pricing/PriceIndexRequest.php +++ b/app/Http/Requests/Pricing/PriceIndexRequest.php @@ -27,4 +27,4 @@ public function rules(): array 'valid_at' => 'nullable|date', ]; } -} \ No newline at end of file +} diff --git a/app/Http/Requests/Quote/QuoteIndexRequest.php b/app/Http/Requests/Quote/QuoteIndexRequest.php index 6840e52..af8fc37 100644 --- a/app/Http/Requests/Quote/QuoteIndexRequest.php +++ b/app/Http/Requests/Quote/QuoteIndexRequest.php @@ -67,4 +67,4 @@ public function rules(): array 'for_order' => 'nullable|boolean', // 수주 전환용: 이미 수주가 생성된 견적 제외 ]; } -} \ No newline at end of file +} diff --git a/app/Http/Requests/TaxInvoice/TaxInvoiceListRequest.php b/app/Http/Requests/TaxInvoice/TaxInvoiceListRequest.php index 51aa13f..57be9b4 100644 --- a/app/Http/Requests/TaxInvoice/TaxInvoiceListRequest.php +++ b/app/Http/Requests/TaxInvoice/TaxInvoiceListRequest.php @@ -31,4 +31,4 @@ public function rules(): array 'nts_confirm_num' => ['nullable', 'string', 'max:24'], ]; } -} \ No newline at end of file +} diff --git a/app/Http/Requests/UserInvitation/ListInvitationRequest.php b/app/Http/Requests/UserInvitation/ListInvitationRequest.php index 7f6a815..6dfab21 100644 --- a/app/Http/Requests/UserInvitation/ListInvitationRequest.php +++ b/app/Http/Requests/UserInvitation/ListInvitationRequest.php @@ -25,4 +25,4 @@ public function rules(): array 'page' => ['nullable', 'integer', 'min:1'], ]; } -} \ No newline at end of file +} diff --git a/app/Http/Requests/V1/AiReport/AiReportListRequest.php b/app/Http/Requests/V1/AiReport/AiReportListRequest.php index 8835f97..16638b4 100644 --- a/app/Http/Requests/V1/AiReport/AiReportListRequest.php +++ b/app/Http/Requests/V1/AiReport/AiReportListRequest.php @@ -38,4 +38,4 @@ public function messages(): array ]), ]; } -} \ No newline at end of file +} diff --git a/app/Http/Requests/V1/Company/CompanyRequestIndexRequest.php b/app/Http/Requests/V1/Company/CompanyRequestIndexRequest.php index 69b2516..834745a 100644 --- a/app/Http/Requests/V1/Company/CompanyRequestIndexRequest.php +++ b/app/Http/Requests/V1/Company/CompanyRequestIndexRequest.php @@ -29,4 +29,4 @@ public function rules(): array 'page' => 'nullable|integer|min:1', ]; } -} \ No newline at end of file +} diff --git a/app/Http/Requests/V1/Payment/PaymentIndexRequest.php b/app/Http/Requests/V1/Payment/PaymentIndexRequest.php index d2f6029..c659bd7 100644 --- a/app/Http/Requests/V1/Payment/PaymentIndexRequest.php +++ b/app/Http/Requests/V1/Payment/PaymentIndexRequest.php @@ -29,4 +29,4 @@ public function rules(): array 'per_page' => ['nullable', 'integer', 'min:1'], ]; } -} \ No newline at end of file +} diff --git a/app/Http/Requests/V1/Plan/PlanIndexRequest.php b/app/Http/Requests/V1/Plan/PlanIndexRequest.php index 08248d0..0de745b 100644 --- a/app/Http/Requests/V1/Plan/PlanIndexRequest.php +++ b/app/Http/Requests/V1/Plan/PlanIndexRequest.php @@ -25,4 +25,4 @@ public function rules(): array 'per_page' => ['nullable', 'integer', 'min:1'], ]; } -} \ No newline at end of file +} diff --git a/app/Http/Requests/V1/Subscription/SubscriptionIndexRequest.php b/app/Http/Requests/V1/Subscription/SubscriptionIndexRequest.php index 4d0a5f6..2191216 100644 --- a/app/Http/Requests/V1/Subscription/SubscriptionIndexRequest.php +++ b/app/Http/Requests/V1/Subscription/SubscriptionIndexRequest.php @@ -29,4 +29,4 @@ public function rules(): array 'per_page' => ['nullable', 'integer', 'min:1'], ]; } -} \ No newline at end of file +} diff --git a/app/Models/Construction/Contract.php b/app/Models/Construction/Contract.php index 2152675..04d55cf 100644 --- a/app/Models/Construction/Contract.php +++ b/app/Models/Construction/Contract.php @@ -7,6 +7,7 @@ use App\Traits\ModelTrait; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\SoftDeletes; /** @@ -140,6 +141,14 @@ public function updater(): BelongsTo return $this->belongsTo(User::class, 'updated_by'); } + /** + * 인수인계 보고서 + */ + public function handoverReport(): HasOne + { + return $this->hasOne(HandoverReport::class, 'contract_id'); + } + // ========================================================================= // 스코프 // ========================================================================= diff --git a/app/Models/Labor.php b/app/Models/Labor.php index cb08cf9..676c639 100644 --- a/app/Models/Labor.php +++ b/app/Models/Labor.php @@ -61,7 +61,7 @@ public function scopeSearch($query, ?string $search) return $query->where(function ($q) use ($search) { $q->where('labor_number', 'like', "%{$search}%") - ->orWhere('category', 'like', "%{$search}%"); + ->orWhere('category', 'like', "%{$search}%"); }); } } diff --git a/app/Models/Orders/Order.php b/app/Models/Orders/Order.php index 0beafaf..726a3d0 100644 --- a/app/Models/Orders/Order.php +++ b/app/Models/Orders/Order.php @@ -5,6 +5,7 @@ use App\Models\Items\Item; use App\Models\Production\WorkOrder; use App\Models\Quote\Quote; +use App\Models\Tenants\Shipment; use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -27,10 +28,48 @@ class Order extends Model public const STATUS_IN_PROGRESS = 'IN_PROGRESS'; + public const STATUS_IN_PRODUCTION = 'IN_PRODUCTION'; // 생산중 + + public const STATUS_PRODUCED = 'PRODUCED'; // 생산완료 + + public const STATUS_SHIPPING = 'SHIPPING'; // 출하중 + + public const STATUS_SHIPPED = 'SHIPPED'; // 출하완료 + public const STATUS_COMPLETED = 'COMPLETED'; public const STATUS_CANCELLED = 'CANCELLED'; + /** + * 전체 상태 목록 + */ + public const STATUSES = [ + self::STATUS_DRAFT, + self::STATUS_CONFIRMED, + self::STATUS_IN_PROGRESS, + self::STATUS_IN_PRODUCTION, + self::STATUS_PRODUCED, + self::STATUS_SHIPPING, + self::STATUS_SHIPPED, + self::STATUS_COMPLETED, + self::STATUS_CANCELLED, + ]; + + /** + * 상태 라벨 + */ + public const STATUS_LABELS = [ + self::STATUS_DRAFT => '임시저장', + self::STATUS_CONFIRMED => '확정', + self::STATUS_IN_PROGRESS => '진행중', + self::STATUS_IN_PRODUCTION => '생산중', + self::STATUS_PRODUCED => '생산완료', + self::STATUS_SHIPPING => '출하중', + self::STATUS_SHIPPED => '출하완료', + self::STATUS_COMPLETED => '완료', + self::STATUS_CANCELLED => '취소', + ]; + // 주문 유형 public const TYPE_ORDER = 'ORDER'; // 수주 @@ -127,6 +166,14 @@ public function client(): BelongsTo return $this->belongsTo(Client::class); } + /** + * 작성자 (담당자) + */ + public function writer(): BelongsTo + { + return $this->belongsTo(\App\Models\Members\User::class, 'writer_id'); + } + /** * 품목 (통합 items 테이블) */ @@ -143,6 +190,14 @@ public function workOrders(): HasMany return $this->hasMany(WorkOrder::class, 'sales_order_id'); } + /** + * 출하 목록 + */ + public function shipments(): HasMany + { + return $this->hasMany(Shipment::class, 'order_id'); + } + /** * 품목들로부터 금액 합계 재계산 */ diff --git a/app/Models/Production/WorkOrder.php b/app/Models/Production/WorkOrder.php index 6eb4998..2d224f6 100644 --- a/app/Models/Production/WorkOrder.php +++ b/app/Models/Production/WorkOrder.php @@ -6,6 +6,7 @@ use App\Models\Orders\Order; use App\Models\Process; use App\Models\Tenants\Department; +use App\Models\Tenants\Shipment; use App\Traits\BelongsToTenant; use App\Traits\ModelTrait; use Illuminate\Database\Eloquent\Model; @@ -193,6 +194,14 @@ public function issues(): HasMany return $this->hasMany(WorkOrderIssue::class); } + /** + * 출하 목록 + */ + public function shipments(): HasMany + { + return $this->hasMany(Shipment::class); + } + /** * 생성자 */ diff --git a/app/Models/Production/WorkOrderItem.php b/app/Models/Production/WorkOrderItem.php index 1b3e4b7..095cdac 100644 --- a/app/Models/Production/WorkOrderItem.php +++ b/app/Models/Production/WorkOrderItem.php @@ -34,7 +34,9 @@ class WorkOrderItem extends Model * 품목 상태 상수 */ public const STATUS_WAITING = 'waiting'; + public const STATUS_IN_PROGRESS = 'in_progress'; + public const STATUS_COMPLETED = 'completed'; public const STATUSES = [ diff --git a/app/Models/Tenants/Shipment.php b/app/Models/Tenants/Shipment.php index 54c3144..3024ac8 100644 --- a/app/Models/Tenants/Shipment.php +++ b/app/Models/Tenants/Shipment.php @@ -2,6 +2,8 @@ namespace App\Models\Tenants; +use App\Models\Orders\Order; +use App\Models\Production\WorkOrder; use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -17,6 +19,7 @@ class Shipment extends Model 'shipment_no', 'lot_no', 'order_id', + 'work_order_id', 'scheduled_date', 'status', 'priority', @@ -65,9 +68,17 @@ class Shipment extends Model 'confirmed_arrival' => 'datetime', 'shipping_cost' => 'decimal:0', 'order_id' => 'integer', + 'work_order_id' => 'integer', 'client_id' => 'integer', ]; + /** + * JSON 응답에 자동 포함할 accessor + */ + protected $appends = [ + 'order_info', + ]; + /** * 출하 상태 목록 */ @@ -96,6 +107,22 @@ class Shipment extends Model 'logistics' => '물류사', ]; + /** + * 수주 관계 + */ + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + /** + * 작업지시 관계 + */ + public function workOrder(): BelongsTo + { + return $this->belongsTo(WorkOrder::class); + } + /** * 출하 품목 관계 */ @@ -184,6 +211,107 @@ public function canProceedToShip(): bool return $this->can_ship && $this->deposit_confirmed; } + // ============================================================ + // 수주 연동 정보 Accessor (수주에서 읽기 전용) + // 배송지 정보의 원본은 수주(Order)에 저장 + // 출하에서 수정 시 updateOrderDeliveryInfo() 메서드로 수주 업데이트 + // ============================================================ + + /** + * 거래처 ID (수주에서 참조) + */ + public function getOrderClientIdAttribute(): ?int + { + return $this->order?->client_id; + } + + /** + * 고객명 (수주.client_name → 수주.거래처.name 순으로 참조) + */ + public function getOrderCustomerNameAttribute(): ?string + { + if ($this->order) { + return $this->order->client_name ?? $this->order->client?->name; + } + + return null; + } + + /** + * 현장명 (수주에서 참조) + */ + public function getOrderSiteNameAttribute(): ?string + { + return $this->order?->site_name; + } + + /** + * 배송주소 (수주.거래처.address 참조) + */ + public function getOrderDeliveryAddressAttribute(): ?string + { + return $this->order?->client?->address; + } + + /** + * 연락처 (수주.client_contact → 수주.거래처.phone 순으로 참조) + */ + public function getOrderContactAttribute(): ?string + { + if ($this->order) { + return $this->order->client_contact ?? $this->order->client?->phone; + } + + return null; + } + + /** + * 수주 연동 정보 일괄 조회 (API 응답용) + * + * @return array 수주에서 참조한 발주처 정보 + */ + public function getOrderInfoAttribute(): array + { + return [ + 'order_id' => $this->order_id, + 'order_no' => $this->order?->order_no, + 'order_status' => $this->order?->status_code, + 'client_id' => $this->order_client_id, + 'customer_name' => $this->order_customer_name, + 'site_name' => $this->order_site_name, + 'delivery_address' => $this->order_delivery_address, + 'contact' => $this->order_contact, + // 추가 정보 + 'delivery_date' => $this->order?->delivery_date?->format('Y-m-d'), 'writer_id' => $this->order?->writer_id, + 'writer_name' => $this->order?->writer?->name, + ]; + } + + /** + * 출하에서 배송 정보 수정 시 수주(Order) 업데이트 + * + * 출하 화면에서 배송 정보를 수정하면 수주의 데이터를 변경합니다. + * 데이터의 원본은 항상 수주(Order)에 저장됩니다. + * + * @param array $data 수정할 배송 정보 (client_name, client_contact, site_name) + * @return bool 업데이트 성공 여부 + */ + public function updateOrderDeliveryInfo(array $data): bool + { + if (! $this->order) { + return false; + } + + $allowedFields = ['client_id', 'client_name', 'client_contact', 'site_name']; + $updateData = array_intersect_key($data, array_flip($allowedFields)); + + if (empty($updateData)) { + return false; + } + + return $this->order->update($updateData); + } + /** * 새 출하번호 생성 */ diff --git a/app/Services/Construction/ContractService.php b/app/Services/Construction/ContractService.php index 0285201..5e3980b 100644 --- a/app/Services/Construction/ContractService.php +++ b/app/Services/Construction/ContractService.php @@ -2,10 +2,14 @@ namespace App\Services\Construction; +use App\Models\Bidding\Bidding; use App\Models\Construction\Contract; use App\Services\Service; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\DB; +use Symfony\Component\HttpKernel\Exception\ConflictHttpException; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; class ContractService extends Service { @@ -280,4 +284,94 @@ public function stageCounts(array $params): array 'other' => $stageCounts[Contract::STAGE_OTHER] ?? 0, ]; } + + /** + * 입찰에서 계약 생성 (낙찰 → 계약 전환) + */ + public function storeFromBidding(int $biddingId, array $data = []): Contract + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + // 1. 입찰 조회 + $bidding = Bidding::where('tenant_id', $tenantId)->find($biddingId); + + if (! $bidding) { + throw new NotFoundHttpException(__('error.bidding.not_found')); + } + + // 2. 낙찰 상태인지 확인 + if (! $bidding->isConvertibleToContract()) { + throw new UnprocessableEntityHttpException( + __('error.contract.bidding_not_awarded') + ); + } + + // 3. 이미 계약이 등록되었는지 확인 + $existingContract = Contract::where('tenant_id', $tenantId) + ->where('bidding_id', $biddingId) + ->first(); + + if ($existingContract) { + throw new ConflictHttpException( + __('error.contract.already_registered', ['code' => $existingContract->contract_code]) + ); + } + + return DB::transaction(function () use ($bidding, $data, $tenantId, $userId) { + // 계약번호 자동 생성 + $contractCode = $this->generateContractCode($tenantId); + + $contract = Contract::create([ + 'tenant_id' => $tenantId, + 'contract_code' => $contractCode, + 'project_name' => $data['project_name'] ?? $bidding->project_name, + 'partner_id' => $data['partner_id'] ?? $bidding->client_id, + 'partner_name' => $data['partner_name'] ?? $bidding->client_name, + 'contract_manager_id' => $data['contract_manager_id'] ?? null, + 'contract_manager_name' => $data['contract_manager_name'] ?? null, + 'construction_pm_id' => $data['construction_pm_id'] ?? null, + 'construction_pm_name' => $data['construction_pm_name'] ?? null, + 'total_locations' => $data['total_locations'] ?? $bidding->total_count ?? 0, + 'contract_amount' => $data['contract_amount'] ?? $bidding->bidding_amount ?? 0, + 'contract_start_date' => $data['contract_start_date'] ?? $bidding->construction_start_date, + 'contract_end_date' => $data['contract_end_date'] ?? $bidding->construction_end_date, + 'status' => $data['status'] ?? Contract::STATUS_PENDING, + 'stage' => $data['stage'] ?? Contract::STAGE_ESTIMATE_SELECTED, + 'bidding_id' => $bidding->id, + 'bidding_code' => $bidding->bidding_code, + 'remarks' => $data['remarks'] ?? $bidding->remarks, + 'is_active' => $data['is_active'] ?? true, + 'created_by' => $userId, + 'updated_by' => $userId, + ]); + + return $contract; + }); + } + + /** + * 계약번호 자동 생성 (CTR-YYYY-NNN) + */ + private function generateContractCode(int $tenantId): string + { + $year = now()->year; + $prefix = "CTR-{$year}-"; + + // 올해 생성된 마지막 계약번호 조회 + $lastContract = Contract::where('tenant_id', $tenantId) + ->where('contract_code', 'like', "{$prefix}%") + ->orderBy('contract_code', 'desc') + ->first(); + + if ($lastContract) { + // 마지막 번호에서 숫자 추출 후 +1 + $lastNumber = (int) substr($lastContract->contract_code, -3); + $nextNumber = $lastNumber + 1; + } else { + $nextNumber = 1; + } + + return $prefix.str_pad($nextNumber, 3, '0', STR_PAD_LEFT); + } } diff --git a/app/Services/Construction/HandoverReportService.php b/app/Services/Construction/HandoverReportService.php index d636a0d..58a0f12 100644 --- a/app/Services/Construction/HandoverReportService.php +++ b/app/Services/Construction/HandoverReportService.php @@ -2,6 +2,7 @@ namespace App\Services\Construction; +use App\Models\Construction\Contract; use App\Models\Construction\HandoverReport; use App\Models\Construction\HandoverReportItem; use App\Models\Construction\HandoverReportManager; @@ -12,28 +13,38 @@ class HandoverReportService extends Service { /** - * 인수인계보고서 목록 조회 + * 인수인계보고서 목록 조회 (계약 완료건 기준) + * 계약(완료) 테이블 LEFT JOIN 인수인계보고서 */ public function index(array $params): LengthAwarePaginator { $tenantId = $this->tenantId(); - $query = HandoverReport::query() - ->where('tenant_id', $tenantId); + // 계약 완료건 기준으로 조회 (LEFT JOIN handover_reports) + $query = Contract::query() + ->where('tenant_id', $tenantId) + ->where('status', Contract::STATUS_COMPLETED) + ->with('handoverReport'); - // 검색 필터 + // 검색 필터 (계약 필드 기준) if (! empty($params['search'])) { $search = $params['search']; $query->where(function ($q) use ($search) { - $q->where('report_number', 'like', "%{$search}%") - ->orWhere('site_name', 'like', "%{$search}%") + $q->where('contract_code', 'like', "%{$search}%") + ->orWhere('project_name', 'like', "%{$search}%") ->orWhere('partner_name', 'like', "%{$search}%"); }); } - // 상태 필터 - if (! empty($params['status'])) { - $query->where('status', $params['status']); + // 인수인계 상태 필터 (보고서 유무) + if (! empty($params['handover_status'])) { + if ($params['handover_status'] === 'completed') { + // 인수인계 완료 = 보고서 있음 + $query->whereHas('handoverReport'); + } elseif ($params['handover_status'] === 'pending') { + // 인수인계 대기 = 보고서 없음 + $query->whereDoesntHave('handoverReport'); + } } // 거래처 필터 @@ -51,12 +62,7 @@ public function index(array $params): LengthAwarePaginator $query->where('construction_pm_id', $params['construction_pm_id']); } - // 연결 계약 필터 - if (! empty($params['contract_id'])) { - $query->where('contract_id', $params['contract_id']); - } - - // 날짜 범위 필터 + // 날짜 범위 필터 (계약 기간) if (! empty($params['start_date'])) { $query->where('contract_start_date', '>=', $params['start_date']); } @@ -81,16 +87,20 @@ public function index(array $params): LengthAwarePaginator } /** - * 인수인계보고서 상세 조회 + * 인수인계보고서 상세 조회 (계약 ID 기준) + * 계약 정보 + 인수인계 보고서 관계 조회 */ - public function show(int $id): HandoverReport + public function show(int $contractId): Contract { $tenantId = $this->tenantId(); - return HandoverReport::query() + return Contract::query() ->where('tenant_id', $tenantId) - ->with(['contract', 'contractManager', 'constructionPm', 'managers', 'items', 'creator', 'updater']) - ->findOrFail($id); + ->where('status', Contract::STATUS_COMPLETED) + ->with(['handoverReport' => function ($query) { + $query->with(['managers', 'items']); + }, 'contractManager', 'constructionPm']) + ->findOrFail($contractId); } /** @@ -149,17 +159,36 @@ public function store(array $data): HandoverReport } /** - * 인수인계보고서 수정 + * 인수인계보고서 수정 (계약 ID 기준) + * Contract ID로 해당 계약의 인수인계보고서를 찾아 수정 + * 보고서가 없으면 새로 생성 */ - public function update(int $id, array $data): HandoverReport + public function update(int $contractId, array $data): HandoverReport { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); - return DB::transaction(function () use ($id, $data, $tenantId, $userId) { + return DB::transaction(function () use ($contractId, $data, $tenantId, $userId) { + // 계약 확인 (완료된 계약만) + $contract = Contract::query() + ->where('tenant_id', $tenantId) + ->where('status', Contract::STATUS_COMPLETED) + ->findOrFail($contractId); + + // 해당 계약의 인수인계보고서 조회 (없으면 생성) $report = HandoverReport::query() ->where('tenant_id', $tenantId) - ->findOrFail($id); + ->where('contract_id', $contractId) + ->first(); + + if (! $report) { + // 보고서가 없으면 새로 생성 + $report = new HandoverReport([ + 'tenant_id' => $tenantId, + 'contract_id' => $contractId, + 'created_by' => $userId, + ]); + } $report->fill([ 'report_number' => $data['report_number'] ?? $report->report_number, @@ -252,14 +281,16 @@ public function bulkDestroy(array $ids): bool } /** - * 인수인계보고서 통계 조회 + * 인수인계보고서 통계 조회 (계약 완료건 기준) */ public function stats(array $params): array { $tenantId = $this->tenantId(); - $query = HandoverReport::query() - ->where('tenant_id', $tenantId); + // 계약 완료건 기준 조회 + $query = Contract::query() + ->where('tenant_id', $tenantId) + ->where('status', Contract::STATUS_COMPLETED); // 날짜 범위 필터 if (! empty($params['start_date'])) { @@ -269,18 +300,27 @@ public function stats(array $params): array $query->where('contract_end_date', '<=', $params['end_date']); } + // 계약 완료건 전체 수 $totalCount = (clone $query)->count(); - $pendingCount = (clone $query)->where('status', HandoverReport::STATUS_PENDING)->count(); - $completedCount = (clone $query)->where('status', HandoverReport::STATUS_COMPLETED)->count(); + + // 인수인계 완료 (보고서 있음) + $handoverCompletedCount = (clone $query)->whereHas('handoverReport')->count(); + + // 인수인계 대기 (보고서 없음) + $handoverPendingCount = (clone $query)->whereDoesntHave('handoverReport')->count(); + + // 총 계약금액 $totalAmount = (clone $query)->sum('contract_amount'); - $totalSites = (clone $query)->sum('total_sites'); + + // 총 현장 수 + $totalLocations = (clone $query)->sum('total_locations'); return [ 'total_count' => $totalCount, - 'pending_count' => $pendingCount, - 'completed_count' => $completedCount, + 'handover_completed_count' => $handoverCompletedCount, + 'handover_pending_count' => $handoverPendingCount, 'total_amount' => (float) $totalAmount, - 'total_sites' => (int) $totalSites, + 'total_locations' => (int) $totalLocations, ]; } diff --git a/app/Services/Quote/QuoteService.php b/app/Services/Quote/QuoteService.php index 504a61b..4707c5a 100644 --- a/app/Services/Quote/QuoteService.php +++ b/app/Services/Quote/QuoteService.php @@ -2,6 +2,7 @@ namespace App\Services\Quote; +use App\Models\Bidding\Bidding; use App\Models\Orders\Order; use App\Models\Orders\OrderItem; use App\Models\Quote\Quote; @@ -731,6 +732,7 @@ private function mergeOptions(?array $existingOptions, ?array $newOptions): ?arr \Log::info('✅ [QuoteService::mergeOptions] 기존 없음, 새 options 반환', [ 'result_keys' => array_keys($newOptions), ]); + return $newOptions; } @@ -743,4 +745,82 @@ private function mergeOptions(?array $existingOptions, ?array $newOptions): ?arr return $merged; } + + /** + * 견적을 입찰로 변환 + * 시공 견적(finalized)만 입찰로 변환 가능 + * + * @param int $quoteId 견적 ID + * @return Bidding 생성된 입찰 정보 + * + * @throws \Illuminate\Database\Eloquent\ModelNotFoundException 견적이 존재하지 않는 경우 + * @throws \RuntimeException 이미 입찰로 변환된 경우 또는 시공 견적이 아닌 경우 + */ + public function convertToBidding(int $quoteId): Bidding + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + // 1. 견적 조회 (시공 견적이어야 함) + $quote = Quote::where('tenant_id', $tenantId) + ->where('id', $quoteId) + ->where('type', Quote::TYPE_CONSTRUCTION) // 시공 견적만 + ->firstOrFail(); + + // 2. 이미 입찰로 변환되었는지 확인 + $existingBidding = Bidding::where('tenant_id', $tenantId) + ->where('quote_id', $quoteId) + ->first(); + + if ($existingBidding) { + throw new \RuntimeException(__('error.bidding.already_converted')); + } + + // 3. 입찰번호 생성 (BID-YYYYMMDD-XXXX) + $today = now()->format('Ymd'); + $lastBidding = Bidding::where('tenant_id', $tenantId) + ->where('bidding_code', 'like', "BID-{$today}-%") + ->orderBy('bidding_code', 'desc') + ->first(); + + $sequence = 1; + if ($lastBidding) { + $parts = explode('-', $lastBidding->bidding_code); + $sequence = (int) ($parts[2] ?? 0) + 1; + } + $biddingCode = sprintf('BID-%s-%04d', $today, $sequence); + + // 4. 입찰 생성 + $bidding = Bidding::create([ + 'tenant_id' => $tenantId, + 'bidding_code' => $biddingCode, + 'quote_id' => $quote->id, + // 거래처/현장 정보 + 'client_id' => $quote->client_id, + 'client_name' => $quote->client_name ?? $quote->client?->name, + 'project_name' => $quote->project_name, + // 입찰 정보 (초기값) + 'bidding_date' => now()->toDateString(), + 'total_count' => 1, + 'bidding_amount' => $quote->total_amount ?? 0, + 'status' => Bidding::STATUS_WAITING, + // 입찰자 정보 (현재 사용자) + 'bidder_id' => $userId, + // VAT 유형 + 'vat_type' => $quote->vat_type ?? Bidding::VAT_INCLUDED, + // 견적 데이터 스냅샷 + 'expense_items' => $quote->options['expense_items'] ?? null, + 'estimate_detail_items' => $quote->options['detail_items'] ?? null, + // 감사 필드 + 'created_by' => $userId, + ]); + + \Log::info('✅ [QuoteService::convertToBidding] 입찰 변환 완료', [ + 'quote_id' => $quoteId, + 'bidding_id' => $bidding->id, + 'bidding_code' => $biddingCode, + ]); + + return $bidding; + } } diff --git a/app/Services/ShipmentService.php b/app/Services/ShipmentService.php index 65b3a52..6710393 100644 --- a/app/Services/ShipmentService.php +++ b/app/Services/ShipmentService.php @@ -2,6 +2,7 @@ namespace App\Services; +use App\Models\Orders\Order; use App\Models\Tenants\Shipment; use App\Models\Tenants\ShipmentItem; use Illuminate\Contracts\Pagination\LengthAwarePaginator; @@ -18,7 +19,7 @@ public function index(array $params): LengthAwarePaginator $query = Shipment::query() ->where('tenant_id', $tenantId) - ->with('items'); + ->with(['items', 'order.client', 'order.writer', 'workOrder']); // 검색어 필터 if (! empty($params['search'])) { @@ -159,9 +160,16 @@ public function show(int $id): Shipment return Shipment::query() ->where('tenant_id', $tenantId) - ->with(['items' => function ($query) { - $query->orderBy('seq'); - }, 'creator', 'updater']) + ->with([ + 'items' => function ($query) { + $query->orderBy('seq'); + }, + 'order.client', + 'order.writer', + 'workOrder', + 'creator', + 'updater', + ]) ->findOrFail($id); } @@ -323,9 +331,49 @@ public function updateStatus(int $id, string $status, ?array $additionalData = n $shipment->update($updateData); + // 연결된 수주(Order) 상태 동기화 + $this->syncOrderStatus($shipment, $tenantId); + return $shipment->load('items'); } + /** + * 출하 상태 변경 시 연결된 수주(Order) 상태 동기화 + * + * 매핑 규칙: + * - 'shipping' → Order::STATUS_SHIPPING (출하중) + * - 'completed' → Order::STATUS_SHIPPED (출하완료) + */ + private function syncOrderStatus(Shipment $shipment, int $tenantId): void + { + // 수주 연결이 없으면 스킵 + if (! $shipment->order_id) { + return; + } + + $order = Order::where('tenant_id', $tenantId)->find($shipment->order_id); + if (! $order) { + return; + } + + // 출하 상태 → 수주 상태 매핑 + $statusMap = [ + 'shipping' => Order::STATUS_SHIPPING, + 'completed' => Order::STATUS_SHIPPED, + ]; + + $newOrderStatus = $statusMap[$shipment->status] ?? null; + + // 매핑되는 상태가 없거나 이미 동일한 상태면 스킵 + if (! $newOrderStatus || $order->status_code === $newOrderStatus) { + return; + } + + $order->status_code = $newOrderStatus; + $order->updated_by = $this->apiUserId(); + $order->save(); + } + /** * 출하 삭제 */ diff --git a/app/Services/WorkOrderService.php b/app/Services/WorkOrderService.php index c45158c..bb7ebe1 100644 --- a/app/Services/WorkOrderService.php +++ b/app/Services/WorkOrderService.php @@ -2,10 +2,13 @@ namespace App\Services; +use App\Models\Orders\Order; use App\Models\Production\WorkOrder; use App\Models\Production\WorkOrderAssignee; use App\Models\Production\WorkOrderBendingDetail; use App\Models\Production\WorkOrderItem; +use App\Models\Tenants\Shipment; +use App\Models\Tenants\ShipmentItem; use App\Services\Audit\AuditLogger; use Illuminate\Support\Facades\DB; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; @@ -431,8 +434,13 @@ public function updateStatus(int $id, string $status, ?array $resultData = null) throw new BadRequestHttpException(__('error.invalid_status')); } - // 상태 전이 규칙 검증 - if (! $workOrder->canTransitionTo($status)) { + // Fast-track 완료 체크: pending/waiting에서 completed로 직접 전환 허용 + // 작업자 화면의 "전량완료" 버튼 지원 + $isFastTrackCompletion = $status === WorkOrder::STATUS_COMPLETED && + in_array($workOrder->status, [WorkOrder::STATUS_PENDING, WorkOrder::STATUS_WAITING]); + + // 일반 상태 전이 규칙 검증 (fast-track이 아닌 경우) + if (! $isFastTrackCompletion && ! $workOrder->canTransitionTo($status)) { $allowed = implode(', ', $workOrder->getAllowedTransitions()); throw new BadRequestHttpException( __('error.work_order.invalid_transition', [ @@ -454,6 +462,8 @@ public function updateStatus(int $id, string $status, ?array $resultData = null) $workOrder->started_at = $workOrder->started_at ?? now(); break; case WorkOrder::STATUS_COMPLETED: + // Fast-track 완료의 경우 started_at도 설정 (중간 상태 생략) + $workOrder->started_at = $workOrder->started_at ?? now(); $workOrder->completed_at = now(); // 모든 품목에 결과 데이터 저장 $this->saveItemResults($workOrder, $resultData, $userId); @@ -475,10 +485,221 @@ public function updateStatus(int $id, string $status, ?array $resultData = null) ['status' => $status] ); + // 연결된 수주(Order) 상태 동기화 + $this->syncOrderStatus($workOrder, $tenantId); + + // 작업완료 시 자동 출하 생성 + if ($status === WorkOrder::STATUS_COMPLETED) { + $this->createShipmentFromWorkOrder($workOrder, $tenantId, $userId); + } + return $workOrder->load(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'process:id,process_name,process_code']); }); } + /** + * 작업지시 완료 시 자동 출하 생성 + * + * 작업지시가 완료(completed) 상태가 되면 출하(Shipment)를 자동 생성하여 출하관리로 넘깁니다. + * 발주처/배송 정보는 출하에 복사하지 않고, 수주(Order)를 참조합니다. + * (Shipment 모델의 accessor 메서드로 수주 정보 참조) + */ + private function createShipmentFromWorkOrder(WorkOrder $workOrder, int $tenantId, int $userId): ?Shipment + { + // 이미 이 작업지시에 연결된 출하가 있으면 스킵 + $existingShipment = Shipment::where('tenant_id', $tenantId) + ->where('work_order_id', $workOrder->id) + ->first(); + + if ($existingShipment) { + return $existingShipment; + } + + // 출하번호 자동 생성 + $shipmentNo = Shipment::generateShipmentNo($tenantId); + + // 출하 생성 데이터 + // 발주처/배송 정보는 수주(Order)를 참조하므로 여기서 복사하지 않음 + $shipmentData = [ + 'tenant_id' => $tenantId, + 'shipment_no' => $shipmentNo, + 'work_order_id' => $workOrder->id, + 'order_id' => $workOrder->sales_order_id, + 'scheduled_date' => now()->toDateString(), // 오늘 날짜로 출하 예정 + 'status' => 'scheduled', // 예정 상태로 생성 + 'priority' => 'normal', + 'delivery_method' => 'pickup', // 기본값 + 'can_ship' => true, // 생산 완료 후 생성되므로 출하가능 + 'created_by' => $userId, + 'updated_by' => $userId, + ]; + + $shipment = Shipment::create($shipmentData); + + // 작업지시 품목을 출하 품목으로 복사 + $this->copyWorkOrderItemsToShipment($workOrder, $shipment, $tenantId); + + // 자동 출하 생성 감사 로그 + $this->auditLogger->log( + $tenantId, + 'shipment', + $shipment->id, + 'auto_created_from_work_order', + null, + [ + 'work_order_id' => $workOrder->id, + 'shipment_no' => $shipmentNo, + 'items_count' => $shipment->items()->count(), + ] + ); + + return $shipment; + } + + /** + * 작업지시 품목을 출하 품목으로 복사 + * + * 작업지시 품목(work_order_items)의 정보를 출하 품목(shipment_items)으로 복사합니다. + * 작업지시 품목이 없으면 수주 품목(order_items)을 대체 사용합니다. + * LOT 번호는 작업지시 품목의 결과 데이터에서 가져옵니다. + * 층/부호(floor_unit)는 원본 수주품목(order_items)에서 가져옵니다. + */ + private function copyWorkOrderItemsToShipment(WorkOrder $workOrder, Shipment $shipment, int $tenantId): void + { + $workOrderItems = $workOrder->items()->get(); + + // 작업지시 품목이 있으면 사용 + if ($workOrderItems->isNotEmpty()) { + foreach ($workOrderItems as $index => $woItem) { + // 작업지시 품목의 결과 데이터에서 LOT 번호 추출 + $result = $woItem->options['result'] ?? []; + $lotNo = $result['lot_no'] ?? null; + + // 원본 수주품목에서 층/부호 정보 조회 + $floorUnit = $this->getFloorUnitFromOrderItem($woItem->source_order_item_id, $tenantId); + + // 출하 품목 생성 + ShipmentItem::create([ + 'tenant_id' => $tenantId, + 'shipment_id' => $shipment->id, + 'seq' => $index + 1, + 'item_code' => $woItem->item_id ? "ITEM-{$woItem->item_id}" : null, + 'item_name' => $woItem->item_name, + 'floor_unit' => $floorUnit, + 'specification' => $woItem->specification, + 'quantity' => $result['good_qty'] ?? $woItem->quantity, // 양품 수량 우선 + 'unit' => $woItem->unit, + 'lot_no' => $lotNo, + 'remarks' => null, + ]); + } + + return; + } + + // 작업지시 품목이 없으면 수주 품목에서 복사 (Fallback) + if ($workOrder->salesOrder) { + $orderItems = $workOrder->salesOrder->items()->get(); + + foreach ($orderItems as $index => $orderItem) { + // 수주품목에서 층/부호 정보 조회 + $floorUnit = $this->getFloorUnitFromOrderItem($orderItem->id, $tenantId); + + // 출하 품목 생성 + ShipmentItem::create([ + 'tenant_id' => $tenantId, + 'shipment_id' => $shipment->id, + 'seq' => $index + 1, + 'item_code' => $orderItem->item_id ? "ITEM-{$orderItem->item_id}" : null, + 'item_name' => $orderItem->item_name, + 'floor_unit' => $floorUnit, + 'specification' => $orderItem->specification, + 'quantity' => $orderItem->quantity, + 'unit' => $orderItem->unit, + 'lot_no' => null, // 수주 품목에는 LOT 번호 없음 + 'remarks' => null, + ]); + } + } + } + + /** + * 수주품목에서 층/부호 정보 조회 + * + * floor_code와 symbol_code를 조합하여 floor_unit 형식으로 반환합니다. + * 예: floor_code='3층', symbol_code='A호' → '3층/A호' + */ + private function getFloorUnitFromOrderItem(?int $orderItemId, int $tenantId): ?string + { + if (! $orderItemId) { + return null; + } + + $orderItem = \App\Models\Orders\OrderItem::where('tenant_id', $tenantId) + ->find($orderItemId); + + if (! $orderItem) { + return null; + } + + $parts = array_filter([ + $orderItem->floor_code, + $orderItem->symbol_code, + ]); + + return ! empty($parts) ? implode('/', $parts) : null; + } + + /** + * 작업지시 상태 변경 시 연결된 수주(Order) 상태 동기화 + * + * 매핑 규칙: + * - WorkOrder::STATUS_IN_PROGRESS → Order::STATUS_IN_PRODUCTION (생산중) + * - WorkOrder::STATUS_COMPLETED → Order::STATUS_PRODUCED (생산완료) + * - WorkOrder::STATUS_SHIPPED → Order::STATUS_SHIPPED (출하완료) + */ + private function syncOrderStatus(WorkOrder $workOrder, int $tenantId): void + { + // 수주 연결이 없으면 스킵 + if (! $workOrder->sales_order_id) { + return; + } + + $order = Order::where('tenant_id', $tenantId)->find($workOrder->sales_order_id); + if (! $order) { + return; + } + + // 작업지시 상태 → 수주 상태 매핑 + $statusMap = [ + WorkOrder::STATUS_IN_PROGRESS => Order::STATUS_IN_PRODUCTION, + WorkOrder::STATUS_COMPLETED => Order::STATUS_PRODUCED, + WorkOrder::STATUS_SHIPPED => Order::STATUS_SHIPPED, + ]; + + $newOrderStatus = $statusMap[$workOrder->status] ?? null; + + // 매핑되는 상태가 없거나 이미 동일한 상태면 스킵 + if (! $newOrderStatus || $order->status_code === $newOrderStatus) { + return; + } + + $oldOrderStatus = $order->status_code; + $order->status_code = $newOrderStatus; + $order->updated_by = $this->apiUserId(); + $order->save(); + + // 수주 상태 동기화 감사 로그 + $this->auditLogger->log( + $tenantId, + 'order', + $order->id, + 'status_synced_from_work_order', + ['status_code' => $oldOrderStatus, 'work_order_id' => $workOrder->id], + ['status_code' => $newOrderStatus, 'work_order_id' => $workOrder->id] + ); + } + /** * 작업지시 품목에 결과 데이터 저장 */ @@ -803,6 +1024,102 @@ public function updateItemStatus(int $workOrderId, int $itemId, string $status) ]; } + /** + * 작업지시에 필요한 자재 목록 조회 (BOM 기반) + * + * 작업지시의 품목에 연결된 BOM 자재 목록을 반환합니다. + * 현재는 품목 정보 기반으로 Mock 데이터를 반환하며, + * 향후 자재 관리 기능 구현 시 실제 데이터로 연동됩니다. + * + * @param int $workOrderId 작업지시 ID + * @return array 자재 목록 (id, material_code, material_name, unit, current_stock, fifo_rank) + */ + public function getMaterials(int $workOrderId): array + { + $tenantId = $this->tenantId(); + + $workOrder = WorkOrder::where('tenant_id', $tenantId) + ->with(['items.item', 'salesOrder.items']) + ->find($workOrderId); + + if (! $workOrder) { + throw new NotFoundHttpException(__('error.not_found')); + } + + $materials = []; + $rank = 1; + + // 1. WorkOrder 자체 items가 있으면 사용 + if ($workOrder->items && $workOrder->items->count() > 0) { + foreach ($workOrder->items as $item) { + $materials[] = [ + 'id' => $item->id, + 'material_code' => $item->item_id ? "MAT-{$item->item_id}" : "MAT-{$item->id}", + 'material_name' => $item->item_name ?? '자재 '.$item->id, + 'unit' => $item->unit ?? 'EA', + 'current_stock' => 100, // Mock: 실제 재고 데이터 연동 필요 + 'fifo_rank' => $rank++, + ]; + } + } + // 2. WorkOrder items가 없으면 SalesOrder items 사용 + elseif ($workOrder->salesOrder && $workOrder->salesOrder->items && $workOrder->salesOrder->items->count() > 0) { + foreach ($workOrder->salesOrder->items as $item) { + $materials[] = [ + 'id' => $item->id, + 'material_code' => $item->item_id ? "MAT-{$item->item_id}" : "SO-{$item->id}", + 'material_name' => $item->item_name ?? '자재 '.$item->id, + 'unit' => $item->unit ?? 'EA', + 'current_stock' => 100, // Mock: 실제 재고 데이터 연동 필요 + 'fifo_rank' => $rank++, + ]; + } + } + + return $materials; + } + + /** + * 자재 투입 등록 + * + * 작업지시에 자재 투입을 등록합니다. + * 현재는 감사 로그만 기록하며, 향후 재고 차감 로직 추가 필요. + * + * @param int $workOrderId 작업지시 ID + * @param array $materialIds 투입할 자재 ID 목록 + * @return array 투입 결과 + */ + public function registerMaterialInput(int $workOrderId, array $materialIds): array + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $workOrder = WorkOrder::where('tenant_id', $tenantId)->find($workOrderId); + if (! $workOrder) { + throw new NotFoundHttpException(__('error.not_found')); + } + + // 자재 투입 감사 로그 + $this->auditLogger->log( + $tenantId, + self::AUDIT_TARGET, + $workOrderId, + 'material_input', + null, + [ + 'material_ids' => $materialIds, + 'input_by' => $userId, + 'input_at' => now()->toDateTimeString(), + ] + ); + + return [ + 'work_order_id' => $workOrderId, + 'material_count' => count($materialIds), + 'input_at' => now()->toDateTimeString(), + ]; + } + /** * 품목 상태 기반으로 작업지시 상태 자동 동기화 * @@ -872,6 +1189,12 @@ private function syncWorkOrderStatusFromItems(WorkOrder $workOrder): bool ['status' => $newStatus] ); + // 완료 시 수주 상태 동기화 및 자동 출하 생성 + if ($newStatus === WorkOrder::STATUS_COMPLETED) { + $this->syncOrderStatus($workOrder, $workOrder->tenant_id); + $this->createShipmentFromWorkOrder($workOrder, $workOrder->tenant_id, $this->apiUserId()); + } + return true; } diff --git a/app/Services/WorkResultService.php b/app/Services/WorkResultService.php index 6e595eb..b1c702f 100644 --- a/app/Services/WorkResultService.php +++ b/app/Services/WorkResultService.php @@ -66,7 +66,7 @@ public function index(array $params) $query->where('options->result->completed_at', '>=', $workDateFrom); } if ($workDateTo !== null) { - $query->where('options->result->completed_at', '<=', $workDateTo . ' 23:59:59'); + $query->where('options->result->completed_at', '<=', $workDateTo.' 23:59:59'); } // 검사 완료 필터 @@ -129,7 +129,7 @@ public function stats(array $params = []): array $query->where('options->result->completed_at', '>=', $workDateFrom); } if ($workDateTo !== null) { - $query->where('options->result->completed_at', '<=', $workDateTo . ' 23:59:59'); + $query->where('options->result->completed_at', '<=', $workDateTo.' 23:59:59'); } // JSON에서 집계 (MySQL/MariaDB 기준) @@ -289,4 +289,4 @@ public function togglePackaging(int $id) return $item->fresh(); } -} \ No newline at end of file +} diff --git a/app/Swagger/v1/ContractApi.php b/app/Swagger/v1/ContractApi.php index 1d2f8e4..74f31f8 100644 --- a/app/Swagger/v1/ContractApi.php +++ b/app/Swagger/v1/ContractApi.php @@ -142,6 +142,28 @@ * @OA\Property(property="inspection", type="integer", example=3, description="검수 수"), * @OA\Property(property="other", type="integer", example=2, description="기타 수") * ) + * + * @OA\Schema( + * schema="ContractFromBiddingRequest", + * type="object", + * description="입찰에서 계약 생성 요청 (모든 필드 선택, 입찰 데이터에서 자동 매핑)", + * + * @OA\Property(property="project_name", type="string", maxLength=255, description="현장명 (미입력시 입찰 데이터 사용)"), + * @OA\Property(property="partner_id", type="integer", nullable=true, description="거래처 ID (미입력시 입찰 거래처 사용)"), + * @OA\Property(property="partner_name", type="string", nullable=true, maxLength=255, description="거래처명 (미입력시 입찰 거래처명 사용)"), + * @OA\Property(property="contract_manager_id", type="integer", nullable=true, description="계약담당자 ID"), + * @OA\Property(property="contract_manager_name", type="string", nullable=true, maxLength=100, description="계약담당자명"), + * @OA\Property(property="construction_pm_id", type="integer", nullable=true, description="공사PM ID"), + * @OA\Property(property="construction_pm_name", type="string", nullable=true, maxLength=100, description="공사PM명"), + * @OA\Property(property="total_locations", type="integer", nullable=true, description="총 개소수 (미입력시 입찰 총수량 사용)"), + * @OA\Property(property="contract_amount", type="number", format="float", nullable=true, description="계약금액 (미입력시 입찰금액 사용)"), + * @OA\Property(property="contract_start_date", type="string", format="date", nullable=true, description="계약시작일 (미입력시 입찰 공사시작일 사용)"), + * @OA\Property(property="contract_end_date", type="string", format="date", nullable=true, description="계약종료일 (미입력시 입찰 공사종료일 사용)"), + * @OA\Property(property="status", type="string", enum={"pending", "completed"}, nullable=true, example="pending", description="상태 (기본: pending)"), + * @OA\Property(property="stage", type="string", enum={"estimate_selected", "estimate_progress", "delivery", "installation", "inspection", "other"}, nullable=true, example="estimate_selected", description="단계 (기본: estimate_selected)"), + * @OA\Property(property="remarks", type="string", nullable=true, description="비고 (미입력시 입찰 비고 사용)"), + * @OA\Property(property="is_active", type="boolean", nullable=true, example=true, description="활성 여부 (기본: true)") + * ) */ class ContractApi { @@ -336,4 +358,43 @@ public function stats() {} * ) */ public function stageCounts() {} + + /** + * @OA\Post( + * path="/api/v1/construction/contracts/from-bidding/{biddingId}", + * tags={"Contract"}, + * summary="입찰에서 계약 생성 (낙찰 → 계약 전환)", + * description="낙찰(awarded) 상태의 입찰을 계약으로 전환합니다. 계약번호는 CTR-YYYY-NNN 형식으로 자동 생성됩니다. 요청 본문의 필드는 모두 선택이며, 미입력시 입찰 데이터에서 자동으로 매핑됩니다.", + * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, + * + * @OA\Parameter( + * name="biddingId", + * in="path", + * required=true, + * description="입찰 ID", + * + * @OA\Schema(type="integer", example=11) + * ), + * + * @OA\RequestBody( + * required=false, + * + * @OA\JsonContent(ref="#/components/schemas/ContractFromBiddingRequest") + * ), + * + * @OA\Response(response=200, description="계약 생성 성공", + * + * @OA\JsonContent(allOf={ + * + * @OA\Schema(ref="#/components/schemas/ApiResponse"), + * @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Contract")) + * }) + * ), + * + * @OA\Response(response=404, description="입찰을 찾을 수 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), + * @OA\Response(response=409, description="이미 계약이 등록된 입찰", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), + * @OA\Response(response=422, description="낙찰 상태가 아닌 입찰", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) + * ) + */ + public function storeFromBidding() {} } diff --git a/app/Swagger/v1/WorkOrderApi.php b/app/Swagger/v1/WorkOrderApi.php index 483a860..4c7f3da 100644 --- a/app/Swagger/v1/WorkOrderApi.php +++ b/app/Swagger/v1/WorkOrderApi.php @@ -413,4 +413,69 @@ public function addIssue() {} * ) */ public function resolveIssue() {} + + /** + * @OA\Get( + * path="/api/v1/work-orders/{id}/materials", + * tags={"WorkOrder"}, + * summary="자재 목록 조회", + * description="작업지시에 필요한 자재 목록을 조회합니다. (BOM 기반)", + * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, + * + * @OA\Parameter(name="id", in="path", required=true, description="작업지시 ID", @OA\Schema(type="integer")), + * + * @OA\Response(response=200, description="조회 성공", + * + * @OA\JsonContent(allOf={ + * + * @OA\Schema(ref="#/components/schemas/ApiResponse"), + * @OA\Schema(@OA\Property(property="data", type="array", @OA\Items( + * type="object", + * @OA\Property(property="id", type="integer", example=1), + * @OA\Property(property="material_code", type="string", example="MAT-100"), + * @OA\Property(property="material_name", type="string", example="방충망 프레임"), + * @OA\Property(property="unit", type="string", example="EA"), + * @OA\Property(property="current_stock", type="integer", example=100), + * @OA\Property(property="fifo_rank", type="integer", example=1) + * ))) + * }) + * ), + * + * @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) + * ) + */ + public function materials() {} + + /** + * @OA\Post( + * path="/api/v1/work-orders/{id}/material-inputs", + * tags={"WorkOrder"}, + * summary="자재 투입 등록", + * description="작업지시에 자재 투입을 등록합니다.", + * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, + * + * @OA\Parameter(name="id", in="path", required=true, description="작업지시 ID", @OA\Schema(type="integer")), + * + * @OA\RequestBody(required=true, @OA\JsonContent( + * + * @OA\Property(property="material_ids", type="array", description="투입할 자재 ID 목록", @OA\Items(type="integer"), example={1, 2, 3}) + * )), + * + * @OA\Response(response=200, description="등록 성공", + * + * @OA\JsonContent(allOf={ + * + * @OA\Schema(ref="#/components/schemas/ApiResponse"), + * @OA\Schema(@OA\Property(property="data", type="object", + * @OA\Property(property="work_order_id", type="integer", example=1), + * @OA\Property(property="material_count", type="integer", example=3), + * @OA\Property(property="input_at", type="string", example="2025-01-20 10:30:00") + * )) + * }) + * ), + * + * @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) + * ) + */ + public function registerMaterialInput() {} } diff --git a/database/migrations/2025_12_26_100400_add_tenant_id_to_work_order_sub_tables.php b/database/migrations/2025_12_26_100400_add_tenant_id_to_work_order_sub_tables.php index 89fbc55..faeaa4a 100644 --- a/database/migrations/2025_12_26_100400_add_tenant_id_to_work_order_sub_tables.php +++ b/database/migrations/2025_12_26_100400_add_tenant_id_to_work_order_sub_tables.php @@ -17,55 +17,59 @@ */ public function up(): void { - // 1. work_order_items - Schema::table('work_order_items', function (Blueprint $table) { - $table->unsignedBigInteger('tenant_id')->nullable()->after('id')->comment('테넌트ID'); - $table->index('tenant_id', 'idx_work_order_items_tenant'); - }); + // 1. work_order_items (이미 존재하면 건너뜀) + if (! Schema::hasColumn('work_order_items', 'tenant_id')) { + Schema::table('work_order_items', function (Blueprint $table) { + $table->unsignedBigInteger('tenant_id')->nullable()->after('id')->comment('테넌트ID'); + $table->index('tenant_id', 'idx_work_order_items_tenant'); + }); - // 기존 데이터 업데이트 - DB::statement(' - UPDATE work_order_items wi - JOIN work_orders wo ON wi.work_order_id = wo.id - SET wi.tenant_id = wo.tenant_id - '); + DB::statement(' + UPDATE work_order_items wi + JOIN work_orders wo ON wi.work_order_id = wo.id + SET wi.tenant_id = wo.tenant_id + '); - // nullable 제거 - Schema::table('work_order_items', function (Blueprint $table) { - $table->unsignedBigInteger('tenant_id')->nullable(false)->change(); - }); + Schema::table('work_order_items', function (Blueprint $table) { + $table->unsignedBigInteger('tenant_id')->nullable(false)->change(); + }); + } - // 2. work_order_bending_details - Schema::table('work_order_bending_details', function (Blueprint $table) { - $table->unsignedBigInteger('tenant_id')->nullable()->after('id')->comment('테넌트ID'); - $table->index('tenant_id', 'idx_work_order_bending_details_tenant'); - }); + // 2. work_order_bending_details (이미 존재하면 건너뜀) + if (! Schema::hasColumn('work_order_bending_details', 'tenant_id')) { + Schema::table('work_order_bending_details', function (Blueprint $table) { + $table->unsignedBigInteger('tenant_id')->nullable()->after('id')->comment('테넌트ID'); + $table->index('tenant_id', 'idx_work_order_bending_details_tenant'); + }); - DB::statement(' - UPDATE work_order_bending_details wbd - JOIN work_orders wo ON wbd.work_order_id = wo.id - SET wbd.tenant_id = wo.tenant_id - '); + DB::statement(' + UPDATE work_order_bending_details wbd + JOIN work_orders wo ON wbd.work_order_id = wo.id + SET wbd.tenant_id = wo.tenant_id + '); - Schema::table('work_order_bending_details', function (Blueprint $table) { - $table->unsignedBigInteger('tenant_id')->nullable(false)->change(); - }); + Schema::table('work_order_bending_details', function (Blueprint $table) { + $table->unsignedBigInteger('tenant_id')->nullable(false)->change(); + }); + } - // 3. work_order_issues - Schema::table('work_order_issues', function (Blueprint $table) { - $table->unsignedBigInteger('tenant_id')->nullable()->after('id')->comment('테넌트ID'); - $table->index('tenant_id', 'idx_work_order_issues_tenant'); - }); + // 3. work_order_issues (이미 존재하면 건너뜀) + if (! Schema::hasColumn('work_order_issues', 'tenant_id')) { + Schema::table('work_order_issues', function (Blueprint $table) { + $table->unsignedBigInteger('tenant_id')->nullable()->after('id')->comment('테넌트ID'); + $table->index('tenant_id', 'idx_work_order_issues_tenant'); + }); - DB::statement(' - UPDATE work_order_issues woi - JOIN work_orders wo ON woi.work_order_id = wo.id - SET woi.tenant_id = wo.tenant_id - '); + DB::statement(' + UPDATE work_order_issues woi + JOIN work_orders wo ON woi.work_order_id = wo.id + SET woi.tenant_id = wo.tenant_id + '); - Schema::table('work_order_issues', function (Blueprint $table) { - $table->unsignedBigInteger('tenant_id')->nullable(false)->change(); - }); + Schema::table('work_order_issues', function (Blueprint $table) { + $table->unsignedBigInteger('tenant_id')->nullable(false)->change(); + }); + } } public function down(): void diff --git a/database/migrations/2025_12_26_100600_create_work_order_assignees_table.php b/database/migrations/2025_12_26_100600_create_work_order_assignees_table.php index f176470..05225cf 100644 --- a/database/migrations/2025_12_26_100600_create_work_order_assignees_table.php +++ b/database/migrations/2025_12_26_100600_create_work_order_assignees_table.php @@ -13,6 +13,10 @@ */ public function up(): void { + if (Schema::hasTable('work_order_assignees')) { + return; // 이미 존재하면 건너뜀 + } + Schema::create('work_order_assignees', function (Blueprint $table) { $table->bigIncrements('id'); $table->unsignedBigInteger('tenant_id')->comment('테넌트ID'); diff --git a/database/migrations/2025_12_26_183200_change_work_orders_process_type_to_process_id.php b/database/migrations/2025_12_26_183200_change_work_orders_process_type_to_process_id.php index fbf1593..cf97a7f 100644 --- a/database/migrations/2025_12_26_183200_change_work_orders_process_type_to_process_id.php +++ b/database/migrations/2025_12_26_183200_change_work_orders_process_type_to_process_id.php @@ -21,11 +21,16 @@ { public function up(): void { + // 이미 마이그레이션이 완료된 경우 (process_type이 없고 process_id가 있음) 건너뜀 + if (! Schema::hasColumn('work_orders', 'process_type') && Schema::hasColumn('work_orders', 'process_id')) { + return; + } + // 1. 절곡 공정이 없으면 각 테넌트별로 생성 $this->ensureBendingProcessExists(); // 2. process_id 컬럼 추가 (이미 존재하면 스킵) - if (!Schema::hasColumn('work_orders', 'process_id')) { + if (! Schema::hasColumn('work_orders', 'process_id')) { Schema::table('work_orders', function (Blueprint $table) { $table->unsignedBigInteger('process_id') ->nullable() @@ -46,7 +51,7 @@ public function up(): void AND REFERENCED_TABLE_NAME IS NOT NULL ")[0]->cnt > 0; - if (!$hasFk) { + if (! $hasFk) { Schema::table('work_orders', function (Blueprint $table) { $table->foreign('process_id') ->references('id') diff --git a/database/migrations/2026_01_15_195530_alter_clients_table_collation.php b/database/migrations/2026_01_15_195530_alter_clients_table_collation.php index 469be8e..8087c6b 100644 --- a/database/migrations/2026_01_15_195530_alter_clients_table_collation.php +++ b/database/migrations/2026_01_15_195530_alter_clients_table_collation.php @@ -21,4 +21,4 @@ public function down(): void { DB::statement('ALTER TABLE clients CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci'); } -}; \ No newline at end of file +}; diff --git a/database/migrations/2026_01_16_100000_add_priority_to_work_orders_table.php b/database/migrations/2026_01_16_100000_add_priority_to_work_orders_table.php index 68b1cef..d1d43a1 100644 --- a/database/migrations/2026_01_16_100000_add_priority_to_work_orders_table.php +++ b/database/migrations/2026_01_16_100000_add_priority_to_work_orders_table.php @@ -25,4 +25,4 @@ public function down(): void $table->dropColumn('priority'); }); } -}; \ No newline at end of file +}; diff --git a/database/migrations/2026_01_16_100001_add_source_order_item_id_to_work_order_items_table.php b/database/migrations/2026_01_16_100001_add_source_order_item_id_to_work_order_items_table.php index 89deb48..a7cd483 100644 --- a/database/migrations/2026_01_16_100001_add_source_order_item_id_to_work_order_items_table.php +++ b/database/migrations/2026_01_16_100001_add_source_order_item_id_to_work_order_items_table.php @@ -26,4 +26,4 @@ public function down(): void $table->dropColumn('source_order_item_id'); }); } -}; \ No newline at end of file +}; diff --git a/database/migrations/2026_01_16_174948_add_estimate_option_codes_to_common_codes.php b/database/migrations/2026_01_16_174948_add_estimate_option_codes_to_common_codes.php index cb4c9bf..90ffb75 100644 --- a/database/migrations/2026_01_16_174948_add_estimate_option_codes_to_common_codes.php +++ b/database/migrations/2026_01_16_174948_add_estimate_option_codes_to_common_codes.php @@ -78,4 +78,4 @@ public function down(): void ->whereIn('code_group', $codeGroups) ->delete(); } -}; \ No newline at end of file +}; diff --git a/database/migrations/2026_01_16_202809_add_options_to_orders_table.php b/database/migrations/2026_01_16_202809_add_options_to_orders_table.php index 596316f..5cf9713 100644 --- a/database/migrations/2026_01_16_202809_add_options_to_orders_table.php +++ b/database/migrations/2026_01_16_202809_add_options_to_orders_table.php @@ -26,4 +26,3 @@ public function down(): void }); } }; - diff --git a/lang/ko/message.php b/lang/ko/message.php index afd6563..168d52c 100644 --- a/lang/ko/message.php +++ b/lang/ko/message.php @@ -434,6 +434,9 @@ 'bending_toggled' => '벤딩 항목이 변경되었습니다.', 'issue_added' => '이슈가 등록되었습니다.', 'issue_resolved' => '이슈가 해결되었습니다.', + 'item_status_updated' => '품목 상태가 변경되었습니다.', + 'materials_fetched' => '자재 목록을 조회했습니다.', + 'material_input_registered' => '자재 투입이 등록되었습니다.', ], // 검사 관리 diff --git a/routes/api.php b/routes/api.php index 87e732c..961cd58 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1220,6 +1220,10 @@ // 품목 상태 변경 Route::patch('/{id}/items/{itemId}/status', [WorkOrderController::class, 'updateItemStatus'])->whereNumber('id')->whereNumber('itemId')->name('v1.work-orders.items.status'); + + // 자재 관리 + Route::get('/{id}/materials', [WorkOrderController::class, 'materials'])->whereNumber('id')->name('v1.work-orders.materials'); // 자재 목록 조회 + Route::post('/{id}/material-inputs', [WorkOrderController::class, 'registerMaterialInput'])->whereNumber('id')->name('v1.work-orders.material-inputs'); // 자재 투입 등록 }); // 작업실적 관리 API (Production)