Compare commits

135 Commits

Author SHA1 Message Date
d8ac3db239 Revert "feat: [auth] 자동산출 API Sanctum 인증 예외 추가"
This reverts commit 5df2c2afce8de1356c021545e2d3650a82bb74be.
2026-03-20 17:05:00 +09:00
2e60ace503 docs: LOGICAL_RELATIONSHIPS 문서 업데이트 2026-03-20 17:04:58 +09:00
4c9526d9d5 fix: [quality] 부적합 접수 통계에 draft 상태 합산 2026-03-20 17:04:14 +09:00
31b3b49f48 feat: [auth] 자동산출 API Sanctum 인증 예외 추가
- quotes/calculate/* 경로를 API Key만으로 접근 허용
- MNG에서 API Key + X-TENANT-ID로 접근하는 자동산출용
2026-03-20 17:04:14 +09:00
1767944a14 fix: [document] formatTemplateForReact 클로저에 $methodCodes 누락 수정
- use ($methodCodes) 추가하여 검사방식 한글 변환 정상화
2026-03-20 17:04:14 +09:00
2df43187bf fix: [inspection] 완료된 검사 수정 차단
- InspectionService.update()에 완료 상태 검증 추가
- 완료된 검사 수정 시 400 에러 반환
- error.php에 cannot_modify_completed 메시지 추가
2026-03-20 17:04:14 +09:00
김보곤
0c04910049 docs: [architecture] LOGICAL_RELATIONSHIPS 문서 보강 2026-03-20 15:16:01 +09:00
김보곤
91a3823c75 fix: [db] daily_work_logs sam→codebridge 데이터 이관 마이그레이션 추가
- 2026_03_19_200000에서 existingTables 누락으로 데이터 미이관된 문제 해결
- 부모→자식 순서로 복사, 자식→부모 순서로 삭제 (FK 안전)
- 건수 검증 후 sam 테이블 삭제
2026-03-20 15:03:02 +09:00
김보곤
4f30de2d28 fix: [입고] order_qty validation을 required로 변경하여 SQL 에러 대신 명확한 안내 반환 2026-03-20 15:03:02 +09:00
1231ee6302 feat: [document] 검사 템플릿 섹션에 image_url(presigned URL) 추가
- DocumentService: 섹션 데이터에 image_url 반환
- QmsLotAuditService: 동일하게 image_url 반환
- file_id 또는 image_path 기반으로 R2 presigned URL 생성
2026-03-20 12:37:23 +09:00
1e7a84d516 feat: [file] path 기반 presigned URL 엔드포인트 추가
- POST /api/v1/files/presigned-url-by-path 추가
- file_id 없이 image_path만 있는 레거시 데이터 지원
2026-03-20 11:24:24 +09:00
김보곤
3417a7eaad feat: [계좌내역] barobill_bank_transaction_overrides에 modified_trans_office 컬럼 추가 2026-03-20 10:52:58 +09:00
700722d5d2 feat: [file] API Resource에서 image_url (presigned URL) 반환
- File 모델에 presignedUrl() 메서드 추가
- GuiderailModelResource: image_url + components[].image_url 반환
- BendingItemResource: image_url 반환
- 소비자(MNG, React)가 별도 처리 없이 R2 직접 로드 가능
2026-03-20 10:22:00 +09:00
9bdb81d8ff fix: presignedUrl에서 R2 exists() 체크 제거
- exists()가 매 요청마다 R2 HTTP HEAD 호출 → 개발서버에서 지연/500 발생
- temporaryUrl()은 로컬 서명 생성만 하므로 R2 접근 불필요
- 파일 미존재 시 브라우저가 R2에서 직접 404 수신

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 09:17:53 +09:00
844a0458ad feat: R2 presigned URL 엔드포인트 추가
- FileStorageController에 presignedUrl() 메서드 추가 (30분 유효)
- GET /api/v1/files/{id}/presigned-url 라우트 추가
- 파일 프록시 스트리밍 대신 R2 직접 접근 지원

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 08:50:39 +09:00
강영보
ce4a61e0ce fix: [auth] Swagger publicRoutes + File 모델 네임스페이스 수정
- publicRoutes에 api/documentation/* 추가 (Swagger UI 공개 접근)
- web.php /files/{id}/view: App\Models\File → App\Models\Commons\File
2026-03-20 08:49:51 +09:00
김보곤
786bb20d86 feat: [finance] 계정별원장 API 서비스 보강 (카드거래 상세, 분리전표 필터링) 2026-03-20 08:35:07 +09:00
fc673be0c1 fix: [pricing] 단가 목록/상세에서 품목코드·품목명 미표시 수정
- Price 모델에 item() BelongsTo 관계 추가
- index/show에서 item:id,code,name,item_category eager load
2026-03-19 23:58:02 +09:00
bb4d9d8fbd fix: [QA] 수주 날짜 필터 COALESCE + 배차 상태 자동 전환 + 견적 날짜 캐스트 수정
- OrderService: 날짜 필터를 COALESCE(received_at, created_at)로 NULL 안전 처리
- VehicleDispatchService: update() 시 freight_cost_type 유무로 status 자동 결정
- Quote 모델: date 캐스트를 date:Y-m-d로 변경 (UTC 직렬화 방지)
2026-03-19 23:57:56 +09:00
강영보
949efc27e2 fix: [auth] ApiKeyMiddleware allowWithoutAuth 화이트리스트 제거
- bending-items, guiderail-models, files/view, files/download, quotes/calculate 등
- MNG에서 Bearer 인증 포함하여 호출하도록 변경
- 불필요한 인증 예외 제거로 보안 강화
2026-03-19 21:59:59 +09:00
6952ae9667 fix: [stat] 출하 집계 shipping_cost 컬럼 참조 오류 수정
- shipments.shipping_cost 컬럼이 3/18 배차 분리 시 삭제되었으나 집계 코드 미수정
- shipment_vehicle_dispatches.options->total_amount JOIN으로 변경
2026-03-19 21:09:49 +09:00
김보곤
0680564164 feat: [db] MNG 전용 테이블 sam→codebridge DB 이관 마이그레이션
- pmis_* 21개 테이블 + daily_work_logs 2개 테이블을 codebridge DB에 생성
- 기존 sam DB의 5개 테이블 데이터 복사 후 건수 검증 → 삭제
- 안전 장치: 데이터 불일치 시 예외 발생으로 롤백
- 원칙: MNG에서 운영하는 테이블은 codebridge DB에서 관리
2026-03-19 20:53:50 +09:00
강영보
c29090a0b8 feat: [bending] 절곡품 전용 테이블 분리 API
- bending_items 전용 테이블 생성 (items.options → 정규 컬럼 승격)
- bending_models 전용 테이블 생성 (가이드레일/케이스/하단마감재 통합)
- bending_data JSON 통합 (별도 테이블 → bending_items.bending_data 컬럼)
- bending_item_mappings 테이블 DROP (bending_items.code에 흡수)
- BendingItemService/BendingCodeService → BendingItem 모델 전환
- GuiderailModelService component 이미지 자동 복사
- ItemsFileController bending_items/bending_models 폴백 지원
- Swagger 스키마 업데이트
2026-03-19 20:00:18 +09:00
유병철
623298dd82 fix: [dashboard] 생산현황 shipments.shipping_cost 컬럼 미존재 에러 수정
- shipments 테이블에 shipping_cost 컬럼이 없어서 500 에러 발생
- 컬럼 추가 전까지 금액 0 고정, 건수만 집계
- TODO: shipping_cost 컬럼 추가 시 금액 집계 복원

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 19:33:25 +09:00
김보곤
d22850a9e3 fix: [finance] 계정별원장 홈택스 분개 UNION 제거 (일반전표만 조회) 2026-03-19 17:36:51 +09:00
김보곤
01c9d5fca0 feat: [loan] 대시보드 API에 경조사비 요약 데이터 추가
- condolence_expenses 테이블에서 경조사비 통계를 조회하여 dashboard 응답에 포함
- condolence_summary: total_count, total_amount, congratulation_amount, condolence_amount
- 대시보드 날짜 필터(start_date, end_date)를 event_date 기준으로 적용
2026-03-19 17:18:55 +09:00
유병철
db60499676 feat: [finance] 경조사비 관리 API 라우트 추가
- 경조사비 CRUD 라우트 등록 (index, store, show, update, destroy)
- 경조사비 요약 API 라우트 추가 (summary)
- 중복 import 정리

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 16:10:51 +09:00
김보곤
abb024f0bd feat: [finance] 경조사비 관리 API 구현
- Model: CondolenceExpense (BelongsToTenant, Auditable, SoftDeletes)
- Service: CRUD + summary 통계 (total_amount 자동 계산)
- Controller: 6개 엔드포인트 (목록/등록/상세/수정/삭제/통계)
- FormRequest: Store/Update 검증 규칙 분리
- Route: /api/v1/condolence-expenses
- Migration: updated_by, deleted_by 컬럼 추가
2026-03-19 16:02:19 +09:00
유병철
5e5aecd992 feat: [material] 부적합보고서 morph map 등록
- NonconformingReport 모델 morph map에 'nonconforming_report' 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 14:28:25 +09:00
유병철
6681bf7e79 fix: [material] 부적합보고서 User 모델 경로 수정
- App\Models\Users\User → App\Models\Members\User

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 14:06:33 +09:00
김보곤
c68cf5dfcf feat: [finance] 손익계산서 월별 조회 API + 리팩토링
- GET /api/v1/income-statement/monthly?year=2026&unit=won 추가
- buildSections 공통 로직 분리
- getAccountCodes, getFiscalYear 헬퍼 분리
2026-03-19 12:49:04 +09:00
김보곤
ea5591c812 fix: [finance] 손익계산서 기수 수정 (1기=2025년, 코드브릿지엑스 설립 기준) 2026-03-19 12:33:06 +09:00
김보곤
a07b15066d feat: [finance] 계정별원장·손익계산서 API 추가
- GET /api/v1/account-ledger: 계정별원장 조회 (이월잔액, 월별소계/누계)
- GET /api/v1/income-statement: 손익계산서 조회 (당기/전기 비교, 단위변환)
- KIS 표준 계정과목 sub_category 기반 자동 매핑
- 일반전표 + 홈택스 분개 UNION ALL 통합 집계
2026-03-19 11:43:24 +09:00
김보곤
866c6f0b10 fix: [material] 부적합관리 Department 모델 경로 수정
- Departments\Department → Tenants\Department
2026-03-19 11:40:32 +09:00
김보곤
a94cdf6eed fix: [item-master] 필드 수정 422 유효성 검증 오류 수정
- field_key regex를 숫자 시작도 허용하도록 변경 (기존 데이터 39건 호환)
- Handler.php 422 응답에 errors 최상위 필드 추가 (프론트엔드 호환)
2026-03-19 10:33:58 +09:00
김보곤
6e50fbd1fa feat: [material] 부적합관리 결재 연동 구현
- Migration: approval_id FK 추가
- Model: approval() BelongsTo 관계
- Service: submitForApproval() 결재상신 (결재문서+결재선 생성)
- ApprovalService: 승인→CLOSED, 반려/회수→approval_id 해제
- Controller: POST /{id}/submit-approval 엔드포인트
- Route: submit-approval 라우트 등록
2026-03-19 09:03:12 +09:00
김보곤
847c60b03d feat: [material] 부적합관리 API Phase 1-A 구현
- Migration: nonconforming_reports, nonconforming_report_items 테이블
- Model: NonconformingReport, NonconformingReportItem (관계, cast, scope)
- FormRequest: Store/Update 검증 (items 배열 포함)
- Service: CRUD + 채번(NC-YYYYMMDD-NNN) + 비용 자동 계산 + 상태 전이
- Controller: REST 7개 엔드포인트 (목록/통계/상세/등록/수정/삭제/상태변경)
- Route: /api/v1/material/nonconforming-reports
- i18n: 부적합관리 에러 메시지 (ko)
2026-03-19 08:39:24 +09:00
d8a57f71c6 fix: [quality] 출하 상세에 order_id 추가 + 검사완료 건 수정 차단
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 23:15:57 +09:00
b3869cfadb fix: [production-order] 생산지시 상세 개선 - 수량을 개소수로 변경, BOM 공정그룹 한글 라벨 매핑
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 23:15:55 +09:00
f70ec4155e feat: [items] 품목 목록 API에 공정 배정 정보(assigned_processes) 응답 추가
- exclude_process_id 파라미터 시 품목 제외 대신 배정 공정 정보 포함하여 응답
- 프론트에서 다른 공정 배정 품목을 disabled 처리하도록 변경

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 23:15:54 +09:00
6563d977ee refactor: [shipment] 배차 정보를 shipment_vehicle_dispatches로 일원화
- shipments 테이블에서 배차 관련 컬럼 8개 삭제 (vehicle_no, driver_name 등)
- shipping 전환 시 배차 정보를 vehicle_dispatches에 저장
- delivery_method ENUM → VARCHAR 변경 (common_codes 기반)
- VehicleDispatchService에 수주/작성자 관계 로딩 추가
- Swagger delivery_method enum 제약 제거

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 23:15:47 +09:00
김보곤
5e52293454 fix: [stock] work_orders에도 deleted_by 컬럼 없는 오류 수정 2026-03-18 22:24:23 +09:00
김보곤
6497a7ab8f fix: [stock] work_order_items에 deleted_by 컬럼 없는 오류 수정 2026-03-18 22:23:21 +09:00
김보곤
d8b8df6f47 fix: [stock] 재고생산 삭제 허용 (IN_PROGRESS + 작업지시 함께 정리)
- STOCK 타입: IN_PROGRESS 상태에서도 삭제 허용 (IN_PRODUCTION부터 불가)
- STOCK 타입: 작업지시(workOrders)가 있어도 삭제 허용
- cleanupStockWorkOrders(): 작업지시 soft delete + 재고예약 해제
- destroy(), bulkDestroy() 양쪽 적용
- 일반 수주(ORDER)는 기존 규칙 유지
2026-03-18 22:21:04 +09:00
유병철
8f8eae92f2 fix: [bending] 품목 resolveItem eager loading 컬럼 수정
- item 관계 로드 시 specification → attributes로 변경

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 21:13:24 +09:00
김보곤
dd2975ba8c fix: [common] ApiResponse 에러 신호 배열의 추가 필드 전달
- handle()에서 에러 감지 시 error/code/message/details 외 추가 필드도 전달
- error()에서 details 외 추가 필드를 error 객체에 포함
- BendingController의 expected_code 등이 API 응답에 노출됨
2026-03-18 20:56:14 +09:00
김보곤
673f521543 feat: [stock] 재고생산 저장 시 자동 확정+생산지시 생성
- 기존: 저장(DRAFT) → 확정(CONFIRMED) → 생산지시 생성 (3단계)
- 변경: 저장 즉시 확정 + 생산지시 자동 생성 (1단계)
- store()에서 STOCK 타입 감지 시 CONFIRMED 전환 + 재고 예약 + createProductionOrder 호출
2026-03-18 20:45:47 +09:00
김보곤
66a75746f3 fix: [bending] 원자재 LOT 조회 재질 검색 개선
- status 필터 확장: completed → completed + inspection_completed
- 재질 키워드 분해 검색: "EGI 1.55T" → "EGI" AND "1.55" (공백/T 무관)
- 기존: LIKE "%EGI 1.55T%" → 매칭 실패 (실제 데이터: "EGI1.55")
2026-03-18 20:33:32 +09:00
김보곤
7ae5ba1264 feat: [bending] resolve-item 응답에 expected_code 추가
- 매핑 성공/실패 모두 expected_code(BD-XX-nn) 포함
- 매핑 실패 시 어떤 품목코드를 찾고 있는지 사용자가 확인 가능
2026-03-18 20:26:52 +09:00
김보곤
8dc21bdda8 fix: [bending] LOT 채번에서 일련번호 제거
- generateLotNumber() 시그니처 변경: lotBase → prod/spec/length/date
- 일련번호(-001) 로직 제거: 같은 날 같은 조합은 동일 LOT
- generate-lot API 응답에서 lot_base/date_code 필드 제거
- 미사용 Order 모델 import 제거
2026-03-18 20:14:24 +09:00
유병철
55f97f67d3 feat: [finance] 경비계정 동기화에 증빙번호(receipt_no) 지원 추가
- StoreManualJournalRequest/UpdateJournalRequest에 receipt_no 필드 추가
- GeneralJournalEntryService: store/update 시 receipt_no 전달
- JournalSyncService: saveForSource에 receiptNo 파라미터 추가
- SyncsExpenseAccounts: 증빙번호 결정 우선순위 (명시 전달 > 바로빌 승인번호 > null)
- SOURCE_BAROBILL_CARD를 카드결제 payment_method에 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 20:07:17 +09:00
김보곤
b7c6e7f69d fix: [numbering] scopeKey 누적 할당 오류 수정
- scopeKey = value → scopeKey .= value 수정 (param, mapping 세그먼트)
- stock_production 문서유형 추가
2026-03-18 19:56:16 +09:00
김보곤
6af9162ce4 feat: [bending] 절곡품 품목 일괄 등록 + LOT 매핑 (222건 신규)
- BendingCodeService에 42(4200mm) 길이코드 복원
- 품목 일괄 등록 스크립트 추가 (scripts/register-bending-items.php)
- 신규 종류(RW/RF/SW/SF/TE/GH) + 신규 길이(06/17/20/45) 조합 등록
- bending_item_mappings 320건 매핑 동시 등록
2026-03-18 19:43:15 +09:00
김보곤
daf292f687 feat: [bending] LOT 채번 코드맵 최신화 (경동기업 2026-03 기준)
- 종류 코드 신규: W(본체L120), F(SUS마감재L120), H(화이바원단W80)
- 길이 코드 신규: 06(610), 17(1750), 20(2000), 45(4500)
- 명칭 변경: 본체디딤(S:M), SUS마감재(3)(S:S/U), 화이바원단(W50)(G:I)
- 가이드레일(R/S)에서 EGI 종류 코드 제거
- MATERIAL_MAP 신규 조합 추가
2026-03-18 19:31:30 +09:00
김보곤
15609a1e5e fix: [document] rendered_html 크기 제한 추가 (500KB)
- 수입검사 저장 시 rendered_html이 Nginx 제한 초과하여 413 발생하던 문제
- max:512000 검증 추가로 413 대신 422(명확한 에러메시지) 반환
2026-03-18 17:28:55 +09:00
김보곤
f1c6653220 feat: [bom] BOM 트리 API 3단계 구조 반환 (category 그룹 노드)
- GET /items/{id}/bom/tree: category 필드가 있으면 CAT 그룹 노드 자동 생성
- expandBomItems에 category 필드 포함
- 3단계: FG → 카테고리(CAT) → PT 품목
2026-03-18 16:49:20 +09:00
김보곤
e2ecbaf8a5 fix: [middleware] 자동산출 API에 Bearer 없이 API Key + X-TENANT-ID 접근 허용
- quotes/calculate/* 를 allowWithoutAuth 화이트리스트에 추가
- X-TENANT-ID 헤더로 tenant 컨텍스트 설정 (Bearer 토큰 없어도 동작)
- MNG 품목관리 수식 산출에서 HTTP 401 오류 해결
2026-03-18 15:54:19 +09:00
decfe57b50 fix: [order] 수주 관리 개선 - stats 상태 추가, 수정 시 item_name 유효성 에러 수정
- stats에 IN_PRODUCTION/PRODUCED 상태 추가
- 수주 수정 시 item_name 유효성 에러 수정 (V1#29)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:06:14 +09:00
59469d4bf6 feat: [shipment] 출하 프로세스 개선 - 수주 품목 기반 변경, 취소→cancelled 상태, 역방향 프로세스, 제품명/오픈사이즈 추가
- 출하 품목을 수주 품목(order_item_id) 기반으로 변경
- 작업 취소 시 출하를 삭제 대신 cancelled 상태로 변경
- 작업 취소 시 역방향 프로세스 구현 (WorkOrderService)
- 출하 상세 API에 제품명(product_name) 매핑 추가
- 출하 상세 제품그룹에 오픈사이즈 추가
- shipment_items 테이블에 없는 item_id 컬럼 참조 제거

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 14:56:07 +09:00
김보곤
abf5b6896e fix: [subscription] 내보내기 stuck 문제 해결 - 동기 처리로 전환
- pending 상태로 영원히 남던 DataExport 문제 수정
- 미구현 비동기 Job 대신 ExportService::store() 동기 처리
- 5분 이상 stuck된 export 자동 만료 처리
- 파일 다운로드 엔드포인트 추가 (GET /export/{id}/download)
2026-03-18 14:10:43 +09:00
김보곤
8f215b235b fix: [items] 레거시 임포트 커맨드 item_type FG → PT 수정
- BendingProductImportLegacy: 케이스(SB-), 하단마감재(BB-) → PT
- GuiderailImportLegacy: 가이드레일(GR-) → PT
- DB 60건 FG → PT 변경 완료 (구성부품이므로 완제품이 아님)
2026-03-18 13:48:45 +09:00
김보곤
529c48f65e fix: [items] 품목 검색 API per_page/itemType 파라미터 호환성 추가
- size 외에 per_page 파라미터도 읽도록 수정 (React에서 per_page로 전송)
- itemType(camelCase) 파라미터도 item_type으로 매핑
2026-03-18 13:08:27 +09:00
김보곤
97c9cff4c7 fix: [internal] MNG 내부 토큰 교환 테넌트 검증 수정
- InternalTokenService: 테넌트 소속 검사 제거 (HMAC으로 이미 신뢰)
- ApiKeyMiddleware: mng_session 토큰 시 X-TENANT-ID 헤더 우선 적용
- MNG 관리자가 모든 테넌트의 BOM 산출 가능하도록 개선
2026-03-18 13:07:54 +09:00
김보곤
13f84467f0 chore: [notification] 알림음 wav 파일 gitignore 예외 + 파일 추가
- .gitignore에 !public/sounds/*.wav 예외 추가
- default.wav, sam_voice.wav 실제 파일 커밋 (788KB each)
2026-03-18 12:32:02 +09:00
김보곤
72bb33e634 feat: [notification] 알림음 파일 서빙 + 응답에 soundUrls 추가
- public/sounds/에 default.wav, sam_voice.wav 배치 (MNG에서 복사)
- getGroupedSettings() 응답에 _soundUrls 맵 추가 (절대 URL)
2026-03-18 12:31:33 +09:00
김보곤
677da324f8 feat: [subscription] usage() API에 AI 토큰 사용량 통합
- api_calls 섹션 제거, ai_tokens 섹션으로 교체
- 월별 토큰 집계: 총 요청수, 입출력 토큰, 비용(USD/KRW)
- 모델별 사용량 내역 (by_model)
- 한도/사용율/경고 기준(80%) 포함
- tenant_id 기반 구독 조회로 변경 (subscription_id 미연결 대응)
2026-03-18 12:27:20 +09:00
김보곤
69a8e573ee feat: [tenant] AI 토큰 한도 컬럼 추가 및 저장공간 100GB 변경
- tenant.ai_token_limit 추가 (기본값 월 100만 토큰)
- tenant.storage_limit 기본값 10GB → 100GB 변경
- 기존 10GB 테넌트를 100GB로 일괄 업데이트
2026-03-18 12:19:36 +09:00
김보곤
540255ec27 feat: [notification] 알림설정 soundType API 연동
- getGroupedSettings()에서 soundType 반환 (settings.sound_type)
- updateGroupedSettings()에서 soundType 저장 (settings JSON)
- UpdateGroupedSettingRequest에 soundType 검증 추가 (default/sam_voice/mute)
2026-03-18 11:25:19 +09:00
김보곤
427e585236 feat: [settings] AI 토큰사용량 조회 API 추가
- GET /settings/ai-token-usage: 목록 + 통계 조회
- GET /settings/ai-token-usage/pricing: 단가 설정 조회 (읽기 전용)
- AiTokenHelper: Gemini/Claude/R2/STT 사용량 기록 헬퍼
- AiPricingConfig 캐시에 cloudflare-r2 provider 추가
2026-03-18 11:25:19 +09:00
f6f08fb810 refactor: [cleanup] kd_price_tables 레거시 단가 테이블 삭제 + 문서 업데이트 2026-03-18 09:22:47 +09:00
26e33fdc13 fix: [production-orders,order] 생산지시 목록 최적화 + 진행률 컬럼 수정 + 중복체크 취소 건 제외 2026-03-18 09:22:41 +09:00
783a41dc82 fix: [quality,qms] 검사 목록·품목명 미노출 + 관련서류 공정 매핑 확대 2026-03-18 09:22:35 +09:00
7a23d77b5f fix: [sales,pricing] 매출 0원 + 단가 수정 0원 수정 2026-03-18 09:22:28 +09:00
3259a64beb feat: [shipment] 출고 프로세스 개선
- 생산완료 시 출하 자동생성 status를 ready(출하대기)로 변경
- stats에 completed_count 등 프론트 호환 필드 추가
- 절곡물/모터/부자재 수량을 개수(EA) 기반으로 변환
2026-03-18 09:22:23 +09:00
김보곤
bba8f6c0a0 feat: [stock] 재고 조정 API 추가
- GET /stocks/{id}/adjustments: 조정 이력 조회
- POST /stocks/{id}/adjustments: 조정 등록 (증감 수량 + 사유)
- StockTransaction에 adjustment reason 추가
- StoreStockAdjustmentRequest 검증 추가
2026-03-17 20:42:53 +09:00
김보곤
ba8fc0834c feat: [receiving] 원자재로트번호 채번규칙 연동
- NumberingService(material_receipt) 우선 호출
- 채번규칙 없으면 레거시 로직(YYMMDD-NN) 폴백
- QuoteNumberService, OrderService와 동일 패턴
2026-03-17 20:26:47 +09:00
김보곤
ecfe389420 fix: [receiving] 원자재로트번호 생성 로직 개선
- rand() 기반 → DB 시퀀스 기반으로 변경 (중복 방지)
- 5130 레거시 방식 차용: YYMMDD-NN (일별 시퀀스, 01부터)
- 테넌트별 격리 적용
2026-03-17 20:26:47 +09:00
유병철
e83d0e90ff feat: [equipment] 설비점검 조회 응답에 휴무일(non_working_days) 추가
- InspectionCycle::getHolidayDates()로 해당 주기/기간의 휴무일 조회
- 응답에 non_working_days 배열 포함하여 프론트 캘린더 표시 지원

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 18:19:30 +09:00
김보곤
d1c65f5465 feat: [finance] vendors 및 supplier-settings API 추가
- GET /api/v1/vendors: 거래처 간단 목록 (id, name) 반환
- GET /api/v1/tax-invoices/supplier-settings: 공급자 설정 조회
- PUT /api/v1/tax-invoices/supplier-settings: 공급자 설정 저장
2026-03-17 16:11:42 +09:00
김보곤
17a0d2f98d feat: [quality] 실적신고 확정건 엑셀 다운로드 API 구현
- PhpSpreadsheet 기반 PerformanceReportExcelService 신규 생성
- 건기원 양식(품질인정자재등의 판매실적 대장) 엑셀 생성
- 카테고리별 배경색, 셀 병합, 회사정보 섹션 포함
- GET /api/v1/quality/performance-reports/export-excel 엔드포인트 추가
- 미확정 4개 필드(인정품목/내화성능시간/사용부위/로트번호) 빈값 처리
2026-03-17 15:56:57 +09:00
김보곤
a96fd254e5 fix: [migration] 이미 존재하는 테이블/컬럼에 대한 안전 가드 추가
- hasTable/hasColumn 체크로 중복 생성 방지
- 로컬/개발서버 DB 상태 불일치 시에도 마이그레이션 안전 실행
2026-03-17 15:41:20 +09:00
김보곤
b10713344a feat: [barobill] SOAP 서비스 27개 메서드 추가 (MNG 100% 동등)
- 카카오톡 15개: 채널/템플릿 관리, 알림톡/친구톡 발송, 전송결과 조회
- SMS 4개: 단문 발송, 발신번호 확인/목록, 전송상태 조회
- 계좌 5개: 관리URL, 입출금URL, 범용URL, 일별/월별 조회
- 카드 6개: 수정/해지/해지취소, 일별/월별 조회, 사용내역URL
- 세금계산서 1개: 목록URL
- API 56개 메서드 = MNG 54개 + 유틸리티 메서드
2026-03-17 14:38:54 +09:00
김보곤
b60e44ea3a feat: [bending] 담당자 기본값 + 원자재 LOT 조회 API + 취소 복원 지원
- STOCK 주문 생성 시 담당자(manager_name) 미입력이면 로그인 사용자명 자동 설정
- GET /bending/material-lots?material={재질}: 수입검사 완료 입고의 LOT 목록 조회
- 취소→등록 복원은 기존 CANCELLED→DRAFT 전환으로 이미 지원됨 (프론트 UI만 필요)
2026-03-17 14:20:36 +09:00
9358c4112e chore: [misc] 거래처 통계 수정 + 문서 템플릿 file_id 추가 + 라우트 정리
- ClientService stats() active/inactive 카운트 추가
- DocumentTemplateSection file_id 컬럼 마이그레이션
- 논리 관계 문서 업데이트 (BendingItemMapping 추가)
2026-03-17 13:56:08 +09:00
0863afc8d0 fix: [items] 품목 규격 accessor + 감사로그 + bom_category 마이그레이션
- Item 모델에 specification accessor 추가 (attributes.spec 조회)
- ItemService.update()에 AuditLogger 감사 로그 추가
- items.options에 bom_category 추가 마이그레이션
2026-03-17 13:55:44 +09:00
afc31be642 fix: [order] 견적→수주 변환 개소별 분리 구현
- CreateFromQuoteRequest 검증 규칙 추가
- Order 모델 견적 연동 관계 보강
- OrderService 변환 시 개소별 분리 로직
2026-03-17 13:55:28 +09:00
e5da452fde fix: [quote] QA 견적 관련 백엔드 버그 수정
- Quote.isEditable() 생산지시 존재 시 수정 차단
- BOM 탭 순서 정렬 + inspection→검사비 매핑 추가
- 제어기 수량 계산 오류 수정 (1개소 고정 → 수량 반영)
- QuoteService for_order/status 필터 조건 수정
2026-03-17 13:55:18 +09:00
5e65cbc93e Merge branch 'develop' of http://114.203.209.83:3000/SamProject/sam-api into develop 2026-03-17 13:38:55 +09:00
8821509c99 fix: [files] FileStorageController/ItemsFileController에 ensureContext 추가 (MNG 이미지 404 수정) 2026-03-17 13:38:37 +09:00
김보곤
7a9b800413 merge: develop 브랜치 충돌 해결 (production 라우트)
- bending + bending-items + guiderail-models 라우트 모두 유지
2026-03-17 13:18:58 +09:00
김보곤
c11ac7867c feat: [bending] 절곡품 코드맵/품목매핑/LOT 채번 API 추가
- bending_item_mappings 테이블 마이그레이션
- BendingCodeService: 코드 체계, 품목 매핑, LOT 일련번호 생성
- BendingController: code-map, resolve-item, generate-lot 엔드포인트
- StoreOrderRequest/UpdateOrderRequest: bending_lot validation 추가
2026-03-17 13:06:29 +09:00
김보곤
269a17b49c feat: [barobill] SOAP 동기화 서비스 신규 구축
- BarobillSoapService: PHP SoapClient 기반 SOAP 래퍼 (회원/계좌/카드/인증서)
- BarobillBankSyncService: 은행 거래내역 SOAP 조회 → DB 캐시 동기화
- BarobillCardSyncService: 카드 거래내역 SOAP 조회 → DB 캐시 동기화
- HometaxSyncService: 홈택스 세금계산서 upsert 동기화
- BarobillSyncController: 동기화/회원/인증서/잔액 API 11개 엔드포인트
- SyncBarobillDataJob: 매일 06:00/06:30 자동 동기화 스케줄러
- BarobillController.status() 보강: 실제 계좌/카드 수 표시
2026-03-17 13:03:24 +09:00
7083057d59 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 업로드
2026-03-17 12:50:26 +09:00
13d91b7ab4 Merge branch 'develop' of http://114.203.209.83:3000/SamProject/sam-api into develop 2026-03-17 12:47:50 +09:00
김보곤
ead546e268 feat: [account] tenant_id=1 계정과목을 KIS 5자리 표준으로 완전 교체
- 기존 3자리 코드 체계 삭제
- 서비스 표준(5자리 KIS) 458개 코드를 활성 상태로 복사
- 기존 전표 account_code 매핑은 수동 진행 예정
2026-03-17 11:13:51 +09:00
김보곤
921f1ecba7 fix: [production] 생산지시 생성 시 $process 미정의 오류 수정
- $process 변수를 if 블록 밖에서 null로 초기화
2026-03-17 11:03:37 +09:00
김보곤
8404f29bca feat: [account] 계정과목 카테고리 영문 통일 마이그레이션
- 한글 카테고리(자산/부채/자본/수익/비용)를 영문(asset/liability/capital/revenue/expense)으로 변환
- API 표준에 맞춰 전체 테넌트 통일
2026-03-17 11:00:06 +09:00
김보곤
053323c144 fix: [production] 생산지시 생성 시 $isStock 미정의 오류 및 수량 정수 변환
- DB::transaction 클로저 use절에 $isStock 변수 추가
- work_order_items 수량을 정수로 캐스팅
2026-03-17 10:46:29 +09:00
유병철
750776d5c8 fix: [exception] BadRequestHttpException 커스텀 메시지 전달
- 하드코딩된 '잘못된 요청' 대신 예외 메시지 우선 사용
- 메시지 없을 경우 기존 기본값 유지

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 10:25:15 +09:00
김보곤
ae73275cf9 feat: [order] 재고생산 생산지시 자동 처리
- STOCK 타입 store() 시 site_name='재고생산' 자동 설정
- createProductionOrder() STOCK 분기 추가:
  - project_name='재고생산' 고정
  - 절곡 공정 자동 선택 (BOM 매칭 스킵)
  - scheduled_date=now() 자동 설정
- 절곡 공정 미존재 시 에러 메시지 추가
2026-03-16 22:24:15 +09:00
김보곤
407afe38e4 feat: [order] 재고생산관리(STOCK) 타입 추가
- Order 모델에 TYPE_STOCK = 'STOCK' 상수 추가
- StoreOrderRequest/UpdateOrderRequest에 STOCK 타입 validation 추가
- options에 production_reason, target_stock_qty 필드 추가
- 재고생산 채번: STK{YYYYMMDD}{NNNN} 형식
- stats()에 order_type 필터 파라미터 추가
- STOCK 타입 확정 시 매출 자동 생성 스킵
2026-03-16 21:27:13 +09:00
57133541d0 feat: [bending] 절곡품 기초관리 API 구현
- BendingItemController: CRUD + filters 엔드포인트 (pagination 메타 보존)
- BendingItemService: items 테이블 item_category=BENDING 필터 기반
- BendingItemResource: options → 최상위 필드 노출 + 계산값(width_sum, bend_count)
- FormRequest: Index/Store/Update 유효성 검증 (unique:items,code 포함)
- BendingFillOptions: BD-* prefix/분류 속성 자동 보강 커맨드
- BendingImportLegacy: chandj 레거시 전개도(bendingData) 임포트 커맨드 (125/170건 매칭)
- ensureContext: Bearer 토큰 없이 X-TENANT-ID 헤더로 컨텍스트 설정
2026-03-16 20:49:20 +09:00
김보곤
a7f98ccdf5 fix: [payment] 더미 시더 무료 체험 기간 14일 → 7일로 변경 2026-03-16 16:03:42 +09:00
유병철
9d2333bfb1 fix: [approval] 문서번호 생성 시 삭제된 문서도 포함하여 중복 방지
- generateDocumentNumber()에서 query() → withTrashed()로 변경
- soft-deleted 결재문서 번호와의 충돌 방지

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 10:47:33 +09:00
유병철
6e6843fd67 feat: [approval] 근태신청·사유서 양식에 select options 추가
- 근태신청: 신청유형에 휴가/출장/재택근무/외근 옵션
- 사유서: 사유유형에 지각/조퇴/결근/외출/기타 옵션
- 기존 테넌트 데이터 마이그레이션 포함

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 09:37:38 +09:00
김보곤
d8560d889c fix: [security] eval() 제거 — SafeMathEvaluator로 교체
- FormulaParser의 eval() 2곳 제거 (executeSimpleMath, evaluateCondition)
- FormulaEvaluatorService의 eval() 1곳 제거 (calculateExpression)
- Shunting-yard 알고리즘 기반 SafeMathEvaluator 신규 추가
- 사칙연산, 비교연산, 단항 마이너스, 괄호, 나머지 연산 지원
2026-03-15 10:20:39 +09:00
김보곤
9d95b2c373 feat: [sales] sales_tenant_managements에 options JSON 컬럼 추가
- 프로모션, 기타 확장 데이터 저장용
2026-03-14 16:53:19 +09:00
김보곤
6b9673d21a feat: [sales] sales_products에 최저 개발비/구독료 컬럼 추가
- min_development_fee: 상품별 최저 개발비
- min_subscription_fee: 상품별 최저 구독료
2026-03-14 14:46:25 +09:00
김보곤
926a7c7da6 refactor: [performance] N+1 쿼리 3건 배치 조회로 최적화
- WorkOrderService.getMaterials(): 기존 BOM 루프 내 find() x2 제거
  → 루프 전 bomItemsMap/bomChildItemsMap 일괄 사전 로드
- OrderService.createWorkOrderFromOrder(): 루프 내 DB 쿼리 x2 제거
  → item_code→id, process_items 사전 배치 조회
- OrderService.checkBendingStockForOrder(): 루프 내 StockService 호출 제거
  → Stock 배치 조회 후 맵 참조
2026-03-14 14:42:22 +09:00
김보곤
877d15420a test: [work-order] 작업지시 API 테스트 16개 추가
- CRUD 테스트 5개 (생성/상세/수정/삭제/404)
- 상태전이 테스트 4개 (미배정→대기→준비→진행→완료)
- 담당자 배정 테스트 1개
- 공정단계/자재 조회 테스트 3개
- 목록/통계/인증 테스트 3개
2026-03-14 14:42:22 +09:00
김보곤
63b174811c test: [approval] 결재 API 워크플로우 테스트 15개 추가
- CRUD 테스트 5개 (생성/목록/상세/수정/삭제)
- 워크플로우 테스트 4개 (상신/승인/반려/회수)
- 결재함/참조함/완료함 목록 조회 4개
- 뱃지 건수, 인증 테스트 2개
- 결재자 별도 사용자 로그인 검증 (loginAs 헬퍼)
2026-03-14 14:42:22 +09:00
김보곤
95bae11042 test: [stock] 재고 API 및 FIFO 로직 테스트 13개 추가
- Stock API 엔드포인트 테스트 5개 (목록/통계/유형별통계/상세/인증)
- FIFO 핵심 로직 테스트 4개 (입고/차감/LOT걸침/재고부족)
- 예약/해제 테스트 2개 (수주확정→예약, 수주취소→해제)
- 거래이력 기록 테스트 1개
- 재고 상태 자동 계산 테스트 1개 (normal/low/out)
- Factory 2개 추가: StockFactory, StockLotFactory
2026-03-14 14:42:22 +09:00
김보곤
adc07b7343 feat: [sales] sales_product_categories에 최저 개발비/구독료 컬럼 추가
- min_development_fee: 최저 개발비 (이 금액 이하 설정 불가)
- min_subscription_fee: 최저 구독료 (이 금액 이하 설정 불가)
2026-03-14 14:42:22 +09:00
김보곤
c942788119 test: [orders] 테스트 인프라 정비 및 수주 API 테스트 추가
- TestCase 공통화: setUpAuthenticatedUser(), api(), assertApiSuccess(), assertApiPaginated()
- 기존 10개 테스트 파일의 중복 setUp 코드 → TestCase 상속으로 전환
- Factory 3개 추가: TenantFactory, ClientFactory, OrderFactory
- OrderApiTest 12개 테스트 신규 작성 (목록/생성/조회/수정/삭제/상태변경/인증)
- 발견: 빈 데이터로 수주 생성 가능 (FormRequest 검증 강화 필요)
2026-03-14 14:42:22 +09:00
김보곤
2e284f6393 feat: [demo] Phase 4 고도화 — 분석 API, 비활성 알림, 통계 시딩
- DemoAnalyticsService: 전환율 퍼널, 파트너 성과, 활동 현황, 대시보드 요약
- DemoAnalyticsController: 분석 API 4개 엔드포인트
- CheckDemoInactiveCommand: 7일 비활성 데모 테넌트 탐지 및 로그 알림
- ManufacturingPresetSeeder: sam_stat DB에 90일 매출/생산 통계 시딩
- 라우트: demo-analytics prefix 4개 GET 엔드포인트 등록
- 스케줄러: demo:check-inactive 매일 09:30 실행
2026-03-14 14:42:21 +09:00
김보곤
e12fc461a7 feat: [demo] 데모 테넌트 관리 API 및 만료 알림 (Phase 3)
- DemoTenantController: 목록/상세/생성/리셋/연장/전환/통계 API
- DemoTenantStoreRequest: 고객 체험 테넌트 생성 검증
- DemoTenantService: API용 메서드 추가 (index/show/reset/extend/convert/stats)
- CheckDemoExpiredCommand: 만료 임박(7일) 알림 + 만료 테넌트 비활성 처리
- 라우트 등록 (api/v1/demo-tenants, 7개 엔드포인트)
- 스케줄러 등록 (04:20 demo:check-expired)
- i18n 메시지 추가 (message.demo_tenant.*, error.demo_tenant.*)
2026-03-14 14:42:21 +09:00
김보곤
1eb8d2cb01 feat: [demo] 데모 테넌트 운영 자동화 (Phase 2)
- DemoLimitMiddleware: 쇼케이스 읽기전용, 만료 체크, 외부연동 차단
- DemoTenantService: 파트너/체험 테넌트 생성, 기간 연장, 정식 전환
- ResetDemoShowcaseCommand: 매일 자정 데이터 리셋 + 샘플 재시드
- ManufacturingPresetSeeder: 부서/거래처/품목/견적/수주 샘플 데이터
- 스케줄러 등록 (00:00 demo:reset-showcase --seed)
- 미들웨어 별칭 등록 (demo.limit)
2026-03-14 14:41:55 +09:00
김보곤
39844a3ba0 fix: [tenant] 데모 관련 필드를 fillable에서 제거
- tenant_type, demo_expires_at, demo_source_partner_id를 fillable에서 제외
- 기존 mass assignment 동작에 영향 없도록 보호
- convertToProduction()에서 forceFill() 사용으로 변경
2026-03-14 14:41:55 +09:00
김보곤
45c30aa2aa feat: [tenant] 데모 테넌트 지원 추가 (Phase 1)
- tenants.tenant_type ENUM 확장: DEMO_SHOWCASE, DEMO_PARTNER, DEMO_TRIAL
- demo_expires_at, demo_source_partner_id 컬럼 추가
- Tenant 모델에 데모 관련 메서드 추가 (isDemoTenant, isDemoShowcase 등)
- getOption/setOption 헬퍼 메서드 추가
- 데모→정식 전환 convertToProduction() 메서드
2026-03-14 14:41:55 +09:00
김보곤
85d5b98966 feat: [vehicle] 법인차량 사진 API 추가
- CorporateVehicle 모델 (photos 관계 포함)
- VehiclePhotoService (R2 저장, 최대 10장 제한)
- VehiclePhotoController (index/store/destroy)
- StoreVehiclePhotoRequest (동적 max 검증)
- finance.php 라우트 등록
2026-03-14 14:41:55 +09:00
aeffd5be61 feat: [work-order] 작업 완료 API 응답에 실제 LOT 번호 포함
- saveItemResults() 반환 타입 void → string (생성된 lot_no 반환)
- updateStatus() 완료 시 lot_no를 setAttribute로 응답에 포함
2026-03-13 23:43:02 +09:00
d7c096b615 feat: [shipment] MES 데이터 정합성 개선 — can_ship 검증, ShipmentItem FK, 재고차감 비활성화
- ShipmentService::updateStatus()에 can_ship 검증 추가 (ready/shipping/completed 전환 시)
- shipment_items에 order_item_id, work_order_item_id 컬럼+인덱스 추가 (마이그레이션)
- ShipmentItem 모델에 orderItem(), workOrderItem() 관계 추가
- createShipmentFromOrder()에서 order_item_id, work_order_item_id 자동 매핑
- decreaseStockForShipment() 호출 비활성화 (수주생산=재고 미경유, 선생산=자재 투입 시 차감)
2026-03-13 22:45:43 +09:00
54686cfc8a fix: [work-order] 생산지시 생성 시 부서/우선순위 자동 매핑
- team_id 미지정 시 공정 담당부서에서 자동 매핑
- priority 문자열→숫자 변환 (urgent=1, high=4, normal=7)
- 부서/담당자 배정 시 작업대기(waiting) 상태로 설정
2026-03-13 18:55:11 +09:00
유병철
a36b7a2514 feat: [vehicle] 법인차량 관리 API 추가
- 법인차량 CRUD (CorporateVehicle)
- 차량 운행일지 CRUD (VehicleLog)
- 차량 정비이력 CRUD (VehicleMaintenance)
- 모델, 서비스, 컨트롤러, 라우트 구성

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:38:18 +09:00
e241c6a681 fix: [work-order] 보조공정/미배정 작업지시 목록 제외
- process_id NOT NULL 기본 필터 추가
- is_auxiliary 보조공정 제외 조건 추가
- stats()에도 동일 필터 적용
- 기타(none) 탭 관련 로직 제거

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:14:15 +09:00
73c8f78788 feat: [qms] 수주로트 감사 상세 정보 확장
- 수주 상세: 개소별 제품, 모터, 절곡물, 부자재 정보 추가
- 출하 상세: 배차정보, 제품 그룹별 품목 분류 추가
- 확인 로직: documentOrders 기준 수주로트 카운트로 변경
- locations relation 경로 수정 (documentOrders.locations)
- 품질관리서 파일 정보 routeDocuments에 포함
- Shipment Client 모델 네임스페이스 수정
- DocumentService data relation null 처리

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:14:09 +09:00
597aecb5e8 feat: [quality] 품질관리서 파일 업로드/삭제 API
- POST /{id}/upload-file: 1건당 1파일, 기존 파일 교체
- DELETE /{id}/file: 파일 soft delete
- QualityDocument.file() relation 추가
- R2 저장 경로: {tenant_id}/quality-documents/{year}/{month}

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:14:00 +09:00
ef591074c7 feat: [storage] R2 테넌트 파일 프록시 라우트 추가
- /storage/tenants/{path} 라우트 추가
- R2에서 스트리밍 방식으로 파일 제공
- 인증 불필요, Cache-Control 1일

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:13:50 +09:00
8b7d932f00 feat: [storage] R2 정적 이미지 프록시 라우트 추가
- /images/{path} 라우트로 R2에서 이미지 스트리밍
- bending 도면 이미지 등 정적 파일을 R2에서 서빙
- 캐시 1일 적용 (Cache-Control: max-age=86400)
2026-03-13 02:06:44 +09:00
42d818596d fix: [employee] 사용자-사원 삭제 동기화 수정
- destroy/bulkDelete 퇴직처리 시 user_tenants.is_active = false 추가
- update에서 employee_status 변경 시 is_active 자동 동기화 (퇴직/복직)
- SwitchTenantRequest에 user_tenants.is_active 검증 추가 (비활성 테넌트 전환 차단)
- tenant_access_denied i18n 메시지 추가 (ko/en)
2026-03-13 00:30:33 +09:00
유병철
0d9a840358 feat: [equipment-inspection] 설비별 점검 템플릿 조회 API 개선
- templates 엔드포인트에 cycle 필터 파라미터 추가
- getTemplatesByEquipment 서비스 메서드 신규 추가
- Controller에서 Request 주입하여 cycle 쿼리 파라미터 전달

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 19:21:38 +09:00
유병철
2c4f5ee91d fix: [equipment] User 모델 네임스페이스 경로 수정
- App\Models\User → App\Models\Members\User 경로 일괄 수정
- Equipment(manager, subManager), EquipmentInspection(inspector), EquipmentRepair(repairer)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 19:03:31 +09:00
유병철
08582261db feat: [equipment] 설비 통계 API에 유형별 분포 및 점검 현황 추가
- 설비 유형별(equipment_type) 현황 집계 추가
- 이번달 점검 대상/완료/이슈 건수 통계 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 18:47:39 +09:00
a50d69b243 sync: main 배포 동기화 2026-03-12 2026-03-12 15:22:18 +09:00
37bb691838 sync: main 배포 동기화 2026-03-12 2026-03-12 15:22:15 +09:00
244 changed files with 19643 additions and 1656 deletions

1
.gitignore vendored
View File

@@ -113,6 +113,7 @@ desktop.ini
*.mp3
*.wav
*.ogg
!public/sounds/*.wav
*.mp4
*.avi
*.mov

View File

@@ -1,6 +1,6 @@
# 논리적 데이터베이스 관계 문서
> **자동 생성**: 2026-03-12 13:58:25
> **자동 생성**: 2026-03-20 11:23:51
> **소스**: Eloquent 모델 관계 분석
## 📊 모델별 관계 현황
@@ -88,6 +88,16 @@ ### hometax_invoice_journals
- **tenant()**: belongsTo → `tenants`
- **invoice()**: belongsTo → `hometax_invoices`
### bending_items
**모델**: `App\Models\BendingItem`
- **files()**: hasMany → `files`
### bending_models
**모델**: `App\Models\BendingModel`
- **files()**: hasMany → `files`
### biddings
**모델**: `App\Models\Bidding\Bidding`
@@ -374,6 +384,8 @@ ### esign_signers
### equipments
**모델**: `App\Models\Equipment\Equipment`
- **manager()**: belongsTo → `users`
- **subManager()**: belongsTo → `users`
- **inspectionTemplates()**: hasMany → `equipment_inspection_templates`
- **inspections()**: hasMany → `equipment_inspections`
- **repairs()**: hasMany → `equipment_repairs`
@@ -384,6 +396,7 @@ ### equipment_inspections
**모델**: `App\Models\Equipment\EquipmentInspection`
- **equipment()**: belongsTo → `equipments`
- **inspector()**: belongsTo → `users`
- **details()**: hasMany → `equipment_inspection_details`
### equipment_inspection_details
@@ -407,6 +420,7 @@ ### equipment_repairs
**모델**: `App\Models\Equipment\EquipmentRepair`
- **equipment()**: belongsTo → `equipments`
- **repairer()**: belongsTo → `users`
### estimates
**모델**: `App\Models\Estimate\Estimate`
@@ -429,6 +443,12 @@ ### file_share_links
- **file()**: belongsTo → `files`
- **tenant()**: belongsTo → `tenants`
### corporate_vehicles
**모델**: `App\Models\Tenants\CorporateVehicle`
- **logs()**: hasMany → `vehicle_logs`
- **maintenances()**: hasMany → `vehicle_maintenances`
### folders
**모델**: `App\Models\Folder`
@@ -553,6 +573,25 @@ ### material_receipts
- **material()**: belongsTo → `materials`
- **inspections()**: hasMany → `material_inspections`
### nonconforming_reports
**모델**: `App\Models\Materials\NonconformingReport`
- **approval()**: belongsTo → `approvals`
- **order()**: belongsTo → `orders`
- **item()**: belongsTo → `items`
- **department()**: belongsTo → `departments`
- **creator()**: belongsTo → `users`
- **actionManager()**: belongsTo → `users`
- **relatedEmployee()**: belongsTo → `users`
- **items()**: hasMany → `nonconforming_report_items`
- **files()**: morphMany → `files`
### nonconforming_report_items
**모델**: `App\Models\Materials\NonconformingReportItem`
- **report()**: belongsTo → `nonconforming_reports`
- **item()**: belongsTo → `items`
### users
**모델**: `App\Models\Members\User`
@@ -800,6 +839,7 @@ ### parts
### prices
**모델**: `App\Models\Products\Price`
- **item()**: belongsTo → `items`
- **clientGroup()**: belongsTo → `client_groups`
- **revisions()**: hasMany → `price_revisions`
@@ -898,6 +938,7 @@ ### quality_documents
- **documentOrders()**: hasMany → `quality_document_orders`
- **locations()**: hasMany → `quality_document_locations`
- **performanceReport()**: hasOne → `performance_reports`
- **file()**: hasOne → `files`
### quality_document_locations
**모델**: `App\Models\Qualitys\QualityDocumentLocation`
@@ -1071,6 +1112,10 @@ ### cards
- **creator()**: belongsTo → `users`
- **updater()**: belongsTo → `users`
### condolence_expenses
**모델**: `App\Models\Tenants\CondolenceExpense`
### data_exports
**모델**: `App\Models\Tenants\DataExport`
@@ -1232,6 +1277,7 @@ ### shipments
- **order()**: belongsTo → `orders`
- **workOrder()**: belongsTo → `work_orders`
- **client()**: belongsTo → `clients`
- **creator()**: belongsTo → `users`
- **updater()**: belongsTo → `users`
- **items()**: hasMany → `shipment_items`
@@ -1242,6 +1288,7 @@ ### shipment_items
- **shipment()**: belongsTo → `shipments`
- **stockLot()**: belongsTo → `stock_lots`
- **orderItem()**: belongsTo → `order_items`
### shipment_vehicle_dispatchs
**모델**: `App\Models\Tenants\ShipmentVehicleDispatch`
@@ -1353,6 +1400,16 @@ ### today_issues
- **reader()**: belongsTo → `users`
- **targetUser()**: belongsTo → `users`
### vehicle_logs
**모델**: `App\Models\Tenants\VehicleLog`
- **vehicle()**: belongsTo → `corporate_vehicles`
### vehicle_maintenances
**모델**: `App\Models\Tenants\VehicleMaintenance`
- **vehicle()**: belongsTo → `corporate_vehicles`
### withdrawals
**모델**: `App\Models\Tenants\Withdrawal`

View File

@@ -0,0 +1,334 @@
<?php
namespace App\Console\Commands;
use App\Models\BendingItem;
// bending_data는 bending_items.bending_data JSON 컬럼에 저장
use App\Models\Commons\File;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
/**
* 클린 재이관: bending_items/bending_data 전체 삭제 → chandj.bending 직접 이관
* 기존 R2 파일도 삭제 처리
*
* 실행: php artisan bending:clean-reimport [--dry-run] [--tenant_id=287]
*/
class BendingCleanReimport extends Command
{
protected $signature = 'bending:clean-reimport
{--tenant_id=287 : 테넌트 ID}
{--dry-run : 실행하지 않고 미리보기만}
{--legacy-img-path=/tmp/bending_img : 레거시 이미지 경로}';
protected $description = 'bending_items 클린 재이관 (chandj.bending 직접)';
private int $tenantId;
public function handle(): int
{
$this->tenantId = (int) $this->option('tenant_id');
$dryRun = $this->option('dry-run');
$legacyImgPath = $this->option('legacy-img-path');
// 1. 현재 상태
$biCount = BendingItem::where('tenant_id', $this->tenantId)->count();
$bdCount = BendingItem::where('tenant_id', $this->tenantId)
->whereNotNull('bending_data')->count();
$fileCount = File::where('field_key', 'bending_diagram')
->where(function ($q) {
$q->where('document_type', 'bending_item')
->orWhere('document_type', '1');
})->count();
$this->info("현재: bending_items={$biCount}, bending_data={$bdCount}, files={$fileCount}");
// chandj 유효 건수
$chandjRows = DB::connection('chandj')->table('bending')
->where(function ($q) {
$q->whereNull('is_deleted')->orWhere('is_deleted', 0);
})
->orderBy('num')
->get();
$this->info("chandj 이관 대상: {$chandjRows->count()}");
if ($dryRun) {
$this->preview($chandjRows);
return 0;
}
if (! $this->confirm("기존 데이터 전체 삭제 후 chandj에서 재이관합니다. 계속?")) {
return 0;
}
DB::transaction(function () use ($chandjRows) {
// 2. 기존 파일 DB 레코드만 삭제 (R2 파일은 유지)
$this->deleteFileRecords();
// 3. 기존 데이터 삭제
BendingItem::where('tenant_id', $this->tenantId)->forceDelete();
$this->info("기존 데이터 삭제 완료");
// 4. chandj에서 직접 이관
$success = 0;
$bdTotal = 0;
foreach ($chandjRows as $row) {
try {
$bi = $this->importItem($row);
$bd = $this->importBendingData($bi, $row);
$bdTotal += $bd;
$success++;
} catch (\Throwable $e) {
$this->error(" ❌ #{$row->num} {$row->itemName}: {$e->getMessage()}");
}
}
$this->newLine();
$this->info("이관 완료: {$success}/{$chandjRows->count()}건, 전개도 {$bdTotal}");
});
// 5. 이미지 이관
$this->importImages($legacyImgPath);
// 6. 최종 검증
$this->verify();
return 0;
}
private function importItem(object $row): BendingItem
{
$code = $this->generateCode($row);
$bi = BendingItem::create([
'tenant_id' => $this->tenantId,
'code' => $code,
'legacy_code' => "CHANDJ-{$row->num}",
'legacy_bending_id' => $row->num,
'item_name' => $row->itemName ?: "부품#{$row->num}",
'item_sep' => $this->clean($row->item_sep),
'item_bending' => $this->clean($row->item_bending),
'material' => $this->clean($row->material),
'item_spec' => $this->clean($row->item_spec),
'model_name' => $this->clean($row->model_name ?? null),
'model_UA' => $this->clean($row->model_UA ?? null),
'rail_width' => $this->toNum($row->rail_width ?? null),
'exit_direction' => $this->clean($row->exit_direction ?? null),
'box_width' => $this->toNum($row->box_width ?? null),
'box_height' => $this->toNum($row->box_height ?? null),
'front_bottom' => $this->toNum($row->front_bottom_width ?? null),
'options' => $this->buildOptions($row),
'is_active' => true,
'created_by' => 1,
]);
$this->line(" ✅ #{$row->num}{$bi->id} ({$row->itemName}) [{$code}]");
return $bi;
}
private function importBendingData(BendingItem $bi, object $row): int
{
$inputs = json_decode($row->inputList ?? '[]', true) ?: [];
if (empty($inputs)) {
return 0;
}
$rates = json_decode($row->bendingrateList ?? '[]', true) ?: [];
$sums = json_decode($row->sumList ?? '[]', true) ?: [];
$colors = json_decode($row->colorList ?? '[]', true) ?: [];
$angles = json_decode($row->AList ?? '[]', true) ?: [];
$data = [];
$count = count($inputs);
for ($i = 0; $i < $count; $i++) {
$data[] = [
'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),
];
}
$bi->update(['bending_data' => $data]);
return $count;
}
private function deleteFileRecords(): void
{
$count = File::where('field_key', 'bending_diagram')
->where('document_type', 'bending_item')
->forceDelete();
$this->info("파일 레코드 삭제: {$count}건 (R2 파일은 유지)");
}
private function importImages(string $legacyImgPath): void
{
$chandjMap = DB::connection('chandj')->table('bending')
->whereNotNull('imgdata')
->where('imgdata', '!=', '')
->pluck('imgdata', 'num');
$items = BendingItem::where('tenant_id', $this->tenantId)
->whereNotNull('legacy_bending_id')
->get();
$uploaded = 0;
$notFound = 0;
foreach ($items as $bi) {
$imgFile = $chandjMap[$bi->legacy_bending_id] ?? null;
if (! $imgFile) {
continue;
}
$filePath = "{$legacyImgPath}/{$imgFile}";
if (! file_exists($filePath)) {
$notFound++;
continue;
}
try {
$extension = pathinfo($imgFile, PATHINFO_EXTENSION);
$storedName = bin2hex(random_bytes(8)) . '.' . $extension;
$directory = sprintf('%d/bending/%s/%s', $this->tenantId, date('Y'), date('m'));
$r2Path = $directory . '/' . $storedName;
Storage::disk('r2')->put($r2Path, file_get_contents($filePath));
File::create([
'tenant_id' => $this->tenantId,
'display_name' => $imgFile,
'stored_name' => $storedName,
'file_path' => $r2Path,
'file_size' => filesize($filePath),
'mime_type' => mime_content_type($filePath),
'file_type' => 'image',
'field_key' => 'bending_diagram',
'document_id' => $bi->id,
'document_type' => 'bending_item',
'is_temp' => false,
'uploaded_by' => 1,
'created_by' => 1,
]);
$uploaded++;
} catch (\Throwable $e) {
$this->warn(" ⚠️ 이미지 업로드 실패: #{$bi->legacy_bending_id}{$e->getMessage()}");
}
}
$this->info("이미지 업로드: {$uploaded}" . ($notFound > 0 ? " (파일없음 {$notFound}건)" : ''));
}
private function generateCode(object $row): string
{
$bending = $row->item_bending ?? '';
$sep = $row->item_sep ?? '';
$material = $row->material ?? '';
$name = $row->itemName ?? '';
$prodCode = match (true) {
$bending === '케이스' => 'C',
$bending === '하단마감재' && str_contains($sep, '철재') => 'T',
$bending === '하단마감재' => 'B',
$bending === '가이드레일' => 'R',
$bending === '마구리' => 'X',
$bending === 'L-BAR' => 'L',
$bending === '연기차단재' => 'G',
default => 'Z',
};
$specCode = match (true) {
str_contains($name, '전면') => 'F',
str_contains($name, '린텔') => 'L',
str_contains($name, '점검') => 'P',
str_contains($name, '후면') => 'B',
str_contains($name, '상부') || str_contains($name, '덮개') => 'X',
str_contains($name, '본체') => 'M',
str_contains($name, 'C형') || str_contains($name, '-C') => 'C',
str_contains($name, 'D형') || str_contains($name, '-D') => 'D',
str_contains($name, '마감') && str_contains($material, 'SUS') => 'S',
str_contains($name, '하장바') && str_contains($material, 'SUS') => 'S',
str_contains($name, '하장바') && str_contains($material, 'EGI') => 'E',
str_contains($name, '보강') => 'H',
str_contains($name, '절단') => 'T',
str_contains($name, '비인정') => 'N',
str_contains($name, '밑면') => 'P',
str_contains($material, 'SUS') => 'S',
str_contains($material, 'EGI') => 'E',
default => 'Z',
};
$date = $row->registration_date ?? now()->format('Y-m-d');
$dateCode = date('ymd', strtotime($date));
$base = "{$prodCode}{$specCode}{$dateCode}";
// 중복 방지 일련번호
$seq = 1;
while (BendingItem::where('tenant_id', $this->tenantId)
->where('code', $seq === 1 ? $base : "{$base}-" . str_pad($seq, 2, '0', STR_PAD_LEFT))
->exists()) {
$seq++;
}
return $seq === 1 ? $base : "{$base}-" . str_pad($seq, 2, '0', STR_PAD_LEFT);
}
private function buildOptions(object $row): ?array
{
$opts = [];
if (! empty($row->memo)) $opts['memo'] = $row->memo;
if (! empty($row->author)) $opts['author'] = $row->author;
if (! empty($row->search_keyword)) $opts['search_keyword'] = $row->search_keyword;
if (! empty($row->registration_date)) $opts['registration_date'] = (string) $row->registration_date;
return empty($opts) ? null : $opts;
}
private function verify(): void
{
$bi = BendingItem::where('tenant_id', $this->tenantId)->count();
$bd = BendingItem::where('tenant_id', $this->tenantId)
->whereNotNull('bending_data')->count();
$mapped = BendingItem::where('tenant_id', $this->tenantId)
->whereNotNull('legacy_bending_id')
->distinct('legacy_bending_id')
->count('legacy_bending_id');
$files = File::where('field_key', 'bending_diagram')->count();
$this->newLine();
$this->info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
$this->info("📊 최종 결과");
$this->info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
$this->info(" bending_items: {$bi}");
$this->info(" bending_data: {$bd}");
$this->info(" chandj 매핑: {$mapped}");
$this->info(" 파일: {$files}건 (이미지 재업로드 필요)");
$this->info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
}
private function preview($rows): void
{
$grouped = $rows->groupBy(fn ($r) => ($r->item_bending ?: '미분류') . '/' . ($r->item_sep ?: '미분류'));
$this->table(['분류', '건수'], $grouped->map(fn ($g, $k) => [$k, $g->count()])->values());
}
private function clean(?string $v): ?string
{
return ($v === null || $v === '' || $v === 'null') ? null : trim($v);
}
private function toNum(mixed $v): ?float
{
return ($v === null || $v === '' || $v === 'null') ? null : (float) $v;
}
}

View File

@@ -0,0 +1,400 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Attributes\AsCommand;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* BD-* 품목의 options 속성 보강
*
* 1단계: BD-PREFIX-LEN 패턴(112건)에서 prefix/length 자동 추출
* 2단계: BD-한글 패턴(58건)에 item_sep/item_bending 등 분류 속성 추가
*
* 실행: php artisan bending:fill-options [--dry-run] [--tenant_id=287]
*/
#[AsCommand(name: 'bending:fill-options', description: 'BD-* 품목 options 속성 보강 (prefix/length + 분류 속성)')]
class BendingFillOptions extends Command
{
protected $signature = 'bending:fill-options
{--tenant_id=287 : Target tenant ID}
{--dry-run : 실제 저장 없이 미리보기}';
// PREFIX → 분류 속성 매핑
private const PREFIX_META = [
// 가이드레일 (벽면)
'RS' => ['item_sep' => '스크린', 'item_bending' => '가이드레일', 'item_name' => 'SUS마감재', 'material' => 'SUS 1.2T'],
'RE' => ['item_sep' => '스크린', 'item_bending' => '가이드레일', 'item_name' => 'EGI마감재', 'material' => 'EGI 1.55T'],
'RM' => ['item_sep' => '스크린', 'item_bending' => '가이드레일', 'item_name' => '본체', 'material' => 'EGI 1.55T'],
'RC' => ['item_sep' => '스크린', 'item_bending' => '가이드레일', 'item_name' => 'C형', 'material' => 'EGI 1.55T'],
'RD' => ['item_sep' => '스크린', 'item_bending' => '가이드레일', 'item_name' => 'D형', 'material' => 'EGI 1.55T'],
'RT' => ['item_sep' => '철재', 'item_bending' => '가이드레일', 'item_name' => '본체(철재)', 'material' => 'EGI 1.55T'],
// 가이드레일 (측면)
'SS' => ['item_sep' => '스크린', 'item_bending' => '가이드레일', 'item_name' => 'SUS마감재', 'material' => 'SUS 1.2T'],
'SE' => ['item_sep' => '스크린', 'item_bending' => '가이드레일', 'item_name' => 'EGI마감재', 'material' => 'EGI 1.55T'],
'SM' => ['item_sep' => '스크린', 'item_bending' => '가이드레일', 'item_name' => '본체', 'material' => 'EGI 1.55T'],
'SC' => ['item_sep' => '스크린', 'item_bending' => '가이드레일', 'item_name' => 'C형', 'material' => 'EGI 1.55T'],
'SD' => ['item_sep' => '스크린', 'item_bending' => '가이드레일', 'item_name' => 'D형', 'material' => 'EGI 1.55T'],
'ST' => ['item_sep' => '철재', 'item_bending' => '가이드레일', 'item_name' => '본체(철재)', 'material' => 'EGI 1.55T'],
'SU' => ['item_sep' => '스크린', 'item_bending' => '가이드레일', 'item_name' => 'SUS마감재2', 'material' => 'SUS 1.2T'],
// 하단마감재
'BE' => ['item_sep' => '스크린', 'item_bending' => '하단마감재', 'item_name' => '하단마감재', 'material' => 'EGI 1.55T'],
'BS' => ['item_sep' => '스크린', 'item_bending' => '하단마감재', 'item_name' => '하단마감재', 'material' => 'SUS 1.5T'],
'TS' => ['item_sep' => '철재', 'item_bending' => '하단마감재', 'item_name' => '하단마감재(철재)', 'material' => 'SUS 1.2T'],
'LA' => ['item_sep' => '스크린', 'item_bending' => 'L-BAR', 'item_name' => 'L-Bar', 'material' => 'EGI 1.55T'],
'HH' => ['item_sep' => '스크린', 'item_bending' => '보강평철', 'item_name' => '보강평철', 'material' => 'EGI 1.55T'],
// 셔터박스
'CF' => ['item_sep' => '스크린', 'item_bending' => '케이스', 'item_name' => '전면부', 'material' => 'EGI 1.55T'],
'CL' => ['item_sep' => '스크린', 'item_bending' => '케이스', 'item_name' => '린텔부', 'material' => 'EGI 1.55T'],
'CP' => ['item_sep' => '스크린', 'item_bending' => '케이스', 'item_name' => '점검구', 'material' => 'EGI 1.55T'],
'CB' => ['item_sep' => '스크린', 'item_bending' => '케이스', 'item_name' => '후면코너부', 'material' => 'EGI 1.55T'],
// 연기차단재
'GI' => ['item_sep' => '스크린', 'item_bending' => '연기차단재', 'item_name' => '연기차단재', 'material' => '화이바원단'],
// 공용
'XX' => ['item_sep' => '스크린', 'item_bending' => '공용', 'item_name' => '하부BASE/상부덮개/마구리', 'material' => 'EGI 1.55T'],
'YY' => ['item_sep' => '스크린', 'item_bending' => '별도마감', 'item_name' => '별도SUS마감', 'material' => 'SUS 1.2T'],
];
// 한글 패턴 → 분류 매핑
private const KOREAN_PATTERN_META = [
'BD-가이드레일' => ['item_sep' => null, 'item_bending' => '가이드레일'],
'BD-케이스' => ['item_sep' => null, 'item_bending' => '케이스'],
'BD-마구리' => ['item_sep' => null, 'item_bending' => '마구리'],
'BD-하단마감재' => ['item_sep' => null, 'item_bending' => '하단마감재'],
'BD-L-BAR' => ['item_sep' => '스크린', 'item_bending' => 'L-BAR'],
'BD-보강평철' => ['item_sep' => '스크린', 'item_bending' => '보강평철'],
];
private const LENGTH_MAP = [
'02' => 200, '12' => 1219, '24' => 2438, '30' => 3000,
'35' => 3500, '40' => 4000, '41' => 4150, '42' => 4200,
'43' => 4300, '53' => 3000, '54' => 4000, '83' => 3000, '84' => 4000,
];
private array $stats = [
'total' => 0,
'prefix_len_filled' => 0,
'korean_filled' => 0,
'already_complete' => 0,
'unknown_pattern' => 0,
];
public function handle(): int
{
$tenantId = (int) $this->option('tenant_id');
$dryRun = $this->option('dry-run');
$this->info('=== BD-* 품목 options 보강 ===');
$this->info('Tenant: '.$tenantId.' | Mode: '.($dryRun ? 'DRY-RUN' : 'LIVE'));
$this->newLine();
// BD-* 전체 품목 조회
$items = DB::table('items')
->where('tenant_id', $tenantId)
->where('code', 'like', 'BD-%')
->whereNull('deleted_at')
->select('id', 'code', 'name', 'options')
->orderBy('code')
->get();
$this->stats['total'] = $items->count();
$this->info("BD-* 품목: {$items->count()}");
$this->newLine();
foreach ($items as $item) {
$options = json_decode($item->options ?? '{}', true) ?: [];
$code = $item->code;
$newOptions = $this->resolveOptions($code, $item->name, $options);
if ($newOptions === null) {
$this->stats['unknown_pattern']++;
$this->warn(" ❓ 미인식 패턴: {$code}");
continue;
}
// 변경 필요 여부 확인
$merged = array_merge($options, $newOptions);
if ($merged == $options) {
$this->stats['already_complete']++;
continue;
}
if (! $dryRun) {
$encoded = json_encode($merged, JSON_UNESCAPED_UNICODE);
if ($encoded === false) {
$this->error(" ❌ JSON 인코딩 실패: {$code}".json_last_error_msg());
continue;
}
DB::table('items')
->where('id', $item->id)
->update([
'options' => $encoded,
'updated_at' => now(),
]);
}
$pattern = $this->detectPattern($code);
if ($pattern === 'prefix_len') {
$this->stats['prefix_len_filled']++;
} else {
$this->stats['korean_filled']++;
}
$this->line("{$code}: +".implode(', ', array_keys($newOptions)));
}
$this->showStats($dryRun);
return self::SUCCESS;
}
/**
* 코드에서 options 속성 추출
*/
private function resolveOptions(string $code, string $name, array $existing): ?array
{
$new = [];
// item_category 보장
if (empty($existing['item_category'])) {
// item_category는 items 테이블 컬럼이므로 여기서는 skip
}
// 패턴 A: BD-PREFIX-LEN (예: BD-RS-30)
if (preg_match('/^BD-([A-Z]{2})-(\d{2})$/', $code, $m)) {
$prefix = $m[1];
$lengthCode = $m[2];
// prefix/length 기본값
if (empty($existing['prefix'])) {
$new['prefix'] = $prefix;
}
if (empty($existing['length_code'])) {
$new['length_code'] = $lengthCode;
}
if (empty($existing['length_mm']) && isset(self::LENGTH_MAP[$lengthCode])) {
$new['length_mm'] = self::LENGTH_MAP[$lengthCode];
}
// PREFIX 기반 분류 속성
$meta = self::PREFIX_META[$prefix] ?? null;
if ($meta) {
foreach ($meta as $key => $value) {
if (empty($existing[$key])) {
$new[$key] = $value;
}
}
}
return $new;
}
// 특수 코드 (패턴 미준수)
$specialCodes = [
'BD-가이드레일용 연기차단재' => ['item_bending' => '연기차단재'],
'BD-케이스용 연기차단재' => ['item_bending' => '연기차단재'],
];
if (isset($specialCodes[$code])) {
foreach ($specialCodes[$code] as $key => $value) {
if (empty($existing[$key])) {
$new[$key] = $value;
}
}
if (empty($existing['item_name'])) {
$new['item_name'] = $name;
}
return $new;
}
// 패턴 B~G: 한글 패턴
foreach (self::KOREAN_PATTERN_META as $patternPrefix => $meta) {
// 정확한 접두사+구분자 매칭 (BD-케이스-xxx는 O, BD-케이스용xxx는 X)
if ($code === $patternPrefix || str_starts_with($code, $patternPrefix.'-')) {
// 분류 속성
foreach ($meta as $key => $value) {
if ($value !== null && empty($existing[$key])) {
$new[$key] = $value;
}
}
// 한글 패턴별 추가 파싱
$this->parseKoreanPattern($code, $patternPrefix, $existing, $new);
// item_name 폴백: options에 없으면 items.name 사용
if (empty($existing['item_name']) && empty($new['item_name'])) {
$new['item_name'] = $name;
}
return $new;
}
}
// 패턴 C: BD-LEGACY-NUM → chandj.bending에서 직접 조회
if (preg_match('/^BD-LEGACY-(\d+)$/', $code, $m)) {
$chandjNum = (int) $m[1];
$chandjRow = DB::connection('chandj')->table('bending')
->where('num', $chandjNum)
->first();
if ($chandjRow) {
$fields = [
'item_name' => $chandjRow->itemName ?? $chandjRow->item_name ?? null,
'item_sep' => $chandjRow->item_sep ?? null,
'item_bending' => $chandjRow->item_bending ?? null,
'material' => $chandjRow->material ?? null,
'item_spec' => $chandjRow->item_spec ?? null,
'model_name' => $chandjRow->model_name ?? null,
'model_UA' => $chandjRow->model_UA ?? null,
'rail_width' => $chandjRow->rail_width ?? null,
'search_keyword' => $chandjRow->search_keyword ?? null,
'legacy_bending_num' => $chandjNum,
];
foreach ($fields as $key => $value) {
if (! empty($value) && empty($existing[$key])) {
$new[$key] = $value;
}
}
// item_name 폴백: chandj에도 없으면 items.name 사용
if (empty($new['item_name']) && empty($existing['item_name'])) {
$new['item_name'] = $name;
}
} else {
// chandj에 없으면 items.name으로 폴백
if (empty($existing['item_name'])) {
$new['item_name'] = $name;
}
}
return $new;
}
return null;
}
/**
* 한글 패턴에서 모델/재질/규격 추출
*/
private function parseKoreanPattern(string $code, string $patternPrefix, array $existing, array &$new): void
{
$suffix = substr($code, strlen($patternPrefix) + 1); // "-" 제거
$parts = explode('-', $suffix);
switch ($patternPrefix) {
case 'BD-가이드레일':
// BD-가이드레일-KSS01-SUS-120*70
if (count($parts) >= 3) {
if (empty($existing['model_name'])) {
$new['model_name'] = $parts[0];
}
if (empty($existing['material'])) {
$material = $parts[1];
$new['material'] = str_contains($material, 'SUS') ? 'SUS 1.2T' : 'EGI 1.55T';
}
if (empty($existing['item_spec'])) {
$new['item_spec'] = $parts[2];
}
// item_sep 추론 (KTE → 철재)
if (empty($existing['item_sep'])) {
$new['item_sep'] = str_starts_with($parts[0], 'KTE') ? '철재' : '스크린';
}
}
break;
case 'BD-하단마감재':
// BD-하단마감재-KSS01-SUS-60*40
if (count($parts) >= 3) {
if (empty($existing['model_name'])) {
$new['model_name'] = $parts[0];
}
if (empty($existing['material'])) {
$material = $parts[1];
$new['material'] = str_contains($material, 'SUS') ? 'SUS 1.5T' : 'EGI 1.55T';
}
if (empty($existing['item_spec'])) {
$new['item_spec'] = $parts[2];
}
if (empty($existing['item_sep'])) {
$new['item_sep'] = str_starts_with($parts[0], 'KTE') ? '철재' : '스크린';
}
}
break;
case 'BD-케이스':
// BD-케이스-650*550
if (count($parts) >= 1 && ! empty($parts[0])) {
if (empty($existing['item_spec'])) {
$new['item_spec'] = $parts[0];
}
// 케이스는 대부분 철재
if (empty($existing['item_sep'])) {
$new['item_sep'] = '철재';
}
}
break;
case 'BD-마구리':
// BD-마구리-655*505
if (count($parts) >= 1 && ! empty($parts[0])) {
if (empty($existing['item_spec'])) {
$new['item_spec'] = $parts[0];
}
if (empty($existing['item_sep'])) {
$new['item_sep'] = '철재';
}
}
break;
case 'BD-L-BAR':
// BD-L-BAR-KSS01-17*60
if (count($parts) >= 2) {
if (empty($existing['model_name'])) {
$new['model_name'] = $parts[0];
}
if (empty($existing['item_spec'])) {
$new['item_spec'] = $parts[1];
}
}
break;
case 'BD-보강평철':
// BD-보강평철-50
if (count($parts) >= 1 && ! empty($parts[0])) {
if (empty($existing['item_spec'])) {
$new['item_spec'] = $parts[0];
}
}
break;
}
}
private function detectPattern(string $code): string
{
return preg_match('/^BD-[A-Z]{2}-\d{2}$/', $code) ? 'prefix_len' : 'korean';
}
private function showStats(bool $dryRun): void
{
$this->newLine();
$this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
$this->info('📊 결과'.($dryRun ? ' (DRY-RUN)' : ''));
$this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
$this->info(" 전체 BD-* 품목: {$this->stats['total']}");
$this->info(" PREFIX-LEN 업데이트: {$this->stats['prefix_len_filled']}");
$this->info(" 한글 패턴 업데이트: {$this->stats['korean_filled']}");
$this->info(" 이미 완료: {$this->stats['already_complete']}");
if ($this->stats['unknown_pattern'] > 0) {
$this->warn(" 미인식 패턴: {$this->stats['unknown_pattern']}");
}
$this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
if ($dryRun) {
$this->newLine();
$this->info('🔍 DRY-RUN 완료. --dry-run 제거하면 실제 반영됩니다.');
}
}
}

View File

@@ -0,0 +1,101 @@
<?php
namespace App\Console\Commands;
use App\Models\Items\Item;
use Illuminate\Console\Attributes\AsCommand;
use Illuminate\Console\Command;
/**
* 모델(GUIDERAIL/SHUTTERBOX/BOTTOMBAR) components에 sam_item_id 일괄 채우기
* legacy_bending_num → SAM BENDING item ID 매핑
*/
#[AsCommand(name: 'bending:fill-sam-item-ids', description: '모델 components의 sam_item_id 일괄 매핑')]
class BendingFillSamItemIds extends Command
{
protected $signature = 'bending:fill-sam-item-ids
{--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_item_id 일괄 매핑 ===');
$this->info('Mode: ' . ($dryRun ? 'DRY-RUN' : 'LIVE'));
// 1. legacy_bending_num → SAM item ID 매핑 테이블 구축
$bendingItems = Item::withoutGlobalScopes()
->where('tenant_id', $tenantId)
->where('item_category', 'BENDING')
->whereNull('deleted_at')
->get();
$legacyMap = [];
foreach ($bendingItems as $item) {
$legacyNum = $item->getOption('legacy_bending_num');
if ($legacyNum !== null) {
$legacyMap[(string) $legacyNum] = $item->id;
}
}
$this->info("BENDING items: {$bendingItems->count()}건, legacy_bending_num 매핑: " . count($legacyMap) . '건');
// 2. 모델 items의 components 순회
$models = Item::withoutGlobalScopes()
->where('tenant_id', $tenantId)
->whereIn('item_category', ['GUIDERAIL_MODEL', 'SHUTTERBOX_MODEL', 'BOTTOMBAR_MODEL'])
->whereNull('deleted_at')
->get();
$this->info("모델: {$models->count()}");
$updated = 0;
$mapped = 0;
$notFound = 0;
foreach ($models as $model) {
$components = $model->getOption('components', []);
if (empty($components)) {
continue;
}
$changed = false;
foreach ($components as &$comp) {
// 이미 sam_item_id가 있으면 스킵
if (! empty($comp['sam_item_id'])) {
continue;
}
$legacyNum = $comp['legacy_bending_num'] ?? null;
if ($legacyNum === null) {
continue;
}
$samId = $legacyMap[(string) $legacyNum] ?? null;
if ($samId) {
$comp['sam_item_id'] = $samId;
$changed = true;
$mapped++;
} else {
$notFound++;
$this->warn(" [{$model->id}] legacy_bending_num={$legacyNum} → SAM ID 없음 ({$comp['itemName']})");
}
}
unset($comp);
if ($changed && ! $dryRun) {
$model->setOption('components', $components);
$model->save();
$updated++;
} elseif ($changed) {
$updated++;
}
}
$this->info('');
$this->info("결과: 모델 {$updated}건 업데이트, 컴포넌트 {$mapped}건 매핑, {$notFound}건 미매핑");
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,115 @@
<?php
namespace App\Console\Commands;
use App\Models\BendingItem;
use App\Models\Commons\File;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
/**
* 레거시 이미지 → R2 업로드 + bending_items 연결
*
* 실행: php artisan bending:import-images [--dry-run] [--tenant_id=287]
*/
class BendingImportImages extends Command
{
protected $signature = 'bending:import-images
{--tenant_id=287 : 테넌트 ID}
{--dry-run : 미리보기}
{--legacy-path=/home/kkk/sam/5130/bending/img : 레거시 이미지 경로}';
protected $description = '레거시 절곡품 이미지 → R2 업로드 + bending_items 연결';
public function handle(): int
{
$tenantId = (int) $this->option('tenant_id');
$dryRun = $this->option('dry-run');
$legacyPath = $this->option('legacy-path');
$items = BendingItem::where('tenant_id', $tenantId)
->whereNotNull('legacy_bending_id')
->get();
$chandjMap = DB::connection('chandj')->table('bending')
->whereNotNull('imgdata')
->where('imgdata', '!=', '')
->pluck('imgdata', 'num');
$this->info("bending_items: {$items->count()}건 / chandj imgdata: {$chandjMap->count()}");
$uploaded = 0;
$skipped = 0;
$notFound = 0;
$errors = 0;
foreach ($items as $bi) {
$imgFile = $chandjMap[$bi->legacy_bending_id] ?? null;
if (! $imgFile) {
$skipped++;
continue;
}
$filePath = "{$legacyPath}/{$imgFile}";
if (! file_exists($filePath)) {
$this->warn(" ⚠️ 파일 없음: {$imgFile} (#{$bi->legacy_bending_id})");
$notFound++;
continue;
}
$existing = File::where('document_type', 'bending_item')
->where('document_id', $bi->id)
->where('field_key', 'bending_diagram')
->whereNull('deleted_at')
->first();
if ($existing) {
$skipped++;
continue;
}
if ($dryRun) {
$this->line(" [DRY] #{$bi->legacy_bending_id}{$bi->id} ({$bi->item_name}) ← {$imgFile}");
$uploaded++;
continue;
}
try {
$extension = pathinfo($imgFile, PATHINFO_EXTENSION);
$storedName = bin2hex(random_bytes(8)) . '.' . $extension;
$directory = sprintf('%d/bending/%s/%s', $tenantId, date('Y'), date('m'));
$r2Path = $directory . '/' . $storedName;
Storage::disk('r2')->put($r2Path, file_get_contents($filePath));
File::create([
'tenant_id' => $tenantId,
'display_name' => $imgFile,
'stored_name' => $storedName,
'file_path' => $r2Path,
'file_size' => filesize($filePath),
'mime_type' => mime_content_type($filePath),
'file_type' => 'image',
'field_key' => 'bending_diagram',
'document_id' => $bi->id,
'document_type' => 'bending_item',
'is_temp' => false,
'uploaded_by' => 1,
'created_by' => 1,
]);
$this->line(" ✅ #{$bi->legacy_bending_id}{$bi->id} ({$bi->item_name}) ← {$imgFile}");
$uploaded++;
} catch (\Throwable $e) {
$this->error(" ❌ #{$bi->legacy_bending_id}: {$e->getMessage()}");
$errors++;
}
}
$this->newLine();
$this->info("완료: 업로드 {$uploaded}, 스킵 {$skipped}, 파일없음 {$notFound}, 오류 {$errors}");
return 0;
}
}

View File

@@ -0,0 +1,357 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Attributes\AsCommand;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* 3단계: chandj.bending → SAM items.options 전개도(bendingData) + 속성 임포트
*
* chandj bending 265건 → SAM items (item_category=BENDING) 170건
*
* 매핑 방식:
* A) 한글 패턴 (58건): code 파싱으로 item_spec/material 매칭
* B) PREFIX-LEN (112건): PREFIX → 부품 유형 → chandj item_bending+itemName+material 매칭
*
* 실행: php artisan bending:import-legacy [--dry-run] [--tenant_id=287]
*/
#[AsCommand(name: 'bending:import-legacy', description: 'chandj 레거시 전개도(bendingData) + 속성 → SAM items.options 임포트')]
class BendingImportLegacy extends Command
{
protected $signature = 'bending:import-legacy
{--tenant_id=287 : Target tenant ID}
{--dry-run : 실제 저장 없이 미리보기}
{--force : 기존 bendingData 덮어쓰기}';
// PREFIX → chandj 매칭 조건 (item_bending + itemName 패턴 + material)
private const PREFIX_TO_CHANDJ = [
// 가이드레일 (벽면) — item_spec=120*70 (KSS01/02 기준)
'RS' => ['item_bending' => '가이드레일', 'itemName_like' => '%마감%', 'material' => 'SUS 1.2T', 'item_spec' => '120*70'],
'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'],
'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' => ['item_bending' => '가이드레일', 'itemName_like' => '%측면형-D%', 'material' => 'EGI 1.55T', 'item_spec' => '120*120'],
'ST' => ['same_as' => 'RT'],
'SE' => ['same_as' => 'RE'],
// 하단마감재
'BS' => ['item_bending' => '하단마감재', 'itemName_like' => '%하장바%', 'material_like' => '%SUS%', 'item_sep' => '스크린'],
'BE' => ['item_bending' => '하단마감재', 'itemName_like' => '%하장바%', 'material_like' => '%EGI%', 'item_sep' => '스크린'],
'TS' => ['item_bending' => '하단마감재', 'itemName_like' => '%하장바%', 'material_like' => '%SUS%', 'item_sep' => '철재'],
'LA' => ['item_bending' => 'L-BAR', 'itemName_like' => '%L-BAR%', 'material' => 'EGI 1.55T'],
'HH' => ['item_bending' => '하단마감재', 'itemName_like' => '%보강평철%', 'material_like' => '%EGI%'],
// 케이스 — spec 없이 itemName으로 구분
'CF' => ['item_bending' => '케이스', 'itemName_like' => '%전면%', 'item_sep' => '스크린'],
'CL' => ['item_bending' => '케이스', 'itemName_like' => '%린텔%', 'item_sep' => '스크린'],
'CP' => ['item_bending' => '케이스', 'itemName_like' => '%점검%', 'item_sep' => '스크린'],
'CB' => ['item_bending' => '케이스', 'itemName_like' => '%후면%', 'item_sep' => '스크린'],
// 연기차단재
'GI' => ['item_bending' => '연기차단재', 'itemName_like' => '%연기%'],
// 공용
'XX' => null, // 여러 부품이 섞여 있어 자동 매핑 불가
'YY' => null, // 별도 마감 — 자동 매핑 불가
];
private array $stats = [
'total_sam' => 0,
'matched' => 0,
'updated' => 0,
'already_has' => 0,
'no_match' => 0,
'no_bending_data' => 0,
];
private array $unmatchedItems = [];
public function handle(): int
{
$tenantId = (int) $this->option('tenant_id');
$dryRun = $this->option('dry-run');
$force = $this->option('force');
$this->info('=== 3단계: chandj 전개도 → SAM options 임포트 ===');
$this->info('Tenant: '.$tenantId.' | Mode: '.($dryRun ? 'DRY-RUN' : 'LIVE').($force ? ' (FORCE)' : ''));
$this->newLine();
// 1. chandj bending 전체 로드
$chandjRows = DB::connection('chandj')->table('bending')
->whereNull('is_deleted')
->get();
$this->info("chandj bending 활성: {$chandjRows->count()}");
// 2. SAM BENDING items 전체 로드
$samItems = DB::table('items')
->where('tenant_id', $tenantId)
->where('item_category', 'BENDING')
->whereNull('deleted_at')
->orderBy('code')
->get(['id', 'code', 'name', 'options']);
$this->stats['total_sam'] = $samItems->count();
$this->info("SAM BENDING items: {$samItems->count()}");
$this->newLine();
// 3. 매칭 + 임포트
foreach ($samItems as $item) {
$options = json_decode($item->options ?? '{}', true) ?: [];
// 이미 bendingData가 있으면 skip (--force 아닌 경우)
if (! empty($options['bendingData']) && ! $force) {
$this->stats['already_has']++;
continue;
}
// chandj 매칭
$chandjRow = $this->findChandjMatch($item->code, $options, $chandjRows);
if (! $chandjRow) {
$this->stats['no_match']++;
$this->unmatchedItems[] = $item->code;
continue;
}
// bendingData 변환
$bendingData = $this->convertBendingData($chandjRow);
if (empty($bendingData)) {
$this->stats['no_bending_data']++;
continue;
}
// options 업데이트
$updates = ['bendingData' => $bendingData];
// 추가 속성 (비어있으면 채우기)
$optionalFields = [
'memo' => $chandjRow->memo,
'author' => $chandjRow->author,
'search_keyword' => $chandjRow->search_keyword,
'registration_date' => $chandjRow->registration_date,
'model_UA' => $chandjRow->model_UA,
'exit_direction' => $chandjRow->exit_direction,
'front_bottom_width' => $chandjRow->front_bottom_width,
'rail_width' => $chandjRow->rail_width,
'box_width' => $chandjRow->box_width,
'box_height' => $chandjRow->box_height,
'item_spec' => $chandjRow->item_spec,
'legacy_bending_num' => $chandjRow->num,
];
foreach ($optionalFields as $key => $value) {
if (! empty($value) && empty($options[$key])) {
$updates[$key] = $value;
}
}
$merged = array_merge($options, $updates);
if (! $dryRun) {
DB::table('items')->where('id', $item->id)->update([
'options' => json_encode($merged, JSON_UNESCAPED_UNICODE),
'updated_at' => now(),
]);
}
$this->stats['matched']++;
$this->stats['updated']++;
$colCount = count($bendingData);
$this->line("{$item->code} ← chandj#{$chandjRow->num} (전개도 {$colCount}열, +".implode(',', array_keys($updates)).')');
}
$this->showStats($dryRun);
return self::SUCCESS;
}
/**
* SAM item code → chandj bending 매칭
*/
private function findChandjMatch(string $code, array $options, $chandjRows): ?object
{
// A) 한글 패턴 — code에서 속성 추출하여 매칭
if (! preg_match('/^BD-[A-Z]{2}-\d{2}$/', $code)) {
return $this->matchKoreanPattern($code, $chandjRows);
}
// B) PREFIX-LEN — PREFIX로 chandj 조건 결정
preg_match('/^BD-([A-Z]{2})-\d{2}$/', $code, $m);
$prefix = $m[1];
$mapping = self::PREFIX_TO_CHANDJ[$prefix] ?? null;
if (! $mapping) {
return null;
}
// same_as 참조
if (isset($mapping['same_as'])) {
$mapping = self::PREFIX_TO_CHANDJ[$mapping['same_as']] ?? null;
if (! $mapping) {
return null;
}
}
return $this->queryChangj($chandjRows, $mapping);
}
/**
* 한글 패턴 매칭
*/
private function matchKoreanPattern(string $code, $chandjRows): ?object
{
// BD-가이드레일-KSS01-SUS-120*70
if (preg_match('/^BD-가이드레일-(\w+)-(\w+)-(.+)$/', $code, $m)) {
$material = str_contains($m[2], 'SUS') ? 'SUS 1.2T' : 'EGI 1.55T';
return $this->queryChangj($chandjRows, [
'item_bending' => '가이드레일',
'material' => $material,
'item_spec' => $m[3],
]);
}
// BD-하단마감재-KSS01-SUS-60*40
if (preg_match('/^BD-하단마감재-(\w+)-(\w+)-(.+)$/', $code, $m)) {
$material = str_contains($m[2], 'SUS') ? 'SUS' : 'EGI';
return $this->queryChangj($chandjRows, [
'item_bending' => '하단마감재',
'material_like' => "%{$material}%",
'item_spec_like' => "%{$m[3]}%",
]);
}
// 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 (str_contains($r->itemName, $spec) || $r->item_spec === $spec)
&& str_contains($r->itemName, '전면');
});
}
// BD-마구리-655*505
if (preg_match('/^BD-마구리-(.+)$/', $code, $m)) {
return $chandjRows->first(fn ($r) => $r->item_bending === '마구리' && $r->item_spec === $m[1]);
}
// BD-L-BAR-KSS01-17*60
if (preg_match('/^BD-L-BAR-\w+-(.+)$/', $code, $m)) {
return $chandjRows->first(fn ($r) => $r->item_bending === 'L-BAR' && $r->item_spec === $m[1]);
}
// BD-보강평철-50
if (preg_match('/^BD-보강평철-(.+)$/', $code, $m)) {
return $chandjRows->first(fn ($r) => str_contains($r->itemName, '보강평철') && $r->item_spec === $m[1]);
}
return null;
}
/**
* chandj 컬렉션에서 조건으로 검색
*/
private function queryChangj($rows, array $cond): ?object
{
return $rows->first(function ($r) use ($cond) {
if (isset($cond['item_sep']) && $r->item_sep !== $cond['item_sep']) {
return false;
}
if (isset($cond['item_bending']) && $r->item_bending !== $cond['item_bending']) {
return false;
}
if (isset($cond['material']) && $r->material !== $cond['material']) {
return false;
}
if (isset($cond['material_like']) && ! str_contains($r->material, str_replace('%', '', $cond['material_like']))) {
return false;
}
if (isset($cond['itemName_like']) && ! str_contains($r->itemName, str_replace('%', '', $cond['itemName_like']))) {
return false;
}
if (isset($cond['item_spec']) && $r->item_spec !== $cond['item_spec']) {
return false;
}
if (isset($cond['item_spec_like']) && ! str_contains($r->item_spec ?? '', str_replace('%', '', $cond['item_spec_like']))) {
return false;
}
return true;
});
}
/**
* chandj bending row → bendingData JSON 배열 변환
*
* 레거시: inputList=["10","11","110"], bendingrateList=["","-1",""], sumList=["10","21","131"], colorList=[false,false,true], AList=[false,false,false]
* SAM: [{"no":1,"input":10,"rate":"","sum":10,"color":false,"aAngle":false}, ...]
*/
private function convertBendingData(object $row): array
{
$inputs = json_decode($row->inputList ?? '[]', true) ?: [];
$rates = json_decode($row->bendingrateList ?? '[]', true) ?: [];
$sums = json_decode($row->sumList ?? '[]', true) ?: [];
$colors = json_decode($row->colorList ?? '[]', true) ?: [];
$angles = json_decode($row->AList ?? '[]', true) ?: [];
if (empty($inputs)) {
return [];
}
$data = [];
$count = count($inputs);
for ($i = 0; $i < $count; $i++) {
$data[] = [
'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),
];
}
return $data;
}
private function showStats(bool $dryRun): void
{
$this->newLine();
$this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
$this->info('📊 결과'.($dryRun ? ' (DRY-RUN)' : ''));
$this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
$this->info(" SAM BENDING 전체: {$this->stats['total_sam']}");
$this->info(" 매칭 성공 (업데이트): {$this->stats['updated']}");
$this->info(" 이미 bendingData 있음 (skip): {$this->stats['already_has']}");
$this->info(" 매칭 실패: {$this->stats['no_match']}");
if ($this->stats['no_bending_data'] > 0) {
$this->warn(" 전개도 데이터 없음: {$this->stats['no_bending_data']}");
}
$this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
if (! empty($this->unmatchedItems)) {
$this->newLine();
$this->warn('⚠️ 매칭 실패 항목:');
foreach ($this->unmatchedItems as $code) {
$this->line(" - {$code}");
}
}
if ($dryRun) {
$this->newLine();
$this->info('🔍 DRY-RUN 완료. --dry-run 제거하면 실제 반영됩니다.');
}
}
}

View File

@@ -0,0 +1,227 @@
<?php
namespace App\Console\Commands;
use App\Models\BendingItem;
use App\Models\BendingDataRow;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* chandj.bending 누락분 → bending_items + bending_data 직접 이관
*
* 실행: php artisan bending:import-missing [--dry-run] [--tenant_id=287]
*/
class BendingImportMissing extends Command
{
protected $signature = 'bending:import-missing
{--tenant_id=287 : 테넌트 ID}
{--dry-run : 실행하지 않고 미리보기만}';
protected $description = 'chandj.bending 누락분 → bending_items 직접 이관';
private int $tenantId;
public function handle(): int
{
$this->tenantId = (int) $this->option('tenant_id');
$dryRun = $this->option('dry-run');
$existingNums = BendingItem::where('tenant_id', $this->tenantId)
->whereNotNull('legacy_bending_id')
->pluck('legacy_bending_id')
->toArray();
$missing = DB::connection('chandj')->table('bending')
->where(function ($q) {
$q->whereNull('is_deleted')->orWhere('is_deleted', 0);
})
->whereNotIn('num', $existingNums)
->orderBy('num')
->get();
$this->info("누락분: {$missing->count()}건 (이미 매핑: " . count($existingNums) . "건)");
if ($dryRun) {
$this->preview($missing);
return 0;
}
$success = 0;
$bdCount = 0;
$errors = 0;
DB::transaction(function () use ($missing, &$success, &$bdCount, &$errors) {
foreach ($missing as $row) {
try {
$bi = $this->importItem($row);
$bd = $this->importBendingData($bi, $row);
$bdCount += $bd;
$success++;
} catch (\Throwable $e) {
$this->error(" ❌ #{$row->num} {$row->itemName}: {$e->getMessage()}");
$errors++;
}
}
});
$this->newLine();
$this->info("완료: {$success}건 이관, {$bdCount}건 전개도, {$errors}건 오류");
return $errors > 0 ? 1 : 0;
}
private function importItem(object $row): BendingItem
{
$code = $this->generateCode($row);
$bi = BendingItem::create([
'tenant_id' => $this->tenantId,
'code' => $code,
'legacy_code' => "CHANDJ-{$row->num}",
'legacy_bending_id' => $row->num,
'item_name' => $row->itemName ?: "부품#{$row->num}",
'item_sep' => $this->clean($row->item_sep),
'item_bending' => $this->clean($row->item_bending),
'material' => $this->clean($row->material),
'item_spec' => $this->clean($row->item_spec),
'model_name' => $this->clean($row->model_name ?? null),
'model_UA' => $this->clean($row->model_UA ?? null),
'rail_width' => $this->toNum($row->rail_width ?? null),
'exit_direction' => $this->clean($row->exit_direction ?? null),
'box_width' => $this->toNum($row->box_width ?? null),
'box_height' => $this->toNum($row->box_height ?? null),
'front_bottom' => $this->toNum($row->front_bottom_width ?? null),
'options' => $this->buildOptions($row),
'is_active' => true,
'created_by' => 1,
]);
$this->line(" ✅ #{$row->num}{$bi->id} ({$row->itemName}) [{$code}]");
return $bi;
}
private function importBendingData(BendingItem $bi, object $row): int
{
$inputs = json_decode($row->inputList ?? '[]', true) ?: [];
if (empty($inputs)) {
return 0;
}
$rates = json_decode($row->bendingrateList ?? '[]', true) ?: [];
$sums = json_decode($row->sumList ?? '[]', true) ?: [];
$colors = json_decode($row->colorList ?? '[]', true) ?: [];
$angles = json_decode($row->AList ?? '[]', true) ?: [];
$count = count($inputs);
for ($i = 0; $i < $count; $i++) {
$input = (float) ($inputs[$i] ?? 0);
$rate = (string) ($rates[$i] ?? '');
$afterRate = ($rate !== '') ? $input + (float) $rate : $input;
BendingDataRow::create([
'bending_item_id' => $bi->id,
'sort_order' => $i + 1,
'input' => $input,
'rate' => $rate !== '' ? $rate : null,
'after_rate' => $afterRate,
'sum' => (float) ($sums[$i] ?? 0),
'color' => (bool) ($colors[$i] ?? false),
'a_angle' => (bool) ($angles[$i] ?? false),
]);
}
return $count;
}
private function generateCode(object $row): string
{
$bending = $row->item_bending ?? '';
$sep = $row->item_sep ?? '';
$material = $row->material ?? '';
$name = $row->itemName ?? '';
$prodCode = match (true) {
$bending === '케이스' => 'C',
$bending === '하단마감재' && str_contains($sep, '철재') => 'T',
$bending === '하단마감재' => 'B',
$bending === '가이드레일' && str_contains($sep, '철재') => 'R',
$bending === '가이드레일' => 'R',
$bending === '마구리' => 'X',
$bending === 'L-BAR' => 'L',
$bending === '연기차단재' => 'G',
default => 'Z',
};
$specCode = match (true) {
str_contains($name, '전면') => 'F',
str_contains($name, '린텔') => 'L',
str_contains($name, '점검') => 'P',
str_contains($name, '후면') => 'B',
str_contains($name, '상부') || str_contains($name, '덮개') => 'X',
str_contains($name, '본체') => 'M',
str_contains($name, 'C형') || str_contains($name, '-C') => 'C',
str_contains($name, 'D형') || str_contains($name, '-D') => 'D',
str_contains($name, '마감') && str_contains($material, 'SUS') => 'S',
str_contains($material, 'SUS') => 'S',
str_contains($material, 'EGI') => 'E',
default => 'Z',
};
$date = $row->registration_date ?? now()->format('Y-m-d');
$dateCode = date('ymd', strtotime($date));
$base = "{$prodCode}{$specCode}{$dateCode}";
// 중복 방지 일련번호
$seq = 1;
while (BendingItem::where('tenant_id', $this->tenantId)
->where('code', $seq === 1 ? $base : "{$base}-" . str_pad($seq, 2, '0', STR_PAD_LEFT))
->whereNull('length_code')
->exists()) {
$seq++;
}
return $seq === 1 ? $base : "{$base}-" . str_pad($seq, 2, '0', STR_PAD_LEFT);
}
private function buildOptions(object $row): ?array
{
$opts = [];
if (! empty($row->memo)) $opts['memo'] = $row->memo;
if (! empty($row->author)) $opts['author'] = $row->author;
if (! empty($row->search_keyword)) $opts['search_keyword'] = $row->search_keyword;
if (! empty($row->registration_date)) $opts['registration_date'] = (string) $row->registration_date;
return empty($opts) ? null : $opts;
}
private function preview($missing): void
{
$grouped = $missing->groupBy(fn ($r) => ($r->item_bending ?: '미분류') . '/' . ($r->item_sep ?: '미분류'));
$this->table(['분류', '건수'], $grouped->map(fn ($g, $k) => [$k, $g->count()])->values());
$this->newLine();
$headers = ['num', 'itemName', 'item_sep', 'item_bending', 'material', 'has_bd'];
$rows = $missing->take(15)->map(fn ($r) => [
$r->num,
mb_substr($r->itemName ?? '', 0, 25),
$r->item_sep ?? '-',
$r->item_bending ?? '-',
mb_substr($r->material ?? '-', 0, 12),
! empty(json_decode($r->inputList ?? '[]', true)) ? '✅' : '❌',
]);
$this->table($headers, $rows);
}
private function clean(?string $v): ?string
{
return ($v === null || $v === '' || $v === 'null') ? null : trim($v);
}
private function toNum(mixed $v): ?float
{
return ($v === null || $v === '' || $v === 'null') ? null : (float) $v;
}
}

View File

@@ -0,0 +1,145 @@
<?php
namespace App\Console\Commands;
use App\Models\BendingItem;
use App\Models\BendingModel;
use App\Models\Commons\File;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
/**
* 모델 component별 이미지 복사 (기초관리 원본 → 독립 복사본)
*
* component.source_num → bending_items.legacy_bending_id → 원본 이미지
* → R2에 복사 → 새 file 레코드 → component.image_file_id 업데이트
*
* 실행: php artisan bending:model-copy-images [--dry-run] [--tenant_id=287]
*/
class BendingModelCopyImages extends Command
{
protected $signature = 'bending:model-copy-images
{--tenant_id=287 : 테넌트 ID}
{--dry-run : 미리보기}';
protected $description = '모델 component별 이미지를 기초관리에서 복사';
public function handle(): int
{
$tenantId = (int) $this->option('tenant_id');
$dryRun = $this->option('dry-run');
// bending_items의 legacy_bending_id → 이미지 파일 매핑
$itemImageMap = [];
$items = BendingItem::where('tenant_id', $tenantId)
->whereNotNull('legacy_bending_id')
->get();
foreach ($items as $bi) {
$file = File::where('document_type', 'bending_item')
->where('document_id', $bi->id)
->where('field_key', 'bending_diagram')
->whereNull('deleted_at')
->first();
if ($file) {
$itemImageMap[$bi->legacy_bending_id] = $file;
}
}
$this->info("기초관리 이미지 매핑: " . count($itemImageMap) . "");
$models = BendingModel::where('tenant_id', $tenantId)
->whereNotNull('components')
->get();
$copied = 0;
$skipped = 0;
$noSource = 0;
foreach ($models as $model) {
$components = $model->components;
if (empty($components)) {
continue;
}
$updated = false;
foreach ($components as $idx => &$comp) {
// 이미 image_file_id가 있으면 skip
if (! empty($comp['image_file_id'])) {
$skipped++;
continue;
}
// source_num으로 기초관리 이미지 찾기
$sourceNum = $comp['num'] ?? $comp['source_num'] ?? null;
if (! $sourceNum) {
$noSource++;
continue;
}
$sourceFile = $itemImageMap[(int) $sourceNum] ?? null;
if (! $sourceFile || ! $sourceFile->file_path) {
$noSource++;
continue;
}
if ($dryRun) {
$this->line(" [DRY] model#{$model->id} comp[{$idx}] ← bending#{$sourceNum} file#{$sourceFile->id}");
$copied++;
continue;
}
// R2에서 파일 복사
try {
$newFile = $this->copyFile($sourceFile, $model->id, $tenantId);
$comp['image_file_id'] = $newFile->id;
$updated = true;
$copied++;
} catch (\Throwable $e) {
$this->warn(" ⚠️ 복사 실패: model#{$model->id} comp[{$idx}] — {$e->getMessage()}");
}
}
unset($comp);
if ($updated && ! $dryRun) {
$model->components = $components;
$model->save();
}
}
$this->newLine();
$this->info("완료: 복사 {$copied}건, 스킵 {$skipped}건, 소스없음 {$noSource}");
return 0;
}
private function copyFile(File $source, int $modelId, int $tenantId): File
{
$extension = pathinfo($source->stored_name, PATHINFO_EXTENSION);
$storedName = bin2hex(random_bytes(8)) . '.' . $extension;
$directory = sprintf('%d/bending/model-parts/%s/%s', $tenantId, date('Y'), date('m'));
$newPath = $directory . '/' . $storedName;
// R2 파일 복사
$content = Storage::disk('r2')->get($source->file_path);
Storage::disk('r2')->put($newPath, $content);
// 새 파일 레코드 생성
return File::create([
'tenant_id' => $tenantId,
'display_name' => $source->display_name,
'stored_name' => $storedName,
'file_path' => $newPath,
'file_size' => $source->file_size,
'mime_type' => $source->mime_type,
'file_type' => 'image',
'field_key' => 'component_image',
'document_id' => $modelId,
'document_type' => 'bending_model',
'is_temp' => false,
'uploaded_by' => 1,
'created_by' => 1,
]);
}
}

View File

@@ -0,0 +1,387 @@
<?php
namespace App\Console\Commands;
use App\Models\BendingItem;
use App\Models\BendingModel;
use App\Models\Commons\File;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
/**
* chandj guiderail/bottombar/shutterbox → bending_models 직접 이관
* + 기존 assembly_image 파일 매핑 보존
* + component별 이미지 복사 (기초관리 원본 → 독립 복사본)
*
* 실행: php artisan bending:model-import [--dry-run] [--tenant_id=287]
*/
class BendingModelImport extends Command
{
protected $signature = 'bending:model-import
{--tenant_id=287 : 테넌트 ID}
{--dry-run : 미리보기}
{--legacy-path=/tmp/legacy_5130 : 레거시 5130 경로}';
protected $description = 'chandj 절곡품 모델 3종 → bending_models 이관 (이미지 포함)';
private int $tenantId;
private string $legacyPath;
private array $itemImageMap = [];
private array $itemIdMap = [];
private array $modelImageMap = []; // "type:model_name:finishing_type" → image path
public function handle(): int
{
$this->tenantId = (int) $this->option('tenant_id');
$dryRun = $this->option('dry-run');
$this->legacyPath = $this->option('legacy-path');
// 기초관리 이미지 매핑 + 모델 JSON 이미지 로드
$this->buildItemImageMap();
$this->loadModelImageJsons();
// 기존 데이터 삭제 (assembly_image 파일 매핑 보존)
$existing = BendingModel::where('tenant_id', $this->tenantId)->count();
$oldFileMap = [];
if ($existing > 0 && ! $dryRun) {
$oldFileMap = $this->buildOldFileMap();
// component_image 삭제 (재생성할 거니까)
File::where('document_type', 'bending_model')
->where('field_key', 'component_image')
->whereNull('deleted_at')
->forceDelete();
BendingModel::where('tenant_id', $this->tenantId)->forceDelete();
$this->info("기존 bending_models {$existing}건 삭제 (component_image 재생성)");
}
$total = 0;
// 1. guiderail
$guiderails = DB::connection('chandj')->table('guiderail')
->where(function ($q) { $q->whereNull('is_deleted')->orWhere('is_deleted', ''); })
->orderBy('num')->get();
$this->info("\n=== 가이드레일: {$guiderails->count()}건 ===");
foreach ($guiderails as $row) {
if ($dryRun) { $this->line(" [DRY] #{$row->num} {$row->model_name}"); continue; }
$this->importModel($row, BendingModel::TYPE_GUIDERAIL, "GR-{$row->num}", $this->buildGuiderailData($row));
$total++;
}
// 2. shutterbox
$shutterboxes = DB::connection('chandj')->table('shutterbox')
->where(function ($q) { $q->whereNull('is_deleted')->orWhere('is_deleted', ''); })
->orderBy('num')->get();
$this->info("\n=== 케이스: {$shutterboxes->count()}건 ===");
foreach ($shutterboxes as $row) {
if ($dryRun) { $this->line(" [DRY] #{$row->num} {$row->exit_direction}"); continue; }
$this->importModel($row, BendingModel::TYPE_SHUTTERBOX, "SB-{$row->num}", $this->buildShutterboxData($row));
$total++;
}
// 3. bottombar
$bottombars = DB::connection('chandj')->table('bottombar')
->where(function ($q) { $q->whereNull('is_deleted')->orWhere('is_deleted', ''); })
->orderBy('num')->get();
$this->info("\n=== 하단마감재: {$bottombars->count()}건 ===");
foreach ($bottombars as $row) {
if ($dryRun) { $this->line(" [DRY] #{$row->num} {$row->model_name}"); continue; }
$this->importModel($row, BendingModel::TYPE_BOTTOMBAR, "BB-{$row->num}", $this->buildBottombarData($row));
$total++;
}
// assembly_image 파일 매핑 업데이트
if (! $dryRun && ! empty($oldFileMap)) {
$this->remapAssemblyImages($oldFileMap);
}
// 최종 결과
$this->newLine();
$final = BendingModel::where('tenant_id', $this->tenantId)->count();
$assemblyFiles = File::where('document_type', 'bending_model')->where('field_key', 'assembly_image')->whereNull('deleted_at')->count();
$compFiles = File::where('document_type', 'bending_model')->where('field_key', 'component_image')->whereNull('deleted_at')->count();
$this->info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
$this->info("모델: {$final}건 / 조립도: {$assemblyFiles}건 / 부품이미지: {$compFiles}");
$this->info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
return 0;
}
private function importModel(object $row, string $type, string $code, array $data): void
{
$components = json_decode($row->bending_components ?? '[]', true) ?: [];
$materialSummary = json_decode($row->material_summary ?? '{}', true) ?: [];
// component별 이미지 복사
$components = $this->copyComponentImages($components);
$bm = BendingModel::create(array_merge($data, [
'tenant_id' => $this->tenantId,
'model_type' => $type,
'code' => $code,
'legacy_num' => $row->num,
'components' => $components,
'material_summary' => $materialSummary,
'registration_date' => $row->registration_date ?? null,
'author' => $this->clean($row->author ?? null),
'remark' => $this->clean($row->remark ?? null),
'search_keyword' => $this->clean($row->search_keyword ?? null),
'is_active' => true,
'created_by' => 1,
]));
// assembly_image 업로드 (JSON 파일에서)
$this->uploadAssemblyImage($bm, $type, $data);
$compCount = count($components);
$imgCount = collect($components)->whereNotNull('image_file_id')->count();
$hasAssembly = File::where('document_type', 'bending_model')->where('document_id', $bm->id)->where('field_key', 'assembly_image')->exists();
$this->line(" ✅ #{$row->num}{$bm->id} ({$data['name']}) [부품:{$compCount} 이미지:{$imgCount} 조립도:" . ($hasAssembly ? 'Y' : 'N') . ']');
}
private function copyComponentImages(array $components): array
{
foreach ($components as &$comp) {
$sourceNum = $comp['num'] ?? $comp['source_num'] ?? null;
if (! $sourceNum) {
continue;
}
// sam_item_id 매핑 (원본수정 링크용)
$samItemId = $this->itemIdMap[(int) $sourceNum] ?? null;
if ($samItemId) {
$comp['sam_item_id'] = $samItemId;
}
$sourceFile = $this->itemImageMap[(int) $sourceNum] ?? null;
if (! $sourceFile || ! $sourceFile->file_path) {
continue;
}
try {
$extension = pathinfo($sourceFile->stored_name, PATHINFO_EXTENSION);
$storedName = bin2hex(random_bytes(8)) . '.' . $extension;
$directory = sprintf('%d/bending/model-parts/%s/%s', $this->tenantId, date('Y'), date('m'));
$newPath = $directory . '/' . $storedName;
$content = Storage::disk('r2')->get($sourceFile->file_path);
Storage::disk('r2')->put($newPath, $content);
$newFile = File::create([
'tenant_id' => $this->tenantId,
'display_name' => $sourceFile->display_name,
'stored_name' => $storedName,
'file_path' => $newPath,
'file_size' => $sourceFile->file_size,
'mime_type' => $sourceFile->mime_type,
'file_type' => 'image',
'field_key' => 'component_image',
'document_id' => 0, // 모델 생성 전이므로 임시, 나중에 update 불필요 (component JSON에 ID 저장)
'document_type' => 'bending_model',
'is_temp' => false,
'uploaded_by' => 1,
'created_by' => 1,
]);
$comp['image_file_id'] = $newFile->id;
} catch (\Throwable $e) {
// 복사 실패 시 무시
}
}
unset($comp);
return $components;
}
private function buildItemImageMap(): void
{
$items = BendingItem::where('tenant_id', $this->tenantId)
->whereNotNull('legacy_bending_id')
->get();
foreach ($items as $bi) {
$file = File::where('document_type', 'bending_item')
->where('document_id', $bi->id)
->where('field_key', 'bending_diagram')
->whereNull('deleted_at')
->first();
$this->itemIdMap[$bi->legacy_bending_id] = $bi->id;
if ($file) {
$this->itemImageMap[$bi->legacy_bending_id] = $file;
}
}
$this->info("기초관리 매핑: " . count($this->itemIdMap) . "건 (이미지: " . count($this->itemImageMap) . "건)");
}
private function buildOldFileMap(): array
{
return File::where('document_type', 'bending_model')
->where('field_key', 'assembly_image')
->whereNull('deleted_at')
->get()
->mapWithKeys(function ($file) {
$bm = BendingModel::find($file->document_id);
return $bm ? [$bm->legacy_num => $file->document_id] : [];
})->toArray();
}
private function remapAssemblyImages(array $oldFileMap): void
{
$remapped = 0;
$newModels = BendingModel::where('tenant_id', $this->tenantId)->get()->keyBy('legacy_num');
foreach ($oldFileMap as $legacyNum => $oldDocId) {
$newBm = $newModels[$legacyNum] ?? null;
if ($newBm && $oldDocId !== $newBm->id) {
File::where('document_type', 'bending_model')
->where('field_key', 'assembly_image')
->where('document_id', $oldDocId)
->whereNull('deleted_at')
->update(['document_id' => $newBm->id]);
$remapped++;
}
}
$this->info("조립도 매핑 업데이트: {$remapped}");
}
private function loadModelImageJsons(): void
{
$jsonFiles = [
'guiderail' => $this->legacyPath . '/guiderail/guiderail.json',
'shutterbox' => $this->legacyPath . '/shutterbox/shutterbox.json',
'bottombar' => $this->legacyPath . '/bottombar/bottombar.json',
];
foreach ($jsonFiles as $type => $path) {
if (! file_exists($path)) {
continue;
}
$items = json_decode(file_get_contents($path), true) ?: [];
foreach ($items as $item) {
$key = $this->makeImageKey($type, $item);
if ($key && ! empty($item['image'])) {
$this->modelImageMap[$key] = $item['image'];
}
}
}
$this->info("모델 이미지 매핑: " . count($this->modelImageMap) . "");
}
private function makeImageKey(string $type, array $item): ?string
{
if ($type === 'guiderail') {
return "GR:{$item['model_name']}:{$item['check_type']}:{$item['finishing_type']}";
}
if ($type === 'shutterbox') {
return "SB:{$item['exit_direction']}:{$item['box_width']}x{$item['box_height']}";
}
if ($type === 'bottombar') {
return "BB:{$item['model_name']}:{$item['finishing_type']}";
}
return null;
}
private function uploadAssemblyImage(BendingModel $bm, string $type, array $data): void
{
$key = match ($type) {
BendingModel::TYPE_GUIDERAIL => "GR:{$data['model_name']}:{$data['check_type']}:{$data['finishing_type']}",
BendingModel::TYPE_SHUTTERBOX => "SB:{$data['exit_direction']}:" . intval($data['box_width'] ?? 0) . 'x' . intval($data['box_height'] ?? 0),
BendingModel::TYPE_BOTTOMBAR => "BB:{$data['model_name']}:{$data['finishing_type']}",
default => null,
};
if (! $key) return;
$imagePath = $this->modelImageMap[$key] ?? null;
if (! $imagePath) return;
// /bottombar/images/xxx.png → legacy-path/bottombar/images/xxx.png
$localPath = $this->legacyPath . $imagePath;
if (! file_exists($localPath)) return;
try {
$extension = pathinfo($localPath, PATHINFO_EXTENSION);
$storedName = bin2hex(random_bytes(8)) . '.' . $extension;
$directory = sprintf('%d/bending/models/%s/%s', $this->tenantId, date('Y'), date('m'));
$r2Path = $directory . '/' . $storedName;
Storage::disk('r2')->put($r2Path, file_get_contents($localPath));
File::create([
'tenant_id' => $this->tenantId,
'display_name' => basename($imagePath),
'stored_name' => $storedName,
'file_path' => $r2Path,
'file_size' => filesize($localPath),
'mime_type' => mime_content_type($localPath),
'file_type' => 'image',
'field_key' => 'assembly_image',
'document_id' => $bm->id,
'document_type' => 'bending_model',
'is_temp' => false,
'uploaded_by' => 1,
'created_by' => 1,
]);
} catch (\Throwable $e) {
$this->warn(" ⚠️ 조립도 업로드 실패: {$bm->name}{$e->getMessage()}");
}
}
// ── 모델별 데이터 빌드 ──
private function buildGuiderailData(object $row): array
{
return [
'name' => trim("{$row->model_name} {$row->firstitem} {$row->check_type} {$row->finishing_type}"),
'model_name' => $this->clean($row->model_name),
'model_UA' => $this->clean($row->model_UA),
'item_sep' => $this->clean($row->firstitem),
'finishing_type' => $this->clean($row->finishing_type),
'check_type' => $this->clean($row->check_type),
'rail_width' => $this->toNum($row->rail_width),
'rail_length' => $this->toNum($row->rail_length),
];
}
private function buildShutterboxData(object $row): array
{
return [
'name' => trim("케이스 {$row->exit_direction} {$row->box_width}x{$row->box_height}"),
'exit_direction' => $this->clean($row->exit_direction),
'front_bottom_width' => $this->toNum($row->front_bottom_width ?? null),
'rail_width' => $this->toNum($row->rail_width ?? null),
'box_width' => $this->toNum($row->box_width),
'box_height' => $this->toNum($row->box_height),
];
}
private function buildBottombarData(object $row): array
{
return [
'name' => trim("{$row->model_name} {$row->firstitem} {$row->finishing_type} {$row->bar_width}x{$row->bar_height}"),
'model_name' => $this->clean($row->model_name),
'model_UA' => $this->clean($row->model_UA),
'item_sep' => $this->clean($row->firstitem),
'finishing_type' => $this->clean($row->finishing_type),
'bar_width' => $this->toNum($row->bar_width),
'bar_height' => $this->toNum($row->bar_height),
];
}
private function clean(?string $v): ?string
{
return ($v === null || $v === '' || $v === 'null') ? null : trim($v);
}
private function toNum(mixed $v): ?float
{
return ($v === null || $v === '' || $v === 'null') ? null : (float) $v;
}
}

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' => 'PT',
'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,92 @@
<?php
namespace App\Console\Commands;
use App\Models\Tenants\Tenant;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
/**
* 데모 테넌트 만료 체크 및 알림 커맨드
*
* - 만료 임박 (7일 이내): 파트너에게 알림 로그
* - 만료된 테넌트: 비활성 상태로 전환
*
* 기존 코드 영향 없음: DEMO_TRIAL 테넌트만 대상
*
* @see docs/features/sales/demo-tenant-policy.md
*/
class CheckDemoExpiredCommand extends Command
{
protected $signature = 'demo:check-expired
{--dry-run : 실제 변경 없이 대상만 표시}';
protected $description = '데모 체험 테넌트 만료 체크 및 비활성 처리';
public function handle(): int
{
// 1. 만료 임박 테넌트 (7일 이내)
$expiringSoon = Tenant::withoutGlobalScopes()
->where('tenant_type', Tenant::TYPE_DEMO_TRIAL)
->where('tenant_st_code', '!=', 'expired')
->whereNotNull('demo_expires_at')
->where('demo_expires_at', '>', now())
->where('demo_expires_at', '<=', now()->addDays(7))
->get();
if ($expiringSoon->isNotEmpty()) {
$this->info("만료 임박 테넌트: {$expiringSoon->count()}");
foreach ($expiringSoon as $tenant) {
$daysLeft = (int) now()->diffInDays($tenant->demo_expires_at, false);
$this->line(" - [{$tenant->id}] {$tenant->company_name} (D-{$daysLeft})");
Log::info('데모 체험 만료 임박', [
'tenant_id' => $tenant->id,
'company_name' => $tenant->company_name,
'expires_at' => $tenant->demo_expires_at->toDateString(),
'days_left' => $daysLeft,
'partner_id' => $tenant->demo_source_partner_id,
]);
}
}
// 2. 이미 만료된 테넌트 → 상태 변경
$expired = Tenant::withoutGlobalScopes()
->where('tenant_type', Tenant::TYPE_DEMO_TRIAL)
->where('tenant_st_code', '!=', 'expired')
->whereNotNull('demo_expires_at')
->where('demo_expires_at', '<', now())
->get();
if ($expired->isEmpty()) {
$this->info('만료 처리 대상 없음');
return self::SUCCESS;
}
$this->info("만료 처리 대상: {$expired->count()}");
foreach ($expired as $tenant) {
$this->line(" - [{$tenant->id}] {$tenant->company_name} (만료: {$tenant->demo_expires_at->toDateString()})");
if (! $this->option('dry-run')) {
$tenant->forceFill(['tenant_st_code' => 'expired']);
$tenant->save();
Log::info('데모 체험 만료 처리', [
'tenant_id' => $tenant->id,
'company_name' => $tenant->company_name,
'partner_id' => $tenant->demo_source_partner_id,
]);
}
}
if ($this->option('dry-run')) {
$this->warn('(dry-run 모드 — 실제 변경 없음)');
} else {
$this->info(" {$expired->count()}건 만료 처리 완료");
}
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,103 @@
<?php
namespace App\Console\Commands;
use App\Models\Tenants\Tenant;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* 데모 테넌트 비활성 알림 커맨드
*
* - 7일 이상 활동 없는 데모 테넌트 탐지
* - 파트너에게 후속 조치 알림 로그
*
* 기존 코드 영향 없음: DEMO 테넌트만 대상
*
* @see docs/features/sales/demo-tenant-policy.md
*/
class CheckDemoInactiveCommand extends Command
{
protected $signature = 'demo:check-inactive
{--days=7 : 비활성 기준 일수}';
protected $description = '데모 테넌트 비활성 알림 (활동 없는 테넌트 탐지)';
public function handle(): int
{
$thresholdDays = (int) $this->option('days');
$demos = Tenant::withoutGlobalScopes()
->whereIn('tenant_type', Tenant::DEMO_TYPES)
->where('tenant_st_code', '!=', 'expired')
->get();
if ($demos->isEmpty()) {
$this->info('활성 데모 테넌트 없음');
return self::SUCCESS;
}
$inactiveCount = 0;
foreach ($demos as $tenant) {
$lastActivity = $this->getLastActivity($tenant->id);
if (! $lastActivity) {
continue;
}
$daysSince = (int) now()->diffInDays($lastActivity);
if ($daysSince < $thresholdDays) {
continue;
}
$inactiveCount++;
$this->line(" - [{$tenant->id}] {$tenant->company_name} ({$daysSince}일 비활성)");
Log::warning('데모 테넌트 비활성 알림', [
'tenant_id' => $tenant->id,
'company_name' => $tenant->company_name,
'tenant_type' => $tenant->tenant_type,
'days_inactive' => $daysSince,
'last_activity' => $lastActivity->toDateString(),
'partner_id' => $tenant->demo_source_partner_id,
]);
}
if ($inactiveCount === 0) {
$this->info("비활성 테넌트 없음 (기준: {$thresholdDays}일)");
} else {
$this->info("비활성 테넌트: {$inactiveCount}건 (기준: {$thresholdDays}일)");
}
return self::SUCCESS;
}
private function getLastActivity(int $tenantId): ?\Carbon\Carbon
{
$tables = ['orders', 'quotes', 'items', 'clients'];
$latest = null;
foreach ($tables as $table) {
if (! \Schema::hasTable($table) || ! \Schema::hasColumn($table, 'tenant_id')) {
continue;
}
$date = DB::table($table)
->where('tenant_id', $tenantId)
->max('updated_at');
if ($date) {
$parsed = \Carbon\Carbon::parse($date);
if (! $latest || $parsed->gt($latest)) {
$latest = $parsed;
}
}
}
return $latest;
}
}

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' => 'PT',
'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,199 @@
<?php
namespace App\Console\Commands;
use App\Models\BendingItem;
use App\Models\Items\Item;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* items(BENDING) + options JSON → bending_items + bending_data 이관
*
* 실행: php artisan bending:migrate-to-new-table
* 롤백: php artisan bending:migrate-to-new-table --rollback
*/
class MigrateBendingItemsToNewTable extends Command
{
protected $signature = 'bending:migrate-to-new-table
{--tenant_id=287 : 테넌트 ID}
{--dry-run : 실행하지 않고 미리보기만}
{--rollback : bending_items/bending_data 전체 삭제}';
protected $description = 'items(BENDING) → bending_items + bending_data 테이블 이관';
public function handle(): int
{
$tenantId = (int) $this->option('tenant_id');
$dryRun = $this->option('dry-run');
$rollback = $this->option('rollback');
if ($rollback) {
return $this->rollback($tenantId);
}
// 이미 이관된 데이터 확인
$existingCount = BendingItem::where('tenant_id', $tenantId)->count();
if ($existingCount > 0) {
$this->warn("이미 bending_items에 {$existingCount}건 존재합니다.");
if (! $this->confirm('기존 데이터 삭제 후 재이관하시겠습니까?')) {
return 0;
}
$this->rollback($tenantId);
}
// items(BENDING) 조회
$items = Item::where('item_category', 'BENDING')
->where('tenant_id', $tenantId)
->get();
$this->info("이관 대상: {$items->count()}");
if ($dryRun) {
$this->previewItems($items);
return 0;
}
$success = 0;
$errors = 0;
$bdCount = 0;
DB::transaction(function () use ($items, $tenantId, &$success, &$errors, &$bdCount) {
foreach ($items as $item) {
try {
$bi = $this->migrateItem($item, $tenantId);
$bdRows = $this->migrateBendingData($bi, $item);
$bdCount += $bdRows;
$success++;
} catch (\Throwable $e) {
$this->error("{$item->code}: {$e->getMessage()}");
$errors++;
}
}
});
$this->newLine();
$this->info("완료: {$success}건 이관, {$bdCount}건 전개도 행, {$errors}건 오류");
return $errors > 0 ? 1 : 0;
}
private function migrateItem(Item $item, int $tenantId): BendingItem
{
$opts = $item->options ?? [];
// item_name: options.item_name → name 폴백
$itemName = $opts['item_name'] ?? null;
if (empty($itemName) || $itemName === 'null') {
$itemName = $item->name;
}
$bi = BendingItem::create([
'tenant_id' => $tenantId,
'code' => $item->code,
'legacy_code' => $item->code,
'legacy_bending_id' => $opts['legacy_bending_num'] ?? null,
// 정규 컬럼 (options에서 승격)
'item_name' => $itemName,
'item_sep' => $this->cleanNull($opts['item_sep'] ?? null),
'item_bending' => $this->cleanNull($opts['item_bending'] ?? null),
'material' => $this->cleanNull($opts['material'] ?? null),
'item_spec' => $this->cleanNull($opts['item_spec'] ?? null),
'model_name' => $this->cleanNull($opts['model_name'] ?? null),
'model_UA' => $this->cleanNull($opts['model_UA'] ?? null),
'rail_width' => $this->toDecimal($opts['rail_width'] ?? null),
'exit_direction' => $this->cleanNull($opts['exit_direction'] ?? null),
'box_width' => $this->toDecimal($opts['box_width'] ?? null),
'box_height' => $this->toDecimal($opts['box_height'] ?? null),
'front_bottom' => $this->toDecimal($opts['front_bottom_width'] ?? $opts['front_bottom'] ?? null),
'inspection_door' => $this->cleanNull($opts['inspection_door'] ?? null),
// 비정형 속성
'options' => $this->buildMetaOptions($opts),
'is_active' => $item->is_active,
'created_by' => $item->created_by,
'updated_by' => $item->updated_by,
]);
$this->line("{$item->code} → bending_items#{$bi->id} ({$itemName})");
return $bi;
}
private function migrateBendingData(BendingItem $bi, Item $item): int
{
$opts = $item->options ?? [];
$bendingData = $opts['bendingData'] ?? [];
if (empty($bendingData) || ! is_array($bendingData)) {
return 0;
}
// bending_items.bending_data JSON 컬럼에 저장
$bi->update(['bending_data' => $bendingData]);
return count($bendingData);
}
private function rollback(int $tenantId): int
{
$biCount = BendingItem::where('tenant_id', $tenantId)->count();
BendingItem::where('tenant_id', $tenantId)->forceDelete();
$this->info("롤백 완료: bending_items {$biCount}건 삭제");
return 0;
}
private function previewItems($items): void
{
$headers = ['code', 'name', 'item_name(opts)', 'item_sep', 'material', 'has_bd'];
$rows = $items->take(20)->map(function ($item) {
$opts = $item->options ?? [];
return [
$item->code,
mb_substr($item->name, 0, 20),
mb_substr($opts['item_name'] ?? '(NULL)', 0, 20),
$opts['item_sep'] ?? '-',
$opts['material'] ?? '-',
! empty($opts['bendingData']) ? '✅' : '❌',
];
});
$this->table($headers, $rows);
$nullNameCount = $items->filter(fn ($i) => empty(($i->options ?? [])['item_name']))->count();
$hasBdCount = $items->filter(fn ($i) => ! empty(($i->options ?? [])['bendingData']))->count();
$this->info("item_name NULL: {$nullNameCount}건 (name 필드로 폴백)");
$this->info("bendingData 있음: {$hasBdCount}");
}
private function cleanNull(?string $value): ?string
{
if ($value === null || $value === 'null' || $value === '') {
return null;
}
return $value;
}
private function toDecimal(mixed $value): ?float
{
if ($value === null || $value === 'null' || $value === '') {
return null;
}
return (float) $value;
}
/**
* options에 남길 비정형 속성만 추출
*/
private function buildMetaOptions(array $opts): ?array
{
$metaKeys = ['search_keyword', 'registration_date', 'author', 'memo', 'parent_num', 'modified_by'];
$meta = [];
foreach ($metaKeys as $key) {
$val = $opts[$key] ?? null;
if ($val !== null && $val !== 'null' && $val !== '') {
$meta[$key] = $val;
}
}
return empty($meta) ? null : $meta;
}
}

View File

@@ -0,0 +1,174 @@
<?php
namespace App\Console\Commands;
use App\Models\Tenants\Tenant;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* 데모 쇼케이스 테넌트 데이터 리셋 커맨드
*
* 매일 자정에 쇼케이스 테넌트의 비즈니스 데이터를 삭제하고
* 샘플 데이터를 다시 시드한다.
*
* 기존 코드 영향 없음: DEMO_SHOWCASE 테넌트만 대상
*
* @see docs/features/sales/demo-tenant-policy.md
*/
class ResetDemoShowcaseCommand extends Command
{
protected $signature = 'demo:reset-showcase
{--seed : 리셋 후 샘플 데이터 시드}
{--dry-run : 실제 삭제 없이 대상만 표시}';
protected $description = '데모 쇼케이스 테넌트의 비즈니스 데이터를 리셋합니다';
/**
* 리셋 대상 테이블 목록 (tenant_id 기반)
* 순서 중요: FK 의존성 역순으로 삭제
*/
private const RESET_TABLES = [
// 영업/주문
'order_item_components',
'order_items',
'order_histories',
'orders',
'quotes',
// 생산
'production_results',
'production_plans',
// 자재/재고
'material_inspection_items',
'material_inspections',
'material_receipts',
'lot_sales',
'lots',
// 마스터
'price_histories',
'product_components',
'items',
'clients',
// 파일 (데모 데이터 관련)
// files는 morphable이므로 별도 처리 필요
// 조직
'departments',
// 감사 로그 (데모 데이터)
'audit_logs',
];
public function handle(): int
{
$showcases = Tenant::withoutGlobalScopes()
->where('tenant_type', Tenant::TYPE_DEMO_SHOWCASE)
->get();
if ($showcases->isEmpty()) {
$this->info('데모 쇼케이스 테넌트가 없습니다.');
return self::SUCCESS;
}
foreach ($showcases as $tenant) {
$this->info("리셋 대상: [{$tenant->id}] {$tenant->company_name}");
if ($this->option('dry-run')) {
$this->showStats($tenant);
continue;
}
$this->resetTenantData($tenant);
if ($this->option('seed')) {
$this->seedSampleData($tenant);
}
}
return self::SUCCESS;
}
private function showStats(Tenant $tenant): void
{
foreach (self::RESET_TABLES as $table) {
if (! \Schema::hasTable($table)) {
continue;
}
if (! \Schema::hasColumn($table, 'tenant_id')) {
continue;
}
$count = DB::table($table)->where('tenant_id', $tenant->id)->count();
if ($count > 0) {
$this->line(" - {$table}: {$count}");
}
}
}
private function resetTenantData(Tenant $tenant): void
{
$totalDeleted = 0;
DB::beginTransaction();
try {
foreach (self::RESET_TABLES as $table) {
if (! \Schema::hasTable($table)) {
continue;
}
if (! \Schema::hasColumn($table, 'tenant_id')) {
continue;
}
$deleted = DB::table($table)->where('tenant_id', $tenant->id)->delete();
if ($deleted > 0) {
$this->line(" 삭제: {$table}{$deleted}");
$totalDeleted += $deleted;
}
}
DB::commit();
$this->info("{$totalDeleted}건 삭제 완료");
Log::info('데모 쇼케이스 리셋 완료', [
'tenant_id' => $tenant->id,
'deleted_count' => $totalDeleted,
]);
} catch (\Exception $e) {
DB::rollBack();
$this->error(" 리셋 실패: {$e->getMessage()}");
Log::error('데모 쇼케이스 리셋 실패', [
'tenant_id' => $tenant->id,
'error' => $e->getMessage(),
]);
return;
}
}
private function seedSampleData(Tenant $tenant): void
{
$preset = $tenant->getDemoPreset() ?? 'manufacturing';
$this->info(" 샘플 데이터 시드: {$preset}");
try {
$seeder = new \Database\Seeders\Demo\ManufacturingPresetSeeder;
$seeder->run($tenant->id);
$this->info(' 샘플 데이터 시드 완료');
} catch (\Exception $e) {
$this->error(" 시드 실패: {$e->getMessage()}");
Log::error('데모 샘플 데이터 시드 실패', [
'tenant_id' => $tenant->id,
'error' => $e->getMessage(),
]);
}
}
}

View File

@@ -84,6 +84,7 @@ public function render($request, Throwable $exception)
return response()->json([
'success' => false,
'message' => '입력값 검증 실패',
'errors' => $exception->errors(),
'error' => [
'code' => 422,
'details' => $exception->errors(),
@@ -95,7 +96,7 @@ public function render($request, Throwable $exception)
if ($exception instanceof BadRequestHttpException) {
return response()->json([
'success' => false,
'message' => '잘못된 요청',
'message' => $exception->getMessage() ?: '잘못된 요청',
'data' => null,
], 400);
}

View File

@@ -0,0 +1,133 @@
<?php
namespace App\Helpers;
use App\Models\Tenants\AiPricingConfig;
use App\Models\Tenants\AiTokenUsage;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class AiTokenHelper
{
/**
* Gemini API 응답에서 토큰 사용량 저장
*/
public static function saveGeminiUsage(array $apiResult, string $model, string $menuName): void
{
try {
$usage = $apiResult['usageMetadata'] ?? null;
if (! $usage) {
return;
}
$promptTokens = $usage['promptTokenCount'] ?? 0;
$completionTokens = $usage['candidatesTokenCount'] ?? 0;
$totalTokens = $usage['totalTokenCount'] ?? 0;
$pricing = AiPricingConfig::getActivePricing('gemini');
$inputPrice = $pricing ? (float) $pricing->input_price_per_million / 1_000_000 : 0.10 / 1_000_000;
$outputPrice = $pricing ? (float) $pricing->output_price_per_million / 1_000_000 : 0.40 / 1_000_000;
self::save($model, $menuName, $promptTokens, $completionTokens, $totalTokens, $inputPrice, $outputPrice);
} catch (\Exception $e) {
Log::warning('AI token usage save failed (Gemini)', ['error' => $e->getMessage()]);
}
}
/**
* Claude API 응답에서 토큰 사용량 저장
*/
public static function saveClaudeUsage(array $apiResult, string $model, string $menuName): void
{
try {
$usage = $apiResult['usage'] ?? null;
if (! $usage) {
return;
}
$promptTokens = $usage['input_tokens'] ?? 0;
$completionTokens = $usage['output_tokens'] ?? 0;
$totalTokens = $promptTokens + $completionTokens;
$pricing = AiPricingConfig::getActivePricing('claude');
$inputPrice = $pricing ? (float) $pricing->input_price_per_million / 1_000_000 : 0.25 / 1_000_000;
$outputPrice = $pricing ? (float) $pricing->output_price_per_million / 1_000_000 : 1.25 / 1_000_000;
self::save($model, $menuName, $promptTokens, $completionTokens, $totalTokens, $inputPrice, $outputPrice);
} catch (\Exception $e) {
Log::warning('AI token usage save failed (Claude)', ['error' => $e->getMessage()]);
}
}
/**
* Cloudflare R2 Storage 업로드 사용량 저장
* Class A (PUT/POST): $0.0045 / 1,000,000건
* Storage: $0.015 / GB / 월
*/
public static function saveR2StorageUsage(string $menuName, int $fileSizeBytes): void
{
try {
$pricing = AiPricingConfig::getActivePricing('cloudflare-r2');
$unitPrice = $pricing ? (float) $pricing->unit_price : 0.0045;
$operationCost = $unitPrice / 1_000_000;
$fileSizeGB = $fileSizeBytes / (1024 * 1024 * 1024);
$storageCost = $fileSizeGB * 0.015;
$costUsd = $operationCost + $storageCost;
self::save('cloudflare-r2', $menuName, $fileSizeBytes, 0, $fileSizeBytes, $costUsd / max($fileSizeBytes, 1), 0);
} catch (\Exception $e) {
Log::warning('AI token usage save failed (R2)', ['error' => $e->getMessage()]);
}
}
/**
* Speech-to-Text 사용량 저장
* STT latest_long 모델: $0.009 / 15초
*/
public static function saveSttUsage(string $menuName, int $durationSeconds): void
{
try {
$pricing = AiPricingConfig::getActivePricing('google-stt');
$sttUnitPrice = $pricing ? (float) $pricing->unit_price : 0.009;
$costUsd = ceil($durationSeconds / 15) * $sttUnitPrice;
self::save('google-speech-to-text', $menuName, $durationSeconds, 0, $durationSeconds, $costUsd / max($durationSeconds, 1), 0);
} catch (\Exception $e) {
Log::warning('AI token usage save failed (STT)', ['error' => $e->getMessage()]);
}
}
/**
* 공통 저장 로직
*/
private static function save(
string $model,
string $menuName,
int $promptTokens,
int $completionTokens,
int $totalTokens,
float $inputPricePerToken,
float $outputPricePerToken,
): void {
$costUsd = ($promptTokens * $inputPricePerToken) + ($completionTokens * $outputPricePerToken);
$exchangeRate = AiPricingConfig::getExchangeRate();
$costKrw = $costUsd * $exchangeRate;
$tenantId = app('tenant_id');
$userId = app('api_user');
AiTokenUsage::create([
'tenant_id' => $tenantId ?: 1,
'model' => $model,
'menu_name' => $menuName,
'prompt_tokens' => $promptTokens,
'completion_tokens' => $completionTokens,
'total_tokens' => $totalTokens,
'cost_usd' => $costUsd,
'cost_krw' => $costKrw,
'request_id' => Str::uuid()->toString(),
'created_by' => $userId ?: null,
]);
}
}

View File

@@ -146,13 +146,22 @@ public static function error(
int $code = 400,
array $error = []
): JsonResponse {
$errorBody = [
'code' => $code,
'details' => $error['details'] ?? null,
];
// details, code 이외 추가 필드(expected_code 등)를 error 객체에 포함
$reserved = ['details'];
$extra = array_diff_key($error, array_flip($reserved));
if ($extra) {
$errorBody = array_merge($errorBody, $extra);
}
return response()->json([
'success' => false,
'message' => "[{$code}] {$message}",
'error' => [
'code' => $code,
'details' => $error['details'] ?? null,
],
'error' => $errorBody,
], $code);
}
@@ -225,8 +234,16 @@ public static function handle(
$message = (string) ($result['message'] ?? ($result['error'] ?? '서버 에러'));
$details = $result['details'] ?? null;
// 에러 신호 배열의 추가 필드(expected_code 등)를 응답에 포함
$reserved = ['error', 'code', 'message', 'details'];
$extra = array_diff_key($result, array_flip($reserved));
$errorData = ['details' => $details];
if ($extra) {
$errorData = array_merge($errorData, $extra);
}
// 에러에도 쿼리 로그 포함되도록 error()가 처리하게 맡김
return self::error($message, $code, ['details' => $details]);
return self::error($message, $code, $errorData);
}
// 표준 박스( ['data'=>..., 'query'=>..., 'statusCode'=>...] ) 하위호환

View File

@@ -0,0 +1,310 @@
<?php
namespace App\Helpers;
use InvalidArgumentException;
/**
* eval() 없이 산술 수식을 안전하게 계산하는 평가기
*
* Shunting-yard 알고리즘으로 중위 표기법 → 후위 표기법(RPN) 변환 후 계산
* 지원: 숫자, +, -, *, /, %, (, ), 단항 마이너스
*/
class SafeMathEvaluator
{
private const OPERATORS = ['+', '-', '*', '/', '%'];
private const PRECEDENCE = [
'+' => 2,
'-' => 2,
'*' => 3,
'/' => 3,
'%' => 3,
'UNARY_MINUS' => 4,
];
/**
* 산술 수식을 계산하여 float 반환
*/
public static function calculate(string $expression): float
{
$expression = trim($expression);
if ($expression === '') {
return 0;
}
$tokens = self::tokenize($expression);
if (empty($tokens)) {
return 0;
}
$rpn = self::toRPN($tokens);
return self::evaluateRPN($rpn);
}
/**
* 비교식을 평가하여 bool 반환
* 예: "3000 <= 6000", "100 == 100", "5 > 3 && 2 < 4"
*/
public static function compare(string $expression): bool
{
$expression = trim($expression);
// && 논리 AND 처리
if (str_contains($expression, '&&')) {
$parts = explode('&&', $expression);
foreach ($parts as $part) {
if (! self::compare(trim($part))) {
return false;
}
}
return true;
}
// || 논리 OR 처리
if (str_contains($expression, '||')) {
$parts = explode('||', $expression);
foreach ($parts as $part) {
if (self::compare(trim($part))) {
return true;
}
}
return false;
}
// 비교 연산자 추출 (2문자 먼저 검사)
$operators = ['>=', '<=', '!=', '==', '>', '<'];
foreach ($operators as $op) {
$pos = strpos($expression, $op);
if ($pos !== false) {
$left = self::calculate(substr($expression, 0, $pos));
$right = self::calculate(substr($expression, $pos + strlen($op)));
return match ($op) {
'>=' => $left >= $right,
'<=' => $left <= $right,
'!=' => $left != $right,
'==' => $left == $right,
'>' => $left > $right,
'<' => $left < $right,
};
}
}
// 비교 연산자가 없으면 수치를 boolean으로 평가
return (bool) self::calculate($expression);
}
/**
* 수식 문자열을 토큰 배열로 분리
*/
private static function tokenize(string $expression): array
{
$tokens = [];
$len = strlen($expression);
$i = 0;
while ($i < $len) {
$char = $expression[$i];
// 공백 건너뛰기
if ($char === ' ' || $char === "\t") {
$i++;
continue;
}
// 숫자 (정수, 소수)
if (is_numeric($char) || ($char === '.' && $i + 1 < $len && is_numeric($expression[$i + 1]))) {
$num = '';
while ($i < $len && (is_numeric($expression[$i]) || $expression[$i] === '.')) {
$num .= $expression[$i];
$i++;
}
$tokens[] = ['type' => 'number', 'value' => (float) $num];
continue;
}
// 괄호
if ($char === '(') {
$tokens[] = ['type' => 'lparen'];
$i++;
continue;
}
if ($char === ')') {
$tokens[] = ['type' => 'rparen'];
$i++;
continue;
}
// 연산자
if (in_array($char, self::OPERATORS)) {
// 단항 마이너스 판별: 맨 앞이거나, 앞이 연산자 또는 여는 괄호인 경우
if ($char === '-') {
$isUnary = empty($tokens)
|| $tokens[count($tokens) - 1]['type'] === 'operator'
|| $tokens[count($tokens) - 1]['type'] === 'lparen';
if ($isUnary) {
$tokens[] = ['type' => 'operator', 'value' => 'UNARY_MINUS'];
$i++;
continue;
}
}
$tokens[] = ['type' => 'operator', 'value' => $char];
$i++;
continue;
}
throw new InvalidArgumentException("허용되지 않는 문자: '{$char}' (위치 {$i})");
}
return $tokens;
}
/**
* 중위 표기법 토큰 → 후위 표기법(RPN) 변환 (Shunting-yard)
*/
private static function toRPN(array $tokens): array
{
$output = [];
$operatorStack = [];
foreach ($tokens as $token) {
if ($token['type'] === 'number') {
$output[] = $token;
continue;
}
if ($token['type'] === 'operator') {
$op = $token['value'];
$prec = self::PRECEDENCE[$op] ?? 0;
while (! empty($operatorStack)) {
$top = end($operatorStack);
if ($top['type'] === 'lparen') {
break;
}
$topPrec = self::PRECEDENCE[$top['value']] ?? 0;
// 단항 연산자는 오른쪽 결합
if ($op === 'UNARY_MINUS') {
if ($topPrec > $prec) {
$output[] = array_pop($operatorStack);
} else {
break;
}
} else {
// 이항 연산자는 왼쪽 결합 (같은 우선순위면 먼저 pop)
if ($topPrec >= $prec) {
$output[] = array_pop($operatorStack);
} else {
break;
}
}
}
$operatorStack[] = $token;
continue;
}
if ($token['type'] === 'lparen') {
$operatorStack[] = $token;
continue;
}
if ($token['type'] === 'rparen') {
while (! empty($operatorStack) && end($operatorStack)['type'] !== 'lparen') {
$output[] = array_pop($operatorStack);
}
if (empty($operatorStack)) {
throw new InvalidArgumentException('괄호 불일치: 여는 괄호 없음');
}
array_pop($operatorStack); // 여는 괄호 제거
continue;
}
}
while (! empty($operatorStack)) {
$top = array_pop($operatorStack);
if ($top['type'] === 'lparen') {
throw new InvalidArgumentException('괄호 불일치: 닫는 괄호 없음');
}
$output[] = $top;
}
return $output;
}
/**
* 후위 표기법(RPN) 계산
*/
private static function evaluateRPN(array $rpn): float
{
$stack = [];
foreach ($rpn as $token) {
if ($token['type'] === 'number') {
$stack[] = $token['value'];
continue;
}
if ($token['type'] === 'operator') {
$op = $token['value'];
// 단항 마이너스
if ($op === 'UNARY_MINUS') {
if (empty($stack)) {
throw new InvalidArgumentException('수식 오류: 단항 마이너스 피연산자 없음');
}
$stack[] = -array_pop($stack);
continue;
}
// 이항 연산자
if (count($stack) < 2) {
throw new InvalidArgumentException('수식 오류: 피연산자 부족');
}
$right = array_pop($stack);
$left = array_pop($stack);
$stack[] = match ($op) {
'+' => $left + $right,
'-' => $left - $right,
'*' => $left * $right,
'/' => $right != 0 ? $left / $right : throw new InvalidArgumentException('0으로 나눌 수 없음'),
'%' => $right != 0 ? fmod($left, $right) : throw new InvalidArgumentException('0으로 나눌 수 없음'),
default => throw new InvalidArgumentException("알 수 없는 연산자: {$op}"),
};
}
}
if (count($stack) !== 1) {
throw new InvalidArgumentException('수식 오류: 결과가 하나가 아님');
}
return (float) $stack[0];
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\V1\AiTokenUsageListRequest;
use App\Services\AiTokenUsageService;
class AiTokenUsageController extends Controller
{
public function __construct(
private readonly AiTokenUsageService $service
) {}
/**
* AI 토큰 사용량 목록 + 통계
*/
public function index(AiTokenUsageListRequest $request)
{
return ApiResponse::handle(fn () => $this->service->list($request->validated()));
}
/**
* AI 단가 설정 조회 (읽기 전용)
*/
public function pricing()
{
return ApiResponse::handle(fn () => $this->service->getPricing());
}
}

View File

@@ -4,13 +4,18 @@
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Models\Barobill\BarobillBankTransaction;
use App\Models\Barobill\BarobillCardTransaction;
use App\Models\Barobill\BarobillMember;
use App\Services\Barobill\BarobillSoapService;
use App\Services\BarobillService;
use Illuminate\Http\Request;
class BarobillController extends Controller
{
public function __construct(
private BarobillService $barobillService
private BarobillService $barobillService,
private BarobillSoapService $soapService,
) {}
/**
@@ -19,17 +24,43 @@ public function __construct(
public function status()
{
return ApiResponse::handle(function () {
$tenantId = app('tenant_id');
$setting = $this->barobillService->getSetting();
$member = BarobillMember::withoutGlobalScopes()
->where('tenant_id', $tenantId)
->first();
$accountCount = 0;
$cardCount = 0;
if ($member) {
$accountCount = BarobillBankTransaction::withoutGlobalScopes()
->where('tenant_id', $tenantId)
->distinct('bank_account_num')
->count('bank_account_num');
$cardCount = BarobillCardTransaction::withoutGlobalScopes()
->where('tenant_id', $tenantId)
->distinct('card_num')
->count('card_num');
}
return [
'bank_service_count' => 0,
'account_link_count' => 0,
'member' => $setting ? [
'bank_service_count' => $accountCount,
'account_link_count' => $accountCount,
'card_count' => $cardCount,
'member' => $member ? [
'barobill_id' => $member->barobill_id,
'biz_no' => $member->formatted_biz_no,
'corp_name' => $member->corp_name,
'status' => $member->status,
'server_mode' => $member->server_mode ?? 'test',
] : ($setting ? [
'barobill_id' => $setting->barobill_id,
'biz_no' => $setting->corp_num,
'status' => $setting->isVerified() ? 'active' : 'inactive',
'server_mode' => $this->barobillService->isTestMode() ? 'test' : 'production',
] : null,
] : null),
];
}, __('message.fetched'));
}

View File

@@ -0,0 +1,306 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Models\Barobill\BarobillMember;
use App\Services\Barobill\BarobillBankSyncService;
use App\Services\Barobill\BarobillCardSyncService;
use App\Services\Barobill\BarobillSoapService;
use App\Services\Barobill\HometaxSyncService;
use Carbon\Carbon;
use Illuminate\Http\Request;
class BarobillSyncController extends Controller
{
public function __construct(
private BarobillSoapService $soapService,
private BarobillBankSyncService $bankSyncService,
private BarobillCardSyncService $cardSyncService,
private HometaxSyncService $hometaxSyncService,
) {}
/**
* 수동 은행 동기화
*/
public function syncBank(Request $request)
{
$data = $request->validate([
'start_date' => 'nullable|date_format:Ymd',
'end_date' => 'nullable|date_format:Ymd',
]);
return ApiResponse::handle(function () use ($data) {
$tenantId = app('tenant_id');
$startDate = $data['start_date'] ?? Carbon::now()->subMonth()->format('Ymd');
$endDate = $data['end_date'] ?? Carbon::now()->format('Ymd');
return $this->bankSyncService->syncIfNeeded($tenantId, $startDate, $endDate);
}, __('message.fetched'));
}
/**
* 수동 카드 동기화
*/
public function syncCard(Request $request)
{
$data = $request->validate([
'start_date' => 'nullable|date_format:Ymd',
'end_date' => 'nullable|date_format:Ymd',
]);
return ApiResponse::handle(function () use ($data) {
$tenantId = app('tenant_id');
$startDate = $data['start_date'] ?? Carbon::now()->subMonth()->format('Ymd');
$endDate = $data['end_date'] ?? Carbon::now()->format('Ymd');
return $this->cardSyncService->syncCardTransactions($tenantId, $startDate, $endDate);
}, __('message.fetched'));
}
/**
* 수동 홈택스 동기화
*/
public function syncHometax(Request $request)
{
$data = $request->validate([
'invoices' => 'required|array',
'invoices.*.ntsConfirmNum' => 'required|string',
'invoice_type' => 'required|in:sales,purchase',
]);
return ApiResponse::handle(function () use ($data) {
$tenantId = app('tenant_id');
return $this->hometaxSyncService->syncInvoices(
$data['invoices'],
$tenantId,
$data['invoice_type']
);
}, __('message.fetched'));
}
/**
* 바로빌 등록계좌 목록 (SOAP 실시간)
*/
public function accounts()
{
return ApiResponse::handle(function () {
$member = $this->getMember();
if (! $member) {
return ['accounts' => [], 'message' => '바로빌 회원 정보 없음'];
}
$this->soapService->initForMember($member);
return [
'accounts' => $this->bankSyncService->getRegisteredAccounts($member),
];
}, __('message.fetched'));
}
/**
* 바로빌 등록카드 목록 (SOAP 실시간)
*/
public function cards()
{
return ApiResponse::handle(function () {
$member = $this->getMember();
if (! $member) {
return ['cards' => [], 'message' => '바로빌 회원 정보 없음'];
}
$this->soapService->initForMember($member);
return [
'cards' => $this->cardSyncService->getRegisteredCards($member),
];
}, __('message.fetched'));
}
/**
* 인증서 상태 조회 (만료일, 유효성)
*/
public function certificate()
{
return ApiResponse::handle(function () {
$member = $this->getMember();
if (! $member) {
return ['certificate' => null, 'message' => '바로빌 회원 정보 없음'];
}
$this->soapService->initForMember($member);
$corpNum = $member->biz_no;
$valid = $this->soapService->checkCertificateValid($corpNum);
$expireDate = $this->soapService->getCertificateExpireDate($corpNum);
$registDate = $this->soapService->getCertificateRegistDate($corpNum);
return [
'certificate' => [
'is_valid' => $valid['success'] && ($valid['data'] ?? 0) >= 0,
'expire_date' => $expireDate['success'] ? ($expireDate['data'] ?? null) : null,
'regist_date' => $registDate['success'] ? ($registDate['data'] ?? null) : null,
],
];
}, __('message.fetched'));
}
/**
* 충전잔액 조회
*/
public function balance()
{
return ApiResponse::handle(function () {
$member = $this->getMember();
if (! $member) {
return ['balance' => null, 'message' => '바로빌 회원 정보 없음'];
}
$this->soapService->initForMember($member);
$result = $this->soapService->getBalanceCostAmount($member->biz_no);
return [
'balance' => $result['success'] ? ($result['data'] ?? 0) : null,
'success' => $result['success'],
'error' => $result['error'] ?? null,
];
}, __('message.fetched'));
}
/**
* 바로빌 회원 등록 (SOAP RegistCorp)
*/
public function registerMember(Request $request)
{
$data = $request->validate([
'biz_no' => 'required|string|size:10',
'corp_name' => 'required|string',
'ceo_name' => 'required|string',
'biz_type' => 'nullable|string',
'biz_class' => 'nullable|string',
'addr' => 'nullable|string',
'barobill_id' => 'required|string',
'barobill_pwd' => 'required|string',
'manager_name' => 'nullable|string',
'manager_hp' => 'nullable|string',
'manager_email' => 'nullable|email',
]);
return ApiResponse::handle(function () use ($data) {
$tenantId = app('tenant_id');
$this->soapService->initForMember(
BarobillMember::withoutGlobalScopes()->where('tenant_id', $tenantId)->first()
?? new BarobillMember(['server_mode' => 'test'])
);
$result = $this->soapService->registCorp($data);
if ($result['success']) {
BarobillMember::withoutGlobalScopes()->updateOrCreate(
['tenant_id' => $tenantId],
[
'biz_no' => $data['biz_no'],
'corp_name' => $data['corp_name'],
'ceo_name' => $data['ceo_name'],
'biz_type' => $data['biz_type'] ?? null,
'biz_class' => $data['biz_class'] ?? null,
'addr' => $data['addr'] ?? null,
'barobill_id' => $data['barobill_id'],
'barobill_pwd' => $data['barobill_pwd'],
'manager_name' => $data['manager_name'] ?? null,
'manager_hp' => $data['manager_hp'] ?? null,
'manager_email' => $data['manager_email'] ?? null,
'status' => 'active',
]
);
}
return $result;
}, __('message.created'));
}
/**
* 바로빌 회원 수정 (SOAP UpdateCorpInfo)
*/
public function updateMember(Request $request)
{
$data = $request->validate([
'corp_name' => 'required|string',
'ceo_name' => 'required|string',
'biz_type' => 'nullable|string',
'biz_class' => 'nullable|string',
'addr' => 'nullable|string',
'manager_name' => 'nullable|string',
'manager_hp' => 'nullable|string',
'manager_email' => 'nullable|email',
]);
return ApiResponse::handle(function () use ($data) {
$member = $this->getMember();
if (! $member) {
return ['error' => 'NO_MEMBER', 'code' => 404, 'message' => '바로빌 회원 정보 없음'];
}
$this->soapService->initForMember($member);
$data['biz_no'] = $member->biz_no;
$result = $this->soapService->updateCorpInfo($data);
if ($result['success']) {
$member->update([
'corp_name' => $data['corp_name'],
'ceo_name' => $data['ceo_name'],
'biz_type' => $data['biz_type'] ?? $member->biz_type,
'biz_class' => $data['biz_class'] ?? $member->biz_class,
'addr' => $data['addr'] ?? $member->addr,
'manager_name' => $data['manager_name'] ?? $member->manager_name,
'manager_hp' => $data['manager_hp'] ?? $member->manager_hp,
'manager_email' => $data['manager_email'] ?? $member->manager_email,
]);
}
return $result;
}, __('message.updated'));
}
/**
* 바로빌 회원 상태 (SOAP GetCorpState)
*/
public function memberStatus()
{
return ApiResponse::handle(function () {
$member = $this->getMember();
if (! $member) {
return ['status' => null, 'message' => '바로빌 회원 정보 없음'];
}
$this->soapService->initForMember($member);
$result = $this->soapService->getCorpState($member->biz_no);
return [
'member' => [
'biz_no' => $member->formatted_biz_no,
'corp_name' => $member->corp_name,
'status' => $member->status,
'server_mode' => $member->server_mode,
],
'barobill_state' => $result['success'] ? $result['data'] : null,
'error' => $result['error'] ?? null,
];
}, __('message.fetched'));
}
/**
* 현재 테넌트의 바로빌 회원 조회
*/
private function getMember(): ?BarobillMember
{
$tenantId = app('tenant_id');
return BarobillMember::withoutGlobalScopes()
->where('tenant_id', $tenantId)
->first();
}
}

View File

@@ -0,0 +1,132 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Models\Tenants\Receiving;
use App\Services\BendingCodeService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class BendingController extends Controller
{
public function __construct(
private readonly BendingCodeService $service
) {}
/**
* 절곡품 코드맵 조회 (캐스케이딩 드롭다운용)
*/
public function codeMap(): JsonResponse
{
return ApiResponse::handle(function () {
return $this->service->getCodeMap();
}, __('message.fetched'));
}
/**
* 드롭다운 선택 → 품목 매핑 조회
*/
public function resolveItem(Request $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
$prodCode = $request->query('prod');
$specCode = $request->query('spec');
$lengthCode = $request->query('length');
if (! $prodCode || ! $specCode || ! $lengthCode) {
return ['error' => 'MISSING_PARAMS', 'code' => 400, 'message' => 'prod, spec, length 파라미터가 필요합니다.'];
}
$expectedCode = "BD-{$prodCode}{$specCode}-{$lengthCode}";
$item = $this->service->resolveItem($prodCode, $specCode, $lengthCode);
if (! $item) {
return [
'error' => 'NOT_MAPPED',
'code' => 404,
'message' => '해당 조합에 매핑된 품목이 없습니다.',
'expected_code' => $expectedCode,
];
}
$item['expected_code'] = $expectedCode;
return $item;
}, __('message.fetched'));
}
/**
* 원자재 LOT 목록 조회 (입고 + 수입검사 완료 기준)
*
* 재질(material) 키워드를 분해하여 유연 검색
* 예: "EGI 1.55T" → "EGI" AND "1.55" 로 검색 (공백/T 무관)
*/
public function materialLots(Request $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
$material = $request->query('material');
$query = Receiving::whereIn('status', ['completed', 'inspection_completed'])
->whereNotNull('lot_no')
->where('lot_no', '!=', '');
// 재질 키워드 분해 검색 (공백/T 접미사 무관)
if ($material) {
// "EGI 1.55T" → ["EGI", "1.55"], "SUS 1.2T" → ["SUS", "1.2"]
$keywords = preg_split('/[\s]+/', preg_replace('/T$/i', '', trim($material)));
$keywords = array_filter($keywords);
$query->where(function ($q) use ($keywords) {
foreach ($keywords as $kw) {
$q->where(function ($sub) use ($kw) {
$sub->where('item_name', 'LIKE', "%{$kw}%")
->orWhere('specification', 'LIKE', "%{$kw}%");
});
}
});
}
return $query->select([
'id',
'lot_no',
'supplier_lot',
'item_name',
'specification',
'receiving_qty',
'receiving_date',
'supplier',
'options',
])
->orderByDesc('receiving_date')
->limit(50)
->get();
}, __('message.fetched'));
}
/**
* LOT 번호 생성 (일련번호 없음 — 같은 날 같은 조합은 동일 LOT)
*/
public function generateLotNumber(Request $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
$prodCode = $request->input('prod_code');
$specCode = $request->input('spec_code');
$lengthCode = $request->input('length_code');
$regDate = $request->input('reg_date', now()->toDateString());
if (! $prodCode || ! $specCode || ! $lengthCode) {
return ['error' => 'MISSING_PARAMS', 'code' => 400, 'message' => 'prod_code, spec_code, length_code가 필요합니다.'];
}
$lotNumber = $this->service->generateLotNumber($prodCode, $specCode, $lengthCode, $regDate);
$material = BendingCodeService::getMaterial($prodCode, $specCode);
return [
'lot_number' => $lotNumber,
'material' => $material,
];
}, __('message.fetched'));
}
}

View File

@@ -0,0 +1,95 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\BendingItemIndexRequest;
use App\Http\Requests\Api\V1\BendingItemStoreRequest;
use App\Http\Requests\Api\V1\BendingItemUpdateRequest;
use App\Http\Resources\Api\V1\BendingItemResource;
use App\Services\BendingItemService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class BendingItemController extends Controller
{
public function __construct(private BendingItemService $service) {}
/**
* Bearer 토큰 없이 X-TENANT-ID 헤더로 컨텍스트 설정
*/
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')) {
// mng에서 Bearer 토큰 없이 호출 시 시스템 사용자(1)로 설정
app()->instance('api_user', 1);
}
}
public function index(BendingItemIndexRequest $request): JsonResponse
{
$this->ensureContext($request);
return ApiResponse::handle(function () use ($request) {
$paginator = $this->service->list($request->validated());
$paginator->getCollection()->transform(fn ($item) => (new BendingItemResource($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 BendingItemResource($this->service->find($id)),
__('message.fetched')
);
}
public function store(BendingItemStoreRequest $request): JsonResponse
{
$this->ensureContext($request);
return ApiResponse::handle(
fn () => new BendingItemResource($this->service->create($request->validated())),
__('message.created')
);
}
public function update(BendingItemUpdateRequest $request, int $id): JsonResponse
{
$this->ensureContext($request);
return ApiResponse::handle(
fn () => new BendingItemResource($this->service->update($id, $request->validated())),
__('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

@@ -20,6 +20,16 @@ public function index(Request $request)
}, __('message.client.fetched'));
}
/**
* 거래처 간단 목록 (id, name만 반환) - vendors 엔드포인트용
*/
public function vendors(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->vendors($request->all());
}, __('message.client.fetched'));
}
public function show(int $id)
{
return ApiResponse::handle(function () use ($id) {

View File

@@ -0,0 +1,88 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\V1\CondolenceExpense\StoreCondolenceExpenseRequest;
use App\Http\Requests\V1\CondolenceExpense\UpdateCondolenceExpenseRequest;
use App\Services\CondolenceExpenseService;
use Illuminate\Http\Request;
class CondolenceExpenseController extends Controller
{
public function __construct(
private readonly CondolenceExpenseService $service
) {}
/**
* 경조사비 목록
*/
public function index(Request $request)
{
$params = $request->only([
'year',
'category',
'search',
'sort_by',
'sort_order',
'per_page',
'page',
]);
$expenses = $this->service->index($params);
return ApiResponse::success($expenses, __('message.fetched'));
}
/**
* 경조사비 통계
*/
public function summary(Request $request)
{
$params = $request->only(['year', 'category']);
$summary = $this->service->summary($params);
return ApiResponse::success($summary, __('message.fetched'));
}
/**
* 경조사비 상세
*/
public function show(int $id)
{
$expense = $this->service->show($id);
return ApiResponse::success($expense, __('message.fetched'));
}
/**
* 경조사비 등록
*/
public function store(StoreCondolenceExpenseRequest $request)
{
$expense = $this->service->store($request->validated());
return ApiResponse::success($expense, __('message.created'), [], 201);
}
/**
* 경조사비 수정
*/
public function update(int $id, UpdateCondolenceExpenseRequest $request)
{
$expense = $this->service->update($id, $request->validated());
return ApiResponse::success($expense, __('message.updated'));
}
/**
* 경조사비 삭제
*/
public function destroy(int $id)
{
$this->service->destroy($id);
return ApiResponse::success(null, __('message.deleted'));
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Services\Demo\DemoAnalyticsService;
use Illuminate\Http\Request;
/**
* 데모 테넌트 분석 API 컨트롤러
*
* 전환율, 파트너 성과, 활동 현황 등 데모 분석 엔드포인트
*
* 기존 코드 영향 없음: 데모 전용 라우트에서만 사용
*
* @see docs/features/sales/demo-tenant-policy.md
*/
class DemoAnalyticsController extends Controller
{
public function __construct(private DemoAnalyticsService $service) {}
/**
* 대시보드 요약
*/
public function summary()
{
return ApiResponse::handle(function () {
return $this->service->summary();
}, __('message.fetched'));
}
/**
* 전환율 퍼널 분석
*/
public function conversionFunnel(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->conversionFunnel($request->all());
}, __('message.fetched'));
}
/**
* 파트너별 성과 분석
*/
public function partnerPerformance(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->partnerPerformance($request->all());
}, __('message.fetched'));
}
/**
* 데모 테넌트 활동 현황
*/
public function activityReport(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->activityReport($request->all());
}, __('message.fetched'));
}
}

View File

@@ -0,0 +1,95 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\Demo\DemoTenantStoreRequest;
use App\Services\Demo\DemoTenantService;
use Illuminate\Http\Request;
/**
* 데모 테넌트 관리 API 컨트롤러
*
* 파트너가 고객 체험 테넌트를 생성/관리하는 엔드포인트
*
* 기존 코드 영향 없음: 데모 전용 라우트에서만 사용
*
* @see docs/features/sales/demo-tenant-policy.md
*/
class DemoTenantController extends Controller
{
public function __construct(private DemoTenantService $service) {}
/**
* 내가 생성한 데모 테넌트 목록
*/
public function index(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->index($request->all());
}, __('message.demo_tenant.fetched'));
}
/**
* 데모 테넌트 상세 조회
*/
public function show(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->show($id);
}, __('message.demo_tenant.fetched'));
}
/**
* 고객 체험 테넌트 생성 (Tier 3)
*/
public function store(DemoTenantStoreRequest $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->createTrialFromApi($request->validated());
}, __('message.demo_tenant.created'));
}
/**
* 데모 데이터 리셋
*/
public function reset(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->resetFromApi($id);
}, __('message.demo_tenant.reset'));
}
/**
* 체험 기간 연장
*/
public function extend(int $id, Request $request)
{
return ApiResponse::handle(function () use ($id, $request) {
$days = (int) $request->input('days', 30);
return $this->service->extendFromApi($id, $days);
}, __('message.demo_tenant.extended'));
}
/**
* 데모 → 정식 전환
*/
public function convert(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->convertFromApi($id);
}, __('message.demo_tenant.converted'));
}
/**
* 데모 현황 통계
*/
public function stats()
{
return ApiResponse::handle(function () {
return $this->service->stats();
}, __('message.fetched'));
}
}

View File

@@ -9,9 +9,24 @@
use App\Http\Requests\Api\V1\ShareLinkRequest;
use App\Services\FileStorageService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class FileStorageController extends Controller
{
/**
* Bearer 토큰 없이 X-TENANT-ID 헤더로 컨텍스트 설정 (MNG 프록시 호출용)
*/
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);
}
}
/**
* Upload file to temp
*/
@@ -85,8 +100,10 @@ public function trash()
/**
* Download file (attachment)
*/
public function download(int $id)
public function download(int $id, Request $request)
{
$this->ensureContext($request);
$service = new FileStorageService;
$file = $service->getFile($id);
@@ -96,14 +113,53 @@ public function download(int $id)
/**
* View file inline (이미지/PDF 브라우저에서 바로 표시)
*/
public function view(int $id)
public function view(int $id, Request $request)
{
$this->ensureContext($request);
$service = new FileStorageService;
$file = $service->getFile($id);
return $file->download(inline: true);
}
/**
* R2 Presigned URL 발급 (30분 유효)
*/
public function presignedUrl(int $id, Request $request)
{
$this->ensureContext($request);
$service = new FileStorageService;
$file = $service->getFile($id);
if (! $file->file_path) {
abort(404, 'File not found');
}
$url = Storage::disk('r2')->temporaryUrl(
$file->file_path,
now()->addMinutes(30)
);
return ApiResponse::handle(fn () => ['url' => $url]);
}
/**
* R2 Presigned URL 발급 (file_path 기반, 30분 유효)
*/
public function presignedUrlByPath(Request $request)
{
$path = $request->input('path');
if (! $path) {
abort(400, 'path is required');
}
$url = Storage::disk('r2')->temporaryUrl($path, now()->addMinutes(30));
return ApiResponse::handle(fn () => ['url' => $url]);
}
/**
* Soft delete file
*/

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

@@ -446,12 +446,13 @@ private function expandBomItems(array $bom): array
'child_item_type' => $childItem?->item_type,
'unit' => $childItem?->unit,
'quantity' => $entry['quantity'] ?? 1,
'category' => $entry['category'] ?? null,
];
})->toArray();
}
/**
* BOM 트리 구조 빌드 (재귀)
* BOM 트리 구조 빌드 (재귀, category 필드가 있으면 3단계 그룹화)
*/
private function buildBomTree(Item $item, int $maxDepth, int $currentDepth): array
{
@@ -478,16 +479,49 @@ private function buildBomTree(Item $item, int $maxDepth, int $currentDepth): arr
->get()
->keyBy('id');
foreach ($bom as $entry) {
$childItemId = $entry['child_item_id'] ?? null;
$childItem = $childItems[$childItemId] ?? null;
// category 필드가 있으면 카테고리별 그룹 노드 생성 (3단계)
$hasCategory = collect($bom)->contains(fn ($b) => ! empty($b['category']));
if ($hasCategory) {
$grouped = [];
foreach ($bom as $entry) {
$cat = $entry['category'] ?? '기타';
$grouped[$cat][] = $entry;
}
foreach ($grouped as $catName => $catEntries) {
$catChildren = [];
foreach ($catEntries as $entry) {
$childItem = $childItems[$entry['child_item_id'] ?? null] ?? null;
if ($childItem) {
$childTree = $this->buildBomTree($childItem, $maxDepth, $currentDepth + 2);
$childTree['quantity'] = $entry['quantity'] ?? 1;
$catChildren[] = $childTree;
}
}
if (! empty($catChildren)) {
$result['children'][] = [
'id' => 0,
'code' => '',
'name' => $catName,
'item_type' => 'CAT',
'unit' => '',
'depth' => $currentDepth + 1,
'count' => count($catChildren),
'children' => $catChildren,
];
}
}
} else {
foreach ($bom as $entry) {
$childItem = $childItems[$entry['child_item_id'] ?? null] ?? null;
if ($childItem) {
$childTree = $this->buildBomTree($childItem, $maxDepth, $currentDepth + 1);
$childTree['quantity'] = $entry['quantity'] ?? 1;
$result['children'][] = $childTree;
}
}
}
return $result;
}

View File

@@ -23,11 +23,12 @@ public function index(Request $request)
{
return ApiResponse::handle(function () use ($request) {
$params = [
'size' => $request->input('size', 20),
'size' => $request->input('size') ?? $request->input('per_page', 20),
'q' => $request->input('q') ?? $request->input('search'),
'category_id' => $request->input('category_id'),
'item_type' => $request->input('type') ?? $request->input('item_type'),
'item_type' => $request->input('type') ?? $request->input('item_type') ?? $request->input('itemType'),
'item_category' => $request->input('item_category'),
'bom_category' => $request->input('bom_category'),
'group_id' => $request->input('group_id'),
'active' => $request->input('is_active') ?? $request->input('active'),
'has_bom' => $request->input('has_bom'),

View File

@@ -6,6 +6,8 @@
use App\Http\Controllers\Controller;
use App\Http\Requests\Item\ItemFileUploadRequest;
use App\Models\Commons\File;
use App\Models\BendingItem;
use App\Models\BendingModel;
use App\Models\Items\Item;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
@@ -26,6 +28,20 @@ class ItemsFileController extends Controller
*/
private const ITEM_GROUP_ID = '1';
/**
* Bearer 토큰 없이 X-TENANT-ID 헤더로 컨텍스트 설정 (MNG 프록시 호출용)
*/
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);
}
}
/**
* 파일 목록 조회
*
@@ -33,17 +49,19 @@ class ItemsFileController extends Controller
*/
public function index(int $id, Request $request)
{
$this->ensureContext($request);
return ApiResponse::handle(function () use ($id, $request) {
$tenantId = app('tenant_id');
$fieldKey = $request->input('field_key');
// 품목 존재 확인
$this->getItemById($id, $tenantId);
$owner = $this->getItemById($id, $tenantId);
$docType = $this->getDocumentType($owner);
// 파일 조회
$query = File::query()
->where('tenant_id', $tenantId)
->where('document_type', self::ITEM_GROUP_ID)
->where('document_type', $docType)
->where('document_id', $id);
// 특정 field_key만 조회
@@ -69,6 +87,7 @@ public function index(int $id, Request $request)
*/
public function upload(int $id, ItemFileUploadRequest $request)
{
$this->ensureContext($request);
return ApiResponse::handle(function () use ($id, $request) {
$tenantId = app('tenant_id');
$userId = auth()->id() ?? app('api_user');
@@ -78,7 +97,8 @@ public function upload(int $id, ItemFileUploadRequest $request)
$existingFileId = $validated['file_id'] ?? null;
// 품목 존재 확인
$this->getItemById($id, $tenantId);
$owner = $this->getItemById($id, $tenantId);
$docType = $this->getDocumentType($owner);
$replaced = false;
@@ -86,7 +106,7 @@ public function upload(int $id, ItemFileUploadRequest $request)
if ($existingFileId) {
$existingFile = File::query()
->where('tenant_id', $tenantId)
->where('document_type', self::ITEM_GROUP_ID)
->where('document_type', $docType)
->where('document_id', $id)
->where('id', $existingFileId)
->first();
@@ -126,7 +146,7 @@ public function upload(int $id, ItemFileUploadRequest $request)
'file_type' => $fileType, // 파일 형식 (image, document, excel, archive)
'field_key' => $fieldKey, // 비즈니스 용도 (drawing, certificate 등)
'document_id' => $id,
'document_type' => self::ITEM_GROUP_ID, // group_id
'document_type' => $docType,
'is_temp' => false,
'uploaded_by' => $userId,
'created_by' => $userId,
@@ -152,18 +172,20 @@ public function upload(int $id, ItemFileUploadRequest $request)
*/
public function delete(int $id, mixed $fileId, Request $request)
{
$this->ensureContext($request);
$fileId = (int) $fileId;
return ApiResponse::handle(function () use ($id, $fileId) {
$tenantId = app('tenant_id');
// 품목 존재 확인
$this->getItemById($id, $tenantId);
$owner = $this->getItemById($id, $tenantId);
$docType = $this->getDocumentType($owner);
// 파일 조회
$file = File::query()
->where('tenant_id', $tenantId)
->where('document_type', self::ITEM_GROUP_ID)
->where('document_type', $docType)
->where('document_id', $id)
->where('id', $fileId)
->first();
@@ -183,19 +205,51 @@ public function delete(int $id, mixed $fileId, Request $request)
}
/**
* ID로 품목 조회 (통합 items 테이블)
* ID로 품목 조회 (items → bending_items 폴백)
*/
private function getItemById(int $id, int $tenantId): Item
private function getItemById(int $id, int $tenantId): Item|BendingItem|BendingModel
{
$item = Item::query()
->where('tenant_id', $tenantId)
->find($id);
if (! $item) {
if ($item) {
return $item;
}
// bending_items 폴백
$bendingItem = BendingItem::query()
->where('tenant_id', $tenantId)
->find($id);
if ($bendingItem) {
return $bendingItem;
}
// bending_models 폴백
$bendingModel = BendingModel::query()
->where('tenant_id', $tenantId)
->find($id);
if ($bendingModel) {
return $bendingModel;
}
throw new NotFoundHttpException(__('error.not_found'));
}
return $item;
/**
* 품목 유형에 따른 document_type 반환
*/
private function getDocumentType(Item|BendingItem|BendingModel $item): string
{
if ($item instanceof BendingItem) {
return 'bending_item';
}
if ($item instanceof BendingModel) {
return 'bending_model';
}
return self::ITEM_GROUP_ID;
}
/**

View File

@@ -0,0 +1,84 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\Material\StoreNonconformingReportRequest;
use App\Http\Requests\Material\UpdateNonconformingReportRequest;
use App\Services\NonconformingReportService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class NonconformingReportController extends Controller
{
public function __construct(private NonconformingReportService $service) {}
public function index(Request $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
return $this->service->index($request->all());
}, __('message.fetched'));
}
public function stats(Request $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
return $this->service->stats($request->all());
}, __('message.fetched'));
}
public function show(int $id): JsonResponse
{
return ApiResponse::handle(function () use ($id) {
return $this->service->show($id);
}, __('message.fetched'));
}
public function store(StoreNonconformingReportRequest $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
return $this->service->store($request->validated());
}, __('message.created'));
}
public function update(UpdateNonconformingReportRequest $request, int $id): JsonResponse
{
return ApiResponse::handle(function () use ($request, $id) {
return $this->service->update($id, $request->validated());
}, __('message.updated'));
}
public function destroy(int $id): JsonResponse
{
return ApiResponse::handle(function () use ($id) {
$this->service->destroy($id);
return 'success';
}, __('message.deleted'));
}
public function changeStatus(Request $request, int $id): JsonResponse
{
$request->validate(['status' => 'required|string|in:RECEIVED,ANALYZING,RESOLVED,CLOSED']);
return ApiResponse::handle(function () use ($request, $id) {
return $this->service->changeStatus($id, $request->input('status'));
}, __('message.updated'));
}
public function submitApproval(Request $request, int $id): JsonResponse
{
$request->validate([
'title' => 'nullable|string|max:200',
'form_id' => 'nullable|integer',
'steps' => 'required|array|min:1',
'steps.*.approver_id' => 'required|integer',
'steps.*.step_type' => 'nullable|string|in:approval,agreement,reference',
]);
return ApiResponse::handle(function () use ($request, $id) {
return $this->service->submitForApproval($id, $request->all());
}, __('message.created'));
}
}

View File

@@ -30,10 +30,10 @@ public function index(Request $request)
/**
* 통계 조회
*/
public function stats()
public function stats(Request $request)
{
return ApiResponse::handle(function () {
return $this->service->stats();
return ApiResponse::handle(function () use ($request) {
return $this->service->stats($request->input('order_type'));
}, __('message.order.fetched'));
}

View File

@@ -56,4 +56,12 @@ public function missing(Request $request)
return $this->service->missing($request->all());
}, __('message.fetched'));
}
public function exportExcel(Request $request)
{
$year = (int) $request->input('year', now()->year);
$quarter = (int) $request->input('quarter', ceil(now()->month / 3));
return $this->service->exportConfirmed($year, $quarter);
}
}

View File

@@ -124,4 +124,24 @@ public function resultDocument(int $id)
return $this->service->resultDocument($id);
}, __('message.fetched'));
}
public function uploadFile(Request $request, int $id)
{
$request->validate([
'file' => ['required', 'file', 'max:51200'], // 50MB
]);
return ApiResponse::handle(function () use ($request, $id) {
return $this->service->uploadFile($id, $request->file('file'));
}, __('message.created'));
}
public function deleteFile(int $id)
{
return ApiResponse::handle(function () use ($id) {
$this->service->deleteFile($id);
return 'success';
}, __('message.deleted'));
}
}

View File

@@ -4,6 +4,7 @@
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\V1\Stock\StoreStockAdjustmentRequest;
use App\Services\StockService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -71,4 +72,32 @@ public function statsByItemType(): JsonResponse
return ApiResponse::success($stats, __('message.fetched'));
}
/**
* 재고 조정 이력 조회
*/
public function adjustments(int $id): JsonResponse
{
try {
$adjustments = $this->service->adjustments($id);
return ApiResponse::success($adjustments, __('message.fetched'));
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
return ApiResponse::error(__('error.stock.not_found'), 404);
}
}
/**
* 재고 조정 등록
*/
public function storeAdjustment(int $id, StoreStockAdjustmentRequest $request): JsonResponse
{
try {
$result = $this->service->createAdjustment($id, $request->validated());
return ApiResponse::success($result, __('message.created'));
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
return ApiResponse::error(__('error.stock.not_found'), 404);
}
}
}

View File

@@ -8,13 +8,17 @@
use App\Http\Requests\V1\Subscription\SubscriptionCancelRequest;
use App\Http\Requests\V1\Subscription\SubscriptionIndexRequest;
use App\Http\Requests\V1\Subscription\SubscriptionStoreRequest;
use App\Services\ExportService;
use App\Services\SubscriptionService;
use Illuminate\Http\JsonResponse;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class SubscriptionController extends Controller
{
public function __construct(
private readonly SubscriptionService $subscriptionService
private readonly SubscriptionService $subscriptionService,
private readonly ExportService $exportService
) {}
/**
@@ -117,12 +121,12 @@ public function usage(): JsonResponse
}
/**
* 내보내기 요청
* 내보내기 요청 (동기 처리)
*/
public function export(ExportStoreRequest $request): JsonResponse
{
return ApiResponse::handle(
fn () => $this->subscriptionService->createExport($request->validated()),
fn () => $this->subscriptionService->createExport($request->validated(), $this->exportService),
__('message.export.requested')
);
}
@@ -137,4 +141,24 @@ public function exportStatus(int $id): JsonResponse
__('message.fetched')
);
}
/**
* 내보내기 파일 다운로드
*/
public function exportDownload(int $id): BinaryFileResponse
{
$export = $this->subscriptionService->getExport($id);
if (! $export->is_downloadable) {
throw new NotFoundHttpException(__('error.export.not_found'));
}
$filePath = storage_path('app/'.$export->file_path);
if (! file_exists($filePath)) {
throw new NotFoundHttpException(__('error.export.not_found'));
}
return response()->download($filePath, $export->file_name);
}
}

View File

@@ -13,14 +13,30 @@
use App\Models\Tenants\JournalEntry;
use App\Services\JournalSyncService;
use App\Services\TaxInvoiceService;
use App\Services\TenantSettingService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class TaxInvoiceController extends Controller
{
private const SUPPLIER_GROUP = 'supplier';
private const SUPPLIER_KEYS = [
'business_number',
'company_name',
'representative_name',
'address',
'business_type',
'business_item',
'contact_name',
'contact_phone',
'contact_email',
];
public function __construct(
private TaxInvoiceService $taxInvoiceService,
private JournalSyncService $journalSyncService,
private TenantSettingService $tenantSettingService,
) {}
/**
@@ -125,6 +141,48 @@ public function summary(TaxInvoiceSummaryRequest $request)
}, __('message.fetched'));
}
// =========================================================================
// 공급자 설정 (Supplier Settings)
// =========================================================================
/**
* 공급자 설정 조회
*/
public function getSupplierSettings(): JsonResponse
{
return ApiResponse::handle(function () {
$settings = $this->tenantSettingService->getByGroup(self::SUPPLIER_GROUP);
$result = [];
foreach (self::SUPPLIER_KEYS as $key) {
$result[$key] = $settings[$key] ?? null;
}
return $result;
}, __('message.fetched'));
}
/**
* 공급자 설정 저장
*/
public function saveSupplierSettings(Request $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
$data = $request->only(self::SUPPLIER_KEYS);
$settings = [];
foreach ($data as $key => $value) {
if (in_array($key, self::SUPPLIER_KEYS)) {
$settings[$key] = $value;
}
}
$this->tenantSettingService->setMany(self::SUPPLIER_GROUP, $settings);
return $settings;
}, __('message.updated'));
}
// =========================================================================
// 분개 (Journal Entries)
// =========================================================================

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\Finance\StoreVehiclePhotoRequest;
use App\Services\Finance\VehiclePhotoService;
use Illuminate\Http\JsonResponse;
class VehiclePhotoController extends Controller
{
public function __construct(private readonly VehiclePhotoService $service) {}
public function index(int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->index($id),
__('message.fetched')
);
}
public function store(StoreVehiclePhotoRequest $request, int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->store($id, $request->file('files')),
__('message.created')
);
}
public function destroy(int $id, int $fileId): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->destroy($id, $fileId),
__('message.deleted')
);
}
}

View File

@@ -84,10 +84,10 @@ public function resetInspection(Request $request): JsonResponse
);
}
public function templates(int $id): JsonResponse
public function templates(Request $request, int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->getActiveCycles($id),
fn () => $this->service->getTemplatesByEquipment($id, $request->input('cycle')),
__('message.fetched')
);
}

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Http\Controllers\V1\Vehicle;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Services\Vehicle\CorporateVehicleService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class CorporateVehicleController extends Controller
{
public function __construct(private readonly CorporateVehicleService $service) {}
public function index(Request $request): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->index($request->only([
'search', 'ownership_type', 'status', 'per_page',
])),
__('message.fetched')
);
}
public function show(int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->show($id),
__('message.fetched')
);
}
public function store(Request $request): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->store($request->all()),
__('message.created')
);
}
public function update(Request $request, int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->update($id, $request->all()),
__('message.updated')
);
}
public function destroy(int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->destroy($id),
__('message.deleted')
);
}
public function dropdown(): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->dropdown(),
__('message.fetched')
);
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Http\Controllers\V1\Vehicle;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Services\Vehicle\VehicleLogService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class VehicleLogController extends Controller
{
public function __construct(private readonly VehicleLogService $service) {}
public function index(Request $request): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->index($request->only([
'search', 'vehicle_id', 'year', 'month', 'trip_type', 'per_page',
])),
__('message.fetched')
);
}
public function show(int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->show($id),
__('message.fetched')
);
}
public function store(Request $request): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->store($request->all()),
__('message.created')
);
}
public function update(Request $request, int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->update($id, $request->all()),
__('message.updated')
);
}
public function destroy(int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->destroy($id),
__('message.deleted')
);
}
public function summary(Request $request): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->summary($request->only([
'vehicle_id', 'year', 'month',
])),
__('message.fetched')
);
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Http\Controllers\V1\Vehicle;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Services\Vehicle\VehicleMaintenanceService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class VehicleMaintenanceController extends Controller
{
public function __construct(private readonly VehicleMaintenanceService $service) {}
public function index(Request $request): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->index($request->only([
'search', 'vehicle_id', 'category', 'start_date', 'end_date', 'per_page',
])),
__('message.fetched')
);
}
public function show(int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->show($id),
__('message.fetched')
);
}
public function store(Request $request): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->store($request->all()),
__('message.created')
);
}
public function update(Request $request, int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->update($id, $request->all()),
__('message.updated')
);
}
public function destroy(int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->destroy($id),
__('message.deleted')
);
}
}

View File

@@ -22,11 +22,13 @@ public function handle(Request $request, Closure $next)
'api/v1/refresh',
'api/v1/debug-apikey',
'api/v1/internal/*', // 내부 서버간 통신 (HMAC 인증 사용)
'api-docs', // Swagger UI
'api-docs', // Swagger UI (정적)
'api-docs/*', // Swagger 하위 경로
'docs', // L5-Swagger UI
'docs/*', // L5-Swagger 하위 경로 (에셋 등)
'docs/api-docs.json', // Swagger JSON
'docs/api-docs.json', // Swagger JSON (기본)
'docs/api-docs-v1.json', // Swagger JSON (v1)
'api/documentation/*', // L5-Swagger v1 문서
'up', // Health check
];
@@ -89,6 +91,7 @@ public function handle(Request $request, Closure $next)
// Bearer 인증 (Sanctum)
$user = [];
$accessToken = null;
if ($token = $request->bearerToken()) {
$accessToken = PersonalAccessToken::findToken($token);
if ($accessToken && $accessToken->tokenable instanceof User) {
@@ -114,6 +117,21 @@ public function handle(Request $request, Closure $next)
}
}
// MNG 내부 통신: X-TENANT-ID 헤더로 테넌트 컨텍스트 설정
$headerTenantId = $request->header('X-TENANT-ID');
if ($headerTenantId && $validApiKey) {
if ($accessToken && $accessToken->name === 'mng_session') {
// Bearer 토큰(mng_session)이 있으면 테넌트 컨텍스트 재설정
$overrideTenantId = (int) $headerTenantId;
$request->attributes->set('tenant_id', $overrideTenantId);
app()->instance('tenant_id', $overrideTenantId);
} elseif (! app()->bound('tenant_id')) {
// Bearer 토큰 없이 API Key + X-TENANT-ID만 있으면 tenant 컨텍스트만 설정
$request->attributes->set('tenant_id', (int) $headerTenantId);
app()->instance('tenant_id', (int) $headerTenantId);
}
}
// 화이트리스트(인증 예외 라우트) - Bearer 토큰 없이 접근 가능
$allowWithoutAuth = [
'api/v1/login',

View File

@@ -0,0 +1,93 @@
<?php
namespace App\Http\Middleware;
use App\Models\Tenants\Tenant;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* 데모 테넌트 제한 미들웨어
*
* - DEMO_SHOWCASE: 모든 쓰기 작업 차단 (읽기 전용)
* - DEMO_PARTNER / DEMO_TRIAL: 만료 체크 + 차단 기능 체크
*
* 기존 코드 영향 없음: 프로덕션 테넌트(STD/TPL/HQ)는 즉시 통과
*
* @see docs/features/sales/demo-tenant-policy.md
*/
class DemoLimitMiddleware
{
/**
* 데모에서 차단하는 라우트 프리픽스 (외부 시스템 연동)
*/
private const BLOCKED_ROUTE_PREFIXES = [
'api/v1/barobill',
'api/v1/ecount',
];
public function handle(Request $request, Closure $next): Response
{
$tenantId = app('tenant_id');
if (! $tenantId) {
return $next($request);
}
$tenant = Tenant::withoutGlobalScopes()->find($tenantId);
if (! $tenant || ! $tenant->isDemoTenant()) {
// 프로덕션 테넌트 → 즉시 통과 (기존 동작 유지)
return $next($request);
}
// 1. 만료 체크 (파트너 데모, 고객 체험)
if ($tenant->isDemoExpired()) {
return response()->json([
'success' => false,
'message' => '체험 기간이 만료되었습니다. 정식 계약을 진행해 주세요.',
'error_code' => 'DEMO_EXPIRED',
], 403);
}
// 2. 쇼케이스 → 읽기 전용 (GET, HEAD, OPTIONS만 허용)
if ($tenant->isDemoShowcase() && ! $request->isMethodSafe()) {
return response()->json([
'success' => false,
'message' => '데모 환경에서는 조회만 가능합니다.',
'error_code' => 'DEMO_READ_ONLY',
], 403);
}
// 3. 읽기전용 옵션이 설정된 데모 테넌트
if ($tenant->isDemoReadOnly() && ! $request->isMethodSafe()) {
return response()->json([
'success' => false,
'message' => '데모 환경에서는 조회만 가능합니다.',
'error_code' => 'DEMO_READ_ONLY',
], 403);
}
// 4. 차단 기능 체크 (바로빌, 이카운트 등 외부 연동)
if ($this->isBlockedRoute($request)) {
return response()->json([
'success' => false,
'message' => '데모 환경에서 사용할 수 없는 기능입니다. 정식 계약 후 이용 가능합니다.',
'error_code' => 'DEMO_FEATURE_BLOCKED',
], 403);
}
return $next($request);
}
private function isBlockedRoute(Request $request): bool
{
$path = $request->path();
foreach (self::BLOCKED_ROUTE_PREFIXES as $prefix) {
if (str_starts_with($path, $prefix)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Http\Requests\Api\V1;
use Illuminate\Foundation\Http\FormRequest;
class BendingItemIndexRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'item_sep' => 'nullable|string|in:스크린,철재',
'item_bending' => 'nullable|string',
'material' => 'nullable|string',
'model_UA' => 'nullable|string|in:인정,비인정',
'model_name' => 'nullable|string',
'legacy_bending_num' => 'nullable|integer',
'search' => 'nullable|string|max:100',
'page' => 'nullable|integer|min:1',
'size' => 'nullable|integer|min:1|max:200',
];
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Http\Requests\Api\V1;
use Illuminate\Foundation\Http\FormRequest;
class BendingItemStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'code' => [
'required', 'string', 'max:50',
\Illuminate\Validation\Rule::unique('bending_items', 'code')->where('tenant_id', request()->header('X-TENANT-ID', app()->bound('tenant_id') ? app('tenant_id') : 1)),
],
'item_name' => 'required|string|max:50',
'item_sep' => 'required|in:스크린,철재',
'item_bending' => 'required|string|max:50',
'material' => 'required|string|max:50',
'model_UA' => 'nullable|in:인정,비인정',
'item_spec' => 'nullable|string|max:50',
'model_name' => 'nullable|string|max:30',
'search_keyword' => 'nullable|string|max:100',
'rail_width' => 'nullable|integer',
'memo' => 'nullable|string|max:500',
'author' => 'nullable|string|max:50',
'registration_date' => 'nullable|date',
// 케이스 전용
'exit_direction' => 'nullable|string|max:30',
'front_bottom_width' => 'nullable|integer',
'box_width' => 'nullable|integer',
'box_height' => 'nullable|integer',
// 전개도
'bendingData' => 'nullable|array',
'bendingData.*.no' => 'required|integer',
'bendingData.*.input' => 'required|numeric',
'bendingData.*.rate' => 'nullable|string',
'bendingData.*.sum' => 'required|numeric',
'bendingData.*.color' => 'required|boolean',
'bendingData.*.aAngle' => 'required|boolean',
];
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Http\Requests\Api\V1;
use Illuminate\Foundation\Http\FormRequest;
class BendingItemUpdateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'code' => 'sometimes|string|max:100',
'name' => 'sometimes|string|max:200',
'item_name' => 'sometimes|string|max:50',
'item_sep' => 'sometimes|in:스크린,철재',
'item_bending' => 'sometimes|string|max:50',
'material' => 'sometimes|string|max:50',
'model_UA' => 'nullable|in:인정,비인정',
'item_spec' => 'nullable|string|max:50',
'model_name' => 'nullable|string|max:30',
'search_keyword' => 'nullable|string|max:100',
'rail_width' => 'nullable|integer',
'memo' => 'nullable|string|max:500',
'author' => 'nullable|string|max:50',
'registration_date' => 'nullable|date',
// 케이스 전용
'exit_direction' => 'nullable|string|max:30',
'front_bottom_width' => 'nullable|integer',
'box_width' => 'nullable|integer',
'box_height' => 'nullable|integer',
// 전개도
'bendingData' => 'nullable|array',
'bendingData.*.no' => 'required|integer',
'bendingData.*.input' => 'required|numeric',
'bendingData.*.rate' => 'nullable|string',
'bendingData.*.sum' => 'required|numeric',
'bendingData.*.color' => 'required|boolean',
'bendingData.*.aAngle' => 'required|boolean',
];
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Http\Requests\Demo;
use Illuminate\Foundation\Http\FormRequest;
class DemoTenantStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'company_name' => 'required|string|max:100',
'email' => 'required|email|max:255',
'duration_days' => 'sometimes|integer|min:7|max:60',
'preset' => 'sometimes|string|in:manufacturing',
];
}
public function messages(): array
{
return [
'company_name.required' => '회사명은 필수입니다.',
'email.required' => '이메일은 필수입니다.',
'email.email' => '올바른 이메일 형식이 아닙니다.',
'duration_days.min' => '체험 기간은 최소 7일입니다.',
'duration_days.max' => '체험 기간은 최대 60일입니다.',
'preset.in' => '유효하지 않은 프리셋입니다.',
];
}
}

View File

@@ -30,8 +30,8 @@ public function rules(): array
'data.*.field_key' => 'required_with:data|string|max:100',
'data.*.field_value' => 'nullable|string',
// HTML 스냅샷
'rendered_html' => 'nullable|string',
// HTML 스냅샷 (500KB 제한 — 초과 시 413 대신 422 반환)
'rendered_html' => 'nullable|string|max:512000',
// 첨부파일
'attachments' => 'nullable|array',
@@ -49,6 +49,7 @@ public function messages(): array
'item_id.required' => __('validation.required', ['attribute' => '품목 ID']),
'data.*.field_key.required_with' => __('validation.required', ['attribute' => '필드 키']),
'attachments.*.file_id.exists' => __('validation.exists', ['attribute' => '파일']),
'rendered_html.max' => 'HTML 스냅샷이 너무 큽니다. (최대 500KB)',
];
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Http\Requests\Finance;
use App\Models\Commons\File;
use Illuminate\Foundation\Http\FormRequest;
class StoreVehiclePhotoRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$vehicleId = $this->route('id');
$currentCount = File::where('document_id', $vehicleId)
->where('document_type', 'corporate_vehicle')
->whereNull('deleted_at')
->count();
$maxFiles = 10 - $currentCount;
return [
'files' => ['required', 'array', 'min:1', "max:{$maxFiles}"],
'files.*' => [
'required',
'file',
'mimes:jpg,jpeg,png,gif,bmp,webp',
'max:10240', // 10MB
],
];
}
public function attributes(): array
{
return [
'files' => '사진 파일',
'files.*' => '사진 파일',
];
}
public function messages(): array
{
return [
'files.required' => __('error.file.required'),
'files.max' => __('error.vehicle.photo_limit_exceeded'),
'files.*.mimes' => __('error.file.invalid_type'),
'files.*.max' => __('error.file.size_exceeded'),
];
}
}

View File

@@ -16,7 +16,7 @@ public function rules(): array
return [
'group_id' => 'nullable|integer',
'field_name' => 'required|string|max:255',
'field_key' => 'nullable|string|max:80|regex:/^[a-zA-Z][a-zA-Z0-9_]*$/',
'field_key' => 'nullable|string|max:80|regex:/^[a-zA-Z0-9][a-zA-Z0-9_]*$/',
'field_type' => 'required|in:textbox,number,dropdown,checkbox,date,textarea',
'is_required' => 'nullable|boolean',
'default_value' => 'nullable|string',

View File

@@ -16,7 +16,7 @@ public function rules(): array
return [
'group_id' => 'nullable|integer|min:1', // 계층번호
'field_name' => 'required|string|max:255',
'field_key' => 'nullable|string|max:80|regex:/^[a-zA-Z][a-zA-Z0-9_]*$/',
'field_key' => 'nullable|string|max:80|regex:/^[a-zA-Z0-9][a-zA-Z0-9_]*$/',
'field_type' => 'required|in:textbox,number,dropdown,checkbox,date,textarea',
'is_required' => 'nullable|boolean',
'default_value' => 'nullable|string',

View File

@@ -15,7 +15,7 @@ public function rules(): array
{
return [
'field_name' => 'sometimes|string|max:255',
'field_key' => 'nullable|string|max:80|regex:/^[a-zA-Z][a-zA-Z0-9_]*$/',
'field_key' => 'nullable|string|max:80|regex:/^[a-zA-Z0-9][a-zA-Z0-9_]*$/',
'field_type' => 'sometimes|in:textbox,number,dropdown,checkbox,date,textarea',
'is_required' => 'nullable|boolean',
'default_value' => 'nullable|string',

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Http\Requests\Material;
use Illuminate\Foundation\Http\FormRequest;
class StoreNonconformingReportRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'nc_type' => 'required|string|in:material,process,construction,other',
'occurred_at' => 'required|date',
'confirmed_at' => 'nullable|date',
'site_name' => 'nullable|string|max:100',
'department_id' => 'nullable|integer|exists:departments,id',
'order_id' => 'nullable|integer|exists:orders,id',
'item_id' => 'nullable|integer|exists:items,id',
'defect_quantity' => 'nullable|numeric|min:0',
'unit' => 'nullable|string|max:20',
'defect_description' => 'nullable|string',
'cause_analysis' => 'nullable|string',
'corrective_action' => 'nullable|string',
'action_completed_at' => 'nullable|date',
'action_manager_id' => 'nullable|integer',
'related_employee_id' => 'nullable|integer',
'material_cost' => 'nullable|integer|min:0',
'shipping_cost' => 'nullable|integer|min:0',
'construction_cost' => 'nullable|integer|min:0',
'other_cost' => 'nullable|integer|min:0',
'remarks' => 'nullable|string',
'drawing_location' => 'nullable|string|max:255',
// 자재 상세 내역
'items' => 'nullable|array',
'items.*.item_id' => 'nullable|integer',
'items.*.item_name' => 'required_with:items|string|max:100',
'items.*.specification' => 'nullable|string|max:100',
'items.*.quantity' => 'nullable|numeric|min:0',
'items.*.unit_price' => 'nullable|integer|min:0',
'items.*.remarks' => 'nullable|string|max:255',
];
}
public function messages(): array
{
return [
'nc_type.required' => __('error.nonconforming.nc_type_required'),
'nc_type.in' => __('error.nonconforming.nc_type_invalid'),
'occurred_at.required' => __('error.nonconforming.occurred_at_required'),
];
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Http\Requests\Material;
use Illuminate\Foundation\Http\FormRequest;
class UpdateNonconformingReportRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'nc_type' => 'sometimes|string|in:material,process,construction,other',
'occurred_at' => 'sometimes|date',
'confirmed_at' => 'nullable|date',
'site_name' => 'nullable|string|max:100',
'department_id' => 'nullable|integer|exists:departments,id',
'order_id' => 'nullable|integer|exists:orders,id',
'item_id' => 'nullable|integer|exists:items,id',
'defect_quantity' => 'nullable|numeric|min:0',
'unit' => 'nullable|string|max:20',
'defect_description' => 'nullable|string',
'cause_analysis' => 'nullable|string',
'corrective_action' => 'nullable|string',
'action_completed_at' => 'nullable|date',
'action_manager_id' => 'nullable|integer',
'related_employee_id' => 'nullable|integer',
'material_cost' => 'nullable|integer|min:0',
'shipping_cost' => 'nullable|integer|min:0',
'construction_cost' => 'nullable|integer|min:0',
'other_cost' => 'nullable|integer|min:0',
'remarks' => 'nullable|string',
'drawing_location' => 'nullable|string|max:255',
'status' => 'sometimes|string|in:RECEIVED,ANALYZING,RESOLVED,CLOSED',
// 자재 상세 내역
'items' => 'nullable|array',
'items.*.id' => 'nullable|integer',
'items.*.item_id' => 'nullable|integer',
'items.*.item_name' => 'required_with:items|string|max:100',
'items.*.specification' => 'nullable|string|max:100',
'items.*.quantity' => 'nullable|numeric|min:0',
'items.*.unit_price' => 'nullable|integer|min:0',
'items.*.remarks' => 'nullable|string|max:255',
];
}
}

View File

@@ -25,75 +25,93 @@ public function rules(): array
'notice.notice' => ['sometimes', 'array'],
'notice.notice.enabled' => ['sometimes', 'boolean'],
'notice.notice.email' => ['sometimes', 'boolean'],
'notice.notice.soundType' => ['sometimes', 'string', 'in:default,sam_voice,mute'],
'notice.event' => ['sometimes', 'array'],
'notice.event.enabled' => ['sometimes', 'boolean'],
'notice.event.email' => ['sometimes', 'boolean'],
'notice.event.soundType' => ['sometimes', 'string', 'in:default,sam_voice,mute'],
'schedule' => ['sometimes', 'array'],
'schedule.enabled' => ['sometimes', 'boolean'],
'schedule.vatReport' => ['sometimes', 'array'],
'schedule.vatReport.enabled' => ['sometimes', 'boolean'],
'schedule.vatReport.email' => ['sometimes', 'boolean'],
'schedule.vatReport.soundType' => ['sometimes', 'string', 'in:default,sam_voice,mute'],
'schedule.incomeTaxReport' => ['sometimes', 'array'],
'schedule.incomeTaxReport.enabled' => ['sometimes', 'boolean'],
'schedule.incomeTaxReport.email' => ['sometimes', 'boolean'],
'schedule.incomeTaxReport.soundType' => ['sometimes', 'string', 'in:default,sam_voice,mute'],
'vendor' => ['sometimes', 'array'],
'vendor.enabled' => ['sometimes', 'boolean'],
'vendor.newVendor' => ['sometimes', 'array'],
'vendor.newVendor.enabled' => ['sometimes', 'boolean'],
'vendor.newVendor.email' => ['sometimes', 'boolean'],
'vendor.newVendor.soundType' => ['sometimes', 'string', 'in:default,sam_voice,mute'],
'vendor.creditRating' => ['sometimes', 'array'],
'vendor.creditRating.enabled' => ['sometimes', 'boolean'],
'vendor.creditRating.email' => ['sometimes', 'boolean'],
'vendor.creditRating.soundType' => ['sometimes', 'string', 'in:default,sam_voice,mute'],
'attendance' => ['sometimes', 'array'],
'attendance.enabled' => ['sometimes', 'boolean'],
'attendance.annualLeave' => ['sometimes', 'array'],
'attendance.annualLeave.enabled' => ['sometimes', 'boolean'],
'attendance.annualLeave.email' => ['sometimes', 'boolean'],
'attendance.annualLeave.soundType' => ['sometimes', 'string', 'in:default,sam_voice,mute'],
'attendance.clockIn' => ['sometimes', 'array'],
'attendance.clockIn.enabled' => ['sometimes', 'boolean'],
'attendance.clockIn.email' => ['sometimes', 'boolean'],
'attendance.clockIn.soundType' => ['sometimes', 'string', 'in:default,sam_voice,mute'],
'attendance.late' => ['sometimes', 'array'],
'attendance.late.enabled' => ['sometimes', 'boolean'],
'attendance.late.email' => ['sometimes', 'boolean'],
'attendance.late.soundType' => ['sometimes', 'string', 'in:default,sam_voice,mute'],
'attendance.absent' => ['sometimes', 'array'],
'attendance.absent.enabled' => ['sometimes', 'boolean'],
'attendance.absent.email' => ['sometimes', 'boolean'],
'attendance.absent.soundType' => ['sometimes', 'string', 'in:default,sam_voice,mute'],
'order' => ['sometimes', 'array'],
'order.enabled' => ['sometimes', 'boolean'],
'order.salesOrder' => ['sometimes', 'array'],
'order.salesOrder.enabled' => ['sometimes', 'boolean'],
'order.salesOrder.email' => ['sometimes', 'boolean'],
'order.salesOrder.soundType' => ['sometimes', 'string', 'in:default,sam_voice,mute'],
'order.purchaseOrder' => ['sometimes', 'array'],
'order.purchaseOrder.enabled' => ['sometimes', 'boolean'],
'order.purchaseOrder.email' => ['sometimes', 'boolean'],
'order.purchaseOrder.soundType' => ['sometimes', 'string', 'in:default,sam_voice,mute'],
'approval' => ['sometimes', 'array'],
'approval.enabled' => ['sometimes', 'boolean'],
'approval.approvalRequest' => ['sometimes', 'array'],
'approval.approvalRequest.enabled' => ['sometimes', 'boolean'],
'approval.approvalRequest.email' => ['sometimes', 'boolean'],
'approval.approvalRequest.soundType' => ['sometimes', 'string', 'in:default,sam_voice,mute'],
'approval.draftApproved' => ['sometimes', 'array'],
'approval.draftApproved.enabled' => ['sometimes', 'boolean'],
'approval.draftApproved.email' => ['sometimes', 'boolean'],
'approval.draftApproved.soundType' => ['sometimes', 'string', 'in:default,sam_voice,mute'],
'approval.draftRejected' => ['sometimes', 'array'],
'approval.draftRejected.enabled' => ['sometimes', 'boolean'],
'approval.draftRejected.email' => ['sometimes', 'boolean'],
'approval.draftRejected.soundType' => ['sometimes', 'string', 'in:default,sam_voice,mute'],
'approval.draftCompleted' => ['sometimes', 'array'],
'approval.draftCompleted.enabled' => ['sometimes', 'boolean'],
'approval.draftCompleted.email' => ['sometimes', 'boolean'],
'approval.draftCompleted.soundType' => ['sometimes', 'string', 'in:default,sam_voice,mute'],
'production' => ['sometimes', 'array'],
'production.enabled' => ['sometimes', 'boolean'],
'production.safetyStock' => ['sometimes', 'array'],
'production.safetyStock.enabled' => ['sometimes', 'boolean'],
'production.safetyStock.email' => ['sometimes', 'boolean'],
'production.safetyStock.soundType' => ['sometimes', 'string', 'in:default,sam_voice,mute'],
'production.productionComplete' => ['sometimes', 'array'],
'production.productionComplete.enabled' => ['sometimes', 'boolean'],
'production.productionComplete.email' => ['sometimes', 'boolean'],
'production.productionComplete.soundType' => ['sometimes', 'string', 'in:default,sam_voice,mute'],
];
}
}

View File

@@ -16,6 +16,13 @@ public function rules(): array
return [
'delivery_date' => 'nullable|date',
'memo' => 'nullable|string',
'delivery_method_code' => 'nullable|string',
'options' => 'nullable|array',
'options.receiver' => 'nullable|string',
'options.receiver_contact' => 'nullable|string',
'options.shipping_address' => 'nullable|string',
'options.shipping_address_detail' => 'nullable|string',
'options.shipping_cost_code' => 'nullable|string',
];
}

View File

@@ -18,7 +18,7 @@ public function rules(): array
return [
// 기본 정보
'quote_id' => 'nullable|integer|exists:quotes,id',
'order_type_code' => ['nullable', Rule::in([Order::TYPE_ORDER, Order::TYPE_PURCHASE])],
'order_type_code' => ['nullable', Rule::in([Order::TYPE_ORDER, Order::TYPE_PURCHASE, Order::TYPE_STOCK])],
'status_code' => ['nullable', Rule::in([
Order::STATUS_DRAFT,
Order::STATUS_CONFIRMED,
@@ -55,6 +55,18 @@ public function rules(): array
'options.shipping_address' => 'nullable|string|max:500',
'options.shipping_address_detail' => 'nullable|string|max:500',
'options.manager_name' => 'nullable|string|max:100',
'options.production_reason' => 'nullable|string|max:500',
'options.target_stock_qty' => 'nullable|numeric|min:0',
// 절곡품 LOT 정보 (STOCK 전용)
'options.bending_lot' => 'nullable|array',
'options.bending_lot.lot_number' => 'nullable|string|max:30',
'options.bending_lot.prod_code' => 'nullable|string|max:2',
'options.bending_lot.spec_code' => 'nullable|string|max:2',
'options.bending_lot.length_code' => 'nullable|string|max:2',
'options.bending_lot.raw_lot_no' => 'nullable|string|max:50',
'options.bending_lot.fabric_lot_no' => 'nullable|string|max:50',
'options.bending_lot.material' => 'nullable|string|max:50',
// 품목 배열
'items' => 'nullable|array',

View File

@@ -17,7 +17,7 @@ public function rules(): array
{
return [
// 기본 정보 (order_no는 수정 불가)
'order_type_code' => ['nullable', Rule::in([Order::TYPE_ORDER, Order::TYPE_PURCHASE])],
'order_type_code' => ['nullable', Rule::in([Order::TYPE_ORDER, Order::TYPE_PURCHASE, Order::TYPE_STOCK])],
'category_code' => 'nullable|string|max:50',
// 거래처 정보
@@ -49,12 +49,24 @@ public function rules(): array
'options.shipping_address' => 'nullable|string|max:500',
'options.shipping_address_detail' => 'nullable|string|max:500',
'options.manager_name' => 'nullable|string|max:100',
'options.production_reason' => 'nullable|string|max:500',
'options.target_stock_qty' => 'nullable|numeric|min:0',
// 절곡품 LOT 정보 (STOCK 전용)
'options.bending_lot' => 'nullable|array',
'options.bending_lot.lot_number' => 'nullable|string|max:30',
'options.bending_lot.prod_code' => 'nullable|string|max:2',
'options.bending_lot.spec_code' => 'nullable|string|max:2',
'options.bending_lot.length_code' => 'nullable|string|max:2',
'options.bending_lot.raw_lot_no' => 'nullable|string|max:50',
'options.bending_lot.fabric_lot_no' => 'nullable|string|max:50',
'options.bending_lot.material' => 'nullable|string|max:50',
// 품목 배열 (전체 교체)
'items' => 'nullable|array',
'items.*.item_id' => 'nullable|integer|exists:items,id',
'items.*.item_code' => 'nullable|string|max:50',
'items.*.item_name' => 'required|string|max:200',
'items.*.item_name' => 'sometimes|required|string|max:200',
'items.*.specification' => 'nullable|string|max:500',
'items.*.quantity' => 'required|numeric|min:0',
'items.*.unit' => 'nullable|string|max:20',

View File

@@ -41,17 +41,6 @@ public function rules(): array
'loading_manager' => 'nullable|string|max:50',
'loading_time' => 'nullable|date',
// 물류/배차 정보
'logistics_company' => 'nullable|string|max:50',
'vehicle_tonnage' => 'nullable|string|max:20',
'shipping_cost' => 'nullable|numeric|min:0',
// 차량/운전자 정보
'vehicle_no' => 'nullable|string|max:20',
'driver_name' => 'nullable|string|max:50',
'driver_contact' => 'nullable|string|max:50',
'expected_arrival' => 'nullable|date',
// 기타
'remarks' => 'nullable|string',

View File

@@ -39,17 +39,6 @@ public function rules(): array
'loading_manager' => 'nullable|string|max:50',
'loading_time' => 'nullable|date',
// 물류/배차 정보
'logistics_company' => 'nullable|string|max:50',
'vehicle_tonnage' => 'nullable|string|max:20',
'shipping_cost' => 'nullable|numeric|min:0',
// 차량/운전자 정보
'vehicle_no' => 'nullable|string|max:20',
'driver_name' => 'nullable|string|max:50',
'driver_contact' => 'nullable|string|max:50',
'expected_arrival' => 'nullable|date',
// 기타
'remarks' => 'nullable|string',

View File

@@ -3,6 +3,7 @@
namespace App\Http\Requests\User;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class SwitchTenantRequest extends FormRequest
{
@@ -13,8 +14,23 @@ public function authorize(): bool
public function rules(): array
{
$userId = app('api_user');
return [
'tenant_id' => 'required|integer|exists:tenants,id',
'tenant_id' => [
'required',
'integer',
Rule::exists('user_tenants', 'tenant_id')
->where('user_id', $userId)
->where('is_active', 1),
],
];
}
public function messages(): array
{
return [
'tenant_id.exists' => __('error.tenant_access_denied'),
];
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Http\Requests\V1;
use Illuminate\Foundation\Http\FormRequest;
class AiTokenUsageListRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'start_date' => 'nullable|date',
'end_date' => 'nullable|date|after_or_equal:start_date',
'menu_name' => 'nullable|string|max:100',
'per_page' => 'nullable|integer|min:10|max:100',
'page' => 'nullable|integer|min:1',
];
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Http\Requests\V1\CondolenceExpense;
use Illuminate\Foundation\Http\FormRequest;
class StoreCondolenceExpenseRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'event_date' => ['nullable', 'date'],
'expense_date' => ['nullable', 'date'],
'partner_name' => ['required', 'string', 'max:100'],
'description' => ['nullable', 'string', 'max:200'],
'category' => ['required', 'string', 'in:congratulation,condolence'],
'has_cash' => ['nullable', 'boolean'],
'cash_method' => ['required_if:has_cash,true', 'nullable', 'string', 'in:cash,transfer,card'],
'cash_amount' => ['required_if:has_cash,true', 'nullable', 'integer', 'min:0'],
'has_gift' => ['nullable', 'boolean'],
'gift_type' => ['nullable', 'string', 'max:50'],
'gift_amount' => ['required_if:has_gift,true', 'nullable', 'integer', 'min:0'],
'memo' => ['nullable', 'string'],
];
}
public function attributes(): array
{
return [
'event_date' => '경조사일자',
'expense_date' => '지출일자',
'partner_name' => '거래처명',
'description' => '내역',
'category' => '구분',
'has_cash' => '부조금 여부',
'cash_method' => '지출방법',
'cash_amount' => '부조금액',
'has_gift' => '선물 여부',
'gift_type' => '선물종류',
'gift_amount' => '선물금액',
'memo' => '비고',
];
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Http\Requests\V1\CondolenceExpense;
use Illuminate\Foundation\Http\FormRequest;
class UpdateCondolenceExpenseRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'event_date' => ['nullable', 'date'],
'expense_date' => ['nullable', 'date'],
'partner_name' => ['sometimes', 'required', 'string', 'max:100'],
'description' => ['nullable', 'string', 'max:200'],
'category' => ['sometimes', 'required', 'string', 'in:congratulation,condolence'],
'has_cash' => ['nullable', 'boolean'],
'cash_method' => ['required_if:has_cash,true', 'nullable', 'string', 'in:cash,transfer,card'],
'cash_amount' => ['required_if:has_cash,true', 'nullable', 'integer', 'min:0'],
'has_gift' => ['nullable', 'boolean'],
'gift_type' => ['nullable', 'string', 'max:50'],
'gift_amount' => ['required_if:has_gift,true', 'nullable', 'integer', 'min:0'],
'memo' => ['nullable', 'string'],
];
}
public function attributes(): array
{
return [
'event_date' => '경조사일자',
'expense_date' => '지출일자',
'partner_name' => '거래처명',
'description' => '내역',
'category' => '구분',
'has_cash' => '부조금 여부',
'cash_method' => '지출방법',
'cash_amount' => '부조금액',
'has_gift' => '선물 여부',
'gift_type' => '선물종류',
'gift_amount' => '선물금액',
'memo' => '비고',
];
}
}

View File

@@ -16,6 +16,7 @@ public function rules(): array
return [
'journal_date' => ['required', 'date'],
'description' => ['nullable', 'string', 'max:500'],
'receipt_no' => ['nullable', 'string', 'max:100'],
'rows' => ['required', 'array', 'min:2'],
'rows.*.side' => ['required', 'in:debit,credit'],
'rows.*.account_subject_id' => ['required', 'string', 'max:10'],

View File

@@ -15,6 +15,7 @@ public function rules(): array
{
return [
'journal_memo' => ['sometimes', 'nullable', 'string', 'max:1000'],
'receipt_no' => ['nullable', 'string', 'max:100'],
'rows' => ['sometimes', 'array', 'min:1'],
'rows.*.side' => ['required_with:rows', 'in:debit,credit'],
'rows.*.account_subject_id' => ['required_with:rows', 'string', 'max:10'],

View File

@@ -21,7 +21,7 @@ public function rules(): array
'item_name' => ['required', 'string', 'max:200'],
'specification' => ['nullable', 'string', 'max:200'],
'supplier' => ['required', 'string', 'max:100'],
'order_qty' => ['nullable', 'numeric', 'min:0'],
'order_qty' => ['required', 'numeric', 'min:0.01'],
'order_unit' => ['nullable', 'string', 'max:20'],
'due_date' => ['nullable', 'date'],
'receiving_qty' => ['nullable', 'numeric', 'min:0'],

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Http\Requests\V1\Stock;
use Illuminate\Foundation\Http\FormRequest;
class StoreStockAdjustmentRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'quantity' => ['required', 'numeric', 'not_in:0'],
'remark' => ['nullable', 'string', 'max:500'],
];
}
public function messages(): array
{
return [
'quantity.required' => __('error.stock.adjustment_qty_required'),
'quantity.not_in' => __('error.stock.adjustment_qty_zero'),
];
}
}

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Http\Resources\Api\V1;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class BendingItemResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'code' => $this->code,
'legacy_code' => $this->legacy_code,
// 정규 컬럼 직접 참조
'item_name' => $this->item_name,
'item_sep' => $this->item_sep,
'item_bending' => $this->item_bending,
'item_spec' => $this->item_spec,
'material' => $this->material,
'model_name' => $this->model_name,
'model_UA' => $this->model_UA,
'rail_width' => $this->rail_width ? (int) $this->rail_width : null,
// 케이스 전용
'exit_direction' => $this->exit_direction,
'front_bottom' => $this->front_bottom ? (int) $this->front_bottom : null,
'box_width' => $this->box_width ? (int) $this->box_width : null,
'box_height' => $this->box_height ? (int) $this->box_height : null,
'inspection_door' => $this->inspection_door,
// 원자재 길이
'length_code' => $this->length_code,
'length_mm' => $this->length_mm,
// 전개도 (JSON 컬럼)
'bendingData' => $this->bending_data,
// 비정형 속성 (options)
'search_keyword' => $this->getOption('search_keyword'),
'author' => $this->getOption('author'),
'memo' => $this->getOption('memo'),
'registration_date' => $this->getOption('registration_date'),
// 이미지
'image_file_id' => $this->getImageFileId(),
'image_url' => $this->getImageUrl(),
// 추적
'legacy_bending_id' => $this->legacy_bending_id,
'legacy_bending_num' => $this->legacy_bending_id, // MNG2 호환
'modified_by' => $this->getOption('modified_by'),
// MNG2 호환 (items 기반 필드명)
'name' => $this->item_name,
'front_bottom_width' => $this->front_bottom ? (int) $this->front_bottom : null,
'item_type' => 'PT',
'item_category' => 'BENDING',
'unit' => 'EA',
// 계산값
'width_sum' => $this->width_sum,
'bend_count' => $this->bend_count,
// 메타
'is_active' => $this->is_active,
'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 getImageFile(): ?\App\Models\Commons\File
{
return $this->files()
->where('field_key', 'bending_diagram')
->orderByDesc('id')
->first();
}
private function getImageFileId(): ?int
{
return $this->getImageFile()?->id;
}
private function getImageUrl(): ?string
{
return $this->getImageFile()?->presignedUrl();
}
}

View File

@@ -0,0 +1,127 @@
<?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->components ?? [];
$materialSummary = $this->material_summary;
if (empty($materialSummary) && ! empty($components)) {
$materialSummary = $this->calcMaterialSummary($components);
}
return [
'id' => $this->id,
'code' => $this->code,
'name' => $this->name,
'is_active' => $this->is_active,
// MNG2 호환
'item_type' => 'FG',
'item_category' => $this->model_type,
// 모델 속성 (정규 컬럼)
'model_name' => $this->model_name,
'check_type' => $this->check_type,
'rail_width' => $this->rail_width ? (int) $this->rail_width : null,
'rail_length' => $this->rail_length ? (int) $this->rail_length : null,
'finishing_type' => $this->finishing_type,
'item_sep' => $this->item_sep,
'model_UA' => $this->model_UA,
'search_keyword' => $this->search_keyword,
'author' => $this->author,
'memo' => $this->getOption('memo'),
'registration_date' => $this->registration_date?->format('Y-m-d'),
// 케이스 전용
'exit_direction' => $this->exit_direction,
'front_bottom_width' => $this->front_bottom_width ? (int) $this->front_bottom_width : null,
'box_width' => $this->box_width ? (int) $this->box_width : null,
'box_height' => $this->box_height ? (int) $this->box_height : null,
// 하단마감재 전용
'bar_width' => $this->bar_width ? (int) $this->bar_width : null,
'bar_height' => $this->bar_height ? (int) $this->bar_height : null,
// 수정자
'modified_by' => $this->getOption('modified_by'),
// 이미지
'image_file_id' => $this->getImageFileId(),
'image_url' => $this->getImageUrl(),
// 부품 조합
'components' => $this->enrichComponentsWithImageUrls($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 getImageFile(): ?\App\Models\Commons\File
{
$file = \App\Models\Commons\File::where('document_id', $this->id)
->where('document_type', 'bending_model')
->where('field_key', 'assembly_image')
->whereNull('deleted_at')
->orderByDesc('id')
->first();
if (! $file) {
$file = $this->files()
->where('field_key', 'bending_diagram')
->orderByDesc('id')
->first();
}
return $file;
}
private function getImageFileId(): ?int
{
return $this->getImageFile()?->id;
}
private function getImageUrl(): ?string
{
return $this->getImageFile()?->presignedUrl();
}
private function enrichComponentsWithImageUrls(array $components): array
{
$fileIds = array_filter(array_column($components, 'image_file_id'));
if (empty($fileIds)) {
return $components;
}
$files = \App\Models\Commons\File::whereIn('id', $fileIds)
->whereNull('deleted_at')
->get()
->keyBy('id');
foreach ($components as &$comp) {
$fileId = $comp['image_file_id'] ?? null;
$comp['image_url'] = $fileId && isset($files[$fileId])
? $files[$fileId]->presignedUrl()
: null;
}
unset($comp);
return $components;
}
private function calcMaterialSummary(array $components): array
{
$summary = [];
foreach ($components as $comp) {
$material = $comp['material'] ?? null;
$widthSum = $comp['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,85 @@
<?php
namespace App\Jobs\Barobill;
use App\Models\Barobill\BarobillMember;
use App\Services\Barobill\BarobillBankSyncService;
use App\Services\Barobill\BarobillCardSyncService;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
/**
* 바로빌 데이터 자동 동기화 Job
*
* 스케줄러에서 매일 실행하여 활성 회원의 은행/카드 거래내역을 동기화한다.
*/
class SyncBarobillDataJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $timeout = 300;
public function __construct(
private string $syncType = 'all',
) {}
public function handle(
BarobillBankSyncService $bankSyncService,
BarobillCardSyncService $cardSyncService,
): void {
$members = BarobillMember::withoutGlobalScopes()
->where('status', 'active')
->where('server_mode', 'production')
->get();
if ($members->isEmpty()) {
Log::info('[SyncBarobill] 활성 회원 없음, 스킵');
return;
}
$yesterday = Carbon::yesterday()->format('Ymd');
$today = Carbon::today()->format('Ymd');
foreach ($members as $member) {
try {
if (in_array($this->syncType, ['all', 'bank'])) {
$result = $bankSyncService->syncIfNeeded(
$member->tenant_id,
$yesterday,
$today
);
Log::info('[SyncBarobill] 은행 동기화 완료', [
'tenant_id' => $member->tenant_id,
'result' => $result,
]);
}
if (in_array($this->syncType, ['all', 'card'])) {
$result = $cardSyncService->syncCardTransactions(
$member->tenant_id,
$yesterday,
$today
);
Log::info('[SyncBarobill] 카드 동기화 완료', [
'tenant_id' => $member->tenant_id,
'result' => $result,
]);
}
} catch (\Throwable $e) {
Log::error('[SyncBarobill] 동기화 실패', [
'tenant_id' => $member->tenant_id,
'sync_type' => $this->syncType,
'error' => $e->getMessage(),
]);
}
}
}
}

136
app/Models/BendingItem.php Normal file
View File

@@ -0,0 +1,136 @@
<?php
namespace App\Models;
use App\Models\Commons\File;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* 절곡 기초관리 마스터
*
* code: {제품Code}{종류Code}{YYMMDD} (예: CP260319 = 케이스 점검구)
* bending_data: 전개도 JSON 배열 [{no, input, rate, sum, color, aAngle}]
*/
class BendingItem extends Model
{
use Auditable, BelongsToTenant, SoftDeletes;
protected $table = 'bending_items';
protected $fillable = [
'tenant_id',
'code',
'legacy_code',
'legacy_bending_id',
'item_name',
'item_sep',
'item_bending',
'material',
'item_spec',
'model_name',
'model_UA',
'rail_width',
'exit_direction',
'box_width',
'box_height',
'front_bottom',
'inspection_door',
'length_code',
'length_mm',
'bending_data',
'options',
'is_active',
'created_by',
'updated_by',
];
protected $casts = [
'bending_data' => 'array',
'options' => 'array',
'is_active' => 'boolean',
'rail_width' => 'decimal:2',
'box_width' => 'decimal:2',
'box_height' => 'decimal:2',
'front_bottom' => 'decimal:2',
];
protected $hidden = ['deleted_at'];
// ──────────────────────────────────────────────────────────────
// 관계
// ──────────────────────────────────────────────────────────────
public function files(): HasMany
{
return $this->hasMany(File::class, 'document_id')
->where('document_type', 'bending_item');
}
// ──────────────────────────────────────────────────────────────
// options 헬퍼
// ──────────────────────────────────────────────────────────────
public function getOption(string $key, mixed $default = null): mixed
{
return data_get($this->options, $key, $default);
}
public function setOption(string $key, mixed $value): self
{
$options = $this->options ?? [];
data_set($options, $key, $value);
$this->options = $options;
return $this;
}
// ──────────────────────────────────────────────────────────────
// 계산 accessor
// ──────────────────────────────────────────────────────────────
public function getWidthSumAttribute(): ?float
{
$data = $this->bending_data ?? [];
if (empty($data)) {
return null;
}
$last = end($data);
return isset($last['sum']) ? (float) $last['sum'] : null;
}
public function getBendCountAttribute(): int
{
$data = $this->bending_data ?? [];
return count(array_filter($data, fn ($d) => ($d['rate'] ?? '') !== ''));
}
// ──────────────────────────────────────────────────────────────
// LOT 코드 테이블
// ──────────────────────────────────────────────────────────────
public const PROD_CODES = [
'R' => '가이드레일(벽면형)',
'S' => '가이드레일(측면형)',
'C' => '케이스',
'B' => '하단마감재(스크린)',
'T' => '하단마감재(철재)',
'L' => 'L-Bar',
'G' => '연기차단재',
];
public const SPEC_CODES = [
'R' => ['M' => '본체', 'T' => '본체(철재)', 'C' => 'C형', 'D' => 'D형', 'S' => 'SUS 마감재'],
'S' => ['M' => '본체디딤', 'T' => '본체(철재)', 'C' => 'C형', 'D' => 'D형', 'S' => 'SUS 마감재①', 'U' => 'SUS 마감재②'],
'C' => ['F' => '전면부', 'P' => '점검구', 'L' => '린텔부', 'B' => '후면코너부'],
'B' => ['S' => 'SUS', 'E' => 'EGI'],
'T' => ['S' => 'SUS', 'E' => 'EGI'],
'L' => ['A' => '스크린용'],
'G' => ['I' => '화이바원단'],
];
}

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Models;
use App\Models\Commons\File;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class BendingModel extends Model
{
use Auditable, BelongsToTenant, SoftDeletes;
protected $table = 'bending_models';
protected $fillable = [
'tenant_id', 'model_type', 'code', 'name', 'legacy_code', 'legacy_num',
'model_name', 'model_UA', 'item_sep', 'finishing_type', 'author', 'remark',
'check_type', 'rail_width', 'rail_length',
'exit_direction', 'front_bottom_width', 'box_width', 'box_height',
'bar_width', 'bar_height',
'components', 'material_summary',
'search_keyword', 'registration_date', 'options',
'is_active', 'created_by', 'updated_by',
];
protected $casts = [
'components' => 'array',
'material_summary' => 'array',
'options' => 'array',
'is_active' => 'boolean',
'registration_date' => 'date',
'rail_width' => 'decimal:2',
'rail_length' => 'decimal:2',
'front_bottom_width' => 'decimal:2',
'box_width' => 'decimal:2',
'box_height' => 'decimal:2',
'bar_width' => 'decimal:2',
'bar_height' => 'decimal:2',
];
protected $hidden = ['deleted_at'];
public function files(): HasMany
{
return $this->hasMany(File::class, 'document_id')
->where('document_type', 'bending_model');
}
public function getOption(string $key, mixed $default = null): mixed
{
return data_get($this->options, $key, $default);
}
public function setOption(string $key, mixed $value): self
{
$options = $this->options ?? [];
data_set($options, $key, $value);
$this->options = $options;
return $this;
}
public const TYPE_GUIDERAIL = 'GUIDERAIL_MODEL';
public const TYPE_SHUTTERBOX = 'SHUTTERBOX_MODEL';
public const TYPE_BOTTOMBAR = 'BOTTOMBAR_MODEL';
}

View File

@@ -98,6 +98,21 @@ public function fileable()
return $this->morphTo();
}
/**
* R2 Presigned URL 생성 (30분 유효)
*/
public function presignedUrl(int $minutes = 30): ?string
{
if (! $this->file_path) {
return null;
}
return Storage::disk('r2')->temporaryUrl(
$this->file_path,
now()->addMinutes($minutes)
);
}
/**
* Get the full storage path
*/

View File

@@ -12,7 +12,8 @@
* @property int $id
* @property int $template_id
* @property string $title 섹션 제목
* @property string|null $image_path 검사 기준 이미지 경로
* @property string|null $image_path 검사 기준 이미지 경로 (R2 key)
* @property int|null $file_id 도해 이미지 파일 ID (files 테이블 참조)
* @property int $sort_order 정렬 순서
*/
class DocumentTemplateSection extends Model
@@ -24,6 +25,7 @@ class DocumentTemplateSection extends Model
'title',
'description',
'image_path',
'file_id',
'sort_order',
];

View File

@@ -72,12 +72,12 @@ public function setOption(string $key, mixed $value): self
public function manager(): BelongsTo
{
return $this->belongsTo(\App\Models\User::class, 'manager_id');
return $this->belongsTo(\App\Models\Members\User::class, 'manager_id');
}
public function subManager(): BelongsTo
{
return $this->belongsTo(\App\Models\User::class, 'sub_manager_id');
return $this->belongsTo(\App\Models\Members\User::class, 'sub_manager_id');
}
public function canInspect(?int $userId = null): bool

View File

@@ -33,7 +33,7 @@ public function equipment(): BelongsTo
public function inspector(): BelongsTo
{
return $this->belongsTo(\App\Models\User::class, 'inspector_id');
return $this->belongsTo(\App\Models\Members\User::class, 'inspector_id');
}
public function details(): HasMany

View File

@@ -57,6 +57,6 @@ public function equipment(): BelongsTo
public function repairer(): BelongsTo
{
return $this->belongsTo(\App\Models\User::class, 'repaired_by');
return $this->belongsTo(\App\Models\Members\User::class, 'repaired_by');
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Models\Finance;
use App\Models\Commons\File;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class CorporateVehicle extends Model
{
use BelongsToTenant, SoftDeletes;
protected $table = 'corporate_vehicles';
protected $fillable = [
'tenant_id',
'plate_number',
'model',
'vehicle_type',
'ownership_type',
'mileage',
'options',
'is_active',
];
protected $casts = [
'mileage' => 'integer',
'options' => 'array',
'is_active' => 'boolean',
];
public function photos(): HasMany
{
return $this->hasMany(File::class, 'document_id')
->where('document_type', 'corporate_vehicle')
->orderBy('id');
}
}

View File

@@ -51,6 +51,24 @@ class Item extends Model
'deleted_at',
];
protected $appends = [
'specification',
];
/**
* 규격 accessor — attributes JSON 내 spec/specification 값을 최상위 필드로 노출
*/
public function getSpecificationAttribute(): ?string
{
$attrs = $this->getAttributeValue('attributes');
if (is_array($attrs)) {
return $attrs['spec'] ?? $attrs['specification'] ?? null;
}
return null;
}
/**
* item_type 상수
*/
@@ -182,6 +200,24 @@ public function scopeActive($query)
return $query->where('is_active', true);
}
// ──────────────────────────────────────────────────────────────
// options 헬퍼
// ──────────────────────────────────────────────────────────────
public function getOption(string $key, mixed $default = null): mixed
{
return data_get($this->options, $key, $default);
}
public function setOption(string $key, mixed $value): self
{
$options = $this->options ?? [];
data_set($options, $key, $value);
$this->options = $options;
return $this;
}
// ──────────────────────────────────────────────────────────────
// 헬퍼 메서드
// ──────────────────────────────────────────────────────────────

View File

@@ -1,341 +0,0 @@
<?php
namespace App\Models\Kyungdong;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
/**
* 경동기업 전용 단가 테이블 모델
*
* 5130 레거시 price_* 테이블 데이터 조회용
*
* @property int $id
* @property int $tenant_id
* @property string $table_type
* @property string|null $item_code
* @property string|null $item_name
* @property string|null $category
* @property string|null $spec1
* @property string|null $spec2
* @property string|null $spec3
* @property float $unit_price
* @property string $unit
* @property array|null $raw_data
* @property bool $is_active
*/
class KdPriceTable extends Model
{
// 테이블 유형 상수
public const TYPE_MOTOR = 'motor';
public const TYPE_SHAFT = 'shaft';
public const TYPE_PIPE = 'pipe';
public const TYPE_ANGLE = 'angle';
public const TYPE_RAW_MATERIAL = 'raw_material';
public const TYPE_BDMODELS = 'bdmodels';
// 경동기업 테넌트 ID
public const TENANT_ID = 287;
protected $table = 'kd_price_tables';
protected $fillable = [
'tenant_id',
'table_type',
'item_code',
'item_name',
'category',
'spec1',
'spec2',
'spec3',
'unit_price',
'unit',
'raw_data',
'is_active',
];
protected $casts = [
'unit_price' => 'decimal:2',
'raw_data' => 'array',
'is_active' => 'boolean',
];
// =========================================================================
// Scopes
// =========================================================================
/**
* 테이블 유형으로 필터링
*/
public function scopeOfType(Builder $query, string $type): Builder
{
return $query->where('table_type', $type);
}
/**
* 활성 데이터만
*/
public function scopeActive(Builder $query): Builder
{
return $query->where('is_active', true);
}
/**
* 모터 단가 조회
*/
public function scopeMotor(Builder $query): Builder
{
return $query->ofType(self::TYPE_MOTOR)->active();
}
/**
* 샤프트 단가 조회
*/
public function scopeShaft(Builder $query): Builder
{
return $query->ofType(self::TYPE_SHAFT)->active();
}
/**
* 파이프 단가 조회
*/
public function scopePipeType(Builder $query): Builder
{
return $query->ofType(self::TYPE_PIPE)->active();
}
/**
* 앵글 단가 조회
*/
public function scopeAngle(Builder $query): Builder
{
return $query->ofType(self::TYPE_ANGLE)->active();
}
// =========================================================================
// Static Query Methods
// =========================================================================
/**
* 모터 단가 조회
*
* @param string $motorCapacity 모터 용량 (150K, 300K, 400K, 500K, 600K, 800K, 1000K)
*/
public static function getMotorPrice(string $motorCapacity): float
{
$record = self::motor()
->where('category', $motorCapacity)
->first();
return (float) ($record?->unit_price ?? 0);
}
/**
* 제어기 단가 조회
*
* @param string $controllerType 제어기 타입 (매립형, 노출형, 뒷박스)
*/
public static function getControllerPrice(string $controllerType): float
{
$record = self::motor()
->where('category', $controllerType)
->first();
return (float) ($record?->unit_price ?? 0);
}
/**
* 샤프트 단가 조회
*
* @param string $size 사이즈 (3, 4, 5인치)
* @param float $length 길이 (m 단위)
*/
public static function getShaftPrice(string $size, float $length): float
{
// 길이를 소수점 1자리 문자열로 변환 (DB 저장 형식: '3.0', '4.0')
$lengthStr = number_format($length, 1, '.', '');
$record = self::shaft()
->where('spec1', $size)
->where('spec2', $lengthStr)
->first();
return (float) ($record?->unit_price ?? 0);
}
/**
* 파이프 단가 조회
*
* @param string $thickness 두께 (1.4 등)
* @param int $length 길이 (3000, 6000)
*/
public static function getPipePrice(string $thickness, int $length): float
{
$record = self::pipeType()
->where('spec1', $thickness)
->where('spec2', (string) $length)
->first();
return (float) ($record?->unit_price ?? 0);
}
/**
* 앵글 단가 조회
*
* @param string $type 타입 (스크린용, 철재용)
* @param string $bracketSize 브라켓크기 (530*320, 600*350, 690*390)
* @param string $angleType 앵글타입 (앵글3T, 앵글4T)
*/
public static function getAnglePrice(string $type, string $bracketSize, string $angleType): float
{
$record = self::angle()
->where('category', $type)
->where('spec1', $bracketSize)
->where('spec2', $angleType)
->first();
return (float) ($record?->unit_price ?? 0);
}
/**
* 원자재 단가 조회
*
* @param string $materialName 원자재명 (실리카, 스크린 등)
*/
public static function getRawMaterialPrice(string $materialName): float
{
$record = self::ofType(self::TYPE_RAW_MATERIAL)
->active()
->where('item_name', $materialName)
->first();
return (float) ($record?->unit_price ?? 0);
}
// =========================================================================
// BDmodels 단가 조회 (절곡품)
// =========================================================================
/**
* BDmodels 스코프
*/
public function scopeBdmodels(Builder $query): Builder
{
return $query->ofType(self::TYPE_BDMODELS)->active();
}
/**
* BDmodels 단가 조회 (케이스, 가이드레일, 하단마감재 등)
*
* @param string $secondItem 부품분류 (케이스, 가이드레일, 하단마감재, L-BAR 등)
* @param string|null $modelName 모델코드 (KSS01, KWS01 등)
* @param string|null $finishingType 마감재질 (SUS, EGI)
* @param string|null $spec 규격 (120*70, 650*550 등)
*/
public static function getBDModelPrice(
string $secondItem,
?string $modelName = null,
?string $finishingType = null,
?string $spec = null
): float {
$query = self::bdmodels()->where('category', $secondItem);
if ($modelName) {
$query->where('item_code', $modelName);
}
if ($finishingType) {
$query->where('spec1', $finishingType);
}
if ($spec) {
$query->where('spec2', $spec);
}
$record = $query->first();
return (float) ($record?->unit_price ?? 0);
}
/**
* 케이스 단가 조회
*
* @param string $spec 케이스 규격 (500*380, 650*550 등)
*/
public static function getCasePrice(string $spec): float
{
return self::getBDModelPrice('케이스', null, null, $spec);
}
/**
* 가이드레일 단가 조회
*
* @param string $modelName 모델코드 (KSS01 등)
* @param string $finishingType 마감재질 (SUS, EGI)
* @param string $spec 규격 (120*70, 120*100)
*/
public static function getGuideRailPrice(string $modelName, string $finishingType, string $spec): float
{
return self::getBDModelPrice('가이드레일', $modelName, $finishingType, $spec);
}
/**
* 하단마감재(하장바) 단가 조회
*
* @param string $modelName 모델코드
* @param string $finishingType 마감재질
*/
public static function getBottomBarPrice(string $modelName, string $finishingType): float
{
return self::getBDModelPrice('하단마감재', $modelName, $finishingType);
}
/**
* L-BAR 단가 조회
*
* @param string $modelName 모델코드
*/
public static function getLBarPrice(string $modelName): float
{
return self::getBDModelPrice('L-BAR', $modelName);
}
/**
* 보강평철 단가 조회
*/
public static function getFlatBarPrice(): float
{
return self::getBDModelPrice('보강평철');
}
/**
* 케이스 마구리 단가 조회
*
* @param string $spec 규격
*/
public static function getCaseCapPrice(string $spec): float
{
return self::getBDModelPrice('마구리', null, null, $spec);
}
/**
* 케이스용 연기차단재 단가 조회
*/
public static function getCaseSmokeBlockPrice(): float
{
return self::getBDModelPrice('케이스용 연기차단재');
}
/**
* 가이드레일용 연기차단재 단가 조회
*/
public static function getRailSmokeBlockPrice(): float
{
return self::getBDModelPrice('가이드레일용 연기차단재');
}
}

View File

@@ -0,0 +1,179 @@
<?php
namespace App\Models\Materials;
use App\Models\Commons\File;
use App\Models\Tenants\Department;
use App\Models\Items\Item;
use App\Models\Orders\Order;
use App\Models\Tenants\Approval;
use App\Models\Members\User;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class NonconformingReport extends Model
{
use Auditable, BelongsToTenant, ModelTrait, SoftDeletes;
protected $fillable = [
'tenant_id',
'nc_number',
'status',
'approval_id',
'nc_type',
'occurred_at',
'confirmed_at',
'site_name',
'department_id',
'order_id',
'item_id',
'defect_quantity',
'unit',
'defect_description',
'cause_analysis',
'corrective_action',
'action_completed_at',
'action_manager_id',
'related_employee_id',
'material_cost',
'shipping_cost',
'construction_cost',
'other_cost',
'total_cost',
'remarks',
'drawing_location',
'options',
'created_by',
'updated_by',
'deleted_by',
];
protected $casts = [
'options' => 'array',
'occurred_at' => 'date',
'confirmed_at' => 'date',
'action_completed_at' => 'date',
'material_cost' => 'integer',
'shipping_cost' => 'integer',
'construction_cost' => 'integer',
'other_cost' => 'integer',
'total_cost' => 'integer',
'defect_quantity' => 'decimal:2',
];
// 상태 상수
public const STATUS_RECEIVED = 'RECEIVED';
public const STATUS_ANALYZING = 'ANALYZING';
public const STATUS_RESOLVED = 'RESOLVED';
public const STATUS_CLOSED = 'CLOSED';
// 부적합 유형 상수
public const TYPE_MATERIAL = 'material';
public const TYPE_PROCESS = 'process';
public const TYPE_CONSTRUCTION = 'construction';
public const TYPE_OTHER = 'other';
public const NC_TYPES = [
self::TYPE_MATERIAL => '자재불량',
self::TYPE_PROCESS => '공정불량',
self::TYPE_CONSTRUCTION => '시공불량',
self::TYPE_OTHER => '기타',
];
public const STATUSES = [
self::STATUS_RECEIVED => '접수',
self::STATUS_ANALYZING => '분석중',
self::STATUS_RESOLVED => '조치완료',
self::STATUS_CLOSED => '종결',
];
// ── 관계 ──
public function items(): HasMany
{
return $this->hasMany(NonconformingReportItem::class)->orderBy('sort_order');
}
public function approval(): BelongsTo
{
return $this->belongsTo(Approval::class);
}
public function order(): BelongsTo
{
return $this->belongsTo(Order::class);
}
public function item(): BelongsTo
{
return $this->belongsTo(Item::class);
}
public function department(): BelongsTo
{
return $this->belongsTo(Department::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function actionManager(): BelongsTo
{
return $this->belongsTo(User::class, 'action_manager_id');
}
public function relatedEmployee(): BelongsTo
{
return $this->belongsTo(User::class, 'related_employee_id');
}
public function files(): MorphMany
{
return $this->morphMany(File::class, 'fileable');
}
// ── 스코프 ──
public function scopeStatus($query, string $status)
{
return $query->where('status', $status);
}
public function scopeNcType($query, string $type)
{
return $query->where('nc_type', $type);
}
// ── 헬퍼 ──
public function recalculateTotalCost(): void
{
$this->total_cost = $this->material_cost + $this->shipping_cost
+ $this->construction_cost + $this->other_cost;
}
public function recalculateMaterialCost(): void
{
$this->material_cost = (int) $this->items()->sum('amount');
$this->recalculateTotalCost();
}
public function isClosed(): bool
{
return $this->status === self::STATUS_CLOSED;
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Models\Materials;
use App\Models\Items\Item;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class NonconformingReportItem extends Model
{
use BelongsToTenant, ModelTrait;
protected $fillable = [
'tenant_id',
'nonconforming_report_id',
'item_id',
'item_name',
'specification',
'quantity',
'unit_price',
'amount',
'sort_order',
'remarks',
'options',
];
protected $casts = [
'options' => 'array',
'quantity' => 'decimal:2',
'unit_price' => 'integer',
'amount' => 'integer',
];
public function report(): BelongsTo
{
return $this->belongsTo(NonconformingReport::class, 'nonconforming_report_id');
}
public function item(): BelongsTo
{
return $this->belongsTo(Item::class);
}
}

View File

@@ -78,6 +78,8 @@ class Order extends Model
public const TYPE_PURCHASE = 'PURCHASE'; // 발주
public const TYPE_STOCK = 'STOCK'; // 재고생산
// 매출 인식 시점
public const SALES_ON_ORDER_CONFIRM = 'on_order_confirm'; // 수주확정 시
@@ -338,4 +340,46 @@ public static function createFromQuote(Quote $quote, string $orderNo): self
],
]);
}
/**
* 견적의 개소(location) 단위로 수주 생성
* 다중 개소 견적 → 개소별 독립 수주
*/
public static function createFromQuoteLocation(Quote $quote, string $orderNo, array $locItem, ?array $bomResult): self
{
$qty = (int) ($locItem['quantity'] ?? 1);
$grandTotal = $bomResult['grand_total'] ?? 0;
$supplyAmount = $grandTotal * $qty;
$floor = $locItem['floor'] ?? '';
$symbol = $locItem['code'] ?? '';
$locLabel = trim("{$floor} {$symbol}") ?: '';
$siteName = $quote->site_name;
if ($locLabel) {
$siteName = "{$siteName} [{$locLabel}]";
}
return new self([
'tenant_id' => $quote->tenant_id,
'quote_id' => $quote->id,
'order_no' => $orderNo,
'order_type_code' => self::TYPE_ORDER,
'status_code' => self::STATUS_DRAFT,
'client_id' => $quote->client_id,
'client_name' => $quote->client?->name,
'client_contact' => $quote->contact,
'site_name' => $siteName,
'quantity' => $qty,
'supply_amount' => $supplyAmount,
'tax_amount' => round($supplyAmount * 0.1, 2),
'total_amount' => round($supplyAmount * 1.1, 2),
'delivery_date' => $quote->completion_date,
'memo' => $quote->remarks,
'options' => [
'manager_name' => $quote->manager,
'product_code' => $locItem['productCode'] ?? null,
'location_floor' => $floor,
'location_code' => $symbol,
],
]);
}
}

View File

@@ -81,6 +81,14 @@ class Price extends Model
// Relations
// ─────────────────────────────────────────────────────────────
/**
* 품목 관계
*/
public function item(): BelongsTo
{
return $this->belongsTo(\App\Models\Items\Item::class, 'item_id');
}
/**
* 고객 그룹 관계
*/

View File

@@ -2,6 +2,7 @@
namespace App\Models\Qualitys;
use App\Models\Commons\File;
use App\Models\Members\User;
use App\Models\Orders\Client;
use App\Traits\Auditable;
@@ -72,6 +73,12 @@ public function performanceReport()
return $this->hasOne(PerformanceReport::class);
}
public function file()
{
return $this->hasOne(File::class, 'document_id')
->where('document_type', static::class);
}
// ===== 채번 =====
public static function generateDocNumber(int $tenantId): string

View File

@@ -78,9 +78,9 @@ class Quote extends Model
];
protected $casts = [
'registration_date' => 'date',
'receipt_date' => 'date',
'completion_date' => 'date',
'registration_date' => 'date:Y-m-d',
'receipt_date' => 'date:Y-m-d',
'completion_date' => 'date:Y-m-d',
'finalized_at' => 'datetime',
'is_final' => 'boolean',
'calculation_inputs' => 'array',
@@ -331,11 +331,21 @@ public function scopeSearch($query, ?string $keyword)
/**
* 수정 가능 여부 확인
* - 모든 상태에서 수정 가능 (finalized, converted 포함)
* - 수주 전환된 견적 수정 시 연결된 수주도 함께 동기화됨
* - 생산지시가 존재하는 수주에 연결된 견적은 수정 불가
* - 그 외 모든 상태에서 수정 가능 (finalized, converted 포함)
*/
public function isEditable(): bool
{
if ($this->order_id) {
$hasWorkOrders = Order::where('id', $this->order_id)
->whereHas('workOrders')
->exists();
if ($hasWorkOrders) {
return false;
}
}
return true;
}

View File

@@ -58,7 +58,7 @@ public static function getExchangeRate(): float
*/
public static function clearCache(): void
{
$providers = ['gemini', 'claude', 'google-stt', 'google-gcs'];
$providers = ['gemini', 'claude', 'google-stt', 'google-gcs', 'cloudflare-r2'];
foreach ($providers as $provider) {
Cache::forget("ai_pricing_{$provider}");
}

View File

@@ -0,0 +1,138 @@
<?php
namespace App\Models\Tenants;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class CondolenceExpense extends Model
{
use Auditable, BelongsToTenant, ModelTrait, SoftDeletes;
protected $table = 'condolence_expenses';
// 카테고리 상수
public const CATEGORY_CONGRATULATION = 'congratulation';
public const CATEGORY_CONDOLENCE = 'condolence';
public const CATEGORY_LABELS = [
self::CATEGORY_CONGRATULATION => '축의',
self::CATEGORY_CONDOLENCE => '부조',
];
// 지출방법 상수
public const CASH_METHOD_CASH = 'cash';
public const CASH_METHOD_TRANSFER = 'transfer';
public const CASH_METHOD_CARD = 'card';
public const CASH_METHOD_LABELS = [
self::CASH_METHOD_CASH => '현금',
self::CASH_METHOD_TRANSFER => '계좌이체',
self::CASH_METHOD_CARD => '카드',
];
protected $fillable = [
'tenant_id',
'event_date',
'expense_date',
'partner_name',
'description',
'category',
'has_cash',
'cash_method',
'cash_amount',
'has_gift',
'gift_type',
'gift_amount',
'total_amount',
'options',
'memo',
'created_by',
'updated_by',
'deleted_by',
];
protected $casts = [
'event_date' => 'date',
'expense_date' => 'date',
'has_cash' => 'boolean',
'has_gift' => 'boolean',
'cash_amount' => 'integer',
'gift_amount' => 'integer',
'total_amount' => 'integer',
'options' => 'array',
];
protected $appends = [
'category_label',
'cash_method_label',
];
/**
* 카테고리 라벨
*/
public function getCategoryLabelAttribute(): string
{
return self::CATEGORY_LABELS[$this->category] ?? $this->category;
}
/**
* 지출방법 라벨
*/
public function getCashMethodLabelAttribute(): ?string
{
if (! $this->cash_method) {
return null;
}
return self::CASH_METHOD_LABELS[$this->cash_method] ?? $this->cash_method;
}
/**
* 등록자
*/
public function creator(): BelongsTo
{
return $this->belongsTo(\App\Models\User::class, 'created_by');
}
/**
* 카테고리 필터 스코프
*/
public function scopeByCategory($query, string $category)
{
return $query->where('category', $category);
}
/**
* 연도 필터 스코프
*/
public function scopeInYear($query, int $year)
{
return $query->whereYear('event_date', $year);
}
/**
* options 헬퍼
*/
public function getOption(string $key, $default = null)
{
return data_get($this->options, $key, $default);
}
public function setOption(string $key, $value): self
{
$options = $this->options ?? [];
data_set($options, $key, $value);
$this->options = $options;
return $this;
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Models\Tenants;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class CorporateVehicle extends Model
{
use BelongsToTenant, SoftDeletes;
protected $fillable = [
'tenant_id',
'plate_number',
'model',
'vehicle_type',
'ownership_type',
'year',
'driver',
'status',
'mileage',
'memo',
'purchase_date',
'purchase_price',
'contract_date',
'rent_company',
'rent_company_tel',
'rent_period',
'agreed_mileage',
'vehicle_price',
'residual_value',
'deposit',
'monthly_rent',
'monthly_rent_tax',
'insurance_company',
'insurance_company_tel',
];
protected $casts = [
'year' => 'integer',
'mileage' => 'integer',
'purchase_price' => 'integer',
'vehicle_price' => 'integer',
'residual_value' => 'integer',
'deposit' => 'integer',
'monthly_rent' => 'integer',
'monthly_rent_tax' => 'integer',
];
public function logs(): HasMany
{
return $this->hasMany(VehicleLog::class, 'vehicle_id');
}
public function maintenances(): HasMany
{
return $this->hasMany(VehicleMaintenance::class, 'vehicle_id');
}
}

View File

@@ -180,16 +180,18 @@ public static function createFromOrder(Order $order, string $saleNumber): self
*/
public static function createFromShipment(Shipment $shipment, string $saleNumber): self
{
$order = $shipment->order;
return new self([
'tenant_id' => $shipment->tenant_id,
'order_id' => $shipment->order_id,
'shipment_id' => $shipment->id,
'sale_number' => $saleNumber,
'sale_date' => $shipment->shipped_date ?? now()->toDateString(),
'client_id' => $shipment->order?->client_id,
'supply_amount' => $shipment->total_amount / 1.1, // 세전 역산
'tax_amount' => $shipment->total_amount - ($shipment->total_amount / 1.1),
'total_amount' => $shipment->total_amount,
'client_id' => $order?->client_id,
'supply_amount' => $order?->supply_amount ?? 0,
'tax_amount' => $order?->tax_amount ?? 0,
'total_amount' => $order?->total_amount ?? 0,
'description' => "출하 {$shipment->shipment_no} 매출",
'status' => 'draft',
'source_type' => self::SOURCE_SHIPMENT_COMPLETE,

View File

@@ -42,16 +42,6 @@ class Shipment extends Model
'loading_manager',
'loading_completed_at',
'loading_time',
// 물류/배차 정보
'logistics_company',
'vehicle_tonnage',
'shipping_cost',
// 차량/운전자 정보
'vehicle_no',
'driver_name',
'driver_contact',
'expected_arrival',
'confirmed_arrival',
// 기타
'remarks',
'created_by',
@@ -66,9 +56,6 @@ class Shipment extends Model
'invoice_issued' => 'boolean',
'loading_completed_at' => 'datetime',
'loading_time' => 'datetime',
'expected_arrival' => 'datetime',
'confirmed_arrival' => 'datetime',
'shipping_cost' => 'decimal:0',
'order_id' => 'integer',
'work_order_id' => 'integer',
'client_id' => 'integer',
@@ -88,6 +75,7 @@ class Shipment extends Model
public const STATUSES = [
'scheduled' => '출고예정',
'ready' => '출하대기',
'cancelled' => '취소',
'shipping' => '배송중',
'completed' => '배송완료',
];
@@ -147,7 +135,7 @@ public function vehicleDispatches(): HasMany
*/
public function client(): BelongsTo
{
return $this->belongsTo(\App\Models\Clients\Client::class);
return $this->belongsTo(\App\Models\Orders\Client::class);
}
/**

View File

@@ -25,6 +25,8 @@ class ShipmentItem extends Model
'unit',
'lot_no',
'stock_lot_id',
'order_item_id',
'work_order_item_id',
'remarks',
];
@@ -34,6 +36,8 @@ class ShipmentItem extends Model
'quantity' => 'decimal:2',
'shipment_id' => 'integer',
'stock_lot_id' => 'integer',
'order_item_id' => 'integer',
'work_order_item_id' => 'integer',
];
/**
@@ -52,6 +56,22 @@ public function stockLot(): BelongsTo
return $this->belongsTo(StockLot::class);
}
/**
* 수주 품목 관계
*/
public function orderItem(): BelongsTo
{
return $this->belongsTo(\App\Models\Orders\OrderItem::class);
}
/**
* 작업지시 품목 관계
*/
public function workOrderItem(): BelongsTo
{
return $this->belongsTo(\App\Models\WorkOrders\WorkOrderItem::class);
}
/**
* 다음 순번 가져오기
*/

Some files were not shown because too many files have changed in this diff Show More