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:
2026-01-16 21:59:06 +09:00
committed by hskwon
parent 0b94da0741
commit 7246ac003f
52 changed files with 1105 additions and 115 deletions

View File

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

View File

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

View File

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

View File

@@ -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'));
}
/**
* 견적번호 미리보기
*/

View File

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

View File

@@ -24,4 +24,4 @@ public function rules(): array
'per_page' => 'nullable|integer|min:1',
];
}
}
}

View File

@@ -25,4 +25,4 @@ public function rules(): array
'per_page' => 'nullable|integer|min:1',
];
}
}
}

View File

@@ -29,4 +29,4 @@ public function rules(): array
'page' => 'nullable|integer|min:1',
];
}
}
}

View File

@@ -24,4 +24,4 @@ public function rules(): array
'page' => 'nullable|integer|min:1',
];
}
}
}

View File

@@ -28,4 +28,4 @@ public function rules(): array
'page' => 'nullable|integer|min:1',
];
}
}
}

View File

@@ -24,4 +24,4 @@ public function rules(): array
'page' => 'nullable|integer|min:1',
];
}
}
}

View File

@@ -24,4 +24,4 @@ public function rules(): array
'page' => 'nullable|integer|min:1',
];
}
}
}

View File

@@ -29,4 +29,4 @@ public function rules(): array
'per_page' => 'nullable|integer|min:1',
];
}
}
}

View File

@@ -53,4 +53,4 @@ public function messages(): array
'quote_id.exists' => __('validation.exists', ['attribute' => '견적']),
];
}
}
}

View File

@@ -32,4 +32,4 @@ public function rules(): array
'per_page' => 'nullable|integer|min:1',
];
}
}
}

View File

@@ -37,4 +37,4 @@ public function messages(): array
'end_date.after_or_equal' => __('validation.after_or_equal', ['attribute' => '종료일', 'date' => '시작일']),
];
}
}
}

View File

@@ -30,4 +30,4 @@ public function rules(): array
'page' => 'nullable|integer|min:1',
];
}
}
}

View File

@@ -56,4 +56,4 @@ public function attributes(): array
'per_page' => __('validation.attributes.per_page'),
];
}
}
}

View File

@@ -59,4 +59,4 @@ public function messages(): array
'name.max' => __('validation.max.string', ['attribute' => '명칭', 'max' => 50]),
];
}
}
}

View File

@@ -27,4 +27,4 @@ public function rules(): array
'valid_at' => 'nullable|date',
];
}
}
}

View File

@@ -67,4 +67,4 @@ public function rules(): array
'for_order' => 'nullable|boolean', // 수주 전환용: 이미 수주가 생성된 견적 제외
];
}
}
}

View File

@@ -31,4 +31,4 @@ public function rules(): array
'nts_confirm_num' => ['nullable', 'string', 'max:24'],
];
}
}
}

View File

@@ -25,4 +25,4 @@ public function rules(): array
'page' => ['nullable', 'integer', 'min:1'],
];
}
}
}

View File

@@ -38,4 +38,4 @@ public function messages(): array
]),
];
}
}
}

View File

@@ -29,4 +29,4 @@ public function rules(): array
'page' => 'nullable|integer|min:1',
];
}
}
}

View File

@@ -29,4 +29,4 @@ public function rules(): array
'per_page' => ['nullable', 'integer', 'min:1'],
];
}
}
}

View File

@@ -25,4 +25,4 @@ public function rules(): array
'per_page' => ['nullable', 'integer', 'min:1'],
];
}
}
}

View File

@@ -29,4 +29,4 @@ public function rules(): array
'per_page' => ['nullable', 'integer', 'min:1'],
];
}
}
}

View File

@@ -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');
}
// =========================================================================
// 스코프
// =========================================================================

View File

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

View File

@@ -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');
}
/**
* 품목들로부터 금액 합계 재계산
*/

View File

@@ -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);
}
/**
* 생성자
*/

View File

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

View File

@@ -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);
}
/**
* 새 출하번호 생성
*/

View File

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

View File

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

View File

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

View File

@@ -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();
}
/**
* 출하 삭제
*/

View File

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

View File

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

View File

@@ -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() {}
}

View File

@@ -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() {}
}

View File

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

View File

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

View File

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

View File

@@ -21,4 +21,4 @@ public function down(): void
{
DB::statement('ALTER TABLE clients CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci');
}
};
};

View File

@@ -25,4 +25,4 @@ public function down(): void
$table->dropColumn('priority');
});
}
};
};

View File

@@ -26,4 +26,4 @@ public function down(): void
$table->dropColumn('source_order_item_id');
});
}
};
};

View File

@@ -78,4 +78,4 @@ public function down(): void
->whereIn('code_group', $codeGroups)
->delete();
}
};
};

View File

@@ -26,4 +26,3 @@ public function down(): void
});
}
};

View File

@@ -434,6 +434,9 @@
'bending_toggled' => '벤딩 항목이 변경되었습니다.',
'issue_added' => '이슈가 등록되었습니다.',
'issue_resolved' => '이슈가 해결되었습니다.',
'item_status_updated' => '품목 상태가 변경되었습니다.',
'materials_fetched' => '자재 목록을 조회했습니다.',
'material_input_registered' => '자재 투입이 등록되었습니다.',
],
// 검사 관리

View File

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