feat:테넌트설정 API 및 다수 서비스 개선

- TenantSetting CRUD API 추가
- Calendar, Entertainment, VAT 서비스 개선
- 5130 BOM 계산 로직 수정
- quote_items에 item_type 컬럼 추가
- tenant_settings 테이블 마이그레이션
- Swagger 문서 업데이트
This commit is contained in:
2026-01-26 20:29:22 +09:00
parent f2da990771
commit 6d05ab815f
54 changed files with 2090 additions and 110 deletions

View File

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

View File

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

View File

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

View File

@@ -506,4 +506,4 @@ public static function validateAgainstLegacy(
'differences' => $differences,
];
}
}
}

View File

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

View File

@@ -51,4 +51,4 @@ public function summary(Request $request)
);
}, __('message.fetched'));
}
}
}

View File

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

View File

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

View File

@@ -24,4 +24,4 @@ public function summary()
return $this->statusBoardService->summary();
}, __('message.fetched'));
}
}
}

View File

@@ -0,0 +1,111 @@
<?php
namespace App\Http\Controllers\Api\v1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\TenantSetting\BulkUpdateSettingsRequest;
use App\Http\Requests\TenantSetting\GetSettingsRequest;
use App\Http\Requests\TenantSetting\UpdateSettingRequest;
use App\Services\TenantSettingService;
class TenantSettingController extends Controller
{
public function __construct(
private TenantSettingService $service
) {}
/**
* 모든 설정 조회 (그룹별)
*/
public function index(GetSettingsRequest $request)
{
$validated = $request->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),
]);
}
}

View File

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

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Http\Requests\TenantSetting;
use Illuminate\Foundation\Http\FormRequest;
class BulkUpdateSettingsRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'group' => 'required|string|max:50',
'settings' => 'required|array',
'settings.*.key' => 'required|string|max:100',
'settings.*.value' => 'required',
'settings.*.description' => 'nullable|string|max:255',
];
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests\TenantSetting;
use Illuminate\Foundation\Http\FormRequest;
class GetSettingsRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'group' => 'sometimes|string|max:50',
];
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Http\Requests\TenantSetting;
use Illuminate\Foundation\Http\FormRequest;
class UpdateSettingRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'group' => 'required|string|max:50',
'key' => 'required|string|max:100',
'value' => 'required',
'description' => 'nullable|string|max:255',
];
}
}

View File

@@ -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);
}
/**
* 활성 품목만
*/

View File

@@ -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;
/**

View File

@@ -16,6 +16,7 @@ class QuoteItem extends Model
'tenant_id',
// 품목 정보
'item_id',
'item_type',
'item_code',
'item_name',
'specification',

View File

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

View File

@@ -221,4 +221,4 @@ public function getRecurrenceRuleLabelAttribute(): ?string
return self::RECURRENCE_RULES[$this->recurrence_rule] ?? $this->recurrence_rule;
}
}
}

View File

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

View File

@@ -0,0 +1,89 @@
<?php
namespace App\Models\Tenants;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
/**
* 테넌트 설정 모델
*
* @property int $id
* @property int $tenant_id
* @property string $setting_group
* @property string $setting_key
* @property array|null $setting_value
* @property string|null $description
* @property int|null $updated_by
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
*/
class TenantSetting extends Model
{
use BelongsToTenant, ModelTrait;
protected $table = 'tenant_settings';
protected $fillable = [
'tenant_id',
'setting_group',
'setting_key',
'setting_value',
'description',
'updated_by',
];
protected $casts = [
'setting_value' => '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,
]
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,205 @@
<?php
namespace App\Services;
use App\Models\Tenants\TenantSetting;
use Illuminate\Support\Collection;
/**
* 테넌트 설정 서비스
*
* 테넌트별 설정값 관리
* - 재고관리 품목유형
* - 안전재고 기본값
* - 알림 설정 등
*/
class TenantSettingService extends Service
{
/**
* 설정 그룹 상수
*/
public const GROUP_STOCK = 'stock';
public const GROUP_ORDER = 'order';
public const GROUP_PRODUCTION = 'production';
public const GROUP_NOTIFICATION = 'notification';
/**
* 설정 키 상수
*/
public const KEY_STOCK_ITEM_TYPES = 'stock_item_types';
public const KEY_DEFAULT_SAFETY_STOCK = 'default_safety_stock';
public const KEY_LOW_STOCK_ALERT = 'low_stock_alert';
/**
* 기본 설정값
*/
private array $defaults = [
self::GROUP_STOCK => [
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] ?? '';
}
}

View File

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

View File

@@ -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(),
];
});
}
/**

View File

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

View File

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

View File

@@ -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="확정 상태가 아닌 매출입니다.")
* )
* )

View File

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

View File

@@ -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="이미 발행된 세금계산서입니다.")
* )
* )

View File

@@ -0,0 +1,293 @@
<?php
namespace App\Swagger\v1;
/**
* @OA\Tag(
* name="TenantSettings",
* description="테넌트 설정 관리 API"
* )
*
* @OA\Schema(
* schema="TenantSetting",
* type="object",
* required={"id", "tenant_id", "setting_group", "setting_key"},
*
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="tenant_id", type="integer", example=287),
* @OA\Property(property="setting_group", type="string", example="stock"),
* @OA\Property(property="setting_key", type="string", example="stock_item_types"),
* @OA\Property(property="setting_value", type="object", example={"RM", "SM", "CS", "PT", "SF"}),
* @OA\Property(property="description", type="string", nullable=true, example="재고관리 대상 품목유형"),
* @OA\Property(property="updated_by", type="integer", nullable=true, example=1),
* @OA\Property(property="created_at", type="string", format="date-time"),
* @OA\Property(property="updated_at", type="string", format="date-time")
* )
*
* @OA\Schema(
* schema="TenantSettingStoreRequest",
* type="object",
* required={"group", "key", "value"},
*
* @OA\Property(property="group", type="string", maxLength=50, example="stock", description="설정 그룹"),
* @OA\Property(property="key", type="string", maxLength=100, example="stock_item_types", description="설정 키"),
* @OA\Property(property="value", type="object", example={"RM", "SM", "CS", "PT", "SF"}, description="설정 값 (JSON)"),
* @OA\Property(property="description", type="string", nullable=true, maxLength=255, example="재고관리 대상 품목유형", description="설정 설명")
* )
*
* @OA\Schema(
* schema="TenantSettingBulkRequest",
* type="object",
* required={"group", "settings"},
*
* @OA\Property(property="group", type="string", maxLength=50, example="stock", description="설정 그룹"),
* @OA\Property(
* property="settings",
* type="array",
*
* @OA\Items(
* type="object",
* required={"key", "value"},
*
* @OA\Property(property="key", type="string", example="stock_item_types"),
* @OA\Property(property="value", type="object", example={"RM", "SM", "CS"}),
* @OA\Property(property="description", type="string", nullable=true)
* )
* )
* )
*/
class TenantSettingApi
{
/**
* @OA\Get(
* path="/api/v1/tenant-settings",
* tags={"TenantSettings"},
* summary="테넌트 설정 목록 조회",
* description="현재 테넌트의 모든 설정을 그룹별로 조회합니다.",
* security={{"ApiKeyAuth":{}}, {"BearerAuth":{}}},
*
* @OA\Parameter(
* name="group",
* in="query",
* required=false,
* description="특정 그룹만 조회",
*
* @OA\Schema(type="string", example="stock")
* ),
*
* @OA\Response(
* response=200,
* description="조회 성공",
*
* @OA\JsonContent(
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="조회되었습니다."),
* @OA\Property(
* property="data",
* type="object",
* @OA\Property(
* property="stock",
* type="object",
* @OA\Property(
* property="stock_item_types",
* type="object",
* @OA\Property(property="value", type="array", @OA\Items(type="string")),
* @OA\Property(property="description", type="string"),
* @OA\Property(property="updated_at", type="string")
* )
* )
* )
* )
* )
* )
*/
public function index() {}
/**
* @OA\Get(
* path="/api/v1/tenant-settings/{group}/{key}",
* tags={"TenantSettings"},
* summary="단일 설정 조회",
* description="그룹과 키로 특정 설정값을 조회합니다.",
* security={{"ApiKeyAuth":{}}, {"BearerAuth":{}}},
*
* @OA\Parameter(
* name="group",
* in="path",
* required=true,
* description="설정 그룹",
*
* @OA\Schema(type="string", example="stock")
* ),
*
* @OA\Parameter(
* name="key",
* in="path",
* required=true,
* description="설정 키",
*
* @OA\Schema(type="string", example="stock_item_types")
* ),
*
* @OA\Response(
* response=200,
* description="조회 성공",
*
* @OA\JsonContent(
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="조회되었습니다."),
* @OA\Property(
* property="data",
* type="object",
* @OA\Property(property="group", type="string", example="stock"),
* @OA\Property(property="key", type="string", example="stock_item_types"),
* @OA\Property(property="value", type="array", @OA\Items(type="string"), example={"RM", "SM", "CS", "PT", "SF"})
* )
* )
* )
* )
*/
public function show() {}
/**
* @OA\Post(
* path="/api/v1/tenant-settings",
* tags={"TenantSettings"},
* summary="설정 저장/업데이트",
* description="설정을 저장하거나 업데이트합니다. 이미 존재하는 경우 업데이트됩니다.",
* security={{"ApiKeyAuth":{}}, {"BearerAuth":{}}},
*
* @OA\RequestBody(
* required=true,
*
* @OA\JsonContent(ref="#/components/schemas/TenantSettingStoreRequest")
* ),
*
* @OA\Response(
* response=200,
* description="저장 성공",
*
* @OA\JsonContent(
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="수정되었습니다."),
* @OA\Property(property="data", ref="#/components/schemas/TenantSetting")
* )
* )
* )
*/
public function store() {}
/**
* @OA\Put(
* path="/api/v1/tenant-settings/bulk",
* tags={"TenantSettings"},
* summary="설정 일괄 저장",
* description="같은 그룹의 여러 설정을 일괄 저장합니다.",
* security={{"ApiKeyAuth":{}}, {"BearerAuth":{}}},
*
* @OA\RequestBody(
* required=true,
*
* @OA\JsonContent(ref="#/components/schemas/TenantSettingBulkRequest")
* ),
*
* @OA\Response(
* response=200,
* description="일괄 저장 성공",
*
* @OA\JsonContent(
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="일괄 저장되었습니다."),
* @OA\Property(
* property="data",
* type="object",
* @OA\Property(property="updated", type="integer", example=3)
* )
* )
* )
* )
*/
public function bulkUpdate() {}
/**
* @OA\Post(
* path="/api/v1/tenant-settings/initialize",
* tags={"TenantSettings"},
* summary="기본 설정 초기화",
* description="시스템 기본 설정값으로 초기화합니다. 이미 존재하는 설정은 건드리지 않습니다.",
* security={{"ApiKeyAuth":{}}, {"BearerAuth":{}}},
*
* @OA\Response(
* response=200,
* description="초기화 성공",
*
* @OA\JsonContent(
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="생성되었습니다."),
* @OA\Property(
* property="data",
* type="object",
* @OA\Property(property="initialized", type="integer", example=3)
* )
* )
* )
* )
*/
public function initialize() {}
/**
* @OA\Delete(
* path="/api/v1/tenant-settings/{group}/{key}",
* tags={"TenantSettings"},
* summary="설정 삭제",
* description="특정 설정을 삭제합니다.",
* security={{"ApiKeyAuth":{}}, {"BearerAuth":{}}},
*
* @OA\Parameter(
* name="group",
* in="path",
* required=true,
* description="설정 그룹",
*
* @OA\Schema(type="string", example="stock")
* ),
*
* @OA\Parameter(
* name="key",
* in="path",
* required=true,
* description="설정 키",
*
* @OA\Schema(type="string", example="stock_item_types")
* ),
*
* @OA\Response(
* response=200,
* description="삭제 성공",
*
* @OA\JsonContent(
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="삭제되었습니다.")
* )
* ),
*
* @OA\Response(
* response=404,
* description="설정을 찾을 수 없음",
*
* @OA\JsonContent(
*
* @OA\Property(property="success", type="boolean", example=false),
* @OA\Property(property="message", type="string", example="해당 데이터를 찾을 수 없습니다.")
* )
* )
* )
*/
public function destroy() {}
}

View File

@@ -13,6 +13,7 @@
* type="object",
* description="오늘의 이슈 항목",
* required={"id", "badge", "content", "time"},
*
* @OA\Property(property="id", type="string", example="order_123", description="항목 고유 ID"),
* @OA\Property(
* property="badge",
@@ -32,12 +33,15 @@
* schema="TodayIssueSummaryResponse",
* type="object",
* description="오늘의 이슈 리스트 응답",
*
* @OA\Property(
* property="items",
* type="array",
* description="이슈 항목 리스트",
*
* @OA\Items(ref="#/components/schemas/TodayIssueItem")
* ),
*
* @OA\Property(property="total_count", type="integer", example=25, description="전체 이슈 건수")
* )
*
@@ -46,6 +50,7 @@
* type="object",
* description="읽지 않은 이슈 항목 (헤더 알림용)",
* required={"id", "badge", "content", "time", "created_at"},
*
* @OA\Property(property="id", type="integer", example=123, description="이슈 고유 ID"),
* @OA\Property(
* property="badge",
@@ -72,12 +77,15 @@
* schema="TodayIssueUnreadResponse",
* type="object",
* description="읽지 않은 이슈 목록 응답",
*
* @OA\Property(
* property="items",
* type="array",
* description="읽지 않은 이슈 항목 리스트",
*
* @OA\Items(ref="#/components/schemas/TodayIssueUnreadItem")
* ),
*
* @OA\Property(property="total", type="integer", example=5, description="읽지 않은 전체 이슈 건수")
* )
*
@@ -85,6 +93,7 @@
* schema="TodayIssueUnreadCountResponse",
* type="object",
* description="읽지 않은 이슈 개수 응답",
*
* @OA\Property(property="count", type="integer", example=5, description="읽지 않은 이슈 건수")
* )
*
@@ -92,6 +101,7 @@
* schema="TodayIssueMarkAllReadResponse",
* type="object",
* description="모든 이슈 읽음 처리 응답",
*
* @OA\Property(property="count", type="integer", example=5, description="읽음 처리된 이슈 건수")
* )
*/
@@ -111,14 +121,17 @@ class TodayIssueApi
* in="query",
* description="조회할 최대 항목 수",
* required=false,
*
* @OA\Schema(type="integer", default=30, minimum=1, maximum=100)
* ),
*
* @OA\Response(
* response=200,
* description="성공",
*
* @OA\JsonContent(
* type="object",
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="데이터를 조회했습니다."),
* @OA\Property(
@@ -131,8 +144,10 @@ class TodayIssueApi
* @OA\Response(
* response=401,
* description="인증 실패",
*
* @OA\JsonContent(
* type="object",
*
* @OA\Property(property="success", type="boolean", example=false),
* @OA\Property(property="message", type="string", example="인증에 실패했습니다.")
* )
@@ -155,14 +170,17 @@ public function summary() {}
* in="query",
* description="조회할 최대 항목 수 (기본 10)",
* required=false,
*
* @OA\Schema(type="integer", default=10, minimum=1, maximum=50)
* ),
*
* @OA\Response(
* response=200,
* description="성공",
*
* @OA\JsonContent(
* type="object",
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="데이터를 조회했습니다."),
* @OA\Property(
@@ -175,8 +193,10 @@ public function summary() {}
* @OA\Response(
* response=401,
* description="인증 실패",
*
* @OA\JsonContent(
* type="object",
*
* @OA\Property(property="success", type="boolean", example=false),
* @OA\Property(property="message", type="string", example="인증에 실패했습니다.")
* )
@@ -197,8 +217,10 @@ public function unread() {}
* @OA\Response(
* response=200,
* description="성공",
*
* @OA\JsonContent(
* type="object",
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="데이터를 조회했습니다."),
* @OA\Property(
@@ -211,8 +233,10 @@ public function unread() {}
* @OA\Response(
* response=401,
* description="인증 실패",
*
* @OA\JsonContent(
* type="object",
*
* @OA\Property(property="success", type="boolean", example=false),
* @OA\Property(property="message", type="string", example="인증에 실패했습니다.")
* )
@@ -235,14 +259,17 @@ public function unreadCount() {}
* in="path",
* description="이슈 ID",
* required=true,
*
* @OA\Schema(type="integer", example=123)
* ),
*
* @OA\Response(
* response=200,
* description="성공",
*
* @OA\JsonContent(
* type="object",
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="알림을 읽음 처리했습니다.")
* )
@@ -251,8 +278,10 @@ public function unreadCount() {}
* @OA\Response(
* response=404,
* description="이슈를 찾을 수 없음",
*
* @OA\JsonContent(
* type="object",
*
* @OA\Property(property="success", type="boolean", example=false),
* @OA\Property(property="message", type="string", example="데이터를 찾을 수 없습니다.")
* )
@@ -261,8 +290,10 @@ public function unreadCount() {}
* @OA\Response(
* response=401,
* description="인증 실패",
*
* @OA\JsonContent(
* type="object",
*
* @OA\Property(property="success", type="boolean", example=false),
* @OA\Property(property="message", type="string", example="인증에 실패했습니다.")
* )
@@ -283,8 +314,10 @@ public function markAsRead() {}
* @OA\Response(
* response=200,
* description="성공",
*
* @OA\JsonContent(
* type="object",
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="모든 알림을 읽음 처리했습니다."),
* @OA\Property(
@@ -297,8 +330,10 @@ public function markAsRead() {}
* @OA\Response(
* response=401,
* description="인증 실패",
*
* @OA\JsonContent(
* type="object",
*
* @OA\Property(property="success", type="boolean", example=false),
* @OA\Property(property="message", type="string", example="인증에 실패했습니다.")
* )
@@ -306,4 +341,4 @@ public function markAsRead() {}
* )
*/
public function markAllAsRead() {}
}
}

View File

@@ -64,6 +64,7 @@
*
* @OA\Items(ref="#/components/schemas/VatAmountCard")
* ),
*
* @OA\Property(
* property="check_points",
* type="array",

View File

@@ -431,6 +431,7 @@ public function resolveIssue() {}
* @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="방충망 프레임"),
@@ -467,6 +468,7 @@ public function materials() {}
*
* @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")

View File

@@ -47,4 +47,4 @@ public function down(): void
{
Schema::dropIfExists('schedules');
}
};
};

View File

@@ -58,4 +58,4 @@ public function down(): void
{
Schema::dropIfExists('expense_accounts');
}
};
};

View File

@@ -37,4 +37,4 @@ public function down(): void
$table->dropColumn(['order_id', 'shipment_id', 'source_type']);
});
}
};
};

View File

@@ -41,4 +41,4 @@ public function down(): void
{
DB::table('common_codes')->where('code_group', 'delivery_method')->delete();
}
};
};

View File

@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* quote_items 테이블에 item_type 컬럼 추가
* - item_type: RM(원자재), SM(부자재), CS(소모품), FG(완제품), PT(부품)
* - 기존 데이터는 items 테이블에서 조회하여 업데이트
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('quote_items', function (Blueprint $table) {
$table->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');
});
}
};

View File

@@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 테넌트별 설정 테이블
*
* Key-Value 형태로 테넌트별 다양한 설정 저장
* - stock_item_types: 재고관리 대상 품목유형
* - default_safety_stock: 기본 안전재고
* - low_stock_alert: 재고부족 알림 설정
* 등
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('tenant_settings', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -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).'개 생성');
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace Database\Seeders;
use App\Models\Items\Item;
use App\Models\Tenants\Stock;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
/**
* 재고 초기화 시더
*
* - 모든 자재 품목(RM, SM, CS)에 대해 Stock 레코드 생성/업데이트
* - 안전재고: 10개
* - 실제 재고: 0~100 랜덤
*/
class StockInitSeeder extends Seeder
{
public function run(): void
{
$tenantId = 287; // 실제 사용 테넌트
// 재고 관리 대상 품목 조회 (RM, SM, CS, PT, SF) - FG(완제품) 제외
$items = Item::where('tenant_id', $tenantId)
->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}");
}
}

View File

@@ -86,4 +86,4 @@ public function run(): void
$this->command->info("부가세 신고 마감일 {$year}년 일정이 등록되었습니다.");
}
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace Database\Seeders;
use App\Models\Tenants\TenantSetting;
use Illuminate\Database\Seeder;
/**
* 테넌트 설정 기본값 시더
*
* tenant_id 287에 대한 기본 설정 생성
*/
class TenantSettingSeeder extends Seeder
{
public function run(): void
{
$tenantId = 287;
$settings = [
// 재고 관련 설정
[
'tenant_id' => $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).'개 설정');
}
}

View File

@@ -81,6 +81,9 @@
// 재고 관리 관련
'stock' => [
'not_found' => '재고 정보를 찾을 수 없습니다.',
'item_id_required' => '입고 정보에 품목 ID가 없습니다.',
'insufficient_qty' => '재고 수량이 부족합니다.',
'lot_not_available' => '가용 LOT가 없습니다.',
],
// 출하 관리 관련

View File

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