From 6d05ab815f90552a151f280c81c1e10bc88852ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Mon, 26 Jan 2026 20:29:22 +0900 Subject: [PATCH] =?UTF-8?q?feat:=ED=85=8C=EB=84=8C=ED=8A=B8=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20API=20=EB=B0=8F=20=EB=8B=A4=EC=88=98=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TenantSetting CRUD API 추가 - Calendar, Entertainment, VAT 서비스 개선 - 5130 BOM 계산 로직 수정 - quote_items에 item_type 컬럼 추가 - tenant_settings 테이블 마이그레이션 - Swagger 문서 업데이트 --- LOGICAL_RELATIONSHIPS.md | 3 +- app/Console/Commands/Migrate5130Bom.php | 6 +- .../Commands/Verify5130Calculation.php | 6 +- app/Helpers/Legacy5130Calculator.php | 2 +- .../Controllers/Api/V1/AiReportController.php | 2 +- .../Controllers/Api/V1/CalendarController.php | 2 +- .../Api/V1/DashboardController.php | 2 +- .../Api/V1/EntertainmentController.php | 5 +- .../Api/V1/StatusBoardController.php | 2 +- .../Api/V1/TenantSettingController.php | 111 +++ app/Http/Controllers/Api/V1/VatController.php | 5 +- .../BulkUpdateSettingsRequest.php | 24 + .../TenantSetting/GetSettingsRequest.php | 20 + .../TenantSetting/UpdateSettingRequest.php | 23 + app/Models/Items/Item.php | 10 + app/Models/Orders/Order.php | 1 - app/Models/Quote/QuoteItem.php | 1 + app/Models/Tenants/ExpenseAccount.php | 9 +- app/Models/Tenants/Schedule.php | 2 +- app/Models/Tenants/ShipmentItem.php | 2 + app/Models/Tenants/TenantSetting.php | 89 +++ app/Providers/AppServiceProvider.php | 10 +- app/Services/CalendarService.php | 3 +- app/Services/EntertainmentService.php | 3 +- app/Services/ExpectedExpenseService.php | 2 +- app/Services/OrderService.php | 64 ++ app/Services/Quote/QuoteService.php | 9 + app/Services/ReceivingService.php | 7 +- app/Services/ShipmentService.php | 52 ++ app/Services/StockService.php | 735 +++++++++++++++++- app/Services/TenantSettingService.php | 205 +++++ app/Services/VatService.php | 5 +- app/Services/WorkOrderService.php | 181 +++-- app/Swagger/v1/CalendarApi.php | 3 +- app/Swagger/v1/EntertainmentApi.php | 3 +- app/Swagger/v1/SaleApi.php | 2 + app/Swagger/v1/StatusBoardApi.php | 3 +- app/Swagger/v1/TaxInvoiceApi.php | 2 + app/Swagger/v1/TenantSettingApi.php | 293 +++++++ app/Swagger/v1/TodayIssueApi.php | 37 +- app/Swagger/v1/VatApi.php | 1 + app/Swagger/v1/WorkOrderApi.php | 2 + ...26_01_21_100000_create_schedules_table.php | 2 +- ...1_103734_create_expense_accounts_table.php | 2 +- ...00000_add_order_linkage_to_sales_table.php | 2 +- ..._delivery_method_codes_to_common_codes.php | 2 +- ...051_add_item_type_to_quote_items_table.php | 37 + ...26_161004_create_tenant_settings_table.php | 40 + database/seeders/FundScheduleSeeder.php | 2 +- database/seeders/StockInitSeeder.php | 87 +++ database/seeders/TaxScheduleSeeder.php | 2 +- database/seeders/TenantSettingSeeder.php | 61 ++ lang/ko/error.php | 3 + routes/api.php | 11 + 54 files changed, 2090 insertions(+), 110 deletions(-) create mode 100644 app/Http/Controllers/Api/V1/TenantSettingController.php create mode 100644 app/Http/Requests/TenantSetting/BulkUpdateSettingsRequest.php create mode 100644 app/Http/Requests/TenantSetting/GetSettingsRequest.php create mode 100644 app/Http/Requests/TenantSetting/UpdateSettingRequest.php create mode 100644 app/Models/Tenants/TenantSetting.php create mode 100644 app/Services/TenantSettingService.php create mode 100644 app/Swagger/v1/TenantSettingApi.php create mode 100644 database/migrations/2026_01_26_111051_add_item_type_to_quote_items_table.php create mode 100644 database/migrations/2026_01_26_161004_create_tenant_settings_table.php create mode 100644 database/seeders/StockInitSeeder.php create mode 100644 database/seeders/TenantSettingSeeder.php diff --git a/LOGICAL_RELATIONSHIPS.md b/LOGICAL_RELATIONSHIPS.md index c950837..aa05c5d 100644 --- a/LOGICAL_RELATIONSHIPS.md +++ b/LOGICAL_RELATIONSHIPS.md @@ -1,6 +1,6 @@ # 논리적 데이터베이스 관계 문서 -> **자동 생성**: 2026-01-23 15:57:29 +> **자동 생성**: 2026-01-26 16:11:41 > **소스**: Eloquent 모델 관계 분석 ## 📊 모델별 관계 현황 @@ -255,6 +255,7 @@ ### items - **category()**: belongsTo → `categories` - **files()**: hasMany → `files` - **details()**: hasOne → `item_details` +- **stock()**: hasOne → `stocks` ### item_details **모델**: `App\Models\Items\ItemDetail` diff --git a/app/Console/Commands/Migrate5130Bom.php b/app/Console/Commands/Migrate5130Bom.php index bbc87c9..d44003a 100644 --- a/app/Console/Commands/Migrate5130Bom.php +++ b/app/Console/Commands/Migrate5130Bom.php @@ -145,7 +145,7 @@ private function loadBomTemplates(int $tenantId): bool $bom = json_decode($sourceItem->bom, true); if (is_array($bom) && count($bom) > 0) { $this->bomTemplates[$category] = $sourceItem->bom; - $this->info(" ✅ {$category}: {$sourceCode} 템플릿 로드됨 (" . count($bom) . "개 항목)"); + $this->info(" ✅ {$category}: {$sourceCode} 템플릿 로드됨 (".count($bom).'개 항목)'); } else { $this->warn(" ⚠️ {$category}: {$sourceCode} BOM이 비어있음"); } @@ -227,9 +227,9 @@ private function applyBomToItem(object $item, bool $dryRun): string return 'success'; } catch (\Exception $e) { - $this->error(" ❌ [{$item->code}] 오류: " . $e->getMessage()); + $this->error(" ❌ [{$item->code}] 오류: ".$e->getMessage()); return 'failed'; } } -} \ No newline at end of file +} diff --git a/app/Console/Commands/Verify5130Calculation.php b/app/Console/Commands/Verify5130Calculation.php index 89474ea..f381519 100644 --- a/app/Console/Commands/Verify5130Calculation.php +++ b/app/Console/Commands/Verify5130Calculation.php @@ -44,7 +44,7 @@ public function handle(FormulaEvaluatorService $formulaEvaluator): int $finishedGoodsCode = $this->option('finished-goods'); $verboseMode = $this->option('verbose-mode'); - $this->info("📥 입력 파라미터:"); + $this->info('📥 입력 파라미터:'); $this->table( ['항목', '값'], [ @@ -87,7 +87,7 @@ public function handle(FormulaEvaluatorService $formulaEvaluator): int ); if (! $samResult['success']) { - $this->error("SAM 계산 실패: " . ($samResult['error'] ?? '알 수 없는 오류')); + $this->error('SAM 계산 실패: '.($samResult['error'] ?? '알 수 없는 오류')); return Command::FAILURE; } @@ -214,4 +214,4 @@ private function calculateSamVariables(float $W0, float $H0, string $productType 'K' => $K, ]; } -} \ No newline at end of file +} diff --git a/app/Helpers/Legacy5130Calculator.php b/app/Helpers/Legacy5130Calculator.php index 90392d8..1d8c59e 100644 --- a/app/Helpers/Legacy5130Calculator.php +++ b/app/Helpers/Legacy5130Calculator.php @@ -506,4 +506,4 @@ public static function validateAgainstLegacy( 'differences' => $differences, ]; } -} \ No newline at end of file +} diff --git a/app/Http/Controllers/Api/V1/AiReportController.php b/app/Http/Controllers/Api/V1/AiReportController.php index 6200a29..249be2c 100644 --- a/app/Http/Controllers/Api/V1/AiReportController.php +++ b/app/Http/Controllers/Api/V1/AiReportController.php @@ -2,10 +2,10 @@ namespace App\Http\Controllers\Api\V1; +use App\Helpers\ApiResponse; use App\Http\Controllers\Controller; use App\Http\Requests\V1\AiReport\AiReportGenerateRequest; use App\Http\Requests\V1\AiReport\AiReportListRequest; -use App\Helpers\ApiResponse; use App\Services\AiReportService; use Illuminate\Http\JsonResponse; diff --git a/app/Http/Controllers/Api/V1/CalendarController.php b/app/Http/Controllers/Api/V1/CalendarController.php index 5197c75..e7bd95e 100644 --- a/app/Http/Controllers/Api/V1/CalendarController.php +++ b/app/Http/Controllers/Api/V1/CalendarController.php @@ -51,4 +51,4 @@ public function summary(Request $request) ); }, __('message.fetched')); } -} \ No newline at end of file +} diff --git a/app/Http/Controllers/Api/V1/DashboardController.php b/app/Http/Controllers/Api/V1/DashboardController.php index 0371219..54a82d3 100644 --- a/app/Http/Controllers/Api/V1/DashboardController.php +++ b/app/Http/Controllers/Api/V1/DashboardController.php @@ -2,10 +2,10 @@ namespace App\Http\Controllers\Api\V1; +use App\Helpers\ApiResponse; use App\Http\Controllers\Controller; use App\Http\Requests\V1\Dashboard\DashboardApprovalsRequest; use App\Http\Requests\V1\Dashboard\DashboardChartsRequest; -use App\Helpers\ApiResponse; use App\Services\DashboardService; use Illuminate\Http\JsonResponse; diff --git a/app/Http/Controllers/Api/V1/EntertainmentController.php b/app/Http/Controllers/Api/V1/EntertainmentController.php index a5b1f12..a010bee 100644 --- a/app/Http/Controllers/Api/V1/EntertainmentController.php +++ b/app/Http/Controllers/Api/V1/EntertainmentController.php @@ -21,9 +21,6 @@ public function __construct( /** * 접대비 현황 요약 조회 - * - * @param Request $request - * @return JsonResponse */ public function summary(Request $request): JsonResponse { @@ -36,4 +33,4 @@ public function summary(Request $request): JsonResponse return $this->entertainmentService->getSummary($limitType, $companyType, $year, $quarter); }, __('message.fetched')); } -} \ No newline at end of file +} diff --git a/app/Http/Controllers/Api/V1/StatusBoardController.php b/app/Http/Controllers/Api/V1/StatusBoardController.php index 7d6311b..e3472fa 100644 --- a/app/Http/Controllers/Api/V1/StatusBoardController.php +++ b/app/Http/Controllers/Api/V1/StatusBoardController.php @@ -24,4 +24,4 @@ public function summary() return $this->statusBoardService->summary(); }, __('message.fetched')); } -} \ No newline at end of file +} diff --git a/app/Http/Controllers/Api/V1/TenantSettingController.php b/app/Http/Controllers/Api/V1/TenantSettingController.php new file mode 100644 index 0000000..bf06135 --- /dev/null +++ b/app/Http/Controllers/Api/V1/TenantSettingController.php @@ -0,0 +1,111 @@ +validated(); + + if (! empty($validated['group'])) { + $data = $this->service->getByGroup($validated['group']); + } else { + $data = $this->service->getAll(); + } + + return ApiResponse::handle(__('message.fetched'), $data); + } + + /** + * 특정 설정 조회 + */ + public function show(string $group, string $key) + { + $value = $this->service->get($group, $key); + + return ApiResponse::handle(__('message.fetched'), [ + 'group' => $group, + 'key' => $key, + 'value' => $value, + ]); + } + + /** + * 설정 저장/업데이트 + */ + public function store(UpdateSettingRequest $request) + { + $validated = $request->validated(); + + $setting = $this->service->set( + $validated['group'], + $validated['key'], + $validated['value'], + $validated['description'] ?? null + ); + + return ApiResponse::handle(__('message.updated'), $setting); + } + + /** + * 여러 설정 일괄 저장 + */ + public function bulkUpdate(BulkUpdateSettingsRequest $request) + { + $validated = $request->validated(); + + $settings = collect($validated['settings'])->mapWithKeys(function ($item) { + return [$item['key'] => [ + 'value' => $item['value'], + 'description' => $item['description'] ?? null, + ]]; + })->toArray(); + + $results = $this->service->setMany($validated['group'], $settings); + + return ApiResponse::handle(__('message.bulk_upsert'), [ + 'updated' => count($results), + ]); + } + + /** + * 설정 삭제 + */ + public function destroy(string $group, string $key) + { + $deleted = $this->service->delete($group, $key); + + if (! $deleted) { + return ApiResponse::handle(__('error.not_found'), null, 404); + } + + return ApiResponse::handle(__('message.deleted')); + } + + /** + * 기본 설정 초기화 + */ + public function initialize() + { + $results = $this->service->initializeDefaults(); + + return ApiResponse::handle(__('message.created'), [ + 'initialized' => count($results), + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/VatController.php b/app/Http/Controllers/Api/V1/VatController.php index 36f4d1f..765692b 100644 --- a/app/Http/Controllers/Api/V1/VatController.php +++ b/app/Http/Controllers/Api/V1/VatController.php @@ -21,9 +21,6 @@ public function __construct( /** * 부가세 현황 요약 조회 - * - * @param Request $request - * @return JsonResponse */ public function summary(Request $request): JsonResponse { @@ -35,4 +32,4 @@ public function summary(Request $request): JsonResponse return $this->vatService->getSummary($periodType, $year, $period); }, __('message.fetched')); } -} \ No newline at end of file +} diff --git a/app/Http/Requests/TenantSetting/BulkUpdateSettingsRequest.php b/app/Http/Requests/TenantSetting/BulkUpdateSettingsRequest.php new file mode 100644 index 0000000..7ac7530 --- /dev/null +++ b/app/Http/Requests/TenantSetting/BulkUpdateSettingsRequest.php @@ -0,0 +1,24 @@ + 'required|string|max:50', + 'settings' => 'required|array', + 'settings.*.key' => 'required|string|max:100', + 'settings.*.value' => 'required', + 'settings.*.description' => 'nullable|string|max:255', + ]; + } +} diff --git a/app/Http/Requests/TenantSetting/GetSettingsRequest.php b/app/Http/Requests/TenantSetting/GetSettingsRequest.php new file mode 100644 index 0000000..c8ba226 --- /dev/null +++ b/app/Http/Requests/TenantSetting/GetSettingsRequest.php @@ -0,0 +1,20 @@ + 'sometimes|string|max:50', + ]; + } +} diff --git a/app/Http/Requests/TenantSetting/UpdateSettingRequest.php b/app/Http/Requests/TenantSetting/UpdateSettingRequest.php new file mode 100644 index 0000000..9c610c5 --- /dev/null +++ b/app/Http/Requests/TenantSetting/UpdateSettingRequest.php @@ -0,0 +1,23 @@ + 'required|string|max:50', + 'key' => 'required|string|max:100', + 'value' => 'required', + 'description' => 'nullable|string|max:255', + ]; + } +} diff --git a/app/Models/Items/Item.php b/app/Models/Items/Item.php index b22f6a7..fa09dcc 100644 --- a/app/Models/Items/Item.php +++ b/app/Models/Items/Item.php @@ -157,12 +157,22 @@ public function scopeProducts($query) /** * Materials 타입만 (SM, RM, CS) * join 시 ambiguous 에러 방지를 위해 테이블 prefix 사용 + * + * @deprecated Use scopeByItemTypes() with tenant settings instead */ public function scopeMaterials($query) { return $query->whereIn('items.item_type', self::MATERIAL_TYPES); } + /** + * 특정 품목유형만 조회 (테넌트 설정 연동용) + */ + public function scopeByItemTypes($query, array $types) + { + return $query->whereIn('items.item_type', $types); + } + /** * 활성 품목만 */ diff --git a/app/Models/Orders/Order.php b/app/Models/Orders/Order.php index b1eaf84..6329f38 100644 --- a/app/Models/Orders/Order.php +++ b/app/Models/Orders/Order.php @@ -12,7 +12,6 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; -use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\SoftDeletes; /** diff --git a/app/Models/Quote/QuoteItem.php b/app/Models/Quote/QuoteItem.php index 690816e..8bf11b9 100644 --- a/app/Models/Quote/QuoteItem.php +++ b/app/Models/Quote/QuoteItem.php @@ -16,6 +16,7 @@ class QuoteItem extends Model 'tenant_id', // 품목 정보 'item_id', + 'item_type', 'item_code', 'item_name', 'specification', diff --git a/app/Models/Tenants/ExpenseAccount.php b/app/Models/Tenants/ExpenseAccount.php index eb3b3f8..1895412 100644 --- a/app/Models/Tenants/ExpenseAccount.php +++ b/app/Models/Tenants/ExpenseAccount.php @@ -45,18 +45,25 @@ class ExpenseAccount extends Model // 계정 유형 상수 public const TYPE_WELFARE = 'welfare'; + public const TYPE_ENTERTAINMENT = 'entertainment'; + public const TYPE_TRAVEL = 'travel'; + public const TYPE_OFFICE = 'office'; // 세부 유형 상수 (복리후생) public const SUB_TYPE_MEAL = 'meal'; + public const SUB_TYPE_HEALTH = 'health'; + public const SUB_TYPE_EDUCATION = 'education'; // 결제 수단 상수 public const PAYMENT_CARD = 'card'; + public const PAYMENT_CASH = 'cash'; + public const PAYMENT_TRANSFER = 'transfer'; /** @@ -90,4 +97,4 @@ public function scopeInPeriod($query, string $startDate, string $endDate) { return $query->whereBetween('expense_date', [$startDate, $endDate]); } -} \ No newline at end of file +} diff --git a/app/Models/Tenants/Schedule.php b/app/Models/Tenants/Schedule.php index f955de5..4cd9cc3 100644 --- a/app/Models/Tenants/Schedule.php +++ b/app/Models/Tenants/Schedule.php @@ -221,4 +221,4 @@ public function getRecurrenceRuleLabelAttribute(): ?string return self::RECURRENCE_RULES[$this->recurrence_rule] ?? $this->recurrence_rule; } -} \ No newline at end of file +} diff --git a/app/Models/Tenants/ShipmentItem.php b/app/Models/Tenants/ShipmentItem.php index 20d5091..37a8c91 100644 --- a/app/Models/Tenants/ShipmentItem.php +++ b/app/Models/Tenants/ShipmentItem.php @@ -15,6 +15,7 @@ class ShipmentItem extends Model 'tenant_id', 'shipment_id', 'seq', + 'item_id', 'item_code', 'item_name', 'floor_unit', @@ -28,6 +29,7 @@ class ShipmentItem extends Model protected $casts = [ 'seq' => 'integer', + 'item_id' => 'integer', 'quantity' => 'decimal:2', 'shipment_id' => 'integer', 'stock_lot_id' => 'integer', diff --git a/app/Models/Tenants/TenantSetting.php b/app/Models/Tenants/TenantSetting.php new file mode 100644 index 0000000..2e6bdeb --- /dev/null +++ b/app/Models/Tenants/TenantSetting.php @@ -0,0 +1,89 @@ + 'array', + ]; + + /** + * 특정 그룹의 모든 설정 조회 + */ + public function scopeGroup($query, string $group) + { + return $query->where('setting_group', $group); + } + + /** + * 특정 키의 설정 조회 + */ + public function scopeKey($query, string $key) + { + return $query->where('setting_key', $key); + } + + /** + * 그룹과 키로 설정 조회 + */ + public static function getValue(int $tenantId, string $group, string $key, $default = null) + { + $setting = static::withoutGlobalScope('tenant') + ->where('tenant_id', $tenantId) + ->where('setting_group', $group) + ->where('setting_key', $key) + ->first(); + + return $setting ? $setting->setting_value : $default; + } + + /** + * 그룹과 키로 설정 저장/업데이트 + */ + public static function setValue(int $tenantId, string $group, string $key, $value, ?string $description = null, ?int $updatedBy = null): self + { + return static::withoutGlobalScope('tenant')->updateOrCreate( + [ + 'tenant_id' => $tenantId, + 'setting_group' => $group, + 'setting_key' => $key, + ], + [ + 'setting_value' => $value, + 'description' => $description, + 'updated_by' => $updatedBy, + ] + ); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 528e54f..f5f97d9 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -9,13 +9,16 @@ use App\Models\Orders\Client; use App\Models\Orders\Order; use App\Models\Tenants\ApprovalStep; +use App\Models\Tenants\Bill; use App\Models\Tenants\Deposit; use App\Models\Tenants\ExpectedExpense; -use App\Models\Tenants\Stock; -use App\Models\Tenants\Bill; use App\Models\Tenants\Purchase; +use App\Models\Tenants\Stock; use App\Models\Tenants\Tenant; use App\Models\Tenants\Withdrawal; +use App\Observers\ExpenseSync\BillExpenseSyncObserver; +use App\Observers\ExpenseSync\PurchaseExpenseSyncObserver; +use App\Observers\ExpenseSync\WithdrawalExpenseSyncObserver; use App\Observers\MenuObserver; use App\Observers\TenantObserver; use App\Observers\TodayIssue\ApprovalStepIssueObserver; @@ -26,9 +29,6 @@ use App\Observers\TodayIssue\OrderIssueObserver; use App\Observers\TodayIssue\StockIssueObserver; use App\Observers\TodayIssue\WithdrawalIssueObserver; -use App\Observers\ExpenseSync\BillExpenseSyncObserver; -use App\Observers\ExpenseSync\PurchaseExpenseSyncObserver; -use App\Observers\ExpenseSync\WithdrawalExpenseSyncObserver; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\Facades\DB; diff --git a/app/Services/CalendarService.php b/app/Services/CalendarService.php index 1447525..61d4150 100644 --- a/app/Services/CalendarService.php +++ b/app/Services/CalendarService.php @@ -6,7 +6,6 @@ use App\Models\Production\WorkOrder; use App\Models\Tenants\Leave; use App\Models\Tenants\Schedule; -use Carbon\Carbon; use Illuminate\Support\Collection; /** @@ -260,4 +259,4 @@ private function getGeneralSchedules( ]; }); } -} \ No newline at end of file +} diff --git a/app/Services/EntertainmentService.php b/app/Services/EntertainmentService.php index 921832b..a701028 100644 --- a/app/Services/EntertainmentService.php +++ b/app/Services/EntertainmentService.php @@ -2,7 +2,6 @@ namespace App\Services; -use App\Models\Tenants\ExpenseAccount; use Carbon\Carbon; use Illuminate\Support\Facades\DB; @@ -258,4 +257,4 @@ private function generateCheckPoints( return $checkPoints; } -} \ No newline at end of file +} diff --git a/app/Services/ExpectedExpenseService.php b/app/Services/ExpectedExpenseService.php index 8d76048..da717d0 100644 --- a/app/Services/ExpectedExpenseService.php +++ b/app/Services/ExpectedExpenseService.php @@ -432,7 +432,7 @@ private function getMonthlyTrend(int $tenantId, ?string $transactionType = null) $date = now()->subMonths($i); $months[] = [ 'month' => $date->format('Y-m'), - 'label' => $date->format('n') . '월', + 'label' => $date->format('n').'월', 'start' => $date->startOfMonth()->toDateString(), 'end' => $date->endOfMonth()->toDateString(), ]; diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php index 7c17250..6b63900 100644 --- a/app/Services/OrderService.php +++ b/app/Services/OrderService.php @@ -201,11 +201,31 @@ public function update(int $id, array $data) // 품목 교체 (있는 경우) if ($items !== null) { + // 기존 품목의 floor_code/symbol_code 매핑 저장 (item_name + specification → floor_code/symbol_code) + $existingMappings = []; + foreach ($order->items as $existingItem) { + $key = ($existingItem->item_name ?? '').'|'.($existingItem->specification ?? ''); + $existingMappings[$key] = [ + 'floor_code' => $existingItem->floor_code, + 'symbol_code' => $existingItem->symbol_code, + ]; + } + $order->items()->delete(); foreach ($items as $index => $item) { $item['tenant_id'] = $tenantId; $item['serial_no'] = $index + 1; // 1부터 시작하는 순번 $item['sort_order'] = $index; + + // floor_code/symbol_code 보존: 프론트엔드에서 전달되지 않으면 기존 값 사용 + if (empty($item['floor_code']) || empty($item['symbol_code'])) { + $key = ($item['item_name'] ?? '').'|'.($item['specification'] ?? ''); + if (isset($existingMappings[$key])) { + $item['floor_code'] = $item['floor_code'] ?? $existingMappings[$key]['floor_code']; + $item['symbol_code'] = $item['symbol_code'] ?? $existingMappings[$key]['symbol_code']; + } + } + $this->calculateItemAmounts($item); $order->items()->create($item); } @@ -277,6 +297,7 @@ public function updateStatus(int $id, string $status) return DB::transaction(function () use ($order, $status, $userId) { $createdSale = null; + $previousStatus = $order->status_code; // 수주확정 시 매출 자동 생성 (sales_recognition = on_order_confirm인 경우) if ($status === Order::STATUS_CONFIRMED && $order->shouldCreateSaleOnConfirm()) { @@ -284,6 +305,18 @@ public function updateStatus(int $id, string $status) $order->sale_id = $createdSale->id; } + // 🆕 수주확정 시 재고 예약 + if ($status === Order::STATUS_CONFIRMED && $previousStatus !== Order::STATUS_CONFIRMED) { + $order->load('items'); + app(StockService::class)->reserveForOrder($order->items, $order->id); + } + + // 🆕 수주취소 시 재고 예약 해제 + if ($status === Order::STATUS_CANCELLED && $previousStatus === Order::STATUS_CONFIRMED) { + $order->load('items'); + app(StockService::class)->releaseReservationForOrder($order->items, $order->id); + } + $order->status_code = $status; $order->updated_by = $userId; $order->save(); @@ -437,14 +470,45 @@ public function createFromQuote(int $quoteId, array $data = []) $order->save(); + // calculation_inputs에서 제품-부품 매핑 정보 추출 + $calculationInputs = $quote->calculation_inputs ?? []; + $calcInputItems = $calculationInputs['items'] ?? []; + // 견적 품목을 수주 품목으로 변환 foreach ($quote->items as $index => $quoteItem) { + // calculation_inputs.items에서 해당 품목의 floor/code 정보 찾기 + // 1. item_index로 매칭 시도 + // 2. 없으면 배열 인덱스로 fallback + $floorCode = null; + $symbolCode = null; + + $itemIndex = $quoteItem->item_index ?? null; + if ($itemIndex !== null) { + // item_index로 매칭 + foreach ($calcInputItems as $calcItem) { + if (($calcItem['index'] ?? null) === $itemIndex) { + $floorCode = $calcItem['floor'] ?? null; + $symbolCode = $calcItem['code'] ?? null; + break; + } + } + } + + // item_index로 못 찾으면 배열 인덱스로 fallback + if ($floorCode === null && $symbolCode === null && isset($calcInputItems[$index])) { + $floorCode = $calcInputItems[$index]['floor'] ?? null; + $symbolCode = $calcInputItems[$index]['code'] ?? null; + } + $order->items()->create([ 'tenant_id' => $tenantId, 'serial_no' => $index + 1, // 1부터 시작하는 순번 'item_id' => $quoteItem->item_id, + 'item_code' => $quoteItem->item_code, 'item_name' => $quoteItem->item_name, 'specification' => $quoteItem->specification, + 'floor_code' => $floorCode, + 'symbol_code' => $symbolCode, 'quantity' => $quoteItem->calculated_quantity, 'unit' => $quoteItem->unit, 'unit_price' => $quoteItem->unit_price, diff --git a/app/Services/Quote/QuoteService.php b/app/Services/Quote/QuoteService.php index 4707c5a..bfb7a88 100644 --- a/app/Services/Quote/QuoteService.php +++ b/app/Services/Quote/QuoteService.php @@ -3,6 +3,7 @@ namespace App\Services\Quote; use App\Models\Bidding\Bidding; +use App\Models\Items\Item; use App\Models\Orders\Order; use App\Models\Orders\OrderItem; use App\Models\Quote\Quote; @@ -180,6 +181,7 @@ private function calculateBomMaterials(Quote $quote): array $allMaterials[] = [ 'item_index' => $index, 'finished_goods_code' => $finishedGoodsCode, + 'item_id' => $material['item_id'] ?? null, 'item_code' => $material['item_code'] ?? '', 'item_name' => $material['item_name'] ?? '', 'item_type' => $material['item_type'] ?? '', @@ -575,10 +577,17 @@ private function createItems(Quote $quote, array $items, int $tenantId): void $unitPrice = (float) ($item['unit_price'] ?? 0); $totalPrice = $quantity * $unitPrice; + // item_type: 전달된 값 또는 items 테이블에서 조회 + $itemType = $item['item_type'] ?? null; + if ($itemType === null && isset($item['item_id'])) { + $itemType = Item::where('id', $item['item_id'])->value('item_type'); + } + QuoteItem::create([ 'quote_id' => $quote->id, 'tenant_id' => $tenantId, 'item_id' => $item['item_id'] ?? null, + 'item_type' => $itemType, 'item_code' => $item['item_code'] ?? '', 'item_name' => $item['item_name'] ?? '', 'specification' => $item['specification'] ?? null, diff --git a/app/Services/ReceivingService.php b/app/Services/ReceivingService.php index f6dd41f..89a34ae 100644 --- a/app/Services/ReceivingService.php +++ b/app/Services/ReceivingService.php @@ -225,7 +225,7 @@ public function destroy(int $id): bool } /** - * 입고처리 (상태 변경 + 입고 정보 입력) + * 입고처리 (상태 변경 + 입고 정보 입력 + 재고 연동) */ public function process(int $id, array $data): Receiving { @@ -255,6 +255,11 @@ public function process(int $id, array $data): Receiving $receiving->updated_by = $userId; $receiving->save(); + // 🆕 재고 연동: Stock + StockLot 생성/갱신 + if ($receiving->item_id) { + app(StockService::class)->increaseFromReceiving($receiving); + } + return $receiving->fresh(); }); } diff --git a/app/Services/ShipmentService.php b/app/Services/ShipmentService.php index 6710393..109b45d 100644 --- a/app/Services/ShipmentService.php +++ b/app/Services/ShipmentService.php @@ -329,14 +329,65 @@ public function updateStatus(int $id, string $status, ?array $additionalData = n $updateData['confirmed_arrival'] = $additionalData['confirmed_arrival']; } + $previousStatus = $shipment->status; $shipment->update($updateData); + // 🆕 출하완료 시 재고 차감 (FIFO) + if ($status === 'completed' && $previousStatus !== 'completed') { + $this->decreaseStockForShipment($shipment); + } + // 연결된 수주(Order) 상태 동기화 $this->syncOrderStatus($shipment, $tenantId); return $shipment->load('items'); } + /** + * 출하 완료 시 재고 차감 + */ + private function decreaseStockForShipment(Shipment $shipment): void + { + $stockService = app(StockService::class); + + // 출하 품목 조회 + $items = $shipment->items; + + foreach ($items as $item) { + // item_id가 없는 경우 item_code로 품목 조회 시도 + $itemId = $item->item_id; + + if (! $itemId && $item->item_code) { + $tenantId = $this->tenantId(); + $foundItem = \App\Models\Items\Item::where('tenant_id', $tenantId) + ->where('code', $item->item_code) + ->first(); + $itemId = $foundItem?->id; + } + + if (! $itemId || ! $item->quantity) { + continue; + } + + try { + $stockService->decreaseForShipment( + itemId: $itemId, + qty: (float) $item->quantity, + shipmentId: $shipment->id, + stockLotId: $item->stock_lot_id + ); + } catch (\Exception $e) { + // 재고 부족 등의 에러는 로그만 기록하고 계속 진행 + \Illuminate\Support\Facades\Log::warning('Failed to decrease stock for shipment item', [ + 'shipment_id' => $shipment->id, + 'item_code' => $item->item_code, + 'quantity' => $item->quantity, + 'error' => $e->getMessage(), + ]); + } + } + } + /** * 출하 상태 변경 시 연결된 수주(Order) 상태 동기화 * @@ -411,6 +462,7 @@ protected function syncItems(Shipment $shipment, array $items, int $tenantId): v 'tenant_id' => $tenantId, 'shipment_id' => $shipment->id, 'seq' => $item['seq'] ?? $seq, + 'item_id' => $item['item_id'] ?? null, 'item_code' => $item['item_code'] ?? null, 'item_name' => $item['item_name'], 'floor_unit' => $item['floor_unit'] ?? null, diff --git a/app/Services/StockService.php b/app/Services/StockService.php index 5ddf6b8..629482e 100644 --- a/app/Services/StockService.php +++ b/app/Services/StockService.php @@ -3,31 +3,53 @@ namespace App\Services; use App\Models\Items\Item; +use App\Models\Tenants\Receiving; use App\Models\Tenants\Stock; +use App\Models\Tenants\StockLot; use Illuminate\Contracts\Pagination\LengthAwarePaginator; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; class StockService extends Service { /** - * Item 타입 → 재고관리 라벨 매핑 + * Item 타입 → 재고관리 라벨 매핑 (기본값) */ public const ITEM_TYPE_LABELS = [ 'RM' => '원자재', 'SM' => '부자재', 'CS' => '소모품', + 'PT' => '부품', + 'SF' => '반제품', ]; + private TenantSettingService $tenantSettingService; + + public function __construct() + { + $this->tenantSettingService = app(TenantSettingService::class); + } + + /** + * 테넌트 설정에서 재고관리 품목유형 조회 + */ + private function getStockItemTypes(): array + { + return $this->tenantSettingService->getStockItemTypes(); + } + /** * 재고 목록 조회 (Item 메인 + Stock LEFT JOIN) */ public function index(array $params): LengthAwarePaginator { $tenantId = $this->tenantId(); + $stockItemTypes = $this->getStockItemTypes(); - // Item 테이블이 메인 (materials 타입만: SM, RM, CS) + // Item 테이블이 메인 (테넌트 설정 기반 품목유형) $query = Item::query() ->where('items.tenant_id', $tenantId) - ->materials() // SM, RM, CS만 + ->byItemTypes($stockItemTypes) ->with('stock'); // 검색어 필터 (Item 기준) @@ -86,29 +108,30 @@ public function index(array $params): LengthAwarePaginator public function stats(): array { $tenantId = $this->tenantId(); + $stockItemTypes = $this->getStockItemTypes(); // 전체 자재 품목 수 (Item 기준) $totalItems = Item::where('tenant_id', $tenantId) - ->materials() + ->byItemTypes($stockItemTypes) ->count(); // 재고 상태별 카운트 (Stock이 있는 Item 기준) $normalCount = Item::where('items.tenant_id', $tenantId) - ->materials() + ->byItemTypes($stockItemTypes) ->whereHas('stock', function ($q) { $q->where('status', 'normal'); }) ->count(); $lowCount = Item::where('items.tenant_id', $tenantId) - ->materials() + ->byItemTypes($stockItemTypes) ->whereHas('stock', function ($q) { $q->where('status', 'low'); }) ->count(); $outCount = Item::where('items.tenant_id', $tenantId) - ->materials() + ->byItemTypes($stockItemTypes) ->whereHas('stock', function ($q) { $q->where('status', 'out'); }) @@ -116,7 +139,7 @@ public function stats(): array // 재고 정보가 없는 Item 수 $noStockCount = Item::where('items.tenant_id', $tenantId) - ->materials() + ->byItemTypes($stockItemTypes) ->whereDoesntHave('stock') ->count(); @@ -135,10 +158,11 @@ public function stats(): array public function show(int $id): Item { $tenantId = $this->tenantId(); + $stockItemTypes = $this->getStockItemTypes(); return Item::query() ->where('tenant_id', $tenantId) - ->materials() + ->byItemTypes($stockItemTypes) ->with(['stock.lots' => function ($query) { $query->orderBy('fifo_order'); }]) @@ -151,10 +175,11 @@ public function show(int $id): Item public function findByItemCode(string $itemCode): ?Item { $tenantId = $this->tenantId(); + $stockItemTypes = $this->getStockItemTypes(); return Item::query() ->where('tenant_id', $tenantId) - ->materials() + ->byItemTypes($stockItemTypes) ->where('code', $itemCode) ->with('stock') ->first(); @@ -166,10 +191,11 @@ public function findByItemCode(string $itemCode): ?Item public function statsByItemType(): array { $tenantId = $this->tenantId(); + $stockItemTypes = $this->getStockItemTypes(); - // Item 기준으로 통계 (materials 타입만) + // Item 기준으로 통계 (테넌트 설정 기반 품목유형) $stats = Item::where('tenant_id', $tenantId) - ->materials() + ->byItemTypes($stockItemTypes) ->selectRaw('item_type, COUNT(*) as count') ->groupBy('item_type') ->get() @@ -177,7 +203,7 @@ public function statsByItemType(): array // 재고 수량 합계 (Stock이 있는 경우) $stockQtys = Item::where('items.tenant_id', $tenantId) - ->materials() + ->byItemTypes($stockItemTypes) ->join('stocks', 'items.id', '=', 'stocks.item_id') ->selectRaw('items.item_type, SUM(stocks.stock_qty) as total_qty') ->groupBy('items.item_type') @@ -185,7 +211,8 @@ public function statsByItemType(): array ->keyBy('item_type'); $result = []; - foreach (self::ITEM_TYPE_LABELS as $key => $label) { + foreach ($stockItemTypes as $key) { + $label = self::ITEM_TYPE_LABELS[$key] ?? $key; $itemData = $stats->get($key); $stockData = $stockQtys->get($key); $result[$key] = [ @@ -197,4 +224,684 @@ public function statsByItemType(): array return $result; } + + // ===================================================== + // 재고 변동 이벤트 메서드 (입고/생산/출하 연동) + // ===================================================== + + /** + * 입고 완료 시 재고 증가 + * + * @param Receiving $receiving 입고 완료된 Receiving 레코드 + * @return StockLot 생성된 StockLot + * + * @throws \Exception item_id가 없는 경우 + */ + public function increaseFromReceiving(Receiving $receiving): StockLot + { + if (! $receiving->item_id) { + throw new \Exception(__('error.stock.item_id_required')); + } + + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($receiving, $tenantId, $userId) { + // 1. Stock 조회 또는 생성 + $stock = $this->getOrCreateStock($receiving->item_id, $receiving); + + // 2. FIFO 순서 계산 + $fifoOrder = $this->getNextFifoOrder($stock->id); + + // 3. StockLot 생성 + $stockLot = new StockLot; + $stockLot->tenant_id = $tenantId; + $stockLot->stock_id = $stock->id; + $stockLot->lot_no = $receiving->lot_no; + $stockLot->fifo_order = $fifoOrder; + $stockLot->receipt_date = $receiving->receiving_date; + $stockLot->qty = $receiving->receiving_qty; + $stockLot->reserved_qty = 0; + $stockLot->available_qty = $receiving->receiving_qty; + $stockLot->unit = $receiving->order_unit ?? 'EA'; + $stockLot->supplier = $receiving->supplier; + $stockLot->supplier_lot = $receiving->supplier_lot; + $stockLot->po_number = $receiving->order_no; + $stockLot->location = $receiving->receiving_location; + $stockLot->status = 'available'; + $stockLot->receiving_id = $receiving->id; + $stockLot->created_by = $userId; + $stockLot->updated_by = $userId; + $stockLot->save(); + + // 4. Stock 정보 갱신 (LOT 기반) + $stock->refreshFromLots(); + + // 5. 감사 로그 기록 + $this->logStockChange( + stock: $stock, + action: 'stock_increase', + reason: 'receiving', + referenceType: 'receiving', + referenceId: $receiving->id, + qtyChange: $receiving->receiving_qty, + lotNo: $receiving->lot_no + ); + + Log::info('Stock increased from receiving', [ + 'receiving_id' => $receiving->id, + 'item_id' => $receiving->item_id, + 'stock_id' => $stock->id, + 'stock_lot_id' => $stockLot->id, + 'qty' => $receiving->receiving_qty, + ]); + + return $stockLot; + }); + } + + /** + * Stock 조회 또는 생성 + * + * @param int $itemId 품목 ID + * @param Receiving|null $receiving 입고 정보 (새 Stock 생성 시 사용) + */ + public function getOrCreateStock(int $itemId, ?Receiving $receiving = null): Stock + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $stock = Stock::where('tenant_id', $tenantId) + ->where('item_id', $itemId) + ->first(); + + if ($stock) { + return $stock; + } + + // Stock이 없으면 새로 생성 + $item = Item::where('tenant_id', $tenantId) + ->findOrFail($itemId); + + $stock = new Stock; + $stock->tenant_id = $tenantId; + $stock->item_id = $itemId; + $stock->item_code = $item->code; + $stock->item_name = $item->name; + $stock->item_type = $item->item_type; + $stock->specification = $item->specification ?? $receiving?->specification; + $stock->unit = $item->unit ?? $receiving?->order_unit ?? 'EA'; + $stock->stock_qty = 0; + $stock->safety_stock = 0; + $stock->reserved_qty = 0; + $stock->available_qty = 0; + $stock->lot_count = 0; + $stock->location = $receiving?->receiving_location; + $stock->status = 'out'; + $stock->created_by = $userId; + $stock->updated_by = $userId; + $stock->save(); + + Log::info('New Stock created', [ + 'stock_id' => $stock->id, + 'item_id' => $itemId, + 'item_code' => $item->code, + ]); + + return $stock; + } + + /** + * 다음 FIFO 순서 계산 + * + * @param int $stockId Stock ID + * @return int 다음 FIFO 순서 + */ + public function getNextFifoOrder(int $stockId): int + { + $maxOrder = StockLot::where('stock_id', $stockId) + ->max('fifo_order'); + + return ($maxOrder ?? 0) + 1; + } + + /** + * FIFO 기반 재고 차감 + * + * @param int $itemId 품목 ID + * @param float $qty 차감할 수량 + * @param string $reason 차감 사유 (work_order_input, shipment 등) + * @param int $referenceId 참조 ID (작업지시 ID, 출하 ID 등) + * @return array 차감된 LOT 정보 배열 + * + * @throws \Exception 재고 부족 시 + */ + public function decreaseFIFO(int $itemId, float $qty, string $reason, int $referenceId): array + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($itemId, $qty, $reason, $referenceId, $tenantId, $userId) { + // 1. Stock 조회 + $stock = Stock::where('tenant_id', $tenantId) + ->where('item_id', $itemId) + ->lockForUpdate() + ->first(); + + if (! $stock) { + throw new \Exception(__('error.stock.not_found')); + } + + // 2. 가용 재고 확인 + if ($stock->available_qty < $qty) { + throw new \Exception(__('error.stock.insufficient_qty')); + } + + // 3. FIFO 순서로 LOT 조회 (가용 수량이 있는 LOT만) + $lots = StockLot::where('stock_id', $stock->id) + ->where('status', '!=', 'used') + ->where('available_qty', '>', 0) + ->orderBy('fifo_order') + ->lockForUpdate() + ->get(); + + if ($lots->isEmpty()) { + throw new \Exception(__('error.stock.lot_not_available')); + } + + // 4. FIFO 순서로 차감 + $remainingQty = $qty; + $deductedLots = []; + + foreach ($lots as $lot) { + if ($remainingQty <= 0) { + break; + } + + $deductQty = min($lot->available_qty, $remainingQty); + + // LOT 수량 차감 + $lot->qty -= $deductQty; + $lot->available_qty -= $deductQty; + $lot->updated_by = $userId; + + // LOT 상태 업데이트 + if ($lot->qty <= 0) { + $lot->status = 'used'; + } + + $lot->save(); + + $deductedLots[] = [ + 'lot_id' => $lot->id, + 'lot_no' => $lot->lot_no, + 'deducted_qty' => $deductQty, + 'remaining_qty' => $lot->qty, + ]; + + $remainingQty -= $deductQty; + } + + // 5. Stock 정보 갱신 + $oldStockQty = $stock->stock_qty; + $stock->refreshFromLots(); + $stock->last_issue_date = now(); + $stock->save(); + + // 6. 감사 로그 기록 + $this->logStockChange( + stock: $stock, + action: 'stock_decrease', + reason: $reason, + referenceType: $reason, + referenceId: $referenceId, + qtyChange: -$qty, + lotNo: implode(',', array_column($deductedLots, 'lot_no')) + ); + + Log::info('Stock decreased (FIFO)', [ + 'item_id' => $itemId, + 'stock_id' => $stock->id, + 'qty' => $qty, + 'reason' => $reason, + 'reference_id' => $referenceId, + 'old_stock_qty' => $oldStockQty, + 'new_stock_qty' => $stock->stock_qty, + 'deducted_lots' => $deductedLots, + ]); + + return $deductedLots; + }); + } + + /** + * 품목별 가용 재고 조회 + * + * @param int $itemId 품목 ID + * @return array|null 재고 정보 (stock_qty, available_qty, reserved_qty, lot_count) + */ + public function getAvailableStock(int $itemId): ?array + { + $tenantId = $this->tenantId(); + + $stock = Stock::where('tenant_id', $tenantId) + ->where('item_id', $itemId) + ->first(); + + if (! $stock) { + return null; + } + + return [ + 'stock_id' => $stock->id, + 'item_id' => $stock->item_id, + 'stock_qty' => (float) $stock->stock_qty, + 'available_qty' => (float) $stock->available_qty, + 'reserved_qty' => (float) $stock->reserved_qty, + 'lot_count' => $stock->lot_count, + 'status' => $stock->status, + ]; + } + + // ===================================================== + // 재고 예약 메서드 (견적/수주 연동) + // ===================================================== + + /** + * 재고 예약 (수주 확정 시) + * + * @param int $itemId 품목 ID + * @param float $qty 예약할 수량 + * @param int $orderId 수주 ID (참조용) + * + * @throws \Exception 재고 부족 시 + */ + public function reserve(int $itemId, float $qty, int $orderId): void + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + DB::transaction(function () use ($itemId, $qty, $orderId, $tenantId, $userId) { + // 1. Stock 조회 (락 적용) + $stock = Stock::where('tenant_id', $tenantId) + ->where('item_id', $itemId) + ->lockForUpdate() + ->first(); + + if (! $stock) { + // 재고가 없으면 예약만 기록 (재고 부족이지만 수주는 가능) + Log::warning('Stock not found for reservation', [ + 'item_id' => $itemId, + 'order_id' => $orderId, + 'qty' => $qty, + ]); + + return; + } + + // 2. 가용 재고 확인 (경고만 기록, 예약은 진행) + if ($stock->available_qty < $qty) { + Log::warning('Insufficient stock for reservation', [ + 'item_id' => $itemId, + 'order_id' => $orderId, + 'requested_qty' => $qty, + 'available_qty' => $stock->available_qty, + ]); + } + + // 3. FIFO 순서로 LOT 예약 처리 + $lots = StockLot::where('stock_id', $stock->id) + ->where('status', '!=', 'used') + ->where('available_qty', '>', 0) + ->orderBy('fifo_order') + ->lockForUpdate() + ->get(); + + $remainingQty = $qty; + $reservedLots = []; + + foreach ($lots as $lot) { + if ($remainingQty <= 0) { + break; + } + + $reserveQty = min($lot->available_qty, $remainingQty); + + // LOT 예약 수량 증가 + $lot->reserved_qty += $reserveQty; + $lot->available_qty -= $reserveQty; + $lot->updated_by = $userId; + + // 상태 업데이트 (전량 예약 시) + if ($lot->available_qty <= 0) { + $lot->status = 'reserved'; + } + + $lot->save(); + + $reservedLots[] = [ + 'lot_id' => $lot->id, + 'lot_no' => $lot->lot_no, + 'reserved_qty' => $reserveQty, + ]; + + $remainingQty -= $reserveQty; + } + + // 4. Stock 정보 갱신 + $stock->refreshFromLots(); + + // 5. 감사 로그 기록 + $this->logStockChange( + stock: $stock, + action: 'stock_reserve', + reason: 'order_confirm', + referenceType: 'order', + referenceId: $orderId, + qtyChange: $qty, + lotNo: implode(',', array_column($reservedLots, 'lot_no')) + ); + + Log::info('Stock reserved for order', [ + 'order_id' => $orderId, + 'item_id' => $itemId, + 'qty' => $qty, + 'reserved_lots' => $reservedLots, + ]); + }); + } + + /** + * 재고 예약 해제 (수주 취소 시) + * + * @param int $itemId 품목 ID + * @param float $qty 해제할 수량 + * @param int $orderId 수주 ID (참조용) + */ + public function releaseReservation(int $itemId, float $qty, int $orderId): void + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + DB::transaction(function () use ($itemId, $qty, $orderId, $tenantId, $userId) { + // 1. Stock 조회 + $stock = Stock::where('tenant_id', $tenantId) + ->where('item_id', $itemId) + ->lockForUpdate() + ->first(); + + if (! $stock) { + Log::warning('Stock not found for release reservation', [ + 'item_id' => $itemId, + 'order_id' => $orderId, + ]); + + return; + } + + // 2. 예약된 LOT 조회 (FIFO 역순으로 해제) + $lots = StockLot::where('stock_id', $stock->id) + ->where('reserved_qty', '>', 0) + ->orderByDesc('fifo_order') + ->lockForUpdate() + ->get(); + + $remainingQty = $qty; + $releasedLots = []; + + foreach ($lots as $lot) { + if ($remainingQty <= 0) { + break; + } + + $releaseQty = min($lot->reserved_qty, $remainingQty); + + // LOT 예약 해제 + $lot->reserved_qty -= $releaseQty; + $lot->available_qty += $releaseQty; + $lot->updated_by = $userId; + + // 상태 업데이트 + if ($lot->qty > 0 && $lot->status === 'reserved') { + $lot->status = 'available'; + } + + $lot->save(); + + $releasedLots[] = [ + 'lot_id' => $lot->id, + 'lot_no' => $lot->lot_no, + 'released_qty' => $releaseQty, + ]; + + $remainingQty -= $releaseQty; + } + + // 3. Stock 정보 갱신 + $stock->refreshFromLots(); + + // 4. 감사 로그 기록 + $this->logStockChange( + stock: $stock, + action: 'stock_release', + reason: 'order_cancel', + referenceType: 'order', + referenceId: $orderId, + qtyChange: -$qty, + lotNo: implode(',', array_column($releasedLots, 'lot_no')) + ); + + Log::info('Stock reservation released', [ + 'order_id' => $orderId, + 'item_id' => $itemId, + 'qty' => $qty, + 'released_lots' => $releasedLots, + ]); + }); + } + + /** + * 수주 품목들의 재고 예약 (일괄 처리) + * + * @param \Illuminate\Support\Collection $orderItems 수주 품목 목록 + * @param int $orderId 수주 ID + */ + public function reserveForOrder($orderItems, int $orderId): void + { + foreach ($orderItems as $item) { + if (! $item->item_id) { + continue; + } + + $this->reserve( + itemId: $item->item_id, + qty: (float) $item->quantity, + orderId: $orderId + ); + } + } + + /** + * 수주 품목들의 재고 예약 해제 (일괄 처리) + * + * @param \Illuminate\Support\Collection $orderItems 수주 품목 목록 + * @param int $orderId 수주 ID + */ + public function releaseReservationForOrder($orderItems, int $orderId): void + { + foreach ($orderItems as $item) { + if (! $item->item_id) { + continue; + } + + $this->releaseReservation( + itemId: $item->item_id, + qty: (float) $item->quantity, + orderId: $orderId + ); + } + } + + // ===================================================== + // 출하 연동 메서드 + // ===================================================== + + /** + * 출하 완료 시 재고 차감 + * + * @param int $itemId 품목 ID + * @param float $qty 차감할 수량 + * @param int $shipmentId 출하 ID + * @param int|null $stockLotId 특정 LOT ID (지정 시 해당 LOT만 차감) + * @return array 차감된 LOT 정보 + */ + public function decreaseForShipment(int $itemId, float $qty, int $shipmentId, ?int $stockLotId = null): array + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($itemId, $qty, $shipmentId, $stockLotId, $tenantId, $userId) { + // 1. Stock 조회 + $stock = Stock::where('tenant_id', $tenantId) + ->where('item_id', $itemId) + ->lockForUpdate() + ->first(); + + if (! $stock) { + throw new \Exception(__('error.stock.not_found')); + } + + // 2. LOT 조회 (특정 LOT 또는 FIFO) + if ($stockLotId) { + $lots = StockLot::where('id', $stockLotId) + ->where('stock_id', $stock->id) + ->lockForUpdate() + ->get(); + } else { + $lots = StockLot::where('stock_id', $stock->id) + ->where('status', '!=', 'used') + ->where('qty', '>', 0) + ->orderBy('fifo_order') + ->lockForUpdate() + ->get(); + } + + if ($lots->isEmpty()) { + throw new \Exception(__('error.stock.lot_not_available')); + } + + // 3. 차감 처리 + $remainingQty = $qty; + $deductedLots = []; + + foreach ($lots as $lot) { + if ($remainingQty <= 0) { + break; + } + + $deductQty = min($lot->qty, $remainingQty); + + // LOT 수량 차감 + $lot->qty -= $deductQty; + + // 예약 수량도 동시에 차감 (출하는 예약된 재고를 사용) + if ($lot->reserved_qty > 0) { + $reserveDeduct = min($lot->reserved_qty, $deductQty); + $lot->reserved_qty -= $reserveDeduct; + } else { + // 예약 없이 출하되는 경우 available에서 차감 + $lot->available_qty = max(0, $lot->available_qty - $deductQty); + } + + $lot->updated_by = $userId; + + // LOT 상태 업데이트 + if ($lot->qty <= 0) { + $lot->status = 'used'; + $lot->available_qty = 0; + $lot->reserved_qty = 0; + } + + $lot->save(); + + $deductedLots[] = [ + 'lot_id' => $lot->id, + 'lot_no' => $lot->lot_no, + 'deducted_qty' => $deductQty, + 'remaining_qty' => $lot->qty, + ]; + + $remainingQty -= $deductQty; + } + + // 4. Stock 정보 갱신 + $stock->refreshFromLots(); + $stock->last_issue_date = now(); + $stock->save(); + + // 5. 감사 로그 기록 + $this->logStockChange( + stock: $stock, + action: 'stock_decrease', + reason: 'shipment', + referenceType: 'shipment', + referenceId: $shipmentId, + qtyChange: -$qty, + lotNo: implode(',', array_column($deductedLots, 'lot_no')) + ); + + Log::info('Stock decreased for shipment', [ + 'shipment_id' => $shipmentId, + 'item_id' => $itemId, + 'qty' => $qty, + 'deducted_lots' => $deductedLots, + ]); + + return $deductedLots; + }); + } + + /** + * 재고 변경 감사 로그 기록 + */ + private function logStockChange( + Stock $stock, + string $action, + string $reason, + string $referenceType, + int $referenceId, + float $qtyChange, + ?string $lotNo = null + ): void { + try { + \App\Models\Audit\AuditLog::create([ + 'tenant_id' => $stock->tenant_id, + 'target_type' => 'Stock', + 'target_id' => $stock->id, + 'action' => $action, + 'before' => [ + 'stock_qty' => (float) ($stock->getOriginal('stock_qty') ?? 0), + ], + 'after' => [ + 'stock_qty' => (float) $stock->stock_qty, + 'qty_change' => $qtyChange, + 'reason' => $reason, + 'reference_type' => $referenceType, + 'reference_id' => $referenceId, + 'lot_no' => $lotNo, + ], + 'actor_id' => $this->apiUserId(), + 'ip' => request()->ip(), + 'ua' => request()->userAgent(), + 'created_at' => now(), + ]); + } catch (\Exception $e) { + // 감사 로그 실패는 비즈니스 로직에 영향을 주지 않음 + Log::warning('Failed to create audit log for stock change', [ + 'stock_id' => $stock->id, + 'action' => $action, + 'error' => $e->getMessage(), + ]); + } + } } diff --git a/app/Services/TenantSettingService.php b/app/Services/TenantSettingService.php new file mode 100644 index 0000000..4d18b5c --- /dev/null +++ b/app/Services/TenantSettingService.php @@ -0,0 +1,205 @@ + [ + self::KEY_STOCK_ITEM_TYPES => ['RM', 'SM', 'CS', 'PT', 'SF'], + self::KEY_DEFAULT_SAFETY_STOCK => 10, + self::KEY_LOW_STOCK_ALERT => true, + ], + ]; + + /** + * 특정 그룹의 모든 설정 조회 + */ + public function getByGroup(string $group): Collection + { + return TenantSetting::where('tenant_id', $this->tenantId()) + ->group($group) + ->get() + ->mapWithKeys(function ($setting) { + return [$setting->setting_key => $setting->setting_value]; + }); + } + + /** + * 모든 설정 조회 (그룹별 구조화) + */ + public function getAll(): array + { + $settings = TenantSetting::where('tenant_id', $this->tenantId()) + ->get() + ->groupBy('setting_group') + ->map(function ($group) { + return $group->mapWithKeys(function ($setting) { + return [$setting->setting_key => [ + 'value' => $setting->setting_value, + 'description' => $setting->description, + 'updated_at' => $setting->updated_at?->toISOString(), + ]]; + }); + }) + ->toArray(); + + return $settings; + } + + /** + * 설정값 조회 (기본값 포함) + */ + public function get(string $group, string $key, $default = null) + { + $value = TenantSetting::getValue($this->tenantId(), $group, $key); + + if ($value !== null) { + return $value; + } + + // 기본값에서 찾기 + if ($default === null && isset($this->defaults[$group][$key])) { + return $this->defaults[$group][$key]; + } + + return $default; + } + + /** + * 설정값 저장 + */ + public function set(string $group, string $key, $value, ?string $description = null): TenantSetting + { + return TenantSetting::setValue( + $this->tenantId(), + $group, + $key, + $value, + $description, + $this->apiUserId() + ); + } + + /** + * 여러 설정값 일괄 저장 + */ + public function setMany(string $group, array $settings): array + { + $results = []; + + foreach ($settings as $key => $data) { + $value = is_array($data) && isset($data['value']) ? $data['value'] : $data; + $description = is_array($data) && isset($data['description']) ? $data['description'] : null; + + $results[$key] = $this->set($group, $key, $value, $description); + } + + return $results; + } + + /** + * 설정 삭제 + */ + public function delete(string $group, string $key): bool + { + return TenantSetting::where('tenant_id', $this->tenantId()) + ->where('setting_group', $group) + ->where('setting_key', $key) + ->delete() > 0; + } + + /** + * 재고관리 품목유형 조회 + */ + public function getStockItemTypes(): array + { + return $this->get(self::GROUP_STOCK, self::KEY_STOCK_ITEM_TYPES, ['RM', 'SM', 'CS', 'PT', 'SF']); + } + + /** + * 기본 안전재고 조회 + */ + public function getDefaultSafetyStock(): int + { + return (int) $this->get(self::GROUP_STOCK, self::KEY_DEFAULT_SAFETY_STOCK, 10); + } + + /** + * 재고부족 알림 활성화 여부 + */ + public function isLowStockAlertEnabled(): bool + { + return (bool) $this->get(self::GROUP_STOCK, self::KEY_LOW_STOCK_ALERT, true); + } + + /** + * 기본 설정으로 초기화 + */ + public function initializeDefaults(): array + { + $results = []; + + foreach ($this->defaults as $group => $settings) { + foreach ($settings as $key => $value) { + // 기존 설정이 없을 때만 생성 + $existing = TenantSetting::getValue($this->tenantId(), $group, $key); + if ($existing === null) { + $results["{$group}.{$key}"] = $this->set($group, $key, $value, $this->getDefaultDescription($group, $key)); + } + } + } + + return $results; + } + + /** + * 기본 설명 가져오기 + */ + private function getDefaultDescription(string $group, string $key): string + { + $descriptions = [ + self::GROUP_STOCK => [ + self::KEY_STOCK_ITEM_TYPES => '재고관리 대상 품목유형 (FG 완제품 제외)', + self::KEY_DEFAULT_SAFETY_STOCK => '안전재고 기본값', + self::KEY_LOW_STOCK_ALERT => '재고부족 알림 활성화', + ], + ]; + + return $descriptions[$group][$key] ?? ''; + } +} diff --git a/app/Services/VatService.php b/app/Services/VatService.php index 8613be3..9f9b0b2 100644 --- a/app/Services/VatService.php +++ b/app/Services/VatService.php @@ -4,7 +4,6 @@ use App\Models\Tenants\TaxInvoice; use Carbon\Carbon; -use Illuminate\Support\Facades\DB; /** * 부가세 현황 서비스 @@ -232,7 +231,7 @@ private function getPeriodLabel(int $year, string $periodType, int $period): str { return match ($periodType) { 'quarter' => "{$year}년 {$period}기 예정신고", - 'half' => "{$year}년 " . ($period === 1 ? '상반기' : '하반기') . ' 확정신고', + 'half' => "{$year}년 ".($period === 1 ? '상반기' : '하반기').' 확정신고', 'year' => "{$year}년 연간", default => "{$year}년 {$period}기", }; @@ -258,4 +257,4 @@ private function getPreviousPeriod(int $year, string $periodType, int $period): : ['year' => $year, 'period' => $period - 1], }; } -} \ No newline at end of file +} diff --git a/app/Services/WorkOrderService.php b/app/Services/WorkOrderService.php index bb7ebe1..ecbfbe6 100644 --- a/app/Services/WorkOrderService.php +++ b/app/Services/WorkOrderService.php @@ -1025,21 +1025,20 @@ public function updateItemStatus(int $workOrderId, int $itemId, string $status) } /** - * 작업지시에 필요한 자재 목록 조회 (BOM 기반) + * 작업지시에 필요한 자재 목록 조회 (BOM 기반 + 실제 재고 연동) * - * 작업지시의 품목에 연결된 BOM 자재 목록을 반환합니다. - * 현재는 품목 정보 기반으로 Mock 데이터를 반환하며, - * 향후 자재 관리 기능 구현 시 실제 데이터로 연동됩니다. + * 작업지시의 품목에 연결된 BOM 자재 목록과 실제 재고 정보를 반환합니다. + * 품목의 BOM 정보를 기반으로 필요 자재를 추출하고, 각 자재의 실제 재고를 조회합니다. * * @param int $workOrderId 작업지시 ID - * @return array 자재 목록 (id, material_code, material_name, unit, current_stock, fifo_rank) + * @return array 자재 목록 (item_id, material_code, material_name, unit, required_qty, current_stock, available_qty, fifo_rank) */ public function getMaterials(int $workOrderId): array { $tenantId = $this->tenantId(); $workOrder = WorkOrder::where('tenant_id', $tenantId) - ->with(['items.item', 'salesOrder.items']) + ->with(['items.item']) ->find($workOrderId); if (! $workOrder) { @@ -1048,29 +1047,75 @@ public function getMaterials(int $workOrderId): array $materials = []; $rank = 1; + $stockService = app(StockService::class); - // 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++, - ]; + // 작업지시 품목들의 BOM에서 자재 추출 + foreach ($workOrder->items as $woItem) { + // item_id가 있으면 해당 Item의 BOM 조회 + if ($woItem->item_id) { + $item = \App\Models\Items\Item::where('tenant_id', $tenantId) + ->find($woItem->item_id); + + if ($item && ! empty($item->bom)) { + // BOM의 각 자재 처리 + foreach ($item->bom as $bomItem) { + $childItemId = $bomItem['child_item_id'] ?? null; + $bomQty = (float) ($bomItem['qty'] ?? 1); + + if (! $childItemId) { + continue; + } + + // 자재(자식 품목) 정보 조회 + $childItem = \App\Models\Items\Item::where('tenant_id', $tenantId) + ->find($childItemId); + + if (! $childItem) { + continue; + } + + // 필요 수량 계산 (BOM 수량 × 작업지시 수량) + $requiredQty = $bomQty * ($woItem->quantity ?? 1); + + // 실제 재고 조회 + $stockInfo = $stockService->getAvailableStock($childItemId); + + $materials[] = [ + 'item_id' => $childItemId, + 'work_order_item_id' => $woItem->id, + 'material_code' => $childItem->code, + 'material_name' => $childItem->name, + 'specification' => $childItem->specification, + 'unit' => $childItem->unit ?? 'EA', + 'bom_qty' => $bomQty, + 'required_qty' => $requiredQty, + 'current_stock' => $stockInfo['stock_qty'] ?? 0, + 'available_qty' => $stockInfo['available_qty'] ?? 0, + 'reserved_qty' => $stockInfo['reserved_qty'] ?? 0, + 'is_sufficient' => ($stockInfo['available_qty'] ?? 0) >= $requiredQty, + '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) { + + // BOM이 없는 경우, 품목 자체를 자재로 취급 (Fallback) + if (empty($materials) && $woItem->item_id) { + $stockInfo = $stockService->getAvailableStock($woItem->item_id); + $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: 실제 재고 데이터 연동 필요 + 'item_id' => $woItem->item_id, + 'work_order_item_id' => $woItem->id, + 'material_code' => $woItem->item_id ? "ITEM-{$woItem->item_id}" : null, + 'material_name' => $woItem->item_name, + 'specification' => $woItem->specification, + 'unit' => $woItem->unit ?? 'EA', + 'bom_qty' => 1, + 'required_qty' => $woItem->quantity ?? 1, + 'current_stock' => $stockInfo['stock_qty'] ?? 0, + 'available_qty' => $stockInfo['available_qty'] ?? 0, + 'reserved_qty' => $stockInfo['reserved_qty'] ?? 0, + 'is_sufficient' => ($stockInfo['available_qty'] ?? 0) >= ($woItem->quantity ?? 1), 'fifo_rank' => $rank++, ]; } @@ -1080,16 +1125,18 @@ public function getMaterials(int $workOrderId): array } /** - * 자재 투입 등록 + * 자재 투입 등록 (재고 차감 포함) * - * 작업지시에 자재 투입을 등록합니다. - * 현재는 감사 로그만 기록하며, 향후 재고 차감 로직 추가 필요. + * 작업지시에 자재 투입을 등록하고 재고를 차감합니다. + * FIFO 기반으로 가장 오래된 LOT부터 차감합니다. * * @param int $workOrderId 작업지시 ID - * @param array $materialIds 투입할 자재 ID 목록 + * @param array $materials 투입할 자재 목록 [['item_id' => int, 'qty' => float], ...] * @return array 투입 결과 + * + * @throws \Exception 재고 부족 시 */ - public function registerMaterialInput(int $workOrderId, array $materialIds): array + public function registerMaterialInput(int $workOrderId, array $materials): array { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); @@ -1099,25 +1146,61 @@ public function registerMaterialInput(int $workOrderId, array $materialIds): arr 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 DB::transaction(function () use ($materials, $tenantId, $userId, $workOrderId) { + $stockService = app(StockService::class); + $inputResults = []; - return [ - 'work_order_id' => $workOrderId, - 'material_count' => count($materialIds), - 'input_at' => now()->toDateTimeString(), - ]; + foreach ($materials as $material) { + $itemId = $material['item_id'] ?? null; + $qty = (float) ($material['qty'] ?? 0); + + if (! $itemId || $qty <= 0) { + continue; + } + + // FIFO 기반 재고 차감 + try { + $deductedLots = $stockService->decreaseFIFO( + itemId: $itemId, + qty: $qty, + reason: 'work_order_input', + referenceId: $workOrderId + ); + + $inputResults[] = [ + 'item_id' => $itemId, + 'qty' => $qty, + 'status' => 'success', + 'deducted_lots' => $deductedLots, + ]; + } catch (\Exception $e) { + // 재고 부족 등의 오류는 전체 트랜잭션 롤백 + throw $e; + } + } + + // 자재 투입 감사 로그 + $this->auditLogger->log( + $tenantId, + self::AUDIT_TARGET, + $workOrderId, + 'material_input', + null, + [ + 'materials' => $materials, + 'input_results' => $inputResults, + 'input_by' => $userId, + 'input_at' => now()->toDateTimeString(), + ] + ); + + return [ + 'work_order_id' => $workOrderId, + 'material_count' => count($inputResults), + 'input_results' => $inputResults, + 'input_at' => now()->toDateTimeString(), + ]; + }); } /** diff --git a/app/Swagger/v1/CalendarApi.php b/app/Swagger/v1/CalendarApi.php index 30dd660..73fe621 100644 --- a/app/Swagger/v1/CalendarApi.php +++ b/app/Swagger/v1/CalendarApi.php @@ -44,6 +44,7 @@ * * @OA\Items(ref="#/components/schemas/CalendarScheduleItem") * ), + * * @OA\Property(property="total_count", type="integer", description="총 일정 수", example=15) * ) */ @@ -143,4 +144,4 @@ class CalendarApi * ) */ public function summary() {} -} \ No newline at end of file +} diff --git a/app/Swagger/v1/EntertainmentApi.php b/app/Swagger/v1/EntertainmentApi.php index b92876b..21ee2eb 100644 --- a/app/Swagger/v1/EntertainmentApi.php +++ b/app/Swagger/v1/EntertainmentApi.php @@ -64,6 +64,7 @@ * * @OA\Items(ref="#/components/schemas/EntertainmentAmountCard") * ), + * * @OA\Property( * property="check_points", * type="array", @@ -144,4 +145,4 @@ class EntertainmentApi * ) */ public function summary() {} -} \ No newline at end of file +} diff --git a/app/Swagger/v1/SaleApi.php b/app/Swagger/v1/SaleApi.php index 1f5f995..5da305c 100644 --- a/app/Swagger/v1/SaleApi.php +++ b/app/Swagger/v1/SaleApi.php @@ -162,6 +162,7 @@ * description="거래명세서 일괄 발행 요청", * * @OA\Property(property="ids", type="array", minItems=1, maxItems=100, description="발행할 매출 ID 목록 (최대 100개)", + * * @OA\Items(type="integer", example=1) * ) * ) @@ -174,6 +175,7 @@ * @OA\Property(property="issued", type="integer", example=8, description="발행 성공 건수"), * @OA\Property(property="failed", type="integer", example=2, description="발행 실패 건수"), * @OA\Property(property="errors", type="object", description="실패 상세 (ID: 에러메시지)", + * * @OA\AdditionalProperties(type="string", example="확정 상태가 아닌 매출입니다.") * ) * ) diff --git a/app/Swagger/v1/StatusBoardApi.php b/app/Swagger/v1/StatusBoardApi.php index 59b7f7e..b8f9ecd 100644 --- a/app/Swagger/v1/StatusBoardApi.php +++ b/app/Swagger/v1/StatusBoardApi.php @@ -25,6 +25,7 @@ * }, * description="건수 또는 텍스트" * ), + * * @OA\Property(property="path", type="string", description="이동 경로", example="/sales/order-management-sales"), * @OA\Property(property="isHighlighted", type="boolean", description="강조 표시 여부", example=false) * ) @@ -91,4 +92,4 @@ class StatusBoardApi * ) */ public function summary() {} -} \ No newline at end of file +} diff --git a/app/Swagger/v1/TaxInvoiceApi.php b/app/Swagger/v1/TaxInvoiceApi.php index 27d6b67..6c1ea52 100644 --- a/app/Swagger/v1/TaxInvoiceApi.php +++ b/app/Swagger/v1/TaxInvoiceApi.php @@ -177,6 +177,7 @@ * description="세금계산서 일괄 발행 요청", * * @OA\Property(property="ids", type="array", minItems=1, maxItems=100, description="발행할 세금계산서 ID 목록 (최대 100개)", + * * @OA\Items(type="integer", example=1) * ) * ) @@ -189,6 +190,7 @@ * @OA\Property(property="issued", type="integer", example=8, description="발행 성공 건수"), * @OA\Property(property="failed", type="integer", example=2, description="발행 실패 건수"), * @OA\Property(property="errors", type="object", description="실패 상세 (ID: 에러메시지)", + * * @OA\AdditionalProperties(type="string", example="이미 발행된 세금계산서입니다.") * ) * ) diff --git a/app/Swagger/v1/TenantSettingApi.php b/app/Swagger/v1/TenantSettingApi.php new file mode 100644 index 0000000..07701cf --- /dev/null +++ b/app/Swagger/v1/TenantSettingApi.php @@ -0,0 +1,293 @@ +dropColumn(['order_id', 'shipment_id', 'source_type']); }); } -}; \ No newline at end of file +}; diff --git a/database/migrations/2026_01_23_100000_add_delivery_method_codes_to_common_codes.php b/database/migrations/2026_01_23_100000_add_delivery_method_codes_to_common_codes.php index dfad681..17f1149 100644 --- a/database/migrations/2026_01_23_100000_add_delivery_method_codes_to_common_codes.php +++ b/database/migrations/2026_01_23_100000_add_delivery_method_codes_to_common_codes.php @@ -41,4 +41,4 @@ public function down(): void { DB::table('common_codes')->where('code_group', 'delivery_method')->delete(); } -}; \ No newline at end of file +}; diff --git a/database/migrations/2026_01_26_111051_add_item_type_to_quote_items_table.php b/database/migrations/2026_01_26_111051_add_item_type_to_quote_items_table.php new file mode 100644 index 0000000..10bee3e --- /dev/null +++ b/database/migrations/2026_01_26_111051_add_item_type_to_quote_items_table.php @@ -0,0 +1,37 @@ +string('item_type', 15)->nullable()->after('item_id') + ->comment('품목 유형: FG, PT, SM, RM, CS'); + }); + + // 기존 데이터 업데이트: items 테이블에서 item_type 가져오기 + DB::statement(' + UPDATE quote_items qi + INNER JOIN items i ON qi.item_id = i.id + SET qi.item_type = i.item_type + WHERE qi.item_id IS NOT NULL + '); + } + + public function down(): void + { + Schema::table('quote_items', function (Blueprint $table) { + $table->dropColumn('item_type'); + }); + } +}; diff --git a/database/migrations/2026_01_26_161004_create_tenant_settings_table.php b/database/migrations/2026_01_26_161004_create_tenant_settings_table.php new file mode 100644 index 0000000..8b672d5 --- /dev/null +++ b/database/migrations/2026_01_26_161004_create_tenant_settings_table.php @@ -0,0 +1,40 @@ +id(); + $table->unsignedBigInteger('tenant_id')->comment('테넌트 ID'); + $table->string('setting_group', 50)->comment('설정 그룹 (stock, order, production 등)'); + $table->string('setting_key', 100)->comment('설정 키'); + $table->json('setting_value')->nullable()->comment('설정 값 (JSON)'); + $table->string('description')->nullable()->comment('설정 설명'); + $table->timestamps(); + $table->unsignedBigInteger('updated_by')->nullable()->comment('수정자 ID'); + + // 인덱스 + $table->unique(['tenant_id', 'setting_group', 'setting_key'], 'tenant_settings_unique'); + $table->index(['tenant_id', 'setting_group'], 'tenant_settings_group_idx'); + }); + } + + public function down(): void + { + Schema::dropIfExists('tenant_settings'); + } +}; diff --git a/database/seeders/FundScheduleSeeder.php b/database/seeders/FundScheduleSeeder.php index d0e98e3..15ae903 100644 --- a/database/seeders/FundScheduleSeeder.php +++ b/database/seeders/FundScheduleSeeder.php @@ -155,6 +155,6 @@ public function run(): void // 데이터 삽입 DB::table('fund_schedules')->insert($schedules); - $this->command->info('FundScheduleSeeder 완료: 자금계획일정 ' . count($schedules) . '개 생성'); + $this->command->info('FundScheduleSeeder 완료: 자금계획일정 '.count($schedules).'개 생성'); } } diff --git a/database/seeders/StockInitSeeder.php b/database/seeders/StockInitSeeder.php new file mode 100644 index 0000000..857e0a0 --- /dev/null +++ b/database/seeders/StockInitSeeder.php @@ -0,0 +1,87 @@ +whereIn('item_type', ['RM', 'SM', 'CS', 'PT', 'SF']) + ->get(); + + $this->command->info("총 {$items->count()}개 품목에 대해 Stock 생성/업데이트 시작..."); + + $created = 0; + $updated = 0; + + DB::transaction(function () use ($items, $tenantId, &$created, &$updated) { + foreach ($items as $item) { + $stockQty = rand(0, 100); + $safetyStock = 10; + + // 재고 상태 계산 + $status = 'normal'; + if ($stockQty <= 0) { + $status = 'out'; + } elseif ($stockQty < $safetyStock) { + $status = 'low'; + } + + $stock = Stock::where('tenant_id', $tenantId) + ->where('item_id', $item->id) + ->first(); + + if ($stock) { + // 기존 Stock 업데이트 + $stock->update([ + 'stock_qty' => $stockQty, + 'safety_stock' => $safetyStock, + 'available_qty' => $stockQty, + 'status' => $status, + 'updated_by' => 1, + ]); + $updated++; + } else { + // 새 Stock 생성 + Stock::create([ + 'tenant_id' => $tenantId, + 'item_id' => $item->id, + 'item_code' => $item->code, + 'item_name' => $item->name, + 'item_type' => $item->item_type, + 'specification' => $item->specification ?? null, + 'unit' => $item->unit ?? 'EA', + 'stock_qty' => $stockQty, + 'safety_stock' => $safetyStock, + 'reserved_qty' => 0, + 'available_qty' => $stockQty, + 'lot_count' => 0, + 'location' => null, + 'status' => $status, + 'created_by' => 1, + 'updated_by' => 1, + ]); + $created++; + } + } + }); + + $this->command->info("완료! 생성: {$created}개, 업데이트: {$updated}개"); + } +} diff --git a/database/seeders/TaxScheduleSeeder.php b/database/seeders/TaxScheduleSeeder.php index 185e643..a0e5937 100644 --- a/database/seeders/TaxScheduleSeeder.php +++ b/database/seeders/TaxScheduleSeeder.php @@ -86,4 +86,4 @@ public function run(): void $this->command->info("부가세 신고 마감일 {$year}년 일정이 등록되었습니다."); } -} \ No newline at end of file +} diff --git a/database/seeders/TenantSettingSeeder.php b/database/seeders/TenantSettingSeeder.php new file mode 100644 index 0000000..92f9ca5 --- /dev/null +++ b/database/seeders/TenantSettingSeeder.php @@ -0,0 +1,61 @@ + $tenantId, + 'setting_group' => 'stock', + 'setting_key' => 'stock_item_types', + 'setting_value' => ['RM', 'SM', 'CS', 'PT', 'SF'], + 'description' => '재고관리 대상 품목유형 (FG 완제품 제외)', + ], + [ + 'tenant_id' => $tenantId, + 'setting_group' => 'stock', + 'setting_key' => 'default_safety_stock', + 'setting_value' => 10, + 'description' => '안전재고 기본값', + ], + [ + 'tenant_id' => $tenantId, + 'setting_group' => 'stock', + 'setting_key' => 'low_stock_alert', + 'setting_value' => true, + 'description' => '재고부족 알림 활성화', + ], + ]; + + foreach ($settings as $setting) { + TenantSetting::updateOrCreate( + [ + 'tenant_id' => $setting['tenant_id'], + 'setting_group' => $setting['setting_group'], + 'setting_key' => $setting['setting_key'], + ], + [ + 'setting_value' => $setting['setting_value'], + 'description' => $setting['description'], + 'updated_by' => 1, + ] + ); + } + + $this->command->info('테넌트 설정 시더 완료: '.count($settings).'개 설정'); + } +} diff --git a/lang/ko/error.php b/lang/ko/error.php index 8d7b6b5..ae1a98f 100644 --- a/lang/ko/error.php +++ b/lang/ko/error.php @@ -81,6 +81,9 @@ // 재고 관리 관련 'stock' => [ 'not_found' => '재고 정보를 찾을 수 없습니다.', + 'item_id_required' => '입고 정보에 품목 ID가 없습니다.', + 'insufficient_qty' => '재고 수량이 부족합니다.', + 'lot_not_available' => '가용 LOT가 없습니다.', ], // 출하 관리 관련 diff --git a/routes/api.php b/routes/api.php index 0c12601..eba164e 100644 --- a/routes/api.php +++ b/routes/api.php @@ -107,6 +107,7 @@ use App\Http\Controllers\Api\V1\TenantFieldSettingController; use App\Http\Controllers\Api\V1\TenantOptionGroupController; use App\Http\Controllers\Api\V1\TenantOptionValueController; +use App\Http\Controllers\Api\V1\TenantSettingController; use App\Http\Controllers\Api\V1\TenantStatFieldController; // 모델셋 관리 (견적 시스템) use App\Http\Controllers\Api\V1\TenantUserProfileController; @@ -241,6 +242,16 @@ Route::put('/bulk-upsert', [TenantStatFieldController::class, 'bulkUpsert'])->name('v1.tenant-stat-fields.bulk-upsert'); // 일괄 저장 }); + // Tenant Settings API (테넌트별 설정) + Route::prefix('tenant-settings')->group(function () { + Route::get('/', [TenantSettingController::class, 'index'])->name('v1.tenant-settings.index'); // 전체 설정 조회 + Route::post('/', [TenantSettingController::class, 'store'])->name('v1.tenant-settings.store'); // 설정 저장 + Route::put('/bulk', [TenantSettingController::class, 'bulkUpdate'])->name('v1.tenant-settings.bulk'); // 일괄 저장 + Route::post('/initialize', [TenantSettingController::class, 'initialize'])->name('v1.tenant-settings.initialize'); // 기본 설정 초기화 + Route::get('/{group}/{key}', [TenantSettingController::class, 'show'])->name('v1.tenant-settings.show'); // 단일 설정 조회 + Route::delete('/{group}/{key}', [TenantSettingController::class, 'destroy'])->name('v1.tenant-settings.destroy'); // 설정 삭제 + }); + // Menu API Route::middleware(['perm.map', 'permission'])->prefix('menus')->group(function () { Route::get('/', [MenuController::class, 'index'])->name('v1.menus.index');