diff --git a/LOGICAL_RELATIONSHIPS.md b/LOGICAL_RELATIONSHIPS.md index e03c910..c950837 100644 --- a/LOGICAL_RELATIONSHIPS.md +++ b/LOGICAL_RELATIONSHIPS.md @@ -1,6 +1,6 @@ # 논리적 데이터베이스 관계 문서 -> **자동 생성**: 2026-01-23 10:19:57 +> **자동 생성**: 2026-01-23 15:57:29 > **소스**: Eloquent 모델 관계 분석 ## 📊 모델별 관계 현황 diff --git a/app/Http/Controllers/Api/V1/BadDebtController.php b/app/Http/Controllers/Api/V1/BadDebtController.php index 0c728c7..383a8a4 100644 --- a/app/Http/Controllers/Api/V1/BadDebtController.php +++ b/app/Http/Controllers/Api/V1/BadDebtController.php @@ -18,14 +18,15 @@ public function __construct( ) {} /** - * 악성채권 목록 + * 악성채권 목록 (거래처 기준) + * + * 거래처별 활성 악성채권 집계 목록을 반환 */ public function index(Request $request) { $params = $request->only([ 'client_id', 'status', - 'is_active', 'search', 'sort_by', 'sort_dir', @@ -33,9 +34,54 @@ public function index(Request $request) 'page', ]); - $badDebts = $this->service->index($params); + $clients = $this->service->index($params); - return ApiResponse::success($badDebts, __('message.fetched')); + // 거래처 기준 응답으로 변환 + $transformedData = $clients->through(function ($client) { + // 가장 최근 활성 악성채권 정보 + $latestBadDebt = $client->activeBadDebts->first(); + + return [ + 'id' => $client->id, + 'client_id' => $client->id, + 'client_code' => $client->client_code, + 'client_name' => $client->name, + 'business_no' => $client->business_no, + 'contact_person' => $client->contact_person, + 'phone' => $client->phone, + 'mobile' => $client->mobile, + 'email' => $client->email, + 'address' => $client->address, + 'client_type' => $client->client_type, + // 집계 데이터 + 'total_debt_amount' => (float) ($client->total_debt_amount ?? 0), + 'max_overdue_days' => (int) ($client->max_overdue_days ?? 0), + 'bad_debt_count' => (int) ($client->active_bad_debt_count ?? 0), + // 대표 상태 (가장 최근 악성채권의 상태) + 'status' => $latestBadDebt?->status ?? 'collecting', + 'is_active' => true, // 리스트에 나온 건 모두 활성 + // 담당자 (가장 최근 악성채권의 담당자) + 'assigned_user' => $latestBadDebt?->assignedUser ? [ + 'id' => $latestBadDebt->assignedUser->id, + 'name' => $latestBadDebt->assignedUser->name, + ] : null, + // 개별 악성채권 목록 + 'bad_debts' => $client->activeBadDebts->map(fn ($bd) => [ + 'id' => $bd->id, + 'debt_amount' => (float) $bd->debt_amount, + 'status' => $bd->status, + 'overdue_days' => $bd->overdue_days, + 'is_active' => $bd->is_active, + 'occurred_at' => $bd->occurred_at?->toDateString(), + 'assigned_user' => $bd->assignedUser ? [ + 'id' => $bd->assignedUser->id, + 'name' => $bd->assignedUser->name, + ] : null, + ])->toArray(), + ]; + }); + + return ApiResponse::success($transformedData, __('message.fetched')); } /** diff --git a/app/Http/Requests/V1/Receiving/ProcessReceivingRequest.php b/app/Http/Requests/V1/Receiving/ProcessReceivingRequest.php index 582e381..59f5662 100644 --- a/app/Http/Requests/V1/Receiving/ProcessReceivingRequest.php +++ b/app/Http/Requests/V1/Receiving/ProcessReceivingRequest.php @@ -18,7 +18,7 @@ public function rules(): array 'receiving_date' => ['nullable', 'date'], 'lot_no' => ['nullable', 'string', 'max:50'], 'supplier_lot' => ['nullable', 'string', 'max:50'], - 'receiving_location' => ['required', 'string', 'max:100'], + 'receiving_location' => ['nullable', 'string', 'max:100'], 'receiving_manager' => ['nullable', 'string', 'max:50'], 'remark' => ['nullable', 'string', 'max:1000'], ]; @@ -30,7 +30,6 @@ public function messages(): array 'receiving_qty.required' => __('validation.required', ['attribute' => '입고수량']), 'receiving_qty.numeric' => __('validation.numeric', ['attribute' => '입고수량']), 'receiving_qty.min' => __('validation.min.numeric', ['attribute' => '입고수량', 'min' => 0]), - 'receiving_location.required' => __('validation.required', ['attribute' => '입고위치']), ]; } } diff --git a/app/Models/Items/Item.php b/app/Models/Items/Item.php index d62ef00..b22f6a7 100644 --- a/app/Models/Items/Item.php +++ b/app/Models/Items/Item.php @@ -85,6 +85,14 @@ public function details() return $this->hasOne(ItemDetail::class); } + /** + * 재고 정보 (1:1) + */ + public function stock() + { + return $this->hasOne(\App\Models\Tenants\Stock::class); + } + /** * 카테고리 */ @@ -130,26 +138,29 @@ public function tags() /** * 특정 타입 필터 + * join 시 ambiguous 에러 방지를 위해 테이블 prefix 사용 */ public function scopeType($query, string $type) { - return $query->where('item_type', strtoupper($type)); + return $query->where('items.item_type', strtoupper($type)); } /** * Products 타입만 (FG, PT) + * join 시 ambiguous 에러 방지를 위해 테이블 prefix 사용 */ public function scopeProducts($query) { - return $query->whereIn('item_type', self::PRODUCT_TYPES); + return $query->whereIn('items.item_type', self::PRODUCT_TYPES); } /** * Materials 타입만 (SM, RM, CS) + * join 시 ambiguous 에러 방지를 위해 테이블 prefix 사용 */ public function scopeMaterials($query) { - return $query->whereIn('item_type', self::MATERIAL_TYPES); + return $query->whereIn('items.item_type', self::MATERIAL_TYPES); } /** diff --git a/app/Models/Orders/Order.php b/app/Models/Orders/Order.php index f36d0ce..b1eaf84 100644 --- a/app/Models/Orders/Order.php +++ b/app/Models/Orders/Order.php @@ -4,6 +4,7 @@ use App\Models\Items\Item; use App\Models\Production\WorkOrder; +use App\Models\Products\CommonCode; use App\Models\Quote\Quote; use App\Models\Tenants\Sale; use App\Models\Tenants\Shipment; @@ -144,6 +145,13 @@ class Order extends Model 'deleted_at' => 'datetime', ]; + /** + * JSON 응답에 자동 포함할 accessor + */ + protected $appends = [ + 'delivery_method_label', + ]; + /** * 수주 상세 품목 */ @@ -240,6 +248,14 @@ public function getSalesRecognitionLabelAttribute(): string return self::SALES_RECOGNITION_TYPES[$this->sales_recognition] ?? '출하완료 시'; } + /** + * 배송방식 라벨 (common_codes 테이블에서 조회) + */ + public function getDeliveryMethodLabelAttribute(): string + { + return CommonCode::getLabel('delivery_method', $this->delivery_method_code); + } + /** * 수주확정 시 매출 생성 여부 */ diff --git a/app/Models/Products/CommonCode.php b/app/Models/Products/CommonCode.php index 9cc1fa9..fedb3ba 100644 --- a/app/Models/Products/CommonCode.php +++ b/app/Models/Products/CommonCode.php @@ -44,4 +44,42 @@ public function children() { return $this->hasMany(self::class, 'parent_id'); } + + /** + * 코드 그룹과 코드로 라벨(name) 조회 + * + * @param string $codeGroup 코드 그룹 (예: 'delivery_method') + * @param string|null $code 코드값 (예: 'direct') + * @return string 라벨 (없으면 코드값 반환) + */ + public static function getLabel(string $codeGroup, ?string $code): string + { + if (empty($code)) { + return ''; + } + + $commonCode = static::withoutGlobalScopes() + ->where('code_group', $codeGroup) + ->where('code', $code) + ->where('is_active', true) + ->first(); + + return $commonCode?->name ?? $code; + } + + /** + * 코드 그룹의 전체 코드 목록 조회 (code => name 배열) + * + * @param string $codeGroup 코드 그룹 + * @return array [code => name] 형태의 배열 + */ + public static function getCodeMap(string $codeGroup): array + { + return static::withoutGlobalScopes() + ->where('code_group', $codeGroup) + ->where('is_active', true) + ->orderBy('sort_order') + ->pluck('name', 'code') + ->toArray(); + } } diff --git a/app/Models/Tenants/Shipment.php b/app/Models/Tenants/Shipment.php index 3024ac8..3bb9d3e 100644 --- a/app/Models/Tenants/Shipment.php +++ b/app/Models/Tenants/Shipment.php @@ -4,6 +4,7 @@ use App\Models\Orders\Order; use App\Models\Production\WorkOrder; +use App\Models\Products\CommonCode; use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -77,6 +78,7 @@ class Shipment extends Model */ protected $appends = [ 'order_info', + 'delivery_method_label', ]; /** @@ -172,11 +174,11 @@ public function getPriorityLabelAttribute(): string } /** - * 배송방식 라벨 + * 배송방식 라벨 (common_codes 테이블에서 조회) */ public function getDeliveryMethodLabelAttribute(): string { - return self::DELIVERY_METHODS[$this->delivery_method] ?? $this->delivery_method; + return CommonCode::getLabel('delivery_method', $this->delivery_method); } /** diff --git a/app/Services/BadDebtService.php b/app/Services/BadDebtService.php index 996e77b..9b04565 100644 --- a/app/Services/BadDebtService.php +++ b/app/Services/BadDebtService.php @@ -5,50 +5,69 @@ use App\Models\BadDebts\BadDebt; use App\Models\BadDebts\BadDebtDocument; use App\Models\BadDebts\BadDebtMemo; +use App\Models\Orders\Client; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\DB; class BadDebtService extends Service { /** - * 악성채권 목록 조회 + * 악성채권 목록 조회 (거래처 기준) + * + * 거래처별로 is_active=true인 악성채권을 집계하여 조회 */ public function index(array $params): LengthAwarePaginator { $tenantId = $this->tenantId(); - $query = BadDebt::query() + $query = Client::query() ->where('tenant_id', $tenantId) - ->with(['client:id,name,client_code', 'assignedUser:id,name']); + // is_active=true인 악성채권이 있는 거래처만 + ->whereHas('badDebts', function ($q) { + $q->where('is_active', true); + }) + // 활성 악성채권 eager loading (추심중/법적조치 + is_active=true) + ->with(['activeBadDebts' => function ($q) { + $q->with('assignedUser:id,name') + ->orderBy('created_at', 'desc'); + }]) + // 집계: 총 미수금액 (is_active=true인 건만) + ->withSum(['badDebts as total_debt_amount' => function ($q) { + $q->where('is_active', true); + }], 'debt_amount') + // 집계: 최대 연체일수 (is_active=true인 건만) + ->withMax(['badDebts as max_overdue_days' => function ($q) { + $q->where('is_active', true); + }], 'overdue_days') + // 집계: 악성채권 건수 (is_active=true인 건만) + ->withCount(['badDebts as active_bad_debt_count' => function ($q) { + $q->where('is_active', true); + }]); // 거래처 필터 if (! empty($params['client_id'])) { - $query->where('client_id', $params['client_id']); + $query->where('id', $params['client_id']); } - // 상태 필터 + // 상태 필터 (해당 상태의 악성채권이 있는 거래처만) if (! empty($params['status'])) { - $query->where('status', $params['status']); - } - - // 활성화 필터 - if (isset($params['is_active'])) { - $query->where('is_active', $params['is_active']); + $query->whereHas('badDebts', function ($q) use ($params) { + $q->where('is_active', true) + ->where('status', $params['status']); + }); } // 검색어 필터 if (! empty($params['search'])) { $search = $params['search']; $query->where(function ($q) use ($search) { - $q->whereHas('client', function ($q) use ($search) { - $q->where('name', 'like', "%{$search}%") - ->orWhere('client_code', 'like', "%{$search}%"); - }); + $q->where('name', 'like', "%{$search}%") + ->orWhere('client_code', 'like', "%{$search}%"); }); } // 정렬 - $sortBy = $params['sort_by'] ?? 'created_at'; + $sortBy = $params['sort_by'] ?? 'total_debt_amount'; $sortDir = $params['sort_dir'] ?? 'desc'; $query->orderBy($sortBy, $sortDir); @@ -59,14 +78,16 @@ public function index(array $params): LengthAwarePaginator } /** - * 악성채권 요약 통계 + * 악성채권 요약 통계 (is_active=true인 건만) */ public function summary(array $params = []): array { $tenantId = $this->tenantId(); + // is_active=true인 악성채권만 통계 $query = BadDebt::query() - ->where('tenant_id', $tenantId); + ->where('tenant_id', $tenantId) + ->where('is_active', true); // 거래처 필터 if (! empty($params['client_id'])) { @@ -82,12 +103,20 @@ public function summary(array $params = []): array $recoveredAmount = (clone $query)->recovered()->sum('debt_amount'); $badDebtAmount = (clone $query)->badDebt()->sum('debt_amount'); + // 거래처 수 (is_active=true인 악성채권이 있는) + $clientCount = BadDebt::query() + ->where('tenant_id', $tenantId) + ->where('is_active', true) + ->distinct('client_id') + ->count('client_id'); + return [ 'total_amount' => (float) $totalAmount, 'collecting_amount' => (float) $collectingAmount, 'legal_action_amount' => (float) $legalActionAmount, 'recovered_amount' => (float) $recoveredAmount, 'bad_debt_amount' => (float) $badDebtAmount, + 'client_count' => $clientCount, ]; } diff --git a/app/Services/StockService.php b/app/Services/StockService.php index 086c34b..5ddf6b8 100644 --- a/app/Services/StockService.php +++ b/app/Services/StockService.php @@ -2,50 +2,77 @@ namespace App\Services; +use App\Models\Items\Item; use App\Models\Tenants\Stock; use Illuminate\Contracts\Pagination\LengthAwarePaginator; class StockService extends Service { /** - * 재고 목록 조회 + * Item 타입 → 재고관리 라벨 매핑 + */ + public const ITEM_TYPE_LABELS = [ + 'RM' => '원자재', + 'SM' => '부자재', + 'CS' => '소모품', + ]; + + /** + * 재고 목록 조회 (Item 메인 + Stock LEFT JOIN) */ public function index(array $params): LengthAwarePaginator { $tenantId = $this->tenantId(); - $query = Stock::query() - ->where('tenant_id', $tenantId) - ->with('item'); + // Item 테이블이 메인 (materials 타입만: SM, RM, CS) + $query = Item::query() + ->where('items.tenant_id', $tenantId) + ->materials() // SM, RM, CS만 + ->with('stock'); - // 검색어 필터 + // 검색어 필터 (Item 기준) if (! empty($params['search'])) { $search = $params['search']; $query->where(function ($q) use ($search) { - $q->where('item_code', 'like', "%{$search}%") - ->orWhere('item_name', 'like', "%{$search}%"); + $q->where('items.code', 'like', "%{$search}%") + ->orWhere('items.name', 'like', "%{$search}%"); }); } - // 품목유형 필터 + // 품목유형 필터 (Item.item_type 기준: RM, SM, CS) if (! empty($params['item_type'])) { - $query->where('item_type', $params['item_type']); + $query->where('items.item_type', strtoupper($params['item_type'])); } - // 재고 상태 필터 + // 재고 상태 필터 (Stock.status) if (! empty($params['status'])) { - $query->where('status', $params['status']); + $query->whereHas('stock', function ($q) use ($params) { + $q->where('status', $params['status']); + }); } - // 위치 필터 + // 위치 필터 (Stock.location) if (! empty($params['location'])) { - $query->where('location', 'like', "%{$params['location']}%"); + $query->whereHas('stock', function ($q) use ($params) { + $q->where('location', 'like', "%{$params['location']}%"); + }); } // 정렬 - $sortBy = $params['sort_by'] ?? 'item_code'; + $sortBy = $params['sort_by'] ?? 'code'; $sortDir = $params['sort_dir'] ?? 'asc'; - $query->orderBy($sortBy, $sortDir); + + // Item 테이블 기준 정렬 필드 매핑 + $sortMapping = [ + 'item_code' => 'items.code', + 'item_name' => 'items.name', + 'item_type' => 'items.item_type', + 'code' => 'items.code', + 'name' => 'items.name', + ]; + + $sortColumn = $sortMapping[$sortBy] ?? 'items.code'; + $query->orderBy($sortColumn, $sortDir); // 페이지네이션 $perPage = $params['per_page'] ?? 20; @@ -54,24 +81,43 @@ public function index(array $params): LengthAwarePaginator } /** - * 재고 통계 조회 + * 재고 통계 조회 (Item 기준) */ public function stats(): array { $tenantId = $this->tenantId(); - $totalItems = Stock::where('tenant_id', $tenantId)->count(); - - $normalCount = Stock::where('tenant_id', $tenantId) - ->where('status', 'normal') + // 전체 자재 품목 수 (Item 기준) + $totalItems = Item::where('tenant_id', $tenantId) + ->materials() ->count(); - $lowCount = Stock::where('tenant_id', $tenantId) - ->where('status', 'low') + // 재고 상태별 카운트 (Stock이 있는 Item 기준) + $normalCount = Item::where('items.tenant_id', $tenantId) + ->materials() + ->whereHas('stock', function ($q) { + $q->where('status', 'normal'); + }) ->count(); - $outCount = Stock::where('tenant_id', $tenantId) - ->where('status', 'out') + $lowCount = Item::where('items.tenant_id', $tenantId) + ->materials() + ->whereHas('stock', function ($q) { + $q->where('status', 'low'); + }) + ->count(); + + $outCount = Item::where('items.tenant_id', $tenantId) + ->materials() + ->whereHas('stock', function ($q) { + $q->where('status', 'out'); + }) + ->count(); + + // 재고 정보가 없는 Item 수 + $noStockCount = Item::where('items.tenant_id', $tenantId) + ->materials() + ->whereDoesntHave('stock') ->count(); return [ @@ -79,57 +125,73 @@ public function stats(): array 'normal_count' => $normalCount, 'low_count' => $lowCount, 'out_count' => $outCount, + 'no_stock_count' => $noStockCount, ]; } /** - * 재고 상세 조회 (LOT 포함) + * 재고 상세 조회 (Item 기준, LOT 포함) */ - public function show(int $id): Stock + public function show(int $id): Item { $tenantId = $this->tenantId(); - return Stock::query() + return Item::query() ->where('tenant_id', $tenantId) - ->with(['item', 'lots' => function ($query) { + ->materials() + ->with(['stock.lots' => function ($query) { $query->orderBy('fifo_order'); }]) ->findOrFail($id); } /** - * 품목코드로 재고 조회 + * 품목코드로 재고 조회 (Item 기준) */ - public function findByItemCode(string $itemCode): ?Stock + public function findByItemCode(string $itemCode): ?Item { $tenantId = $this->tenantId(); - return Stock::query() + return Item::query() ->where('tenant_id', $tenantId) - ->where('item_code', $itemCode) + ->materials() + ->where('code', $itemCode) + ->with('stock') ->first(); } /** - * 품목유형별 통계 + * 품목유형별 통계 (Item.item_type 기준) */ public function statsByItemType(): array { $tenantId = $this->tenantId(); - $stats = Stock::where('tenant_id', $tenantId) - ->selectRaw('item_type, COUNT(*) as count, SUM(stock_qty) as total_qty') + // Item 기준으로 통계 (materials 타입만) + $stats = Item::where('tenant_id', $tenantId) + ->materials() + ->selectRaw('item_type, COUNT(*) as count') ->groupBy('item_type') ->get() ->keyBy('item_type'); + // 재고 수량 합계 (Stock이 있는 경우) + $stockQtys = Item::where('items.tenant_id', $tenantId) + ->materials() + ->join('stocks', 'items.id', '=', 'stocks.item_id') + ->selectRaw('items.item_type, SUM(stocks.stock_qty) as total_qty') + ->groupBy('items.item_type') + ->get() + ->keyBy('item_type'); + $result = []; - foreach (Stock::ITEM_TYPES as $key => $label) { - $data = $stats->get($key); + foreach (self::ITEM_TYPE_LABELS as $key => $label) { + $itemData = $stats->get($key); + $stockData = $stockQtys->get($key); $result[$key] = [ 'label' => $label, - 'count' => $data?->count ?? 0, - 'total_qty' => $data?->total_qty ?? 0, + 'count' => $itemData?->count ?? 0, + 'total_qty' => $stockData?->total_qty ?? 0, ]; } diff --git a/app/Services/TodayIssueObserverService.php b/app/Services/TodayIssueObserverService.php index b2eb875..715430f 100644 --- a/app/Services/TodayIssueObserverService.php +++ b/app/Services/TodayIssueObserverService.php @@ -87,14 +87,19 @@ public function handleOrderDeleted(Order $order): void } /** - * 미수금(주식 이슈) 생성/삭제 + * 미수금(추심 이슈) 생성/삭제 + * + * is_active가 true로 설정(토글 ON)되면 오늘의 이슈에 추가 + * is_active가 false로 설정(토글 OFF)되면 오늘의 이슈에서 삭제 */ public function handleBadDebtChange(BadDebt $badDebt): void { - // 추심 진행 중인 건만 이슈 생성 - if (in_array($badDebt->status, ['in_progress', 'legal_action'])) { + // is_active(설정 토글)이 켜져 있고, 추심중 또는 법적조치 상태인 경우 이슈 생성 + $isActiveStatus = in_array($badDebt->status, [BadDebt::STATUS_COLLECTING, BadDebt::STATUS_LEGAL_ACTION]); + + if ($badDebt->is_active && $isActiveStatus) { $clientName = $badDebt->client?->name ?? __('message.today_issue.unknown_client'); - $amount = number_format($badDebt->total_amount ?? 0); + $amount = number_format($badDebt->debt_amount ?? 0); $days = $badDebt->overdue_days ?? 0; $this->createIssueWithFcm( @@ -112,6 +117,7 @@ public function handleBadDebtChange(BadDebt $badDebt): void expiresAt: null // 해결될 때까지 유지 ); } else { + // is_active가 false이거나 상태가 회수완료/대손처리인 경우 이슈 삭제 TodayIssue::removeBySource($badDebt->tenant_id, TodayIssue::SOURCE_BAD_DEBT, $badDebt->id); } }