fix(WEB): 수주 페이지 필드 매핑 및 제품-부품 트리 구조 개선
- ApiClient 인터페이스: representative → manager_name, contact_person 변경 - transformApiToFrontend: client.representative → client.manager_name 수정 - ApiOrderItem에 floor_code, symbol_code 필드 추가 (제품-부품 매핑) - ApiOrder에 options 타입 정의 추가 - ApiQuote에 calculation_inputs 타입 정의 추가 - 수주 상세 페이지 제품-부품 트리 구조 UI 개선
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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') ?? '');
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 견적번호 미리보기
|
||||
*/
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,4 +24,4 @@ public function rules(): array
|
||||
'per_page' => 'nullable|integer|min:1',
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,4 +25,4 @@ public function rules(): array
|
||||
'per_page' => 'nullable|integer|min:1',
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,4 +29,4 @@ public function rules(): array
|
||||
'page' => 'nullable|integer|min:1',
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,4 +24,4 @@ public function rules(): array
|
||||
'page' => 'nullable|integer|min:1',
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,4 +28,4 @@ public function rules(): array
|
||||
'page' => 'nullable|integer|min:1',
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,4 +24,4 @@ public function rules(): array
|
||||
'page' => 'nullable|integer|min:1',
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,4 +24,4 @@ public function rules(): array
|
||||
'page' => 'nullable|integer|min:1',
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,4 +29,4 @@ public function rules(): array
|
||||
'per_page' => 'nullable|integer|min:1',
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,4 +53,4 @@ public function messages(): array
|
||||
'quote_id.exists' => __('validation.exists', ['attribute' => '견적']),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,4 +32,4 @@ public function rules(): array
|
||||
'per_page' => 'nullable|integer|min:1',
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,4 +37,4 @@ public function messages(): array
|
||||
'end_date.after_or_equal' => __('validation.after_or_equal', ['attribute' => '종료일', 'date' => '시작일']),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,4 +30,4 @@ public function rules(): array
|
||||
'page' => 'nullable|integer|min:1',
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,4 +56,4 @@ public function attributes(): array
|
||||
'per_page' => __('validation.attributes.per_page'),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,4 +59,4 @@ public function messages(): array
|
||||
'name.max' => __('validation.max.string', ['attribute' => '명칭', 'max' => 50]),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,4 +27,4 @@ public function rules(): array
|
||||
'valid_at' => 'nullable|date',
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,4 +67,4 @@ public function rules(): array
|
||||
'for_order' => 'nullable|boolean', // 수주 전환용: 이미 수주가 생성된 견적 제외
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,4 +31,4 @@ public function rules(): array
|
||||
'nts_confirm_num' => ['nullable', 'string', 'max:24'],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,4 +25,4 @@ public function rules(): array
|
||||
'page' => ['nullable', 'integer', 'min:1'],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,4 +38,4 @@ public function messages(): array
|
||||
]),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,4 +29,4 @@ public function rules(): array
|
||||
'page' => 'nullable|integer|min:1',
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,4 +29,4 @@ public function rules(): array
|
||||
'per_page' => ['nullable', 'integer', 'min:1'],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,4 +25,4 @@ public function rules(): array
|
||||
'per_page' => ['nullable', 'integer', 'min:1'],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,4 +29,4 @@ public function rules(): array
|
||||
'per_page' => ['nullable', 'integer', 'min:1'],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 스코프
|
||||
// =========================================================================
|
||||
|
||||
@@ -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}%");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
* 품목들로부터 금액 합계 재계산
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 생성자
|
||||
*/
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 새 출하번호 생성
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 출하 삭제
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {}
|
||||
}
|
||||
|
||||
@@ -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() {}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -21,4 +21,4 @@ public function down(): void
|
||||
{
|
||||
DB::statement('ALTER TABLE clients CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci');
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -25,4 +25,4 @@ public function down(): void
|
||||
$table->dropColumn('priority');
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -26,4 +26,4 @@ public function down(): void
|
||||
$table->dropColumn('source_order_item_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -78,4 +78,4 @@ public function down(): void
|
||||
->whereIn('code_group', $codeGroups)
|
||||
->delete();
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -26,4 +26,3 @@ public function down(): void
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -434,6 +434,9 @@
|
||||
'bending_toggled' => '벤딩 항목이 변경되었습니다.',
|
||||
'issue_added' => '이슈가 등록되었습니다.',
|
||||
'issue_resolved' => '이슈가 해결되었습니다.',
|
||||
'item_status_updated' => '품목 상태가 변경되었습니다.',
|
||||
'materials_fetched' => '자재 목록을 조회했습니다.',
|
||||
'material_input_registered' => '자재 투입이 등록되었습니다.',
|
||||
],
|
||||
|
||||
// 검사 관리
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user