feat: [bending] 절곡품 관리 API 완성 + 데이터 마이그레이션

- GuiderailModelController/Service/Resource: 가이드레일/케이스/하단마감재 통합 CRUD
- item_category 필터 (GUIDERAIL_MODEL/SHUTTERBOX_MODEL/BOTTOMBAR_MODEL)
- BendingItemResource: legacy_bending_num 노출 추가
- ApiKeyMiddleware: guiderail-models, files 화이트리스트 추가
- Swagger: BendingItemApi, GuiderailModelApi 문서 (케이스/하단마감재 필드 포함)
- 마이그레이션 커맨드 5개: GuiderailImportLegacy, BendingProductImportLegacy, BendingImportImages, BendingModelImportImages, BendingModelImportAssemblyImages
- 데이터: GR 20건 + SB 30건 + BB 10건 + 이미지 473건 R2 업로드
This commit is contained in:
2026-03-17 12:50:26 +09:00
parent 13d91b7ab4
commit 7083057d59
15 changed files with 2236 additions and 6 deletions

View File

@@ -0,0 +1,169 @@
<?php
namespace App\Console\Commands;
use App\Models\Commons\File;
use Illuminate\Console\Attributes\AsCommand;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
/**
* 레거시 5130 절곡 이미지 → SAM R2 + files 테이블 마이그레이션
*
* 소스: https://5130.codebridge-x.com/bending/img/{imgdata}
* 대상: R2 저장 + files 테이블 + items.options 업데이트
*/
#[AsCommand(name: 'bending:import-images', description: '레거시 절곡 이미지 → R2 마이그레이션')]
class BendingImportImages extends Command
{
protected $signature = 'bending:import-images
{--tenant_id=287 : Target tenant ID}
{--dry-run : 실제 저장 없이 미리보기}
{--source=https://5130.codebridge-x.com/bending/img : 이미지 소스 URL}';
private int $uploaded = 0;
private int $skipped = 0;
private int $failed = 0;
public function handle(): int
{
$tenantId = (int) $this->option('tenant_id');
$dryRun = $this->option('dry-run');
$sourceBase = rtrim($this->option('source'), '/');
$this->info('=== 레거시 절곡 이미지 → R2 마이그레이션 ===');
$this->info('Source: '.$sourceBase);
$this->info('Mode: '.($dryRun ? 'DRY-RUN' : 'LIVE'));
$this->newLine();
// 1. BENDING 아이템에서 legacy_bending_num이 있는 것 조회
$items = DB::table('items')
->where('tenant_id', $tenantId)
->where('item_category', 'BENDING')
->whereNull('deleted_at')
->get(['id', 'code', 'options']);
$this->info("BENDING 아이템: {$items->count()}");
// legacy_bending_num → chandj imgdata 매핑
$chandjImages = DB::connection('chandj')->table('bending')
->whereNull('is_deleted')
->whereNotNull('imgdata')
->where('imgdata', '!=', '')
->pluck('imgdata', 'num');
$this->info("chandj 이미지: {$chandjImages->count()}");
$this->newLine();
foreach ($items as $item) {
$opts = json_decode($item->options ?? '{}', true) ?: [];
$legacyNum = $opts['legacy_bending_num'] ?? null;
if (! $legacyNum || ! isset($chandjImages[$legacyNum])) {
continue;
}
// 이미 파일이 연결되어 있으면 스킵
$existingFile = File::where('tenant_id', $tenantId)
->where('document_type', '1')
->where('document_id', $item->id)
->where('field_key', 'bending_diagram')
->whereNull('deleted_at')
->first();
if ($existingFile) {
$this->skipped++;
continue;
}
$imgFilename = $chandjImages[$legacyNum];
$imageUrl = "{$sourceBase}/{$imgFilename}";
if ($dryRun) {
$this->line("{$item->code}{$imgFilename}");
$this->uploaded++;
continue;
}
// 이미지 다운로드
try {
$response = Http::withoutVerifying()->timeout(15)->get($imageUrl);
if (! $response->successful()) {
$this->warn("{$item->code}: HTTP {$response->status()} ({$imageUrl})");
$this->failed++;
continue;
}
$imageContent = $response->body();
$mimeType = $response->header('Content-Type', 'image/png');
$extension = $this->getExtension($imgFilename, $mimeType);
// R2 저장
$storedName = bin2hex(random_bytes(8)).'.'.$extension;
$year = date('Y');
$month = date('m');
$directory = sprintf('%d/items/%s/%s', $tenantId, $year, $month);
$filePath = $directory.'/'.$storedName;
Storage::disk('r2')->put($filePath, $imageContent);
// files 테이블 저장
$file = File::create([
'tenant_id' => $tenantId,
'display_name' => $imgFilename,
'stored_name' => $storedName,
'file_path' => $filePath,
'file_size' => strlen($imageContent),
'mime_type' => $mimeType,
'file_type' => 'image',
'field_key' => 'bending_diagram',
'document_id' => $item->id,
'document_type' => '1',
'is_temp' => false,
'uploaded_by' => 1,
'created_by' => 1,
]);
$this->line("{$item->code}{$imgFilename} → file_id={$file->id}");
$this->uploaded++;
} catch (\Exception $e) {
$this->error("{$item->code}: {$e->getMessage()}");
$this->failed++;
}
}
$this->newLine();
$this->info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
$this->info("업로드: {$this->uploaded}건 | 스킵(이미 있음): {$this->skipped}건 | 실패: {$this->failed}");
if ($dryRun) {
$this->info('🔍 DRY-RUN 완료.');
}
return self::SUCCESS;
}
private function getExtension(string $filename, string $mimeType): string
{
$ext = pathinfo($filename, PATHINFO_EXTENSION);
if ($ext) {
return strtolower($ext);
}
return match ($mimeType) {
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
default => 'png',
};
}
}

View File

@@ -32,14 +32,14 @@ class BendingImportLegacy extends Command
'RE' => ['item_bending' => '가이드레일', 'itemName_like' => '%마감%', 'material' => 'EGI 1.55T', 'item_spec' => '120*70'],
'RM' => ['item_bending' => '가이드레일', 'itemName_like' => '%본체%', 'material' => 'EGI 1.55T', 'item_spec' => '120*70'],
'RC' => ['item_bending' => '가이드레일', 'itemName_like' => '%C형%', 'material' => 'EGI 1.55T', 'item_spec' => '120*70'],
'RD' => ['item_bending' => '가이드레일', 'itemName_like' => '%D%', 'material' => 'EGI 1.55T', 'item_spec' => '120*70'],
'RD' => ['item_bending' => '가이드레일', 'itemName_like' => '%벽면형-D%', 'material' => 'EGI 1.55T', 'item_spec' => '120*70'],
'RT' => ['item_bending' => '가이드레일', 'itemName_like' => '%본체%', 'material' => 'EGI 1.55T', 'item_spec' => '130*75'],
// 가이드레일 (측면) — 벽면과 같은 전개도
'SS' => ['same_as' => 'RS'],
'SU' => ['same_as' => 'RS'],
'SM' => ['same_as' => 'RM'],
'SC' => ['same_as' => 'RC'],
'SD' => ['same_as' => 'RD'],
'SD' => ['item_bending' => '가이드레일', 'itemName_like' => '%측면형-D%', 'material' => 'EGI 1.55T', 'item_spec' => '120*120'],
'ST' => ['same_as' => 'RT'],
'SE' => ['same_as' => 'RE'],
// 하단마감재
@@ -231,13 +231,13 @@ private function matchKoreanPattern(string $code, $chandjRows): ?object
]);
}
// BD-케이스-650*550
// BD-케이스-650*550 → chandj에서 itemName에 "650*550" 포함된 전면판 매칭
if (preg_match('/^BD-케이스-(\d+)\*(\d+)$/', $code, $m)) {
$spec = $m[1].'*'.$m[2];
return $chandjRows->first(function ($r) use ($spec) {
return $r->item_bending === '케이스'
&& (str_contains($r->itemName, $spec) || $r->item_spec === $spec);
return (str_contains($r->itemName, $spec) || $r->item_spec === $spec)
&& str_contains($r->itemName, '전면');
});
}

View File

@@ -0,0 +1,192 @@
<?php
namespace App\Console\Commands;
use App\Models\Commons\File;
use Illuminate\Console\Attributes\AsCommand;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
/**
* 레거시 guiderail.json 결합형태 이미지 → SAM 모델 연결
*/
#[AsCommand(name: 'bending-model:import-assembly-images', description: '결합형태 이미지 → R2 마이그레이션')]
class BendingModelImportAssemblyImages extends Command
{
protected $signature = 'bending-model:import-assembly-images
{--tenant_id=287 : Target tenant ID}
{--dry-run : 실제 저장 없이 미리보기}
{--source=https://5130.codebridge-x.com : 소스 URL}';
public function handle(): int
{
$tenantId = (int) $this->option('tenant_id');
$dryRun = $this->option('dry-run');
$sourceBase = rtrim($this->option('source'), '/');
$this->info('=== 결합형태 이미지 → R2 마이그레이션 ===');
// 3개 JSON 파일 순차 처리
$jsonConfigs = [
['file' => 'guiderail/guiderail.json', 'category' => 'GUIDERAIL_MODEL', 'imageBase' => ''],
['file' => 'shutterbox/shutterbox.json', 'category' => 'SHUTTERBOX_MODEL', 'imageBase' => ''],
['file' => 'bottombar/bottombar.json', 'category' => 'BOTTOMBAR_MODEL', 'imageBase' => ''],
];
$uploaded = 0;
$skipped = 0;
$failed = 0;
foreach ($jsonConfigs as $jsonConfig) {
$jsonPath = base_path('../5130/' . $jsonConfig['file']);
if (! file_exists($jsonPath)) {
$resp = Http::withoutVerifying()->get("{$sourceBase}/{$jsonConfig['file']}");
$assemblyData = $resp->successful() ? $resp->json() : [];
} else {
$assemblyData = json_decode(file_get_contents($jsonPath), true) ?: [];
}
$this->info("--- {$jsonConfig['category']} ({$jsonConfig['file']}): " . count($assemblyData) . '건 ---');
foreach ($assemblyData as $entry) {
$imagePath = $entry['image'] ?? '';
if (! $imagePath) {
continue;
}
// SAM 코드 생성 (카테고리별)
$code = $this->buildCode($entry, $jsonConfig['category']);
if (! $code) {
continue;
}
$samItem = DB::table('items')
->where('tenant_id', $tenantId)
->where('code', $code)
->where('item_category', $jsonConfig['category'])
->whereNull('deleted_at')
->first(['id', 'code', 'options']);
if (! $samItem) {
$this->warn(" ⚠️ {$code}: SAM 모델 없음");
$failed++;
continue;
}
// 이미 이미지 있으면 스킵
$existing = File::where('tenant_id', $tenantId)
->where('document_id', $samItem->id)
->where('field_key', 'assembly_image')
->whereNull('deleted_at')
->first();
if ($existing) {
$skipped++;
continue;
}
$imageUrl = "{$sourceBase}{$imagePath}";
if ($dryRun) {
$this->line("{$code}{$imagePath}");
$uploaded++;
continue;
}
try {
$response = Http::withoutVerifying()->timeout(15)->get($imageUrl);
if (! $response->successful()) {
$this->warn("{$code}: HTTP {$response->status()}");
$failed++;
continue;
}
$content = $response->body();
$ext = pathinfo($imagePath, PATHINFO_EXTENSION) ?: 'png';
$storedName = bin2hex(random_bytes(8)).'.'.strtolower($ext);
$directory = sprintf('%d/items/%s/%s', $tenantId, date('Y'), date('m'));
$filePath = $directory.'/'.$storedName;
Storage::disk('r2')->put($filePath, $content);
$file = File::create([
'tenant_id' => $tenantId,
'display_name' => basename($imagePath),
'stored_name' => $storedName,
'file_path' => $filePath,
'file_size' => strlen($content),
'mime_type' => $response->header('Content-Type', 'image/png'),
'file_type' => 'image',
'field_key' => 'assembly_image',
'document_id' => $samItem->id,
'document_type' => '1',
'is_temp' => false,
'uploaded_by' => 1,
'created_by' => 1,
]);
$this->line("{$code}{$imagePath} → file_id={$file->id}");
$uploaded++;
} catch (\Exception $e) {
$this->error("{$code}: {$e->getMessage()}");
$failed++;
}
}
} // end foreach jsonConfigs
$this->newLine();
$this->info("업로드: {$uploaded}건 | 스킵: {$skipped}건 | 실패: {$failed}");
return self::SUCCESS;
}
private function buildCode(array $entry, string $category): ?string
{
if ($category === 'GUIDERAIL_MODEL') {
$modelName = $entry['model_name'] ?? '';
$checkType = $entry['check_type'] ?? '';
$finishType = $entry['finishing_type'] ?? '';
if (! $modelName) {
return null;
}
$finish = str_contains($finishType, 'SUS') ? 'SUS' : 'EGI';
return "GR-{$modelName}-{$checkType}-{$finish}";
}
if ($category === 'SHUTTERBOX_MODEL') {
$w = $entry['box_width'] ?? '';
$h = $entry['box_height'] ?? '';
$exit = $entry['exit_direction'] ?? '';
$exitShort = match ($exit) {
'양면 점검구' => '양면',
'밑면 점검구' => '밑면',
'후면 점검구' => '후면',
default => $exit,
};
return "SB-{$w}*{$h}-{$exitShort}";
}
if ($category === 'BOTTOMBAR_MODEL') {
$modelName = $entry['model_name'] ?? '';
$finishType = $entry['finishing_type'] ?? '';
if (! $modelName) {
return null;
}
$finish = str_contains($finishType, 'SUS') ? 'SUS' : 'EGI';
return "BB-{$modelName}-{$finish}";
}
return null;
}
}

View File

@@ -0,0 +1,160 @@
<?php
namespace App\Console\Commands;
use App\Models\Commons\File;
use Illuminate\Console\Attributes\AsCommand;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
/**
* 가이드레일/케이스/하단마감재 모델의 부품별 이미지 임포트
*
* chandj guiderail/shutterbox/bottombar components의 imgdata →
* 5130.codebridge-x.com에서 다운로드 → R2 업로드 → components에 file_id 추가
*/
#[AsCommand(name: 'bending-model:import-images', description: '절곡품 모델 부품별 이미지 → R2 마이그레이션')]
class BendingModelImportImages extends Command
{
protected $signature = 'bending-model:import-images
{--tenant_id=287 : Target tenant ID}
{--dry-run : 실제 저장 없이 미리보기}
{--source=https://5130.codebridge-x.com/bending/img : 이미지 소스 URL}';
private int $uploaded = 0;
private int $skipped = 0;
private int $failed = 0;
public function handle(): int
{
$tenantId = (int) $this->option('tenant_id');
$dryRun = $this->option('dry-run');
$sourceBase = rtrim($this->option('source'), '/');
$this->info('=== 절곡품 모델 부품 이미지 → R2 마이그레이션 ===');
$this->info('Mode: '.($dryRun ? 'DRY-RUN' : 'LIVE'));
$this->newLine();
// chandj에서 원본 imgdata 조회
$chandjTables = [
'GUIDERAIL_MODEL' => 'guiderail',
'SHUTTERBOX_MODEL' => 'shutterbox',
'BOTTOMBAR_MODEL' => 'bottombar',
];
foreach ($chandjTables as $category => $table) {
$this->info("--- {$category} ({$table}) ---");
$chandjRows = DB::connection('chandj')->table($table)->whereNull('is_deleted')->get();
$samItems = DB::table('items')->where('tenant_id', $tenantId)
->where('item_category', $category)->whereNull('deleted_at')
->get(['id', 'code', 'options']);
// legacy_num → chandj row 매핑
$chandjMap = [];
foreach ($chandjRows as $row) {
$chandjMap[$row->num] = $row;
}
foreach ($samItems as $samItem) {
$opts = json_decode($samItem->options, true) ?? [];
$legacyNum = $opts['legacy_num'] ?? $opts['legacy_guiderail_num'] ?? null;
if (! $legacyNum || ! isset($chandjMap[$legacyNum])) {
continue;
}
$chandjRow = $chandjMap[$legacyNum];
$chandjComps = json_decode($chandjRow->bending_components ?? '[]', true) ?: [];
$components = $opts['components'] ?? [];
$updated = false;
foreach ($components as $idx => &$comp) {
// chandj component에서 imgdata 찾기
$chandjComp = $chandjComps[$idx] ?? null;
$imgdata = $chandjComp['imgdata'] ?? null;
if (! $imgdata || ! empty($comp['image_file_id'])) {
continue;
}
$imageUrl = "{$sourceBase}/{$imgdata}";
if ($dryRun) {
$this->line("{$samItem->code} #{$idx}{$imgdata}");
$this->uploaded++;
$updated = true;
continue;
}
try {
$response = Http::withoutVerifying()->timeout(15)->get($imageUrl);
if (! $response->successful()) {
$this->warn("{$samItem->code} #{$idx}: HTTP {$response->status()}");
$this->failed++;
continue;
}
$imageContent = $response->body();
$extension = pathinfo($imgdata, PATHINFO_EXTENSION) ?: 'png';
$storedName = bin2hex(random_bytes(8)).'.'.strtolower($extension);
$directory = sprintf('%d/items/%s/%s', $tenantId, date('Y'), date('m'));
$filePath = $directory.'/'.$storedName;
Storage::disk('r2')->put($filePath, $imageContent);
$file = File::create([
'tenant_id' => $tenantId,
'display_name' => $imgdata,
'stored_name' => $storedName,
'file_path' => $filePath,
'file_size' => strlen($imageContent),
'mime_type' => $response->header('Content-Type', 'image/png'),
'file_type' => 'image',
'field_key' => 'bending_component_image',
'document_id' => $samItem->id,
'document_type' => '1',
'is_temp' => false,
'uploaded_by' => 1,
'created_by' => 1,
]);
$comp['image_file_id'] = $file->id;
$comp['imgdata'] = $imgdata;
$updated = true;
$this->uploaded++;
$this->line("{$samItem->code} #{$idx} {$comp['itemName']}{$imgdata} → file_id={$file->id}");
} catch (\Exception $e) {
$this->error("{$samItem->code} #{$idx}: {$e->getMessage()}");
$this->failed++;
}
}
unset($comp);
// components 업데이트
if ($updated && ! $dryRun) {
$opts['components'] = $components;
DB::table('items')->where('id', $samItem->id)->update([
'options' => json_encode($opts, JSON_UNESCAPED_UNICODE),
'updated_at' => now(),
]);
}
}
}
$this->newLine();
$this->info("업로드: {$this->uploaded}건 | 스킵: {$this->skipped}건 | 실패: {$this->failed}");
if ($dryRun) {
$this->info('🔍 DRY-RUN 완료.');
}
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,200 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Attributes\AsCommand;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* chandj shutterbox(케이스) + bottombar(하단마감재) → SAM items 임포트
*/
#[AsCommand(name: 'bending-product:import-legacy', description: 'chandj 케이스/하단마감재 모델 → SAM items 임포트')]
class BendingProductImportLegacy extends Command
{
protected $signature = 'bending-product:import-legacy
{--tenant_id=287 : Target tenant ID}
{--dry-run : 실제 저장 없이 미리보기}';
public function handle(): int
{
$tenantId = (int) $this->option('tenant_id');
$dryRun = $this->option('dry-run');
$this->info('=== 케이스/하단마감재 모델 → SAM 임포트 ===');
$this->info('Mode: '.($dryRun ? 'DRY-RUN' : 'LIVE'));
$this->newLine();
// 케이스 (shutterbox)
$this->info('--- 케이스 (shutterbox) ---');
$cases = DB::connection('chandj')->table('shutterbox')->whereNull('is_deleted')->get();
$this->info("chandj shutterbox: {$cases->count()}");
$caseCreated = $this->importItems($cases, 'SHUTTERBOX_MODEL', $tenantId, $dryRun);
$this->newLine();
// 하단마감재 (bottombar)
$this->info('--- 하단마감재 (bottombar) ---');
$bars = DB::connection('chandj')->table('bottombar')->whereNull('is_deleted')->get();
$this->info("chandj bottombar: {$bars->count()}");
$barCreated = $this->importItems($bars, 'BOTTOMBAR_MODEL', $tenantId, $dryRun);
$this->newLine();
$this->info("결과: 케이스 {$caseCreated}건 + 하단마감재 {$barCreated}");
if ($dryRun) {
$this->info('🔍 DRY-RUN 완료.');
}
return self::SUCCESS;
}
private function importItems($rows, string $category, int $tenantId, bool $dryRun): int
{
$created = 0;
$skipped = 0;
foreach ($rows as $row) {
$code = $this->buildCode($row, $category);
$name = $this->buildName($row, $category);
$existing = DB::table('items')
->where('tenant_id', $tenantId)
->where('code', $code)
->whereNull('deleted_at')
->first();
if ($existing) {
$skipped++;
continue;
}
$components = $this->convertComponents(json_decode($row->bending_components ?? '[]', true) ?: []);
$materialSummary = json_decode($row->material_summary ?? '{}', true) ?: [];
$options = $this->buildOptions($row, $category, $components, $materialSummary);
if (! $dryRun) {
DB::table('items')->insert([
'tenant_id' => $tenantId,
'code' => $code,
'name' => $name,
'item_type' => 'FG',
'item_category' => $category,
'unit' => 'SET',
'options' => json_encode($options, JSON_UNESCAPED_UNICODE),
'is_active' => true,
'created_at' => now(),
'updated_at' => now(),
]);
}
$created++;
$this->line("{$code} ({$name}) — 부품 ".count($components).'개');
}
$this->info(" 생성: {$created}건 | 스킵: {$skipped}");
return $created;
}
private function buildCode(object $row, string $category): string
{
if ($category === 'SHUTTERBOX_MODEL') {
$size = ($row->box_width ?? '').
'*'.($row->box_height ?? '');
$exit = match ($row->exit_direction ?? '') {
'양면 점검구' => '양면',
'밑면 점검구' => '밑면',
'후면 점검구' => '후면',
default => $row->exit_direction ?? '',
};
return "SB-{$size}-{$exit}";
}
// BOTTOMBAR_MODEL
$model = $row->model_name ?? 'UNKNOWN';
$finish = str_contains($row->finishing_type ?? '', 'SUS') ? 'SUS' : 'EGI';
return "BB-{$model}-{$finish}";
}
private function buildName(object $row, string $category): string
{
if ($category === 'SHUTTERBOX_MODEL') {
return "케이스 {$row->box_width}*{$row->box_height} {$row->exit_direction}";
}
return "하단마감재 {$row->model_name} {$row->firstitem}";
}
private function buildOptions(object $row, string $category, array $components, array $materialSummary): array
{
$base = [
'author' => $row->author ?? null,
'registration_date' => $row->registration_date ?? null,
'search_keyword' => $row->search_keyword ?? null,
'memo' => $row->remark ?? null,
'components' => $components,
'material_summary' => $materialSummary,
'source' => 'chandj_'.(strtolower($category)),
'legacy_num' => $row->num,
];
if ($category === 'SHUTTERBOX_MODEL') {
return array_merge($base, [
'box_width' => (int) ($row->box_width ?? 0),
'box_height' => (int) ($row->box_height ?? 0),
'exit_direction' => $row->exit_direction ?? null,
'front_bottom_width' => $row->front_bottom_width ?? null,
'rail_width' => $row->rail_width ?? null,
]);
}
// BOTTOMBAR_MODEL
return array_merge($base, [
'model_name' => $row->model_name ?? null,
'item_sep' => $row->firstitem ?? null,
'model_UA' => $row->model_UA ?? null,
'finishing_type' => $row->finishing_type ?? null,
'bar_width' => $row->bar_width ?? null,
'bar_height' => $row->bar_height ?? null,
]);
}
private function convertComponents(array $legacyComps): array
{
return array_map(function ($c, $idx) {
$inputs = $c['inputList'] ?? [];
$rates = $c['bendingrateList'] ?? [];
$sums = $c['sumList'] ?? [];
$colors = $c['colorList'] ?? [];
$angles = $c['AList'] ?? [];
$bendingData = [];
for ($i = 0; $i < count($inputs); $i++) {
$bendingData[] = [
'no' => $i + 1,
'input' => (float) ($inputs[$i] ?? 0),
'rate' => (string) ($rates[$i] ?? ''),
'sum' => (float) ($sums[$i] ?? 0),
'color' => (bool) ($colors[$i] ?? false),
'aAngle' => (bool) ($angles[$i] ?? false),
];
}
$lastSum = ! empty($sums) ? (float) end($sums) : ($c['widthsum'] ?? 0);
return [
'orderNumber' => $idx + 1,
'itemName' => $c['itemName'] ?? '',
'material' => $c['material'] ?? '',
'quantity' => (int) ($c['quantity'] ?? 1),
'width_sum' => (float) $lastSum,
'bendingData' => $bendingData,
'legacy_bending_num' => $c['source_num'] ?? $c['num'] ?? null,
];
}, $legacyComps, array_keys($legacyComps));
}
}

View File

@@ -0,0 +1,136 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Attributes\AsCommand;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* chandj.guiderail → SAM items (item_category=GUIDERAIL_MODEL) 임포트
*/
#[AsCommand(name: 'guiderail:import-legacy', description: 'chandj 가이드레일 모델 → SAM items 임포트')]
class GuiderailImportLegacy extends Command
{
protected $signature = 'guiderail:import-legacy
{--tenant_id=287 : Target tenant ID}
{--dry-run : 실제 저장 없이 미리보기}';
public function handle(): int
{
$tenantId = (int) $this->option('tenant_id');
$dryRun = $this->option('dry-run');
$this->info('=== chandj guiderail → SAM 임포트 ===');
$this->info('Mode: '.($dryRun ? 'DRY-RUN' : 'LIVE'));
$rows = DB::connection('chandj')->table('guiderail')->whereNull('is_deleted')->get();
$this->info("chandj guiderail: {$rows->count()}");
$created = 0;
$skipped = 0;
foreach ($rows as $row) {
$finish = str_contains($row->finishing_type ?? '', 'SUS') ? 'SUS' : 'EGI';
$code = 'GR-'.($row->model_name ?? 'UNKNOWN').'-'.($row->check_type ?? '').'-'.$finish;
$name = implode(' ', array_filter([$row->model_name, $row->check_type, $row->finishing_type]));
// 중복 확인
$existing = DB::table('items')
->where('tenant_id', $tenantId)
->where('code', $code)
->whereNull('deleted_at')
->first();
if ($existing) {
$skipped++;
continue;
}
// components 변환
$legacyComps = json_decode($row->bending_components ?? '[]', true) ?: [];
$components = array_map(fn ($c) => $this->convertComponent($c), $legacyComps);
$materialSummary = json_decode($row->material_summary ?? '{}', true) ?: [];
$options = [
'model_name' => $row->model_name,
'check_type' => $row->check_type,
'rail_width' => (int) $row->rail_width,
'rail_length' => (int) $row->rail_length,
'finishing_type' => $row->finishing_type,
'item_sep' => $row->firstitem,
'model_UA' => $row->model_UA,
'search_keyword' => $row->search_keyword,
'author' => $row->author,
'registration_date' => $row->registration_date,
'memo' => $row->remark,
'components' => $components,
'material_summary' => $materialSummary,
'source' => 'chandj_guiderail',
'legacy_guiderail_num' => $row->num,
];
if (! $dryRun) {
DB::table('items')->insert([
'tenant_id' => $tenantId,
'code' => $code,
'name' => $name,
'item_type' => 'FG',
'item_category' => 'GUIDERAIL_MODEL',
'unit' => 'SET',
'options' => json_encode($options, JSON_UNESCAPED_UNICODE),
'is_active' => true,
'created_at' => now(),
'updated_at' => now(),
]);
}
$created++;
$this->line("{$code} ({$name}) — {$row->firstitem}/{$row->model_UA} — 부품 ".count($components).'개');
}
$this->newLine();
$this->info("생성: {$created}건 | 스킵(중복): {$skipped}");
if ($dryRun) {
$this->info('🔍 DRY-RUN 완료.');
}
return self::SUCCESS;
}
private function convertComponent(array $c): array
{
$inputs = $c['inputList'] ?? [];
$rates = $c['bendingrateList'] ?? [];
$sums = $c['sumList'] ?? [];
$colors = $c['colorList'] ?? [];
$angles = $c['AList'] ?? [];
// bendingData 형식으로 변환
$bendingData = [];
for ($i = 0; $i < count($inputs); $i++) {
$bendingData[] = [
'no' => $i + 1,
'input' => (float) ($inputs[$i] ?? 0),
'rate' => (string) ($rates[$i] ?? ''),
'sum' => (float) ($sums[$i] ?? 0),
'color' => (bool) ($colors[$i] ?? false),
'aAngle' => (bool) ($angles[$i] ?? false),
];
}
$lastSum = ! empty($sums) ? (float) end($sums) : 0;
return [
'orderNumber' => $c['orderNumber'] ?? null,
'itemName' => $c['itemName'] ?? '',
'material' => $c['material'] ?? '',
'quantity' => (int) ($c['quantity'] ?? 1),
'width_sum' => $lastSum,
'bendingData' => $bendingData,
'legacy_bending_num' => $c['num'] ?? null,
];
}
}

View File

@@ -0,0 +1,89 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Resources\Api\V1\GuiderailModelResource;
use App\Services\GuiderailModelService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class GuiderailModelController extends Controller
{
public function __construct(private GuiderailModelService $service) {}
private function ensureContext(Request $request): void
{
if (! app()->bound('tenant_id') || ! app('tenant_id')) {
$tenantId = (int) ($request->header('X-TENANT-ID') ?: 287);
app()->instance('tenant_id', $tenantId);
}
if (! app()->bound('api_user') || ! app('api_user')) {
app()->instance('api_user', 1);
}
}
public function index(Request $request): JsonResponse
{
$this->ensureContext($request);
return ApiResponse::handle(function () use ($request) {
$params = $request->only(['item_category', 'item_sep', 'model_UA', 'check_type', 'model_name', 'search', 'page', 'size']);
$paginator = $this->service->list($params);
$paginator->getCollection()->transform(fn ($item) => (new GuiderailModelResource($item))->resolve());
return $paginator;
}, __('message.fetched'));
}
public function filters(Request $request): JsonResponse
{
$this->ensureContext($request);
return ApiResponse::handle(
fn () => $this->service->filters(),
__('message.fetched')
);
}
public function show(Request $request, int $id): JsonResponse
{
$this->ensureContext($request);
return ApiResponse::handle(
fn () => new GuiderailModelResource($this->service->find($id)),
__('message.fetched')
);
}
public function store(Request $request): JsonResponse
{
$this->ensureContext($request);
return ApiResponse::handle(
fn () => new GuiderailModelResource($this->service->create($request->all())),
__('message.created')
);
}
public function update(Request $request, int $id): JsonResponse
{
$this->ensureContext($request);
return ApiResponse::handle(
fn () => new GuiderailModelResource($this->service->update($id, $request->all())),
__('message.updated')
);
}
public function destroy(Request $request, int $id): JsonResponse
{
$this->ensureContext($request);
return ApiResponse::handle(
fn () => $this->service->delete($id),
__('message.deleted')
);
}
}

View File

@@ -127,6 +127,11 @@ public function handle(Request $request, Closure $next)
'api/v1/app/*', // 앱 버전 확인/다운로드 (API Key만 필요)
'api/v1/bending-items', // 절곡품 목록 (MNG에서 API Key만으로 접근)
'api/v1/bending-items/*', // 절곡품 상세/필터
'api/v1/guiderail-models', // 절곡품 모델 목록
'api/v1/guiderail-models/*', // 절곡품 모델 상세/필터
'api/v1/items/*/files', // 품목 파일 업로드/조회
'api/v1/files/*/view', // 파일 인라인 보기 (MNG 이미지 표시)
'api/v1/files/*/download', // 파일 다운로드
];
// 현재 라우트 확인 (경로 또는 이름)

View File

@@ -41,6 +41,8 @@ public function toArray(Request $request): array
'prefix' => $this->getOption('prefix'),
'length_code' => $this->getOption('length_code'),
'length_mm' => $this->getOption('length_mm'),
// 추적
'legacy_bending_num' => $this->getOption('legacy_bending_num'),
// 계산값
'width_sum' => $this->getWidthSum(),
'bend_count' => $this->getBendCount(),

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Http\Resources\Api\V1;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class GuiderailModelResource extends JsonResource
{
public function toArray(Request $request): array
{
$components = $this->getOption('components', []);
$materialSummary = $this->getOption('material_summary');
// material_summary가 없으면 components에서 계산
if (empty($materialSummary) && ! empty($components)) {
$materialSummary = $this->calcMaterialSummary($components);
}
return [
'id' => $this->id,
'code' => $this->code,
'name' => $this->name,
'item_type' => $this->item_type,
'item_category' => $this->item_category,
'is_active' => $this->is_active,
// 모델 속성
'model_name' => $this->getOption('model_name'),
'check_type' => $this->getOption('check_type'),
'rail_width' => $this->getOption('rail_width'),
'rail_length' => $this->getOption('rail_length'),
'finishing_type' => $this->getOption('finishing_type'),
'item_sep' => $this->getOption('item_sep'),
'model_UA' => $this->getOption('model_UA'),
'search_keyword' => $this->getOption('search_keyword'),
'author' => $this->getOption('author'),
'memo' => $this->getOption('memo'),
'registration_date' => $this->getOption('registration_date'),
// 케이스(SHUTTERBOX_MODEL) 전용
'exit_direction' => $this->getOption('exit_direction'),
'front_bottom_width' => $this->getOption('front_bottom_width'),
'box_width' => $this->getOption('box_width'),
'box_height' => $this->getOption('box_height'),
// 하단마감재(BOTTOMBAR_MODEL) 전용
'bar_width' => $this->getOption('bar_width'),
'bar_height' => $this->getOption('bar_height'),
// 부품 조합
'components' => $components,
'material_summary' => $materialSummary,
'component_count' => count($components),
// 메타
'created_at' => $this->created_at?->format('Y-m-d H:i:s'),
'updated_at' => $this->updated_at?->format('Y-m-d H:i:s'),
];
}
private function calcMaterialSummary(array $components): array
{
$summary = [];
foreach ($components as $comp) {
$material = $comp['material'] ?? null;
$widthSum = $comp['width_sum'] ?? 0;
$qty = $comp['quantity'] ?? 1;
if ($material && $widthSum) {
$summary[$material] = ($summary[$material] ?? 0) + ($widthSum * $qty);
}
}
return $summary;
}
}

View File

@@ -0,0 +1,120 @@
<?php
namespace App\Services;
use App\Models\Items\Item;
use Illuminate\Pagination\LengthAwarePaginator;
class GuiderailModelService extends Service
{
private const CATEGORIES = ['GUIDERAIL_MODEL', 'SHUTTERBOX_MODEL', 'BOTTOMBAR_MODEL'];
public function list(array $params): LengthAwarePaginator
{
return Item::whereIn('item_category', self::CATEGORIES)
->when($params['item_category'] ?? null, fn ($q, $v) => $q->where('item_category', $v))
->when($params['item_sep'] ?? null, fn ($q, $v) => $q->where('options->item_sep', $v))
->when($params['model_UA'] ?? null, fn ($q, $v) => $q->where('options->model_UA', $v))
->when($params['check_type'] ?? null, fn ($q, $v) => $q->where('options->check_type', $v))
->when($params['model_name'] ?? null, fn ($q, $v) => $q->where('options->model_name', $v))
->when($params['search'] ?? null, fn ($q, $v) => $q->where(
fn ($q2) => $q2
->where('name', 'like', "%{$v}%")
->orWhere('code', 'like', "%{$v}%")
->orWhere('options->model_name', 'like', "%{$v}%")
->orWhere('options->search_keyword', 'like', "%{$v}%")
))
->orderBy('code')
->paginate($params['size'] ?? 50);
}
public function filters(): array
{
$items = Item::whereIn('item_category', self::CATEGORIES)->select('options')->get();
return [
'item_sep' => $items->pluck('options.item_sep')->filter()->unique()->sort()->values(),
'model_UA' => $items->pluck('options.model_UA')->filter()->unique()->sort()->values(),
'check_type' => $items->pluck('options.check_type')->filter()->unique()->sort()->values(),
'model_name' => $items->pluck('options.model_name')->filter()->unique()->sort()->values(),
'finishing_type' => $items->pluck('options.finishing_type')->filter()->unique()->sort()->values(),
];
}
public function find(int $id): Item
{
return Item::whereIn('item_category', self::CATEGORIES)->findOrFail($id);
}
public function create(array $data): Item
{
$options = $this->buildOptions($data);
return Item::create([
'tenant_id' => $this->tenantId(),
'item_type' => 'FG',
'item_category' => $data['item_category'] ?? 'GUIDERAIL_MODEL',
'code' => $data['code'],
'name' => $data['name'],
'unit' => 'SET',
'options' => $options,
'is_active' => true,
'created_by' => $this->apiUserId(),
]);
}
public function update(int $id, array $data): Item
{
$item = Item::whereIn('item_category', self::CATEGORIES)->findOrFail($id);
if (isset($data['code'])) {
$item->code = $data['code'];
}
if (isset($data['name'])) {
$item->name = $data['name'];
}
foreach (self::OPTION_KEYS as $key) {
if (array_key_exists($key, $data)) {
$item->setOption($key, $data[$key]);
}
}
$item->updated_by = $this->apiUserId();
$item->save();
return $item;
}
public function delete(int $id): bool
{
$item = Item::whereIn('item_category', self::CATEGORIES)->findOrFail($id);
$item->deleted_by = $this->apiUserId();
$item->save();
return $item->delete();
}
private function buildOptions(array $data): array
{
$options = [];
foreach (self::OPTION_KEYS as $key) {
if (isset($data[$key])) {
$options[$key] = $data[$key];
}
}
return $options;
}
private const OPTION_KEYS = [
'model_name', 'check_type', 'rail_width', 'rail_length',
'finishing_type', 'item_sep', 'model_UA', 'search_keyword',
'author', 'memo', 'registration_date',
'components', 'material_summary',
// 케이스(SHUTTERBOX_MODEL) 전용
'exit_direction', 'front_bottom_width', 'box_width', 'box_height',
// 하단마감재(BOTTOMBAR_MODEL) 전용
'bar_width', 'bar_height',
];
}

View File

@@ -0,0 +1,138 @@
<?php
namespace App\Swagger\v1;
/**
* @OA\Tag(name="BendingItem", description="절곡품 기초관리")
*
* @OA\Schema(
* schema="BendingItem",
* @OA\Property(property="id", type="integer", example=15862),
* @OA\Property(property="code", type="string", example="BD-BE-30"),
* @OA\Property(property="name", type="string", example="하단마감재(스크린) EGI 3000mm"),
* @OA\Property(property="item_type", type="string", example="PT"),
* @OA\Property(property="item_category", type="string", example="BENDING"),
* @OA\Property(property="unit", type="string", example="EA"),
* @OA\Property(property="is_active", type="boolean", example=true),
* @OA\Property(property="item_name", type="string", example="하단마감재"),
* @OA\Property(property="item_sep", type="string", enum={"스크린","철재"}),
* @OA\Property(property="item_bending", type="string", example="하단마감재"),
* @OA\Property(property="item_spec", type="string", nullable=true, example="60*40"),
* @OA\Property(property="material", type="string", example="EGI 1.55T"),
* @OA\Property(property="model_name", type="string", nullable=true, example="KSS01"),
* @OA\Property(property="model_UA", type="string", nullable=true, enum={"인정","비인정"}),
* @OA\Property(property="search_keyword", type="string", nullable=true),
* @OA\Property(property="rail_width", type="integer", nullable=true),
* @OA\Property(property="registration_date", type="string", format="date", nullable=true),
* @OA\Property(property="author", type="string", nullable=true),
* @OA\Property(property="memo", type="string", nullable=true),
* @OA\Property(property="exit_direction", type="string", nullable=true),
* @OA\Property(property="front_bottom_width", type="integer", nullable=true),
* @OA\Property(property="box_width", type="integer", nullable=true),
* @OA\Property(property="box_height", type="integer", nullable=true),
* @OA\Property(property="bendingData", type="array", nullable=true, @OA\Items(
* @OA\Property(property="no", type="integer"),
* @OA\Property(property="input", type="number"),
* @OA\Property(property="rate", type="string"),
* @OA\Property(property="sum", type="number"),
* @OA\Property(property="color", type="boolean"),
* @OA\Property(property="aAngle", type="boolean")
* )),
* @OA\Property(property="prefix", type="string", nullable=true, example="BE"),
* @OA\Property(property="length_code", type="string", nullable=true, example="30"),
* @OA\Property(property="length_mm", type="integer", nullable=true, example=3000),
* @OA\Property(property="legacy_bending_num", type="integer", nullable=true, description="레거시 chandj bending.num (운영 전 삭제 예정)"),
* @OA\Property(property="width_sum", type="integer", nullable=true, example=193),
* @OA\Property(property="bend_count", type="integer", example=5),
* @OA\Property(property="created_at", type="string", format="date-time"),
* @OA\Property(property="updated_at", type="string", format="date-time")
* )
*/
class BendingItemApi
{
/**
* @OA\Get(
* path="/api/v1/bending-items",
* tags={"BendingItem"},
* summary="절곡품 목록 조회",
* @OA\Parameter(name="item_sep", in="query", required=false, @OA\Schema(type="string", enum={"스크린","철재"})),
* @OA\Parameter(name="item_bending", in="query", required=false, @OA\Schema(type="string")),
* @OA\Parameter(name="material", in="query", required=false, @OA\Schema(type="string")),
* @OA\Parameter(name="model_UA", in="query", required=false, @OA\Schema(type="string", enum={"인정","비인정"})),
* @OA\Parameter(name="search", in="query", required=false, @OA\Schema(type="string")),
* @OA\Parameter(name="page", in="query", required=false, @OA\Schema(type="integer")),
* @OA\Parameter(name="size", in="query", required=false, @OA\Schema(type="integer", default=50)),
* @OA\Response(response=200, description="성공")
* )
*/
public function index() {}
/**
* @OA\Get(
* path="/api/v1/bending-items/filters",
* tags={"BendingItem"},
* summary="절곡품 필터 옵션 조회",
* @OA\Response(response=200, description="성공")
* )
*/
public function filters() {}
/**
* @OA\Get(
* path="/api/v1/bending-items/{id}",
* tags={"BendingItem"},
* summary="절곡품 상세 조회",
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
* @OA\Response(response=200, description="성공")
* )
*/
public function show() {}
/**
* @OA\Post(
* path="/api/v1/bending-items",
* tags={"BendingItem"},
* summary="절곡품 등록",
* @OA\RequestBody(@OA\JsonContent(
* required={"code","name","item_name","item_sep","item_bending","material"},
* @OA\Property(property="code", type="string"),
* @OA\Property(property="name", type="string"),
* @OA\Property(property="item_name", type="string"),
* @OA\Property(property="item_sep", type="string", enum={"스크린","철재"}),
* @OA\Property(property="item_bending", type="string"),
* @OA\Property(property="material", type="string"),
* @OA\Property(property="bendingData", type="array", nullable=true, @OA\Items(type="object"))
* )),
* @OA\Response(response=200, description="성공")
* )
*/
public function store() {}
/**
* @OA\Put(
* path="/api/v1/bending-items/{id}",
* tags={"BendingItem"},
* summary="절곡품 수정",
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
* @OA\RequestBody(@OA\JsonContent(
* @OA\Property(property="code", type="string"),
* @OA\Property(property="name", type="string"),
* @OA\Property(property="memo", type="string"),
* @OA\Property(property="bendingData", type="array", nullable=true, @OA\Items(type="object"))
* )),
* @OA\Response(response=200, description="성공")
* )
*/
public function update() {}
/**
* @OA\Delete(
* path="/api/v1/bending-items/{id}",
* tags={"BendingItem"},
* summary="절곡품 삭제",
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
* @OA\Response(response=200, description="성공")
* )
*/
public function destroy() {}
}

View File

@@ -0,0 +1,125 @@
<?php
namespace App\Swagger\v1;
/**
* @OA\Tag(name="GuiderailModel", description="절곡품 모델 관리 (가이드레일/케이스/하단마감재)")
*
* @OA\Schema(
* schema="GuiderailModel",
* @OA\Property(property="id", type="integer"),
* @OA\Property(property="code", type="string", example="GR-KSS01-벽면형-SUS"),
* @OA\Property(property="name", type="string", example="KSS01 벽면형 SUS마감"),
* @OA\Property(property="item_category", type="string", enum={"GUIDERAIL_MODEL","SHUTTERBOX_MODEL","BOTTOMBAR_MODEL"}),
* @OA\Property(property="model_name", type="string", nullable=true, example="KSS01"),
* @OA\Property(property="check_type", type="string", nullable=true, enum={"벽면형","측면형"}),
* @OA\Property(property="rail_width", type="integer", nullable=true, example=70),
* @OA\Property(property="rail_length", type="integer", nullable=true, example=120),
* @OA\Property(property="finishing_type", type="string", nullable=true, enum={"SUS마감","EGI마감"}),
* @OA\Property(property="item_sep", type="string", nullable=true, enum={"스크린","철재"}),
* @OA\Property(property="model_UA", type="string", nullable=true, enum={"인정","비인정"}),
* @OA\Property(property="exit_direction", type="string", nullable=true, description="케이스 점검구 방향"),
* @OA\Property(property="front_bottom_width", type="integer", nullable=true, description="케이스 전면밑"),
* @OA\Property(property="box_width", type="integer", nullable=true, description="케이스 너비"),
* @OA\Property(property="box_height", type="integer", nullable=true, description="케이스 높이"),
* @OA\Property(property="bar_width", type="integer", nullable=true, description="하단마감재 폭"),
* @OA\Property(property="bar_height", type="integer", nullable=true, description="하단마감재 높이"),
* @OA\Property(property="components", type="array", @OA\Items(
* @OA\Property(property="orderNumber", type="integer"),
* @OA\Property(property="itemName", type="string"),
* @OA\Property(property="material", type="string"),
* @OA\Property(property="quantity", type="integer"),
* @OA\Property(property="width_sum", type="number"),
* @OA\Property(property="image_file_id", type="integer", nullable=true, description="부품 이미지 → /api/v1/files/{id}/view"),
* @OA\Property(property="bendingData", type="array", @OA\Items(type="object"))
* )),
* @OA\Property(property="material_summary", type="object", example={"SUS 1.2T": 406, "EGI 1.55T": 398}),
* @OA\Property(property="component_count", type="integer", example=4)
* )
*/
class GuiderailModelApi
{
/**
* @OA\Get(
* path="/api/v1/guiderail-models",
* tags={"GuiderailModel"},
* summary="절곡품 모델 목록 (가이드레일/케이스/하단마감재 통합)",
* @OA\Parameter(name="item_category", in="query", required=true, description="필수: 카테고리 구분 (없으면 60건 전부 반환)", @OA\Schema(type="string", enum={"GUIDERAIL_MODEL","SHUTTERBOX_MODEL","BOTTOMBAR_MODEL"})),
* @OA\Parameter(name="item_sep", in="query", required=false, @OA\Schema(type="string")),
* @OA\Parameter(name="model_UA", in="query", required=false, @OA\Schema(type="string")),
* @OA\Parameter(name="check_type", in="query", required=false, @OA\Schema(type="string")),
* @OA\Parameter(name="model_name", in="query", required=false, @OA\Schema(type="string")),
* @OA\Parameter(name="search", in="query", required=false, @OA\Schema(type="string")),
* @OA\Parameter(name="page", in="query", required=false, @OA\Schema(type="integer")),
* @OA\Parameter(name="size", in="query", required=false, @OA\Schema(type="integer", default=50)),
* @OA\Response(response=200, description="성공")
* )
*/
public function index() {}
/**
* @OA\Get(
* path="/api/v1/guiderail-models/filters",
* tags={"GuiderailModel"},
* summary="절곡품 모델 필터 옵션",
* @OA\Response(response=200, description="성공")
* )
*/
public function filters() {}
/**
* @OA\Get(
* path="/api/v1/guiderail-models/{id}",
* tags={"GuiderailModel"},
* summary="절곡품 모델 상세 (부품 조합 포함)",
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
* @OA\Response(response=200, description="성공")
* )
*/
public function show() {}
/**
* @OA\Post(
* path="/api/v1/guiderail-models",
* tags={"GuiderailModel"},
* summary="절곡품 모델 등록",
* @OA\RequestBody(@OA\JsonContent(
* required={"code","name"},
* @OA\Property(property="code", type="string"),
* @OA\Property(property="name", type="string"),
* @OA\Property(property="model_name", type="string"),
* @OA\Property(property="check_type", type="string"),
* @OA\Property(property="components", type="array", @OA\Items(type="object"))
* )),
* @OA\Response(response=200, description="성공")
* )
*/
public function store() {}
/**
* @OA\Put(
* path="/api/v1/guiderail-models/{id}",
* tags={"GuiderailModel"},
* summary="절곡품 모델 수정",
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
* @OA\RequestBody(@OA\JsonContent(
* @OA\Property(property="model_name", type="string"),
* @OA\Property(property="components", type="array", @OA\Items(type="object")),
* @OA\Property(property="material_summary", type="object")
* )),
* @OA\Response(response=200, description="성공")
* )
*/
public function update() {}
/**
* @OA\Delete(
* path="/api/v1/guiderail-models/{id}",
* tags={"GuiderailModel"},
* summary="절곡품 모델 삭제",
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
* @OA\Response(response=200, description="성공")
* )
*/
public function destroy() {}
}

View File

@@ -10,6 +10,7 @@
*/
use App\Http\Controllers\Api\V1\BendingItemController;
use App\Http\Controllers\Api\V1\GuiderailModelController;
use App\Http\Controllers\Api\V1\InspectionController;
use App\Http\Controllers\Api\V1\ProductionOrderController;
use App\Http\Controllers\Api\V1\WorkOrderController;
@@ -134,6 +135,16 @@
Route::delete('/{id}', [BendingItemController::class, 'destroy'])->whereNumber('id')->name('v1.bending-items.destroy');
});
// Guiderail Model API (절곡품 모델 관리)
Route::prefix('guiderail-models')->group(function () {
Route::get('', [GuiderailModelController::class, 'index'])->name('v1.guiderail-models.index');
Route::get('/filters', [GuiderailModelController::class, 'filters'])->name('v1.guiderail-models.filters');
Route::post('', [GuiderailModelController::class, 'store'])->name('v1.guiderail-models.store');
Route::get('/{id}', [GuiderailModelController::class, 'show'])->whereNumber('id')->name('v1.guiderail-models.show');
Route::put('/{id}', [GuiderailModelController::class, 'update'])->whereNumber('id')->name('v1.guiderail-models.update');
Route::delete('/{id}', [GuiderailModelController::class, 'destroy'])->whereNumber('id')->name('v1.guiderail-models.destroy');
});
// Production Order API (생산지시 조회)
Route::prefix('production-orders')->group(function () {
Route::get('', [ProductionOrderController::class, 'index'])->name('v1.production-orders.index');

View File

@@ -11,7 +11,7 @@
"servers": [
{
"url": "https://api.sam.kr/",
"description": "SAM관리시스템 API 서버"
"description": "SAM API 서버"
}
],
"paths": {
@@ -9436,6 +9436,256 @@
]
}
},
"/api/v1/bending-items": {
"get": {
"tags": [
"BendingItem"
],
"summary": "절곡품 목록 조회",
"operationId": "c497d5bfebed3fb08cd4d5be9224c795",
"parameters": [
{
"name": "item_sep",
"in": "query",
"required": false,
"schema": {
"type": "string",
"enum": [
"스크린",
"철재"
]
}
},
{
"name": "item_bending",
"in": "query",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "material",
"in": "query",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "model_UA",
"in": "query",
"required": false,
"schema": {
"type": "string",
"enum": [
"인정",
"비인정"
]
}
},
{
"name": "search",
"in": "query",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "page",
"in": "query",
"required": false,
"schema": {
"type": "integer"
}
},
{
"name": "size",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"default": 50
}
}
],
"responses": {
"200": {
"description": "성공"
}
}
},
"post": {
"tags": [
"BendingItem"
],
"summary": "절곡품 등록",
"operationId": "8c9e50c74611ec24e621ceb665031059",
"requestBody": {
"content": {
"application/json": {
"schema": {
"required": [
"code",
"name",
"item_name",
"item_sep",
"item_bending",
"material"
],
"properties": {
"code": {
"type": "string"
},
"name": {
"type": "string"
},
"item_name": {
"type": "string"
},
"item_sep": {
"type": "string",
"enum": [
"스크린",
"철재"
]
},
"item_bending": {
"type": "string"
},
"material": {
"type": "string"
},
"bendingData": {
"type": "array",
"items": {
"type": "object"
},
"nullable": true
}
},
"type": "object"
}
}
}
},
"responses": {
"200": {
"description": "성공"
}
}
}
},
"/api/v1/bending-items/filters": {
"get": {
"tags": [
"BendingItem"
],
"summary": "절곡품 필터 옵션 조회",
"operationId": "f5dd325adc791e1b8cab40b9fa2fb77d",
"responses": {
"200": {
"description": "성공"
}
}
}
},
"/api/v1/bending-items/{id}": {
"get": {
"tags": [
"BendingItem"
],
"summary": "절곡품 상세 조회",
"operationId": "d364f4d4cf76bcce7167561b73216382",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "성공"
}
}
},
"put": {
"tags": [
"BendingItem"
],
"summary": "절곡품 수정",
"operationId": "9ce1223d6528c5f926f6726c2b1f65f2",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"properties": {
"code": {
"type": "string"
},
"name": {
"type": "string"
},
"memo": {
"type": "string"
},
"bendingData": {
"type": "array",
"items": {
"type": "object"
},
"nullable": true
}
},
"type": "object"
}
}
}
},
"responses": {
"200": {
"description": "성공"
}
}
},
"delete": {
"tags": [
"BendingItem"
],
"summary": "절곡품 삭제",
"operationId": "46dcd93439505ae5ffb1dd2c8c1e5685",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "성공"
}
}
}
},
"/api/v1/biddings": {
"get": {
"tags": [
@@ -26052,6 +26302,243 @@
]
}
},
"/api/v1/guiderail-models": {
"get": {
"tags": [
"GuiderailModel"
],
"summary": "절곡품 모델 목록 (가이드레일/케이스/하단마감재 통합)",
"operationId": "f06bc491c0801ca7ec8676381ac5dc53",
"parameters": [
{
"name": "item_category",
"in": "query",
"description": "필수: 카테고리 구분 (없으면 60건 전부 반환)",
"required": true,
"schema": {
"type": "string",
"enum": [
"GUIDERAIL_MODEL",
"SHUTTERBOX_MODEL",
"BOTTOMBAR_MODEL"
]
}
},
{
"name": "item_sep",
"in": "query",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "model_UA",
"in": "query",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "check_type",
"in": "query",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "model_name",
"in": "query",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "search",
"in": "query",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "page",
"in": "query",
"required": false,
"schema": {
"type": "integer"
}
},
{
"name": "size",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"default": 50
}
}
],
"responses": {
"200": {
"description": "성공"
}
}
},
"post": {
"tags": [
"GuiderailModel"
],
"summary": "절곡품 모델 등록",
"operationId": "6b4f759362fa6db48357bab171175f4f",
"requestBody": {
"content": {
"application/json": {
"schema": {
"required": [
"code",
"name"
],
"properties": {
"code": {
"type": "string"
},
"name": {
"type": "string"
},
"model_name": {
"type": "string"
},
"check_type": {
"type": "string"
},
"components": {
"type": "array",
"items": {
"type": "object"
}
}
},
"type": "object"
}
}
}
},
"responses": {
"200": {
"description": "성공"
}
}
}
},
"/api/v1/guiderail-models/filters": {
"get": {
"tags": [
"GuiderailModel"
],
"summary": "절곡품 모델 필터 옵션",
"operationId": "85422fba3619fcb7fac3db2a1dc20bb1",
"responses": {
"200": {
"description": "성공"
}
}
}
},
"/api/v1/guiderail-models/{id}": {
"get": {
"tags": [
"GuiderailModel"
],
"summary": "절곡품 모델 상세 (부품 조합 포함)",
"operationId": "6c24a276c41bef2fb211b4d6d5a3c45a",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "성공"
}
}
},
"put": {
"tags": [
"GuiderailModel"
],
"summary": "절곡품 모델 수정",
"operationId": "0284e070bc05779f643d8579c584de23",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"properties": {
"model_name": {
"type": "string"
},
"components": {
"type": "array",
"items": {
"type": "object"
}
},
"material_summary": {
"type": "object"
}
},
"type": "object"
}
}
}
},
"responses": {
"200": {
"description": "성공"
}
}
},
"delete": {
"tags": [
"GuiderailModel"
],
"summary": "절곡품 모델 삭제",
"operationId": "3468308d63c19adb4325812d9636bf5a",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "성공"
}
}
}
},
"/api/v1/internal/exchange-token": {
"post": {
"tags": [
@@ -64189,6 +64676,177 @@
},
"type": "object"
},
"BendingItem": {
"properties": {
"id": {
"type": "integer",
"example": 15862
},
"code": {
"type": "string",
"example": "BD-BE-30"
},
"name": {
"type": "string",
"example": "하단마감재(스크린) EGI 3000mm"
},
"item_type": {
"type": "string",
"example": "PT"
},
"item_category": {
"type": "string",
"example": "BENDING"
},
"unit": {
"type": "string",
"example": "EA"
},
"is_active": {
"type": "boolean",
"example": true
},
"item_name": {
"type": "string",
"example": "하단마감재"
},
"item_sep": {
"type": "string",
"enum": [
"스크린",
"철재"
]
},
"item_bending": {
"type": "string",
"example": "하단마감재"
},
"item_spec": {
"type": "string",
"example": "60*40",
"nullable": true
},
"material": {
"type": "string",
"example": "EGI 1.55T"
},
"model_name": {
"type": "string",
"example": "KSS01",
"nullable": true
},
"model_UA": {
"type": "string",
"enum": [
"인정",
"비인정"
],
"nullable": true
},
"search_keyword": {
"type": "string",
"nullable": true
},
"rail_width": {
"type": "integer",
"nullable": true
},
"registration_date": {
"type": "string",
"format": "date",
"nullable": true
},
"author": {
"type": "string",
"nullable": true
},
"memo": {
"type": "string",
"nullable": true
},
"exit_direction": {
"type": "string",
"nullable": true
},
"front_bottom_width": {
"type": "integer",
"nullable": true
},
"box_width": {
"type": "integer",
"nullable": true
},
"box_height": {
"type": "integer",
"nullable": true
},
"bendingData": {
"type": "array",
"items": {
"properties": {
"no": {
"type": "integer"
},
"input": {
"type": "number"
},
"rate": {
"type": "string"
},
"sum": {
"type": "number"
},
"color": {
"type": "boolean"
},
"aAngle": {
"type": "boolean"
}
},
"type": "object"
},
"nullable": true
},
"prefix": {
"type": "string",
"example": "BE",
"nullable": true
},
"length_code": {
"type": "string",
"example": "30",
"nullable": true
},
"length_mm": {
"type": "integer",
"example": 3000,
"nullable": true
},
"legacy_bending_num": {
"description": "레거시 chandj bending.num (운영 전 삭제 예정)",
"type": "integer",
"nullable": true
},
"width_sum": {
"type": "integer",
"example": 193,
"nullable": true
},
"bend_count": {
"type": "integer",
"example": 5
},
"created_at": {
"type": "string",
"format": "date-time"
},
"updated_at": {
"type": "string",
"format": "date-time"
}
},
"type": "object"
},
"Bidding": {
"properties": {
"id": {
@@ -73925,6 +74583,152 @@
},
"type": "object"
},
"GuiderailModel": {
"properties": {
"id": {
"type": "integer"
},
"code": {
"type": "string",
"example": "GR-KSS01-벽면형-SUS"
},
"name": {
"type": "string",
"example": "KSS01 벽면형 SUS마감"
},
"item_category": {
"type": "string",
"enum": [
"GUIDERAIL_MODEL",
"SHUTTERBOX_MODEL",
"BOTTOMBAR_MODEL"
]
},
"model_name": {
"type": "string",
"example": "KSS01",
"nullable": true
},
"check_type": {
"type": "string",
"enum": [
"벽면형",
"측면형"
],
"nullable": true
},
"rail_width": {
"type": "integer",
"example": 70,
"nullable": true
},
"rail_length": {
"type": "integer",
"example": 120,
"nullable": true
},
"finishing_type": {
"type": "string",
"enum": [
"SUS마감",
"EGI마감"
],
"nullable": true
},
"item_sep": {
"type": "string",
"enum": [
"스크린",
"철재"
],
"nullable": true
},
"model_UA": {
"type": "string",
"enum": [
"인정",
"비인정"
],
"nullable": true
},
"exit_direction": {
"description": "케이스 점검구 방향",
"type": "string",
"nullable": true
},
"front_bottom_width": {
"description": "케이스 전면밑",
"type": "integer",
"nullable": true
},
"box_width": {
"description": "케이스 너비",
"type": "integer",
"nullable": true
},
"box_height": {
"description": "케이스 높이",
"type": "integer",
"nullable": true
},
"bar_width": {
"description": "하단마감재 폭",
"type": "integer",
"nullable": true
},
"bar_height": {
"description": "하단마감재 높이",
"type": "integer",
"nullable": true
},
"components": {
"type": "array",
"items": {
"properties": {
"orderNumber": {
"type": "integer"
},
"itemName": {
"type": "string"
},
"material": {
"type": "string"
},
"quantity": {
"type": "integer"
},
"width_sum": {
"type": "number"
},
"image_file_id": {
"description": "부품 이미지 → /api/v1/files/{id}/view",
"type": "integer",
"nullable": true
},
"bendingData": {
"type": "array",
"items": {
"type": "object"
}
}
},
"type": "object"
}
},
"material_summary": {
"type": "object",
"example": {
"SUS 1.2T": 406,
"EGI 1.55T": 398
}
},
"component_count": {
"type": "integer",
"example": 4
}
},
"type": "object"
},
"ExchangeTokenRequest": {
"required": [
"user_id",
@@ -93801,6 +94605,10 @@
"name": "BarobillSettings",
"description": "바로빌 설정 관리"
},
{
"name": "BendingItem",
"description": "절곡품 기초관리"
},
{
"name": "Bidding",
"description": "입찰관리 API"
@@ -93905,6 +94713,10 @@
"name": "Folder",
"description": "폴더 관리"
},
{
"name": "GuiderailModel",
"description": "절곡품 모델 관리 (가이드레일/케이스/하단마감재)"
},
{
"name": "ItemMaster",
"description": "품목기준관리 API"