Compare commits

...

268 Commits

Author SHA1 Message Date
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
김보곤
88d9192618 refactor: [pmis] 마이그레이션을 MNG 프로젝트로 이관
- PMIS 테이블은 MNG 전용이므로 API에서 제거
- pmis_workers, pmis_job_types, pmis_construction_workers, pmis_equipments, pmis_materials
2026-03-12 14:43:51 +09:00
김보곤
cefad468b9 feat: [pmis] 자재관리 테이블 마이그레이션 추가 2026-03-12 14:37:41 +09:00
김보곤
9ad76ceb82 feat: [pmis] 장비관리 테이블 생성 (pmis_equipments) 2026-03-12 14:13:34 +09:00
김보곤
76e098337f feat: [pmis] 시공관리 인원관리 테이블 생성 (job_types, construction_workers) 2026-03-12 14:03:35 +09:00
57d8b97dde chore: [API] 문서/설정 업데이트
- LOGICAL_RELATIONSHIPS.md 관계 정보 추가
- Swagger 서버 설명 변경
- files 테이블 mime_type 컬럼 확장 마이그레이션

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 14:00:59 +09:00
f5b60aab38 fix: [QMS] 로트심사 서류 목록 개선
- 작업일지/중간검사: 인식 가능한 공정만 표시, 공정별 그룹핑
- 중간검사 detail: PQC Inspection 대신 WorkOrder 기반으로 변경
- 문서 아이템 표시 개선 (공정명, 작업지시번호, 문서번호 추가)
- 루트 정보에 거래처(client) 필드 추가
- location에 document 관계 eager loading 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 14:00:49 +09:00
f3849808d5 feat: [QMS] 점검표 토글 API 추가 + 레거시 AuditChecklist 라우트 제거
- ChecklistTemplateController.toggleItem() 추가 (PATCH /{id}/items/{subItemId}/toggle)
- ChecklistTemplate 모델 User 클래스 경로 수정 (Members\User)
- AuditChecklistController 라우트 제거 (checklist_templates로 통합)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 14:00:43 +09:00
김보곤
2d32faa9b5 refactor: [equipment] 사진 업로드를 R2(FileStorageSystem) 기반으로 전환
- GCS 스텁 코드를 Cloudflare R2 기반 실제 파일 업로드로 교체
- File 모델 import를 Boards\File에서 Commons\File로 수정
- StoreEquipmentPhotoRequest FormRequest 추가 (파일 검증)
- 다중 파일 업로드 지원 (최대 10장 제한)
- softDeleteFile 패턴 적용 (삭제 시 soft delete)
- ItemsFileController 패턴 준용 (R2 저장, 랜덤 파일명)
2026-03-12 13:45:15 +09:00
김보곤
723b5a8e1a feat: [pmis] pmis_workers 테이블 마이그레이션 추가
- 건설PMIS 현장 작업자 전용 프로필 테이블
- tenant_id + user_id 유니크 제약 포함
2026-03-12 12:22:58 +09:00
김보곤
8c301b54e3 fix: [payroll] 일괄 생성 시 삭제된 사용자 건너뛰기
- bulkGenerate에서 users 테이블에 존재하지 않는 user_id로 인한 FK 위반 해결
- whereHas('user')로 유효한 사용자만 조회
2026-03-12 11:30:50 +09:00
김보곤
19c524d692 Merge branch 'develop' of http://114.203.209.83:3000/SamProject/sam-api into develop 2026-03-12 11:06:53 +09:00
김보곤
964ee40e8d fix: [equipment] 기본 DB에 equipment 테이블 생성 마이그레이션 추가
- 기존 마이그레이션이 codebridge DB에만 테이블을 생성하는 문제 수정
- 운영서버(API+React)에서는 기본 DB(sam/sam_prod)에 테이블 필요
- hasTable() 체크로 이미 존재하는 환경에서는 건너뜀
- 모든 컬럼 최신 스키마 반영 (inspection_cycle, sub_manager_id, options)
- options 마이그레이션도 hasTable/hasColumn 안전 체크 추가
2026-03-12 11:00:15 +09:00
김보곤
f401e17447 feat: [payroll] 엑셀 내보내기 및 전표 생성 API 추가
- GET /payrolls/export: 급여 현황 엑셀 다운로드 (필터 지원)
- POST /payrolls/journal-entries: 연월 기준 급여 전표 일괄 생성
- JournalEntry SOURCE_PAYROLL 상수 추가
- StorePayrollJournalRequest 유효성 검증 추가
2026-03-12 10:55:17 +09:00
김보곤
069d0206a0 feat: [equipment] 설비관리 API 백엔드 구현 (Phase 1)
- 모델 6개: Equipment, EquipmentInspection, EquipmentInspectionDetail, EquipmentInspectionTemplate, EquipmentRepair, EquipmentProcess
- InspectionCycle Enum: 6주기(일/주/월/격월/분기/반기) 날짜 해석
- 서비스 4개: EquipmentService, EquipmentInspectionService, EquipmentRepairService, EquipmentPhotoService
- 컨트롤러 4개: CRUD + 점검 토글/결과 설정/메모/초기화 + 템플릿 관리 + 수리이력 + 사진
- FormRequest 6개: 설비등록/수정, 수리이력, 점검템플릿, 토글, 메모
- 라우트 26개: equipment prefix 하위 RESTful 엔드포인트
- i18n 메시지: message.equipment.*, error.equipment.*
- 마이그레이션: equipments/equipment_repairs options JSON 컬럼 추가
2026-03-12 10:52:30 +09:00
8c16993746 fix: token-login API KEY 미들웨어 화이트리스트 추가 2026-03-12 10:19:10 +09:00
3a889b33ef fix: [QMS] 인정품목 표시 수정 + 제품검사 성적서 필터 개선
- getFgProductName(): BOM 순회 대신 Order.item_id 직접 참조로 변경
- 제품검사 성적서 필터: document_id만 → document_id || inspection_status=completed
- getLocationDetail(): FQC 문서 데이터 포함 (template + data)
- formatFqcTemplate(): DB item → item_name 매핑 추가
- formatDocumentItem('product'): 개소별 층/기호 코드 표시

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 10:19:10 +09:00
073ad11ecd fix: [QMS] 점검표 토글 해제 안되는 버그 수정
- PHP foreach 참조(&)와 ?? 연산자 조합 시 임시 복사본이 생성되어 원본 배열 수정 불가
- `$category['subItems'] ?? []` → `empty() + continue` + `$category['subItems']` 로 변경
- 토글 API가 항상 is_completed: true 반환하던 문제 해결

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 10:19:10 +09:00
479059747b feat: [생산/출하] 수주 단위 출하 자동생성 + 상태 흐름 개선
- 출하를 작업지시(WO) 단위 → 수주(Order) 단위로 변경
  - createShipmentFromOrder: 모든 메인 WO 품목을 통합하여 출하 1건 생성
  - 출하에 수주 정보 복사 안함 (order_info accessor로 조인 참조)
- syncOrderStatus에서 PRODUCED 전환 시 자동 출하 생성
  - ensureShipmentExists: 이미 PRODUCED인데 출하 없으면 재생성
- POST /shipments/from-order/{orderId} 수동 출하 생성 API 추가
  - createShipmentForOrder: 상태 검증 + 작업지시 조회 + 출하 생성
- Shipment order_info accessor 확장 (receiver, delivery_address_detail, delivery_method)
- ShipmentService index에 creator 관계 추가 (목록 작성자 표시)
- autoCompleteWorkOrderIfAllStepsDone: 전체 step 완료 시 WO 자동완료
- autoCompleteOrphanedSteps: 고아 step 자동보정
- syncOrderStatus: 공정 미지정 WO 바이패스
- ApiResponse::success 201 인자 오류 수정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 10:19:10 +09:00
12373edf8c feat: [QMS] 점검표 템플릿 관리 백엔드 구현
- checklist_templates 테이블 마이그레이션 + 기본 시딩
- ChecklistTemplate 모델 (BelongsToTenant, Auditable, SoftDeletes)
- ChecklistTemplateService: 조회/저장/파일 업로드/삭제
- SaveChecklistTemplateRequest: 중첩 JSON 검증
- ChecklistTemplateController: 5개 엔드포인트
- 라우트 등록 (quality/checklist-templates, quality/qms-documents)
2026-03-12 10:19:10 +09:00
3bae303447 fix: [작업지시] syncOrderStatus 집계 방식으로 변경
- 기존: 단일 작업지시 상태만 보고 수주 상태 매핑 (첫 WO 완료 시 즉시 PRODUCED)
- 변경: 수주의 모든 비보조 작업지시 상태를 집계하여 결정
  - 전부 shipped → SHIPPED
  - 전부 completed/shipped → PRODUCED
  - 하나라도 진행중/완료/출하 → IN_PRODUCTION
- 감사 로그에 집계 내역(work_order_counts) 포함
2026-03-12 10:19:10 +09:00
b55cbc2ec4 feat: [견적] 제어기 타입 체계 변경 (basic/smart/premium → exposed/embedded/embedded_no_box)
- QuoteBomBulkCalculateRequest: controller validation 값 변경, 기본값 exposed
- QuoteBomCalculateRequest: 동일 변경
- FormulaEvaluatorService: CT → controller_type 매핑 추가 (exposed→노출형, embedded→매립형)
- FormulaEvaluatorService: CT 값에 따라 backbox_qty 자동 설정 (embedded만 뒷박스 포함)
- QuoteService: CT 기본값 exposed로 변경

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 10:19:10 +09:00
f0b1b5e33a feat: [배포] Jenkinsfile 롤백 기능 추가
- parameters 블록 추가 (ACTION, ROLLBACK_TARGET, ROLLBACK_RELEASE)
- Jenkins 웹에서 Build with Parameters로 롤백 실행 가능
- 릴리스 목록 조회 + symlink 전환 + 캐시 재생성
- production/stage 환경 선택 가능
- 서버 IP를 PROD_SERVER 환경변수로 추출
- 롤백 시 Slack 알림 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 10:19:10 +09:00
김보곤
918ae0ebc1 feat: [email] 테넌트 메일 설정 마이그레이션 및 모델 추가
- tenant_mail_configs 테이블 생성 (SMTP 설정, 브랜딩, 연결 테스트 결과)
- mail_logs 테이블 생성 (발송 이력 추적)
- TenantMailConfig, MailLog 모델 추가 (options JSON 정책 준수)
2026-03-12 07:42:06 +09:00
유병철
2c9e5ae2da feat: [receiving] 입고 성적서 파일 연결 기능 추가
- receivings 테이블에 certificate_file_id 컬럼 추가 (마이그레이션)
- Receiving 모델에 certificateFile 관계 및 fillable/casts 추가
- Store/Update Request에 certificate_file_id 검증 규칙 추가
- ReceivingService index/show에 certificateFile eager loading 추가
- store/update 시 certificate_file_id 저장 처리

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 21:32:45 +09:00
김보곤
eeda6d980e feat: [barobill] React 연동용 바로빌 카드/은행/홈택스 REST API 구현
- 바로빌 카드 거래 API (16 엔드포인트): 조회, 분할, 수동입력, 숨김/복원, 금액수정, 분개
- 바로빌 은행 거래 API (13 엔드포인트): 조회, 분할, 오버라이드, 수동입력, 잔액요약, 분개
- 홈택스 세금계산서 API (13 엔드포인트): 매출/매입 조회, 수동입력, 자체분개, 통합분개
- JournalEntry 소스 타입 상수 추가 (barobill_card, barobill_bank, hometax_invoice)
2026-03-11 19:41:07 +09:00
김보곤
82621a6045 feat: [payroll] MNG 급여관리 계산 엔진 및 일괄 처리 API 구현
- IncomeTaxBracket 모델 추가 (2024 간이세액표 DB 조회)
- PayrollService 전면 개편: 4대보험 + 소득세 자동 계산 엔진
- 10,000천원 초과 고소득 구간 공식 계산 지원
- 과세표준 = 총지급액 - 식대(비과세), 10원 단위 절삭
- 일괄 생성(bulkGenerate), 전월 복사(copyFromPrevious) 기능
- 확정취소(unconfirm), 지급취소(unpay) 상태 관리
- 계산 미리보기(calculatePreview) 엔드포인트 추가
- 공제항목 수동 오버라이드(deduction_overrides) 지원
- Payroll 모델에 long_term_care, options 필드 추가
2026-03-11 19:18:27 +09:00
김보곤
18a6f3e7aa refactor: [barobill] 바로빌 연동 코드 전면 개선
- config/services.php에 barobill 설정 등록 (운영/테스트 모드 분기 정상화)
- BarobillSetting 모델에 BelongsToTenant 적용 및 use_* 필드 casts 추가
- BarobillService API URL을 baroservice.com(SOAP)으로 수정
- BarobillService callApi 메서드 경로 하드코딩 제거 (서비스별 분기)
- BarobillService 예외 이중 래핑 문제 수정
- BarobillController URL 메서드 중복 코드 제거
- 누락 모델 16개 생성 (MNG 패턴 준수, BelongsToTenant 적용)
- 바로빌 전 테이블 options JSON 컬럼 추가 마이그레이션
2026-03-11 18:16:59 +09:00
김보곤
5828261dce Merge branch 'develop' of http://114.203.209.83:3000/SamProject/sam-api into develop 2026-03-11 17:49:20 +09:00
김보곤
0ab3d5ab88 R2 파일 업로드 2026-03-11 17:49:16 +09:00
김보곤
0be88f95ca refactor: [approval] SAM API 규칙 준수 코드 리뷰 반영
- ApprovalStep에 BelongsToTenant, SoftDeletes 추가 (마이그레이션 포함)
- ApprovalForm, ApprovalDelegation에 ModelTrait 추가 (중복 scopeActive 제거)
- ApprovalDelegation에 Auditable 추가
- 모든 결재 액션에 FormRequest 적용 (approve, cancel, hold, preDecide)
- 위임 CRUD에 DelegationStoreRequest, DelegationUpdateRequest 적용
- ApprovalStep 생성 시 tenant_id 포함
2026-03-11 17:13:08 +09:00
김보곤
3fd412f89d feat: [approval] 결재관리 시스템 MNG 스타일로 전면 개선
- 보류/보류해제 기능 추가 (hold, releaseHold)
- 전결 기능 추가 (preDecide - 이후 결재 건너뛰고 최종 승인)
- 복사 재기안 기능 추가 (copyForRedraft)
- 반려 후 재상신 로직 (rejection_history 저장, resubmit_count 증가)
- 결재자 스냅샷 저장 (approver_name, department, position)
- 완료함 목록/현황 API 추가 (completed, completedSummary)
- 뱃지 카운트 API 추가 (badgeCounts)
- 완료함 일괄 읽음 처리 (markCompletedAsRead)
- 위임 관리 CRUD API 추가 (delegations)
- Leave 연동 (승인/반려/회수/삭제 시 휴가 상태 동기화)
- ApprovalDelegation 모델 신규 생성
- STATUS_ON_HOLD 상수 추가 (Approval, ApprovalStep)
- isEditable/isSubmittable 반려 상태 허용으로 확장
- isCancellable 보류 상태 포함
- 회수 시 첫 번째 결재자 처리 여부 검증 추가
- i18n 에러/메시지 키 추가
2026-03-11 16:57:54 +09:00
김보곤
6f48b86206 fix: [account-codes] 계정과목 중복 데이터 정리 마이그레이션
- 비표준 코드(5자리 KIS 중복, 1-2자리 카테고리 헤더) 비활성화
- 홈택스 분개 코드 수정: 135→117, 251→201, 255→208
2026-03-11 10:15:59 +09:00
김보곤
f0464d4f8c fix: [db] codebridge DB 분리 후 깨진 FK 제약조건 52개 제거
- sam → codebridge 테이블 이동 후 users, tenants 등 참조하는 FK 잔존
- esign_field_templates INSERT 시 FK violation 발생 수정
2026-03-11 10:06:36 +09:00
bbaeefb6b5 sync: main 배포 동기화 2026-03-11 2026-03-11 02:04:47 +09:00
079f4b0ffb feat: [문서] document_data, document_approvals, document_attachments에 tenant_id 추가
- 3개 테이블에 tenant_id 컬럼 + 인덱스 추가
- 기존 데이터는 부모 테이블(documents)에서 tenant_id 자동 채움
- 멀티테넌시 일관성 확보 및 데이터 동기화 지원

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 23:01:12 +09:00
e061faadc2 feat: [QMS] Auditable trait 경로 수정 + 품질 더미 데이터 Seeder
- AuditChecklist.php: App\Models\Traits → App\Traits 경로 수정
- QualityDummyDataSeeder: 3개 품질 페이지용 더미 데이터 생성
  - 품질관리서 10건, 실적신고 6건, 점검표 2건(Q1/Q2), 항목 54건, 기준문서 114건

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 17:55:00 +09:00
30c2484440 feat: [QMS] 1일차 기준/매뉴얼 심사 백엔드 구현 (Phase 2)
- 마이그레이션: audit_checklists, audit_checklist_categories, audit_checklist_items, audit_standard_documents (4테이블)
- 모델 4개: AuditChecklist, AuditChecklistCategory, AuditChecklistItem, AuditStandardDocument
- AuditChecklistService: CRUD, 완료처리, 항목 토글(lockForUpdate), 기준 문서 연결/해제, 카테고리+항목 일괄 동기화
- AuditChecklistController: 9개 엔드포인트
- FormRequest 2개: Store(카테고리+항목 중첩 검증), Update
- 라우트 9개 등록 (/api/v1/qms/checklists, checklist-items)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 17:11:56 +09:00
334e39d2de feat: [QMS] 2일차 로트 추적 심사 API 구현 (Phase 1)
- QmsLotAuditService: 품질관리서 목록/상세, 8종 서류 조합, 서류 상세(2단계 로딩), 확인 토글
- QmsLotAuditController: 5개 엔드포인트 (index, show, routeDocuments, documentDetail, confirm)
- FormRequest 3개: Index, Confirm, DocumentDetail 파라미터 검증
- QualityDocumentLocation: options JSON 컬럼 추가 (마이그레이션 + 모델 casts)
- IQC 추적: WorkOrderMaterialInput → StockLot → lot_no → Inspection(IQC) 경로
- 비관적 업데이트: DB::transaction + lockForUpdate() 원자성 보장

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 17:11:56 +09:00
e372b9543b fix: [stats] QuoteStatService에서 codebridge 테이블 조회 제거
- sales_prospect_consultations, sales_prospects 쿼리 제거
- codebridge DB에 이관된 테이블이며 tenant_id 없어 테넌트별 집계 불가
- prospect_*, consultation_count 필드는 DB default(0) 처리

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 17:11:56 +09:00
유병철
4e192d1c00 feat: [로그인] 사용자 정보에 department_id 추가 반환
- getUserInfoForLogin에서 department_id도 함께 반환

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 14:07:16 +09:00
유병철
6d1925fcd1 feat: [캘린더] 매입결제·수주납기·출고 예정일 일정 연동 추가
- expected_expense: 매입 결제 예정일 (미결제 건만)
- delivery: 수주 납기일 (활성 상태 수주만)
- shipment: 출고 예정일 (scheduled/ready 상태만)
- type 필터에 3개 타입 추가, null(전체)일 때 모두 포함

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 11:44:43 +09:00
22f72f1bbc fix: [stats] QuoteStatService codebridge DB 커넥션 연결
- codebridge DB로 이관된 테이블(sales_prospect_consultations, sales_prospects) 커넥션을 mysql → codebridge로 변경
- config/database.php에 codebridge 커넥션 추가
- quote_daily 집계 실패 해결

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 11:04:22 +09:00
유병철
6f0ad1cf2d feat: [캘린더] 어음 만기일 일정 연동 추가
- Bill 모델 기반 만기일 일정 조회 (getBillSchedules)
- type 필터에 'bill' 추가, null(전체)일 때도 포함
- 완료/부도 상태 제외, 만기일 기준 정렬
- 표시 형식: [만기] 거래처명 금액원

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 10:59:19 +09:00
김보곤
d8f2361c88 feat: [payroll] payrolls 테이블에 options JSON 컬럼 추가
- 이메일 발송 이력 등 확장 속성 저장용
2026-03-10 01:21:11 +09:00
74e3c21ee0 feat: [database] codebridge 이관 완료 테이블 58개 삭제 마이그레이션
- sam DB에서 codebridge DB로 이관된 58개 테이블 DROP
- FK 체크 비활성화 후 일괄 삭제
- 복원: ~/backups/sam_codebridge_tables_20260309.sql
2026-03-09 23:13:31 +09:00
45a207d4a8 feat: [결재] 테넌트 부트스트랩에 기본 결재 양식 자동 시딩 추가
- ApprovalFormsStep 신규 생성 (proposal, expenseReport, expenseEstimate, attendance_request, reason_report)
- RecipeRegistry STANDARD 레시피에 등록
- 테넌트 생성 시 TenantObserver → TenantBootstrapper로 자동 실행
- 기존 테넌트는 php artisan tenants:bootstrap --all로 적용

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:06:10 +09:00
3fc5f511bc feat: [quality] 검사 상태 자동 재계산 + 수주처 선택 연동
- 개소별 inspection_status를 검사 데이터 내용 기반으로 자동 판정
  (15개 판정필드 + 사진 유무 → pending/in_progress/completed)
- 문서 status를 개소 상태 집계로 자동 재계산
- inspectLocation, updateLocations 모두 적용
- QualityDocumentLocation에 STATUS_IN_PROGRESS 상수 추가
- transformToFrontend에 client_id 매핑 추가
2026-03-09 20:45:35 +09:00
유병철
ee9f4d0b8f fix: [현황판] 결재 카드 조회에 approvalOnly 스코프 추가
- ApprovalStep 쿼리에 approvalOnly() 스코프 적용
- 결재 유형만 필터링되도록 보정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 19:00:40 +09:00
유병철
ca259ccb18 fix: [악성채권] JOIN 쿼리 나머지 컬럼 테이블 prefix 보완
- is_active, status 컬럼에도 bad_debts. prefix 추가
- BadDebtService, StatusBoardService 동일 수정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 16:43:12 +09:00
유병철
3929c5fd1e fix: [악성채권] tenant_id 컬럼 ambiguous 에러 수정
- JOIN 쿼리에서 bad_debts.tenant_id로 테이블 명시
- BadDebtService, StatusBoardService 동일 수정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 16:38:17 +09:00
유병철
56c60ec3df feat: [현황판/악성채권] 카드별 sub_label(대표 거래처명 + 건수) 추가
- BadDebtService: summary에 카드별(전체/추심중/법적조치/회수완료) sub_labels 추가
- StatusBoardService: 악성채권·신규거래처·결재 카드에 sub_label 추가
  - 악성채권: 최다 금액 거래처명
  - 신규거래처: 최근 등록 업체명
  - 결재: 최근 결재 제목

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 16:32:58 +09:00
유병철
60c4256bd0 feat: [복리후생] 상세 조회 커스텀 날짜 범위 필터 추가
- start_date, end_date 쿼리 파라미터 추가
- 커스텀 날짜 범위 지정 시 해당 범위로 일별 사용 내역 조회
- 미지정 시 기존 분기 기준 유지

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 13:20:30 +09:00
유병철
1861f4daf2 fix: [세금계산서] NOT NULL 컬럼 null 방어 처리
- supplier/buyer corp_num, corp_name null→빈문자열 보정
- Laravel ConvertEmptyStringsToNull 미들웨어로 인한 DB 에러 방지

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 12:40:08 +09:00
유병철
c62e59ad17 fix: [세금계산서] 매입/매출 방향별 공급자·공급받는자 필수값 조건 분리
- 매입(purchases): supplier 정보 필수, buyer 선택
- 매출(sales): buyer 정보 필수, supplier 선택
- required → required_if:direction 조건부 검증으로 변경

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 11:35:39 +09:00
유병철
e6f13e3870 refactor: [세금계산서/바로빌] ApiResponse::handle() 클로저 패턴으로 통일
- BarobillSettingController: show/save/testConnection 클로저 방식 전환
- TaxInvoiceController: 전체 액션(index/show/store/update/destroy/issue/bulkIssue/cancel/checkStatus/summary) 클로저 방식 전환
- 중간 변수 할당 제거, 일관된 응답 패턴 적용

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 11:21:20 +09:00
유병철
1d5d161e05 feat: [finance] 더존 Smart A 표준 계정과목 추가 시딩 마이그레이션
- 기획서 14장 기준 누락분 보완
- tenant_id + code 중복 시 skip (기존 데이터 보호)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 10:56:49 +09:00
유병철
0044779eb4 feat: [finance] 계정과목 확장 및 전표 연동 시스템 구현
- AccountCode 모델/서비스 확장 (업데이트, 기본 계정과목 시딩)
- JournalSyncService 추가 (전표 자동 연동)
- SyncsExpenseAccounts 트레이트 추가
- CardTransactionController, TaxInvoiceController 기능 확장
- expense_accounts 테이블에 전표 연결 컬럼 마이그레이션
- account_codes 테이블 확장 마이그레이션
- 전체 테넌트 기본 계정과목 시딩 마이그레이션

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 10:32:20 +09:00
3ac64d5b76 feat: [품질검사] 수주 선택 필터링 + 개소 상세 + 검사 상태 개선
- availableOrders: client_id/item_id 필터 파라미터 지원
- availableOrders: 응답에 client_id, client_name, item_id, item_name, locations(개소 상세) 추가
- show: 개소별 데이터에 거래처/모델 정보 포함
- DocumentService: fqcStatus rootNodes 기반으로 변경

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
2231c9a48f feat: 제품검사 요청서 Document(EAV) 자동생성 및 동기화
- document_template_sections에 description 컬럼 추가 (마이그레이션)
- DocumentTemplateSection 모델에 description fillable 추가
- QualityDocumentService에 syncRequestDocument() 메서드 추가
  - quality_document 생성/수정/수주연결 시 요청서 Document 자동생성
  - 기본필드, 섹션 데이터, 사전고지 테이블 EAV 자동매핑
  - rendered_html 초기화 (데이터 변경 시 재캡처 트리거)
- transformToFrontend에 request_document_id 포함

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
ff8553055c chore: [API] logging, docs, seeder 등 부수 정리
- LOGICAL_RELATIONSHIPS.md 보완
- Legacy5130Calculator 수정
- logging.php 설정 추가
- KyungdongItemSeeder 수정
- docs/INDEX.md, changes 문서 경로 수정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
f2eede6e3a feat: [품질관리] order_ids 영속성 + location 데이터 저장
- StoreRequest/UpdateRequest에 order_ids 검증 추가
- UpdateRequest에 locations 검증 추가 (시공규격, 변경사유, 검사데이터)
- QualityDocumentLocation에 inspection_data(JSON) fillable/cast 추가
- QualityDocumentService store()에 syncOrders 연동
- QualityDocumentService update()에 syncOrders + updateLocations 연동
- inspection_data 컬럼 추가 migration 신규

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
c5d5b5d076 feat: [문서스냅샷] Lazy Snapshot API - snapshot 엔드포인트 + resolve에 snapshot_document_id 추가
- PATCH /documents/{id}/snapshot: canEdit 체크 없이 rendered_html만 업데이트
- DocumentService::patchSnapshot() 메서드 추가
- WorkOrderService::resolveInspectionDocument()에 snapshot_document_id 반환 (상태 무관, rendered_html NULL인 문서)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
5ebf940873 fix: [문서스냅샷] UpsertRequest rendered_html 검증 추가 및 upsert() 전달 누락 수정
- UpsertRequest에 rendered_html nullable string 검증 추가
- DocumentService upsert()에서 create/update 시 rendered_html 전달

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
293330c418 feat: 문서 rendered_html 스냅샷 저장 지원
- Document 모델 $fillable에 rendered_html 추가
- DocumentService create/update에서 rendered_html 저장
- StoreRequest/UpdateRequest에 rendered_html 검증 추가
- WorkOrderService 검사문서/작업일지 생성 시 rendered_html 전달

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
a845f52fc0 fix: [생산지시] withCount에서도 보조 공정(재고생산) WO 제외
- 목록 조회 시 work_orders_count에서 is_auxiliary WO 제외
- whereNotNull(process_id) + options->is_auxiliary 조건 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
0f26ea546a feat: [품질관리] 수주선택 API에 발주처(client_name) 필드 추가
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
3600c7b12b fix: [품질관리] 수주선택 모달 납품일 포맷 및 개소 수 수정
- delivery_date: ISO 타임스탬프 → Y-m-d 포맷으로 변환
- location_count: order_items 수 → order_nodes 루트 노드(개소) 수로 변경

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
a6e29bc1f3 feat: [품질관리] 백엔드 API 구현 - 품질관리서 + 실적신고
- 품질관리서(quality_documents) CRUD API 14개 엔드포인트
- 실적신고(performance_reports) 관리 API 6개 엔드포인트
- DB 마이그레이션 4개 테이블 (quality_documents, quality_document_orders, quality_document_locations, performance_reports)
- 모델 4개 + 서비스 2개 + 컨트롤러 2개 + FormRequest 4개
- stats() ambiguous column 버그 수정 (JOIN 시 테이블 접두사 추가)
- missing() status_code 컬럼명/값 수정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
0aa0a8592d feat: [생산지시] 재고생산 보조 공정 일반 워크플로우에서 분리
- Process P-004 options에 is_auxiliary 플래그 도입
- WO 생성 시 Process의 is_auxiliary를 WO options에 자동 복사
- ProductionOrderService: 보조 공정 WO를 공정 진행 현황에서 제외
- WorkOrderService: 보조 공정 WO의 상태 변경이 수주 상태에 영향 주지 않도록 처리
  - syncOrderStatus(): 보조 공정이면 스킵
  - autoStartWorkOrderOnMaterialInput(): WO는 진행중 전환하되 수주 상태는 유지

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
38c2402771 fix: [생산지시] 공정 진행 현황 WO 필터링 + BOM 파싱 수정
- 공정 진행 현황: process_id=null인 구매품/서비스 WO 제외 (withCount, 목록/상세 모두)
- extractBomProcessGroups: bom_result.items[] 구조에 맞게 파싱 수정
  - process_name → process_group 키 사용
  - 품목 필드 매핑 수정 (item_id, specification, unit, quantity, unit_price, total_price, node_name)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
59d13eeb9f fix: [생산지시] 날짜포맷·개소수·자재투입 시 자동 상태전환
- ProductionOrderService: production_ordered_at를 Y-m-d 포맷으로 변환
- ProductionOrderService: withCount('nodes')로 개소수(node_count) 응답 추가
- WorkOrderService: autoStartWorkOrderOnMaterialInput() 신규 메서드
  - 자재투입 시 WO가 unassigned/pending/waiting이면 in_progress로 자동 전환
  - syncOrderStatus()로 Order도 IN_PRODUCTION 동기화
- Swagger: node_count 필드 문서화, 날짜 포맷 수정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
2df8ecf765 feat: [생산지시] 전용 API 엔드포인트 신규 생성
- ProductionOrderService: 목록(index), 통계(stats), 상세(show) 구현
  - Order 기반 생산지시 대상 필터링 (IN_PROGRESS~SHIPPED)
  - workOrderProgress 가공 필드 (total/completed/inProgress)
  - production_ordered_at (첫 WorkOrder created_at 기반)
  - BOM 공정 분류 추출 (order_nodes.options.bom_result)
- ProductionOrderController: FormRequest + ApiResponse 패턴
- ProductionOrderIndexRequest: search, production_status, sort, pagination 검증
- ProductionOrderApi.php: Swagger 문서 (목록/통계/상세)
- production.php: GET /production-orders, /stats, /{orderId} 라우트 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
김보곤
ad93743bdc feat: [approval] 연차사용촉진 통지서 1차/2차 양식 마이그레이션 추가
- leave_promotion_1st: 연차사용촉진 통지서 (1차) - hr 카테고리
- leave_promotion_2nd: 연차사용촉진 통지서 (2차) - hr 카테고리
2026-03-07 00:28:59 +09:00
김보곤
449fce1d2b feat: [approval] 공문서 양식 마이그레이션 추가 2026-03-06 23:38:56 +09:00
김보곤
9d4143a4dc feat: [approval] 견적서 양식 마이그레이션 추가 2026-03-06 23:21:50 +09:00
김보곤
c5a0115e01 feat: [approval] 이사회의사록 양식 데이터 마이그레이션 추가 2026-03-06 23:00:55 +09:00
김보곤
eb28b577e0 feat: [approval] 위임장 양식 데이터 마이그레이션 추가
- 전체 테넌트에 delegation 양식 레코드 자동 삽입
2026-03-06 22:41:31 +09:00
김보곤
22160e5904 feat: [menu] 경조사비관리 메뉴 추가 마이그레이션
- 각 테넌트의 부가세관리 메뉴 하위에 경조사비관리 메뉴 자동 추가
- 중복 방지 (이미 존재하면 skip)
2026-03-06 21:44:52 +09:00
김보곤
0ea5fa5eb9 feat: [database] 경조사비 관리 테이블 생성
- condolence_expenses 테이블: 거래처 경조사비 관리대장
- 경조사일자, 지출일자, 거래처명, 내역, 구분(축의/부조)
- 부조금(여부/지출방법/금액), 선물(여부/종류/금액), 총금액
2026-03-06 21:39:02 +09:00
김보곤
58fedb0d43 feat: [approvals] 사용인감계 양식 데이터 마이그레이션 추가
- 모든 테넌트에 seal_usage 양식 자동 등록
2026-03-06 20:51:38 +09:00
김보곤
56e7164243 feat: [departments] options JSON 컬럼 추가
- 조직도 숨기기 등 확장 속성 저장용
2026-03-06 20:24:56 +09:00
김보곤
a67c5d9fca feat: [menu] menu_favorites 테이블 마이그레이션 추가
- tenant_id, user_id, menu_id, sort_order 컬럼
- unique 제약: (tenant_id, user_id, menu_id)
- FK cascade delete: users, menus
2026-03-06 14:35:14 +09:00
유병철
816c25a631 fix: [finance] 일반전표 목록 source 필드 및 페이지네이션 구조 수정
- deposits/withdrawals 조회 시 source를 항상 'linked'로 고정
- 페이지네이션 meta 래핑 제거하여 플랫 구조로 변경

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 13:26:58 +09:00
유병철
12d172e4c3 feat: [finance] 계정과목 및 일반전표 API 추가
- AccountCode 모델/서비스/컨트롤러 구현
- JournalEntry, JournalEntryLine 모델 구현
- GeneralJournalEntry 서비스/컨트롤러 구현
- FormRequest 검증 클래스 추가
- finance 라우트 등록
- i18n 메시지 키 추가 (message.php, error.php)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 13:10:45 +09:00
유병철
be9c1baa34 fix: [receivables] 상위 거래처 집계에서 soft delete 레코드 제외
- orders, deposits, bills 서브쿼리에 whereNull('deleted_at') 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:59:16 +09:00
유병철
a7973bb555 feat: [loan] 상품권 summary에 접대비 해당 집계 추가
- expense_accounts 테이블에서 접대비(상품권) 건수/금액 조회
- entertainment_count, entertainment_amount 응답 필드 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:37:57 +09:00
김보곤
96def0d71e feat: [approval] 사직서 양식 마이그레이션 추가 2026-03-06 00:13:18 +09:00
김보곤
846ced3ead feat: [approval] 위촉증명서 양식 데이터 마이그레이션 2026-03-05 23:57:45 +09:00
김보곤
0f25a5d4e1 feat: [approval] 경력증명서 양식 데이터 마이그레이션 2026-03-05 23:42:16 +09:00
유병철
c57e768b87 fix: [loan] 상품권 접대비 연동 시 receipt_no에 시리얼번호 매핑
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 22:12:46 +09:00
유병철
7fe856f3b7 fix: [loan] 상품권 카테고리는 상태 무관하게 수정/삭제 가능하도록 변경
- isEditable(): 상품권이면 상태와 무관하게 수정 허용
- isDeletable(): 상품권이면 상태와 무관하게 삭제 허용

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 21:42:00 +09:00
유병철
652ac3d1ec fix: [loan] dashboard summary/목록에서도 used/disposed 상품권 제외
- dashboard summary 쿼리에 excludeUsedGiftCert 조건 적용
- 가지급금 목록 쿼리에도 동일 조건 적용

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 21:39:17 +09:00
유병철
03f86f375e fix: [loan] 상품권 store 시 접대비 연동 + 카테고리 집계에서 사용/폐기 제외
- store()에서도 상품권 접대비 자동 연동 호출
- getCategoryBreakdown: used/disposed 상품권은 가지급금 집계에서 제외

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 21:32:36 +09:00
유병철
31d2f08dd8 feat: [loan] 상품권 접대비 자동 연동 기능 추가
- ExpenseAccount: loan_id 필드 + SUB_TYPE_GIFT_CERTIFICATE 상수 추가
- LoanService: 상품권 used+접대비해당 시 expense_accounts 자동 upsert/삭제
- 마이그레이션: expense_accounts에 loan_id 컬럼 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 21:22:44 +09:00
유병철
8c9f2fcfb5 feat: [bill,loan] 어음 V8 확장 필드 및 가지급금 상품권 카테고리 지원
- Bill 모델: V8 확장 필드 54개 추가 (증권종류, 할인, 배서, 추심, 개서, 부도 등)
- Bill 상태: 수취/발행 어음·수표별 세분화된 상태 체계
- BillService: assignV8Fields/syncInstallments 헬퍼 추출, instrument_type/medium 필터
- BillInstallment: type/counterparty 필드 추가
- Loan 모델: holding/used/disposed 상태 + metadata(JSON) 필드 추가
- LoanService: 상품권 카테고리 지원 (summary 상태별 집계, store 기본상태 holding)
- FormRequest: V8 확장 필드 검증 규칙 추가
- 마이그레이션: bills V8 필드 + loans metadata 컬럼

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 20:45:54 +09:00
김보곤
f41605ca73 feat: [approval] 재직증명서 양식 마이그레이션 추가
- approval_forms 테이블에 employment_cert 폼 삽입
2026-03-05 18:54:25 +09:00
김보곤
66d1004bc2 feat: [rd] CM송 저장 테이블 마이그레이션 추가
- cm_songs 테이블: tenant_id, user_id, company_name, industry, lyrics, audio_path, options
2026-03-05 14:36:47 +09:00
김보곤
ce1f91074e feat: [approval] approvals 테이블에 rejection_history JSON 컬럼 추가 2026-03-05 13:50:46 +09:00
김보곤
558a393c85 feat: [approval] approvals 테이블에 resubmit_count 컬럼 추가 2026-03-05 13:06:31 +09:00
김보곤
ac72487eff feat: [approval] approvals 테이블에 drafter_read_at 컬럼 추가
- 기안자가 완료 결과를 확인했는지 추적하는 타임스탬프
- 완료함 미읽음 뱃지 기능 지원
2026-03-05 11:37:31 +09:00
3d4dd9f252 chore: [infra] Slack 알림 채널 분리 — product_infra → deploy_api
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:32:16 +09:00
2507dcf142 chore: [docs] 모델 관계 문서 갱신 + summary 플레이스홀더 API 추가
- LOGICAL_RELATIONSHIPS.md: 최신 모델 관계 반영 (inspections, shipment dispatches 등)
- stats.php: production/construction/unshipped/attendance summary 플레이스홀더

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:01:24 +09:00
9b8cdfa2a5 refactor: [core] 모델 스코프 적용 — Tenant::active() 사용 및 코드 규칙 추가
- RecordStorageUsage: where 하드코딩 → Tenant::active() 스코프
- CLAUDE.md: 쿼리 수정 시 모델 스코프 우선 규칙 명시

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:01:10 +09:00
7432fb16aa feat: [production] 자재투입 replace 모드 지원
- registerMaterialInputForItem에 replace 파라미터 추가
- 기존 투입 교체 방식 선택 가능

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:01:07 +09:00
d4f21f06d6 refactor: [production] 셔터박스 prefix — isStandard 파라미터 제거
- CF/CL/CP/CB 품목이 모든 길이에 등록되어 boxSize 무관하게 적용
- resolveShutterBoxPrefix()에서 불필요한 isStandard 분기 제거

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:01:04 +09:00
1f7f45ee60 feat: [process] 공정단계 options 컬럼 추가 — 검사 설정/범위 지원
- ProcessStep 모델에 options JSON 컬럼 추가 (fillable, cast)
- Store/UpdateProcessStepRequest에 inspection_setting, inspection_scope 검증 규칙
- process_steps 테이블 마이그레이션

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:01:01 +09:00
cd847e01a0 feat: [approval] Document ↔ Approval 브릿지 연동 (Phase 4.2)
- Approval 모델에 linkable morphTo 관계 추가
- DocumentService: 상신 시 Approval 자동 생성 + approval_steps 변환
- ApprovalService: 승인/반려/회수 시 Document 상태 동기화
- approvals 테이블에 linkable_type, linkable_id 컬럼 마이그레이션

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:00:53 +09:00
ef7d9fae24 fix: [production] 절곡 검사 products 배열 FormRequest 검증 규칙 추가
- StoreItemInspectionRequest에 inspection_data.products 검증 규칙 누락으로 validated()에서 제거되던 버그 수정
- products.*.id, bendingStatus, lengthMeasured, widthMeasured, gapPoints 규칙 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:44:54 +09:00
1a8bb46137 feat: [outbound] 배차차량 관리 API — CRUD + options JSON 정책
- VehicleDispatchService: index(검색/필터/페이지네이션), stats(선불/착불/합계), show, update
- VehicleDispatchController + VehicleDispatchUpdateRequest
- options JSON 컬럼 추가 (dispatch_no, status, freight_cost_type, supply_amount, vat, total_amount, writer)
- ShipmentService.syncDispatches에 options 필드 지원 추가
- inventory.php에 vehicle-dispatches 라우트 4개 등록

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:44:54 +09:00
897511cb55 fix: [production] 절곡 검사 데이터 전체 item 복제 + bending EAV 변환
- storeItemInspection: bending/bending_wip 시 동일 작업지시 모든 item에 복제 저장
- transformBendingProductsToRecords: products 배열 → bending EAV 레코드 변환
- getMaterialInputLots: 품목코드별 그룹핑으로 변경

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:44:54 +09:00
5ee97c2d74 fix: [production] 자재투입 bom_group_key 개별 저장 — 동일 자재 다중 BOM 그룹 지원
- bom_group_key 컬럼 추가 마이그레이션 (work_order_material_inputs)
- WorkOrderMaterialInput 모델 fillable에 bom_group_key 추가
- MaterialInputForItemRequest에 bom_group_key 검증 + replace 옵션 추가
- WorkOrderService.getMaterialsForItem: stock_lot_id+bom_group_key 복합키 기투입 조회 (하위호환)
- WorkOrderService.registerMaterialInputForItem: bom_group_key 저장 + replace 모드 (기존 삭제→재등록)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:44:54 +09:00
8518621432 feat: [shipment] 배차정보 다중 행 시스템 — shipment_vehicle_dispatches 테이블 추가
- 신규 마이그레이션: shipment_vehicle_dispatches 테이블 (seq, logistics_company, arrival_datetime, tonnage, vehicle_no, driver_contact, remarks)
- 신규 모델: ShipmentVehicleDispatch (ShipmentItem 패턴 복제)
- Shipment 모델: vehicleDispatches() HasMany 관계 추가
- ShipmentService: syncDispatches() 추가, store/update/delete/show/index에서 연동
- FormRequest: Store/Update에 vehicle_dispatches 배열 검증 규칙 추가
- delivery_method 검증에 확장 옵션 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:44:54 +09:00
fc537898fc fix: [production] 자재투입 모달 개선 — lot_managed 필터링, BOM 그룹키, 셔터박스 순서
- getMaterialsForItem: lot_managed===false 품목 자재투입 목록에서 제외 (L-Bar, 보강평철)
- getMaterialsForItem: bom_group_key 필드 추가 (category+partType 기반 고유키)
- BendingInfoBuilder: shutterPartTypes에서 top_cover/fin_cover 제거 (별도 생성과 중복)
- BendingInfoBuilder: 셔터박스 루프 순서 파트→길이로 변경 (작업일지 순서 일치)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:44:54 +09:00
유병철
fefd129795 refactor: [daily-report] 엑셀 내보내기 화면 데이터 기반으로 리팩토링
- DailyReportService: exportData를 dailyAccounts() 재사용 구조로 변경
- DailyReportExport: 헤더 라벨 전월이월/당월입금/당월출금/잔액으로 수정
- 화면 합계와 엑셀 합계 일치하도록 개선

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:44:29 +09:00
유병철
1b2363d661 feat: [daily-report] 엑셀 내보내기에 어음/외상매출채권 현황 섹션 추가
- DailyReportExport: 어음 현황 테이블 + 합계 + 스타일링 추가
- DailyReportService: exportData에 noteReceivables 데이터 포함

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:06:48 +09:00
유병철
f1a3e0f164 fix: [dashboard-ceo] 공정명 컬럼 및 근태 부서 조인 수정
- processes 테이블: p.name → p.process_name 컬럼명 수정
- 근태: users.department_id → tenant_user_profiles 경유 조인으로 변경
- 직급: users.position → tup.position_key 컬럼 수정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 09:41:54 +09:00
유병철
e8da2ea1b1 feat: [dashboard-ceo] CEO 대시보드 섹션별 API 및 일일보고서 엑셀 추가
- DashboardCeoController/Service: 6개 섹션 API 신규 (매출/매입/생산/미출고/시공/근태)
- DailyReportController/Service: 엑셀 다운로드 API 추가 (GET /daily-report/export)
- 라우트: dashboard 하위 6개 + daily-report/export 엔드포인트 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 09:35:30 +09:00
김보곤
e0bb19a017 fix: [storage] RecordStorageUsage 명령어 tenants 테이블 컬럼명 오류 수정
- Tenant::where('status', 'active') → Tenant::active() 스코프 사용
- tenants 테이블에 status 컬럼 없음, tenant_st_code 사용
2026-03-05 09:16:19 +09:00
유병철
74a60e06bc feat: [calendar,vat] 캘린더 CRUD 및 부가세 상세 조회 API 추가
- CalendarController/Service: 일정 등록/수정/삭제 API 추가
- VatController/Service: getDetail() 상세 조회 (요약, 참조테이블, 미발행 목록, 신고기간 옵션)
- 라우트: POST/PUT/DELETE /calendar/schedules, GET /vat/detail 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 20:33:04 +09:00
유병철
2f3ec13b24 fix: [entertainment] 분기 사용액 조회에 날짜 필터 적용
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 20:00:04 +09:00
유병철
94b96e22f6 feat: [entertainment] 접대비 상세 조회 날짜 필터 파라미터 추가
- EntertainmentController: detail에 start_date/end_date 파라미터 전달
- EntertainmentService: getDetail 리스크/사용자분포/거래내역에 날짜 필터 적용

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 15:32:16 +09:00
유병철
a173a5a4fc fix: [loan] getCategoryBreakdown SQL alias 충돌 수정
- outstanding_amount → cat_outstanding alias 변경 (Loan accessor 충돌 방지)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 15:30:31 +09:00
유병철
66da2972fa feat: [entertainment,loan] 접대비 상세 조회 API 및 가지급금 날짜 필터 추가
- EntertainmentController/Service: getDetail() 상세 조회 API 추가 (손금한도, 월별추이, 사용자분포, 거래내역, 분기현황)
- EntertainmentService: 수입금액별 추가한도 계산(세법 기준), 거래건별 리스크 감지
- LoanController/Service: dashboard에 start_date/end_date 파라미터 지원
- LoanService: getCategoryBreakdown 날짜 필터 적용, 목록 limit 10→50 확대
- 라우트: GET /entertainment/detail 엔드포인트 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 15:11:37 +09:00
김보곤
282bf26eec feat: [approval] 지출결의서 body_template 고도화
- 참조 문서 기반으로 정형 양식 HTML 리디자인
- 지출형식/세금계산서 체크박스, 기본정보, 8열 내역 테이블, 합계, 첨부 섹션 포함
2026-03-04 14:52:19 +09:00
김보곤
b86af29cc9 feat: [approval] body_template 컬럼 추가 및 지출결의서 양식 등록
- approval_forms 테이블에 body_template TEXT 컬럼 추가
- 지출결의서(expense) 양식 데이터 등록 (HTML 테이블 본문 템플릿 포함)
2026-03-04 14:52:19 +09:00
유병철
f665d3aea8 fix: [entertainment,welfare] 바로빌 조인 컬럼명 및 심야 시간 파싱 수정
- approval_no → approval_num 컬럼명 수정
- use_time 심야 판별: HOUR() → SUBSTRING 문자열 파싱으로 변경
- whereNotNull('bct.use_time') 조건 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 14:28:00 +09:00
유병철
e637e3d1f7 feat: [dashboard] D1.7 기획서 기반 리스크 감지형 서비스 리팩토링
- EntertainmentService: 리스크 감지형 전환 (주말/심야, 기피업종, 고액결제, 증빙미비)
- WelfareService: 리스크 감지형 전환 (비과세 한도 초과, 사적 사용 의심, 특정인 편중, 항목별 한도 초과)
- ReceivablesService: summary를 cards + check_points 구조로 개선 (누적/당월 미수금, Top3 거래처)
- LoanService: getCategoryBreakdown 전체 대상으로 집계 조건 변경

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 14:21:05 +09:00
김보곤
7cf70dbcaa fix: [address] 주소 필드 255자 → 500자 확장
- DB 마이그레이션: clients, tenants, site_briefings, sites 테이블 address 컬럼 varchar(500)
- FormRequest 8개 파일 max:255 → max:500 변경
2026-03-04 11:29:08 +09:00
김보곤
76192fc177 fix: [cards] cards/stats → card-transactions/dashboard 리다이렉트 추가 2026-03-04 11:10:01 +09:00
김보곤
da04b84bb5 fix: [models] User 모델 import 누락/오류 수정
- Loan.php: User import 누락 → App\Models\Members\User 추가
- TodayIssue.php: App\Models\Users\User → App\Models\Members\User 수정
- Tenants 네임스페이스에서 User::class가 App\Models\Tenants\User로 잘못 해석되는 문제 해결
2026-03-04 11:06:11 +09:00
유병철
1deeafc4de feat: [expense,loan] 대시보드 상세 필터 및 가지급금 카테고리 분류
- ExpectedExpenseController/Service: dashboardDetail에 start_date/end_date/search 파라미터 추가
- Loan 모델: category 상수 및 라벨 정의 (카드/경조사/상품권/접대비)
- LoanService: dashboard에 category_breakdown 집계 추가
- 마이그레이션: loans 테이블 category 컬럼 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:42:53 +09:00
김보곤
4f3467c3b0 feat: [barobill] 바로빌 연동 API 엔드포인트 추가
- GET /api/v1/barobill/status — 연동 현황 조회
- POST /api/v1/barobill/login — 로그인 정보 등록
- POST /api/v1/barobill/signup — 회원가입 정보 등록
- GET /api/v1/barobill/bank-service-url — 은행 서비스 URL
- GET /api/v1/barobill/account-link-url — 계좌 연동 URL
- GET /api/v1/barobill/card-link-url — 카드 연동 URL
- GET /api/v1/barobill/certificate-url — 공인인증서 URL
2026-03-04 09:03:55 +09:00
김보곤
e9fd75fa74 feat: [inspection] 캘린더 스케줄 조회 API 추가
- GET /api/v1/inspections/calendar 엔드포인트 추가
- year, month, inspector, status 파라미터 지원
- React 프론트엔드 CalendarItemApi 형식에 맞춰 응답
2026-03-04 08:33:32 +09:00
김보곤
23c6cf6919 feat: [hr] Leave 모델 확장 + 결재양식 마이그레이션 추가
- Leave 타입 6개 추가: business_trip, remote, field_work, early_leave, late_reason, absent_reason
- 그룹 상수 추가: VACATION_TYPES, ATTENDANCE_REQUEST_TYPES, REASON_REPORT_TYPES
- FORM_CODE_MAP: 유형 → 결재양식코드 매핑 상수
- ATTENDANCE_STATUS_MAP: 유형 → 근태상태 매핑 상수
- 결재양식 2개 추가: attendance_request(근태신청), reason_report(사유서)
2026-03-03 23:52:13 +09:00
유병철
42443349dc feat: [stock,client,status-board] 날짜 필터 및 조건 보완
- StockController/StockService: 입출고 이력 기반 날짜 범위 필터 추가
- ClientService: 등록일 기간 필터(start_date/end_date) 추가
- StatusBoardService: 부실채권 현황에 is_active 조건 추가 (목록 페이지 일치)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 21:53:30 +09:00
유병철
ad27090bfc feat: [daily-report] 자금현황 카드용 필드 추가
- 미수금 잔액(receivable_balance) 계산 로직 구현
- 미지급금 잔액(payable_balance) 계산 로직 구현
- 당월 예상 지출(monthly_expense_total) 계산 로직 구현
- summary API 응답에 자금현황 3개 필드 포함

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 21:06:09 +09:00
유병철
b7465becab feat: [approval] 결재 수신함 날짜 범위 필터 추가
- InboxIndexRequest에 start_date/end_date 검증 룰 추가
- ApprovalService.inbox()에 created_at 날짜 범위 필터 구현

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 20:02:07 +09:00
유병철
83a774572a feat: [today-issue] 날짜 기반 이전 이슈 조회 기능 추가
- TodayIssueController에 date 파라미터(YYYY-MM-DD) 추가
- TodayIssueService.summary()에 날짜 기반 필터링 로직 구현
- 이전 이슈 조회 시 만료(active) 필터 무시하여 과거 데이터 조회 가능

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 18:48:30 +09:00
김보곤
da1142af62 feat: [ai-quotation] 제조 견적서 마이그레이션 추가
- ai_quotations: quote_mode, quote_number, product_category 컬럼 추가
- ai_quotation_items: specification, unit, quantity, unit_price, total_price, item_category, floor_code 컬럼 추가
- ai_quote_price_tables 테이블 신규 생성
2026-03-03 15:57:19 +09:00
김보곤
b3c7d08b2c feat: [hr] 사업소득자 임금대장 display_name/business_reg_number 컬럼 추가
- user_id nullable 변경 (직접 입력 대상자 지원)
- display_name, business_reg_number 컬럼 추가
- 기존 데이터 earner 프로필에서 자동 채움
2026-03-03 14:33:03 +09:00
7e309e4057 fix: [deploy] 배포 시 .env 권한 640 보장 추가
- Stage/Production 배포 스크립트에 chmod 640 추가
- vi 편집으로 인한 .env 권한 변경(600) 방지
- 2026-03-03 장애 재발 방지 (PHP-FPM이 .env 읽기 실패 → 500)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 09:51:07 +09:00
김보곤
f79d008777 chore: [ai] Gemini 모델 gemini-2.0-flash → gemini-2.5-flash 마이그레이션
- config/services.php fallback 기본값 변경
- AiReportService fallback 기본값 변경
2026-03-03 08:09:08 +09:00
김보곤
abe04607e4 feat: [rd] AI 견적 엔진 테이블 생성 + 모듈 카탈로그 시더
- ai_quotation_modules: SAM 모듈 카탈로그 (18개 모듈)
- ai_quotations: AI 견적 요청/결과
- ai_quotation_items: AI 추천 모듈 목록
- AiQuotationModuleSeeder: customer-pricing 기반 초기 데이터
2026-03-02 17:43:36 +09:00
김보곤
3ca161e9e2 feat: [roadmap] 중장기 계획 테이블 마이그레이션 추가
- admin_roadmap_plans: 계획 테이블 (제목, 카테고리, 상태, Phase, 진행률 등)
- admin_roadmap_milestones: 마일스톤 테이블 (plan_id FK, 상태, 예정일 등)
2026-03-02 15:50:05 +09:00
김보곤
587bdf5d80 feat: [interview] 마스터 질문 데이터 시드 마이그레이션 추가
- 8개 도메인, 16개 템플릿, 80개 마스터 질문 INSERT
- idempotent 처리: 이미 도메인 카테고리 존재 시 스킵
- Jenkins 자동 배포로 운영서버 데이터 반영 목적
2026-02-28 22:05:59 +09:00
김보곤
f2d36c3616 feat: [interview] 카테고리 계층 구조 parent_id 마이그레이션 추가
- interview_categories 테이블에 parent_id 컬럼 추가
- self-referencing FK, nullOnDelete
2026-02-28 21:23:19 +09:00
김보곤
397d50de1f feat: [interview] 인터뷰 시나리오 고도화 마이그레이션
- interview_projects 테이블 신규 (회사별 프로젝트)
- interview_attachments 테이블 신규 (첨부파일 + AI 분석)
- interview_knowledge 테이블 신규 (AI 추출 지식)
- interview_categories에 project_id, domain 컬럼 추가
- interview_questions에 ai_hint, expected_format, depends_on, domain 추가
- interview_answers에 answer_data, attachments JSON 추가
- interview_sessions에 project_id, session_type, voice_recording_id 추가
2026-02-28 20:02:33 +09:00
김보곤
73949b1282 feat: [document] 블록 빌더 지원 마이그레이션 추가
- document_templates: builder_type, schema, page_config 컬럼 추가
- documents: data JSON, rendered_html, pdf_path 컬럼 추가
2026-02-28 19:31:46 +09:00
김보곤
e40555ad37 feat: [leaves] 휴가-결재 연동을 위한 DB 변경
- leaves 테이블에 approval_id 컬럼 추가 (마이그레이션)
- 휴가신청 결재 양식(approval_forms) 등록 (마이그레이션)
- Leave 모델 fillable에 approval_id 추가
2026-02-28 15:54:34 +09:00
김보곤
79da7a6da7 feat: [equipment] 다중 점검주기 + 부 담당자 DB 스키마 추가
- equipments: sub_manager_id 컬럼 추가
- equipment_inspection_templates: inspection_cycle 컬럼 + 유니크 변경
- equipment_inspections: inspection_cycle 컬럼 + 유니크 변경
2026-02-28 12:37:26 +09:00
김보곤
b04d407fdb feat: [approval] Phase 2 마이그레이션 추가
- approval_steps: parallel_group, acted_by, approval_type 컬럼 추가
- approvals: recall_reason, parent_doc_id 컬럼 추가
- approval_delegations 테이블 생성 (위임/대결)
2026-02-27 23:41:34 +09:00
김보곤
c32d68f069 feat: [approval] 결재관리 Phase 1 마이그레이션
- approvals 테이블: line_id, body, is_urgent, department_id 컬럼 추가
- approval_steps 테이블: approver_name, approver_department, approver_position 스냅샷 컬럼 추가
2026-02-27 23:26:40 +09:00
afc1aa72a8 feat: [inspection] 절곡 검사 마감유형(S1/S2/S3) 프로파일 분리 — 5130 레거시 기준
- BENDING_GAP_PROFILES를 S1/S2/S3 + common 계층 구조로 재구성
  - S1(KSS01): 벽면 4pt, 측면 6pt, 하단 1pt
  - S2(KSS02): 벽면 3pt, 측면 5pt, 하단 1pt
  - S3(KWE01/KSE01+SUS): 벽면 5pt, 측면 7pt, 하단 2pt
- resolveFinishingType() 신규: product_code → 마감유형 자동 판별
- buildBendingInspectionItems()에 마감유형별 프로파일 적용
- getInspectionConfig() 응답에 finishing_type 필드 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 23:18:09 +09:00
f970b6bf4b feat: [inspection] Phase 3 절곡 검사 동적 구현 — inspection-config API + 트랜잭션 보강
- inspection-config API 신규: GET /work-orders/{id}/inspection-config
  - 공정 자동 판별 (resolveInspectionProcessType)
  - bending_info 기반 구성품 목록 + gap_points 반환
  - BENDING_GAP_PROFILES 상수 (6개 구성품 간격 기준치)
- createInspectionDocument 트랜잭션 보강
  - DB::transaction() + lockForUpdate() 적용
  - 동시 생성 race condition 방지

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 23:18:09 +09:00
e6c02292d2 feat: [quote/quality] Phase 2B 견적 product_code 자동추출 + inspections work_order_id FK
- QuoteService: extractProductCodeFromInputs() 추가, store/update에서 자동 추출
- BackfillQuoteProductCodeCommand: 기존 quotes 25건 product_code 보정
- inspections 테이블에 work_order_id FK 마이그레이션 (nullable, nullOnDelete)
- Inspection↔WorkOrder 양방향 관계 추가
- InspectionService: store/show/index에 work_order_id 처리 + transformToFrontend
- InspectionStoreRequest: work_order_id 검증 규칙 추가
2026-02-27 23:18:09 +09:00
9e84fa04a6 fix: [production] product_code 전파 버그 수정
- OrderService::createProductionOrder에 product_code/product_name 추가
- WorkOrderService::store 수주복사 경로에 product_code/product_name 추가
- order_nodes.options → work_order_items.options 전파 누락 해결
2026-02-27 23:18:09 +09:00
김보곤
ff8b37670e feat: [hr] 사업소득자 임금대장 테이블 마이그레이션 추가
- business_income_payments 테이블 생성
- 소득세(3%)/지방소득세(0.3%) 고정세율 구조
- (tenant_id, user_id, pay_year, pay_month) 유니크 제약
2026-02-27 20:21:59 +09:00
김보곤
75408d925f feat: [esign] esign_contracts 테이블에 completion_template_name 컬럼 추가
- 완료 알림톡 템플릿명을 저장하기 위한 nullable string 컬럼
2026-02-27 16:28:57 +09:00
김보곤
dd11f780b4 feat: [payroll] 근로소득세 간이세액표 DB 테이블 및 시더 추가
- income_tax_brackets 테이블 마이그레이션 생성
- 2024년 국세청 간이세액표 데이터 시더 (7,117건)
- salary_from/salary_to(천원), family_count(1~11), tax_amount(원)
2026-02-27 13:58:39 +09:00
김보곤
c94bef1dae feat: [hr] 사업소득자관리 worker_type 컬럼 추가
- tenant_user_profiles 테이블에 worker_type 컬럼 추가 (employee/business_income)
- TenantUserProfile 모델 fillable에 worker_type 추가
2026-02-27 13:46:42 +09:00
f53f04de65 fix: [cicd] 배포 시 storage/bootstrap 권한 설정 추가
- mkdir 후 www-data:webservice 소유권 + 775 권한 설정
- Stage/Production 배포 모두 적용
- 원인: PHP-FPM(www-data)이 storage 쓰기 불가 → 500 에러
2026-02-27 10:43:11 +09:00
김보곤
96c4f71607 Merge branch 'develop' of http://114.203.209.83:3000/SamProject/sam-api into develop 2026-02-27 10:08:06 +09:00
김보곤
0d1d056b13 feat: [payroll] payrolls 테이블에 long_term_care 컬럼 추가 2026-02-27 10:06:25 +09:00
ac7279606d chore: 운영 배포 승인 단계 비활성화 (개발 단계)
- Production Approval stage 주석처리
- 런칭 후 다시 활성화 예정
- 배포 흐름: main push → Stage → Production (승인 없이 자동)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 22:32:13 +09:00
김보곤
fcb377a40c feat: [attendance] attendance_requests 테이블 마이그레이션 추가
- 근태 승인 워크플로우용 신청 테이블
- tenant_id, user_id, request_type, start_date, end_date, status 등
2026-02-26 20:56:41 +09:00
김보곤
1a2350db7d feat: [calendar] 달력 일정 관리 API 구현
- GET /api/v1/calendar-schedules — 연도별 일정 목록 조회
- GET /api/v1/calendar-schedules/stats — 통계 조회
- GET /api/v1/calendar-schedules/{id} — 단건 조회
- POST /api/v1/calendar-schedules — 등록
- PUT /api/v1/calendar-schedules/{id} — 수정
- DELETE /api/v1/calendar-schedules/{id} — 삭제
- POST /api/v1/calendar-schedules/bulk — 대량 등록
2026-02-26 14:29:12 +09:00
김보곤
0ff731ab09 Merge branch 'develop' of http://114.203.209.83:3000/SamProject/sam-api into develop 2026-02-25 21:35:53 +09:00
0345ddcce3 fix: 세션 만료 예외를 슬랙 알림에서 제외
- '회원정보 정보 없음' AuthenticationException은 API Key 검증 통과 후 발생하므로 세션 만료 정상 케이스
- IP 기반 필터링(EXCEPTION_IGNORED_IPS) 대신 예외 자체를 무조건 제외하도록 단순화

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 21:14:48 +09:00
김보곤
a6e547f40d feat: [equipment] files 테이블에 GCS 컬럼 추가
- gcs_object_name, gcs_uri 컬럼 추가
- 설비 사진 멀티 업로드 기능 지원
2026-02-25 20:14:41 +09:00
김보곤
e819635ea6 feat: [equipment] 설비관리 테이블 마이그레이션 6개 생성
- equipments (설비 마스터)
- equipment_inspection_templates (점검항목 템플릿)
- equipment_inspections (월간 점검 헤더)
- equipment_inspection_details (일자별 점검 결과)
- equipment_repairs (수리이력)
- equipment_process (설비-공정 피봇)
2026-02-25 19:39:52 +09:00
495 changed files with 42993 additions and 3442 deletions

1
.gitignore vendored
View File

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

View File

@@ -54,4 +54,4 @@ ## 관련 파일
- `api/app/Services/ComprehensiveAnalysisService.php` - `api/app/Services/ComprehensiveAnalysisService.php`
- `api/database/seeders/ComprehensiveAnalysisSeeder.php` - `api/database/seeders/ComprehensiveAnalysisSeeder.php`
- `docs/plans/react-mock-remaining-tasks.md` - `docs/dev/dev_plans/react-mock-remaining-tasks.md`

View File

@@ -15,7 +15,7 @@ ## Phase 구성
- Phase 5: MNG 관리자 패널 (4항목) — mng/ /system/alerts - Phase 5: MNG 관리자 패널 (4항목) — mng/ /system/alerts
## 핵심 파일 ## 핵심 파일
- 계획 문서: docs/plans/db-backup-system-plan.md - 계획 문서: docs/dev/dev_plans/db-backup-system-plan.md
- 개발서버: 114.203.209.83 (SSH: hskwon) - 개발서버: 114.203.209.83 (SSH: hskwon)
- DB: sam (메인) + sam_stat (통계) - DB: sam (메인) + sam_stat (통계)
- Slack 웹훅: api/.env → LOG_SLACK_WEBHOOK_URL (이미 설정됨) - Slack 웹훅: api/.env → LOG_SLACK_WEBHOOK_URL (이미 설정됨)

View File

@@ -16,7 +16,7 @@ ### 생성된 파일
| 파일 | 설명 | | 파일 | 설명 |
|------|------| |------|------|
| `app/Http/Requests/Quote/QuoteBomBulkCalculateRequest.php` | 다건 BOM 산출 FormRequest | | `app/Http/Requests/Quote/QuoteBomBulkCalculateRequest.php` | 다건 BOM 산출 FormRequest |
| `api/docs/changes/20260102_1300_quote_bom_bulk_calculation.md` | 변경 내용 문서 | | `api/docs/dev/changes/20260102_1300_quote_bom_bulk_calculation.md` | 변경 내용 문서 |
### 수정된 파일 ### 수정된 파일
| 파일 | 설명 | | 파일 | 설명 |
@@ -93,9 +93,9 @@ ### QuoteCalculationService::calculateBomBulk()
- 개별 품목 실패가 전체에 영향 없음 (예외 처리) - 개별 품목 실패가 전체에 영향 없음 (예외 처리)
## 관련 문서 ## 관련 문서
- 계획 문서: `docs/plans/quote-calculation-api-plan.md` - 계획 문서: `docs/dev/dev_plans/quote-calculation-api-plan.md`
- Phase 1.1 문서: `docs/changes/20260102_quote_bom_calculation_api.md` - Phase 1.1 문서: `docs/dev/changes/20260102_quote_bom_calculation_api.md`
- Phase 1.2 문서: `docs/changes/20260102_1300_quote_bom_bulk_calculation.md` - Phase 1.2 문서: `docs/dev/changes/20260102_1300_quote_bom_bulk_calculation.md`
## 다음 단계 ## 다음 단계
- React 프론트엔드에서 `/calculate/bom/bulk` API 연동 - React 프론트엔드에서 `/calculate/bom/bulk` API 연동

View File

@@ -107,3 +107,19 @@ fixed_tools: []
# override of the corresponding setting in serena_config.yml, see the documentation there. # override of the corresponding setting in serena_config.yml, see the documentation there.
# If null or missing, the value from the global config is used. # If null or missing, the value from the global config is used.
symbol_info_budget: symbol_info_budget:
# The language backend to use for this project.
# If not set, the global setting from serena_config.yml is used.
# Valid values: LSP, JetBrains
# Note: the backend is fixed at startup. If a project with a different backend
# is activated post-init, an error will be returned.
language_backend:
# line ending convention to use when writing source files.
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
line_ending:
# list of regex patterns which, when matched, mark a memory entry as readonly.
# Extends the list from the global configuration, merging the two lists.
read_only_memory_patterns: []

View File

@@ -509,6 +509,7 @@ ### 2. Multi-tenancy & Models
- SoftDeletes by default - SoftDeletes by default
- Common columns: tenant_id, created_by, updated_by, deleted_by (COMMENT required) - Common columns: tenant_id, created_by, updated_by, deleted_by (COMMENT required)
- FK constraints: Created during design, minimal in production - FK constraints: Created during design, minimal in production
- **🔴 쿼리 수정 시 모델 스코프 우선**: `where('컬럼', '값')` 하드코딩 전에 반드시 모델에 정의된 스코프(scopeActive 등)를 먼저 확인하고, 스코프가 있으면 `Model::active()` 형태로 사용할 것
### 3. Middleware Stack ### 3. Middleware Stack
- ApiKeyMiddleware, CheckSwaggerAuth, CorsMiddleware, CheckPermission, PermMapper - ApiKeyMiddleware, CheckSwaggerAuth, CorsMiddleware, CheckPermission, PermMapper

162
Jenkinsfile vendored
View File

@@ -1,6 +1,12 @@
pipeline { pipeline {
agent any agent any
parameters {
choice(name: 'ACTION', choices: ['deploy', 'rollback'], description: '배포 또는 롤백')
choice(name: 'ROLLBACK_TARGET', choices: ['production', 'stage'], description: '롤백 대상 환경')
string(name: 'ROLLBACK_RELEASE', defaultValue: '', description: '롤백할 릴리스 ID (예: 20260310_120000). 비워두면 직전 릴리스로 롤백')
}
options { options {
disableConcurrentBuilds() disableConcurrentBuilds()
} }
@@ -8,36 +14,107 @@ pipeline {
environment { environment {
DEPLOY_USER = 'hskwon' DEPLOY_USER = 'hskwon'
RELEASE_ID = new Date().format('yyyyMMdd_HHmmss') RELEASE_ID = new Date().format('yyyyMMdd_HHmmss')
PROD_SERVER = '211.117.60.189'
} }
stages { stages {
// ── 롤백: 릴리스 목록 조회 ──
stage('Rollback: List Releases') {
when { expression { params.ACTION == 'rollback' } }
steps {
script {
def basePath = params.ROLLBACK_TARGET == 'production' ? '/home/webservice/api' : '/home/webservice/api-stage'
sshagent(credentials: ['deploy-ssh-key']) {
def releases = sh(script: "ssh ${DEPLOY_USER}@${PROD_SERVER} 'ls -1dt ${basePath}/releases/*/ | head -6 | xargs -I{} basename {}'", returnStdout: true).trim()
def current = sh(script: "ssh ${DEPLOY_USER}@${PROD_SERVER} 'basename \$(readlink -f ${basePath}/current)'", returnStdout: true).trim()
echo "=== ${params.ROLLBACK_TARGET} 릴리스 목록 ==="
echo "현재 활성: ${current}"
echo "사용 가능:\n${releases}"
}
}
}
}
// ── 롤백: symlink 전환 ──
stage('Rollback: Switch Release') {
when { expression { params.ACTION == 'rollback' } }
steps {
script {
def basePath = params.ROLLBACK_TARGET == 'production' ? '/home/webservice/api' : '/home/webservice/api-stage'
sshagent(credentials: ['deploy-ssh-key']) {
def targetRelease = params.ROLLBACK_RELEASE
if (!targetRelease?.trim()) {
// 비워두면 직전 릴리스로 롤백
targetRelease = sh(script: "ssh ${DEPLOY_USER}@${PROD_SERVER} 'ls -1dt ${basePath}/releases/*/ | sed -n 2p | xargs basename'", returnStdout: true).trim()
}
// 릴리스 존재 여부 확인
sh "ssh ${DEPLOY_USER}@${PROD_SERVER} 'test -d ${basePath}/releases/${targetRelease}'"
slackSend channel: '#deploy_api', color: '#FF9800', tokenCredentialId: 'slack-token',
message: "🔄 *api* ${params.ROLLBACK_TARGET} 롤백 시작 → ${targetRelease}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
sh """
ssh ${DEPLOY_USER}@${PROD_SERVER} '
ln -sfn ${basePath}/releases/${targetRelease} ${basePath}/current &&
cd ${basePath}/current &&
php artisan config:cache &&
php artisan route:cache &&
php artisan view:cache &&
sudo systemctl reload php8.4-fpm
'
"""
if (params.ROLLBACK_TARGET == 'production') {
sh "ssh ${DEPLOY_USER}@${PROD_SERVER} 'sudo supervisorctl restart sam-queue-worker:*'"
}
slackSend channel: '#deploy_api', color: 'good', tokenCredentialId: 'slack-token',
message: "✅ *api* ${params.ROLLBACK_TARGET} 롤백 완료 → ${targetRelease}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
}
}
}
}
// ── 일반 배포: Checkout ──
stage('Checkout') { stage('Checkout') {
when { expression { params.ACTION == 'deploy' } }
steps { steps {
checkout scm checkout scm
script { script {
env.GIT_COMMIT_MSG = sh(script: "git log -1 --pretty=format:'%s'", returnStdout: true).trim() env.GIT_COMMIT_MSG = sh(script: "git log -1 --pretty=format:'%s'", returnStdout: true).trim()
} }
slackSend channel: '#product_infra', color: '#439FE0', tokenCredentialId: 'slack-token', slackSend channel: '#deploy_api', color: '#439FE0', tokenCredentialId: 'slack-token',
message: "🚀 *api* 빌드 시작 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>" message: "🚀 *api* 빌드 시작 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
} }
} }
// ── main → 운영서버 Stage 배포 ── // ── main → 운영서버 Stage 배포 ──
stage('Deploy Stage') { stage('Deploy Stage') {
when { branch 'main' } when {
allOf {
branch 'main'
expression { params.ACTION == 'deploy' }
}
}
steps { steps {
sshagent(credentials: ['deploy-ssh-key']) { sshagent(credentials: ['deploy-ssh-key']) {
sh """ sh """
ssh ${DEPLOY_USER}@211.117.60.189 'mkdir -p /home/webservice/api-stage/releases/${RELEASE_ID}' ssh ${DEPLOY_USER}@${PROD_SERVER} 'mkdir -p /home/webservice/api-stage/releases/${RELEASE_ID}'
rsync -az --delete \ rsync -az --delete \
--exclude='.git' --exclude='.env' \ --exclude='.git' --exclude='.env' \
--exclude='storage/app' --exclude='storage/logs' \ --exclude='storage/app' --exclude='storage/logs' \
--exclude='storage/framework/sessions' --exclude='storage/framework/cache' \ --exclude='storage/framework/sessions' --exclude='storage/framework/cache' \
. ${DEPLOY_USER}@211.117.60.189:/home/webservice/api-stage/releases/${RELEASE_ID}/ . ${DEPLOY_USER}@${PROD_SERVER}:/home/webservice/api-stage/releases/${RELEASE_ID}/
ssh ${DEPLOY_USER}@211.117.60.189 ' ssh ${DEPLOY_USER}@${PROD_SERVER} '
cd /home/webservice/api-stage/releases/${RELEASE_ID} && cd /home/webservice/api-stage/releases/${RELEASE_ID} &&
mkdir -p bootstrap/cache storage/framework/{views,cache/data,sessions} storage/logs && mkdir -p bootstrap/cache storage/framework/{views,cache/data,sessions} storage/logs &&
sudo chown -R www-data:webservice storage bootstrap/cache &&
sudo chmod -R 775 storage bootstrap/cache &&
ln -sfn /home/webservice/api-stage/shared/.env .env && ln -sfn /home/webservice/api-stage/shared/.env .env &&
sudo chmod 640 /home/webservice/api-stage/shared/.env &&
ln -sfn /home/webservice/api-stage/shared/storage/app storage/app && ln -sfn /home/webservice/api-stage/shared/storage/app storage/app &&
composer install --no-dev --optimize-autoloader --no-interaction && composer install --no-dev --optimize-autoloader --no-interaction &&
php artisan config:cache && php artisan config:cache &&
@@ -53,35 +130,43 @@ pipeline {
} }
} }
// ── 운영 배포 승인 ── // ── 운영 배포 승인 (런칭 후 활성화) ──
stage('Production Approval') { // stage('Production Approval') {
when { branch 'main' } // when { branch 'main' }
steps { // steps {
slackSend channel: '#product_deploy', color: '#FF9800', tokenCredentialId: 'slack-token', // slackSend channel: '#product_deploy', color: '#FF9800', tokenCredentialId: 'slack-token',
message: "🔔 *api* 운영 배포 승인 대기 중\n${env.GIT_COMMIT_MSG}\nStage API: https://stage-api.sam.it.kr\n<${env.BUILD_URL}input|승인하러 가기>" // message: "🔔 *api* 운영 배포 승인 대기 중\n${env.GIT_COMMIT_MSG}\nStage API: https://stage-api.sam.it.kr\n<${env.BUILD_URL}input|승인하러 가기>"
timeout(time: 24, unit: 'HOURS') { // timeout(time: 24, unit: 'HOURS') {
input message: 'Stage 확인 후 운영 배포를 진행하시겠습니까?\nStage API: https://stage-api.sam.it.kr', // input message: 'Stage 확인 후 운영 배포를 진행하시겠습니까?\nStage API: https://stage-api.sam.it.kr',
ok: '운영 배포 진행' // ok: '운영 배포 진행'
} // }
} // }
} // }
// ── main → 운영서버 Production 배포 ── // ── main → 운영서버 Production 배포 ──
stage('Deploy Production') { stage('Deploy Production') {
when { branch 'main' } when {
allOf {
branch 'main'
expression { params.ACTION == 'deploy' }
}
}
steps { steps {
sshagent(credentials: ['deploy-ssh-key']) { sshagent(credentials: ['deploy-ssh-key']) {
sh """ sh """
ssh ${DEPLOY_USER}@211.117.60.189 'mkdir -p /home/webservice/api/releases/${RELEASE_ID}' ssh ${DEPLOY_USER}@${PROD_SERVER} 'mkdir -p /home/webservice/api/releases/${RELEASE_ID}'
rsync -az --delete \ rsync -az --delete \
--exclude='.git' --exclude='.env' \ --exclude='.git' --exclude='.env' \
--exclude='storage/app' --exclude='storage/logs' \ --exclude='storage/app' --exclude='storage/logs' \
--exclude='storage/framework/sessions' --exclude='storage/framework/cache' \ --exclude='storage/framework/sessions' --exclude='storage/framework/cache' \
. ${DEPLOY_USER}@211.117.60.189:/home/webservice/api/releases/${RELEASE_ID}/ . ${DEPLOY_USER}@${PROD_SERVER}:/home/webservice/api/releases/${RELEASE_ID}/
ssh ${DEPLOY_USER}@211.117.60.189 ' ssh ${DEPLOY_USER}@${PROD_SERVER} '
cd /home/webservice/api/releases/${RELEASE_ID} && cd /home/webservice/api/releases/${RELEASE_ID} &&
mkdir -p bootstrap/cache storage/framework/{views,cache/data,sessions} storage/logs && mkdir -p bootstrap/cache storage/framework/{views,cache/data,sessions} storage/logs &&
sudo chown -R www-data:webservice storage bootstrap/cache &&
sudo chmod -R 775 storage bootstrap/cache &&
ln -sfn /home/webservice/api/shared/.env .env && ln -sfn /home/webservice/api/shared/.env .env &&
sudo chmod 640 /home/webservice/api/shared/.env &&
ln -sfn /home/webservice/api/shared/storage/app storage/app && ln -sfn /home/webservice/api/shared/storage/app storage/app &&
composer install --no-dev --optimize-autoloader --no-interaction && composer install --no-dev --optimize-autoloader --no-interaction &&
php artisan config:cache && php artisan config:cache &&
@@ -103,23 +188,32 @@ pipeline {
post { post {
success { success {
slackSend channel: '#product_infra', color: 'good', tokenCredentialId: 'slack-token', script {
message: "✅ *api* 배포 성공 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>" if (params.ACTION == 'deploy') {
slackSend channel: '#deploy_api', color: 'good', tokenCredentialId: 'slack-token',
message: "✅ *api* 배포 성공 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
}
}
} }
failure { failure {
slackSend channel: '#product_infra', color: 'danger', tokenCredentialId: 'slack-token',
message: "❌ *api* 배포 실패 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
script { script {
if (env.BRANCH_NAME == 'main') { if (params.ACTION == 'deploy') {
sshagent(credentials: ['deploy-ssh-key']) { slackSend channel: '#deploy_api', color: 'danger', tokenCredentialId: 'slack-token',
sh """ message: "❌ *api* 배포 실패 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
ssh ${DEPLOY_USER}@211.117.60.189 ' if (env.BRANCH_NAME == 'main') {
PREV=\$(ls -1dt /home/webservice/api/releases/*/ | sed -n "2p" | xargs basename 2>/dev/null) && sshagent(credentials: ['deploy-ssh-key']) {
[ -n "\$PREV" ] && ln -sfn /home/webservice/api/releases/\$PREV /home/webservice/api/current && sh """
sudo systemctl reload php8.4-fpm ssh ${DEPLOY_USER}@${PROD_SERVER} '
' || true PREV=\$(ls -1dt /home/webservice/api/releases/*/ | sed -n "2p" | xargs basename 2>/dev/null) &&
""" [ -n "\$PREV" ] && ln -sfn /home/webservice/api/releases/\$PREV /home/webservice/api/current &&
sudo systemctl reload php8.4-fpm
' || true
"""
}
} }
} else {
slackSend channel: '#deploy_api', color: 'danger', tokenCredentialId: 'slack-token',
message: "❌ *api* ${params.ROLLBACK_TARGET} 롤백 실패\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
} }
} }
} }

View File

@@ -1,6 +1,6 @@
# 논리적 데이터베이스 관계 문서 # 논리적 데이터베이스 관계 문서
> **자동 생성**: 2026-02-21 16:28:35 > **자동 생성**: 2026-03-17 14:05:31
> **소스**: Eloquent 모델 관계 분석 > **소스**: Eloquent 모델 관계 분석
## 📊 모델별 관계 현황 ## 📊 모델별 관계 현황
@@ -26,6 +26,68 @@ ### bad_debt_memos
- **badDebt()**: belongsTo → `bad_debts` - **badDebt()**: belongsTo → `bad_debts`
- **creator()**: belongsTo → `users` - **creator()**: belongsTo → `users`
### barobill_bank_sync_status
**모델**: `App\Models\Barobill\BarobillBankSyncStatus`
- **tenant()**: belongsTo → `tenants`
### barobill_bank_transactions
**모델**: `App\Models\Barobill\BarobillBankTransaction`
- **tenant()**: belongsTo → `tenants`
### barobill_bank_transaction_splits
**모델**: `App\Models\Barobill\BarobillBankTransactionSplit`
- **tenant()**: belongsTo → `tenants`
### barobill_billing_records
**모델**: `App\Models\Barobill\BarobillBillingRecord`
- **member()**: belongsTo → `barobill_members`
### barobill_card_transactions
**모델**: `App\Models\Barobill\BarobillCardTransaction`
- **tenant()**: belongsTo → `tenants`
### barobill_card_transaction_amount_logs
**모델**: `App\Models\Barobill\BarobillCardTransactionAmountLog`
- **cardTransaction()**: belongsTo → `barobill_card_transactions`
### barobill_card_transaction_splits
**모델**: `App\Models\Barobill\BarobillCardTransactionSplit`
- **tenant()**: belongsTo → `tenants`
### barobill_members
**모델**: `App\Models\Barobill\BarobillMember`
- **tenant()**: belongsTo → `tenants`
### barobill_monthly_summarys
**모델**: `App\Models\Barobill\BarobillMonthlySummary`
- **member()**: belongsTo → `barobill_members`
### barobill_subscriptions
**모델**: `App\Models\Barobill\BarobillSubscription`
- **member()**: belongsTo → `barobill_members`
### hometax_invoices
**모델**: `App\Models\Barobill\HometaxInvoice`
- **tenant()**: belongsTo → `tenants`
- **journals()**: hasMany → `hometax_invoice_journals`
### hometax_invoice_journals
**모델**: `App\Models\Barobill\HometaxInvoiceJournal`
- **tenant()**: belongsTo → `tenants`
- **invoice()**: belongsTo → `hometax_invoices`
### biddings ### biddings
**모델**: `App\Models\Bidding\Bidding` **모델**: `App\Models\Bidding\Bidding`
@@ -309,6 +371,47 @@ ### esign_signers
- **contract()**: belongsTo → `esign_contracts` - **contract()**: belongsTo → `esign_contracts`
- **signFields()**: hasMany → `esign_sign_fields` - **signFields()**: hasMany → `esign_sign_fields`
### equipments
**모델**: `App\Models\Equipment\Equipment`
- **manager()**: belongsTo → `users`
- **subManager()**: belongsTo → `users`
- **inspectionTemplates()**: hasMany → `equipment_inspection_templates`
- **inspections()**: hasMany → `equipment_inspections`
- **repairs()**: hasMany → `equipment_repairs`
- **photos()**: hasMany → `files`
- **processes()**: belongsToMany → `processes`
### equipment_inspections
**모델**: `App\Models\Equipment\EquipmentInspection`
- **equipment()**: belongsTo → `equipments`
- **inspector()**: belongsTo → `users`
- **details()**: hasMany → `equipment_inspection_details`
### equipment_inspection_details
**모델**: `App\Models\Equipment\EquipmentInspectionDetail`
- **inspection()**: belongsTo → `equipment_inspections`
- **templateItem()**: belongsTo → `equipment_inspection_templates`
### equipment_inspection_templates
**모델**: `App\Models\Equipment\EquipmentInspectionTemplate`
- **equipment()**: belongsTo → `equipments`
### equipment_process
**모델**: `App\Models\Equipment\EquipmentProcess`
- **equipment()**: belongsTo → `equipments`
- **process()**: belongsTo → `processes`
### equipment_repairs
**모델**: `App\Models\Equipment\EquipmentRepair`
- **equipment()**: belongsTo → `equipments`
- **repairer()**: belongsTo → `users`
### estimates ### estimates
**모델**: `App\Models\Estimate\Estimate` **모델**: `App\Models\Estimate\Estimate`
@@ -330,6 +433,12 @@ ### file_share_links
- **file()**: belongsTo → `files` - **file()**: belongsTo → `files`
- **tenant()**: belongsTo → `tenants` - **tenant()**: belongsTo → `tenants`
### corporate_vehicles
**모델**: `App\Models\Tenants\CorporateVehicle`
- **logs()**: hasMany → `vehicle_logs`
- **maintenances()**: hasMany → `vehicle_maintenances`
### folders ### folders
**모델**: `App\Models\Folder` **모델**: `App\Models\Folder`
@@ -580,17 +689,10 @@ ### roles
**모델**: `App\Models\Permissions\Role` **모델**: `App\Models\Permissions\Role`
- **tenant()**: belongsTo → `tenants` - **tenant()**: belongsTo → `tenants`
- **menuPermissions()**: hasMany → `role_menu_permissions`
- **userRoles()**: hasMany → `user_roles` - **userRoles()**: hasMany → `user_roles`
- **users()**: belongsToMany → `users` - **users()**: belongsToMany → `users`
- **permissions()**: belongsToMany → `permissions` - **permissions()**: belongsToMany → `permissions`
### role_menu_permissions
**모델**: `App\Models\Permissions\RoleMenuPermission`
- **role()**: belongsTo → `roles`
- **menu()**: belongsTo → `menus`
### popups ### popups
**모델**: `App\Models\Popups\Popup` **모델**: `App\Models\Popups\Popup`
@@ -621,6 +723,11 @@ ### process_steps
- **process()**: belongsTo → `processes` - **process()**: belongsTo → `processes`
### bending_item_mappings
**모델**: `App\Models\Production\BendingItemMapping`
- **item()**: belongsTo → `items`
### work_orders ### work_orders
**모델**: `App\Models\Production\WorkOrder` **모델**: `App\Models\Production\WorkOrder`
@@ -637,6 +744,7 @@ ### work_orders
- **stepProgress()**: hasMany → `work_order_step_progress` - **stepProgress()**: hasMany → `work_order_step_progress`
- **materialInputs()**: hasMany → `work_order_material_inputs` - **materialInputs()**: hasMany → `work_order_material_inputs`
- **shipments()**: hasMany → `shipments` - **shipments()**: hasMany → `shipments`
- **inspections()**: hasMany → `inspections`
- **bendingDetail()**: hasOne → `work_order_bending_details` - **bendingDetail()**: hasOne → `work_order_bending_details`
- **documents()**: morphMany → `documents` - **documents()**: morphMany → `documents`
@@ -740,9 +848,40 @@ ### push_notification_settings
**모델**: `App\Models\PushNotificationSetting` **모델**: `App\Models\PushNotificationSetting`
### audit_checklists
**모델**: `App\Models\Qualitys\AuditChecklist`
- **categories()**: hasMany → `audit_checklist_categories`
### audit_checklist_categorys
**모델**: `App\Models\Qualitys\AuditChecklistCategory`
- **checklist()**: belongsTo → `audit_checklists`
- **items()**: hasMany → `audit_checklist_items`
### audit_checklist_items
**모델**: `App\Models\Qualitys\AuditChecklistItem`
- **category()**: belongsTo → `audit_checklist_categories`
- **standardDocuments()**: hasMany → `audit_standard_documents`
### audit_standard_documents
**모델**: `App\Models\Qualitys\AuditStandardDocument`
- **checklistItem()**: belongsTo → `audit_checklist_items`
- **document()**: belongsTo → `documents`
### checklist_templates
**모델**: `App\Models\Qualitys\ChecklistTemplate`
- **creator()**: belongsTo → `users`
- **updater()**: belongsTo → `users`
- **documents()**: morphMany → `files`
### inspections ### inspections
**모델**: `App\Models\Qualitys\Inspection` **모델**: `App\Models\Qualitys\Inspection`
- **workOrder()**: belongsTo → `work_orders`
- **item()**: belongsTo → `items` - **item()**: belongsTo → `items`
- **inspector()**: belongsTo → `users` - **inspector()**: belongsTo → `users`
- **creator()**: belongsTo → `users` - **creator()**: belongsTo → `users`
@@ -758,6 +897,39 @@ ### lot_sales
- **lot()**: belongsTo → `lots` - **lot()**: belongsTo → `lots`
### performance_reports
**모델**: `App\Models\Qualitys\PerformanceReport`
- **qualityDocument()**: belongsTo → `quality_documents`
- **confirmer()**: belongsTo → `users`
- **creator()**: belongsTo → `users`
### quality_documents
**모델**: `App\Models\Qualitys\QualityDocument`
- **client()**: belongsTo → `clients`
- **inspector()**: belongsTo → `users`
- **creator()**: belongsTo → `users`
- **documentOrders()**: hasMany → `quality_document_orders`
- **locations()**: hasMany → `quality_document_locations`
- **performanceReport()**: hasOne → `performance_reports`
- **file()**: hasOne → `files`
### quality_document_locations
**모델**: `App\Models\Qualitys\QualityDocumentLocation`
- **qualityDocument()**: belongsTo → `quality_documents`
- **qualityDocumentOrder()**: belongsTo → `quality_document_orders`
- **orderItem()**: belongsTo → `order_items`
- **document()**: belongsTo → `documents`
### quality_document_orders
**모델**: `App\Models\Qualitys\QualityDocumentOrder`
- **qualityDocument()**: belongsTo → `quality_documents`
- **order()**: belongsTo → `orders`
- **locations()**: hasMany → `quality_document_locations`
### quotes ### quotes
**모델**: `App\Models\Quote\Quote` **모델**: `App\Models\Quote\Quote`
@@ -811,6 +983,11 @@ ### quote_revisions
- **quote()**: belongsTo → `quotes` - **quote()**: belongsTo → `quotes`
- **reviser()**: belongsTo → `users` - **reviser()**: belongsTo → `users`
### account_codes
**모델**: `App\Models\Tenants\AccountCode`
- **children()**: hasMany → `account_codes`
### ai_reports ### ai_reports
**모델**: `App\Models\Tenants\AiReport` **모델**: `App\Models\Tenants\AiReport`
@@ -830,12 +1007,23 @@ ### approvals
**모델**: `App\Models\Tenants\Approval` **모델**: `App\Models\Tenants\Approval`
- **form()**: belongsTo → `approval_forms` - **form()**: belongsTo → `approval_forms`
- **line()**: belongsTo → `approval_lines`
- **drafter()**: belongsTo → `users` - **drafter()**: belongsTo → `users`
- **department()**: belongsTo → `departments`
- **parentDocument()**: belongsTo → `approvals`
- **creator()**: belongsTo → `users` - **creator()**: belongsTo → `users`
- **updater()**: belongsTo → `users` - **updater()**: belongsTo → `users`
- **childDocuments()**: hasMany → `approvals`
- **steps()**: hasMany → `approval_steps` - **steps()**: hasMany → `approval_steps`
- **approverSteps()**: hasMany → `approval_steps` - **approverSteps()**: hasMany → `approval_steps`
- **referenceSteps()**: hasMany → `approval_steps` - **referenceSteps()**: hasMany → `approval_steps`
- **linkable()**: morphTo → `(Polymorphic)`
### approval_delegations
**모델**: `App\Models\Tenants\ApprovalDelegation`
- **delegator()**: belongsTo → `users`
- **delegate()**: belongsTo → `users`
### approval_forms ### approval_forms
**모델**: `App\Models\Tenants\ApprovalForm` **모델**: `App\Models\Tenants\ApprovalForm`
@@ -855,6 +1043,7 @@ ### approval_steps
- **approval()**: belongsTo → `approvals` - **approval()**: belongsTo → `approvals`
- **approver()**: belongsTo → `users` - **approver()**: belongsTo → `users`
- **actedBy()**: belongsTo → `users`
### attendances ### attendances
**모델**: `App\Models\Tenants\Attendance` **모델**: `App\Models\Tenants\Attendance`
@@ -933,6 +1122,16 @@ ### expense_accounts
- **vendor()**: belongsTo → `clients` - **vendor()**: belongsTo → `clients`
### journal_entrys
**모델**: `App\Models\Tenants\JournalEntry`
- **lines()**: hasMany → `journal_entry_lines`
### journal_entry_lines
**모델**: `App\Models\Tenants\JournalEntryLine`
- **journalEntry()**: belongsTo → `journal_entries`
### leaves ### leaves
**모델**: `App\Models\Tenants\Leave` **모델**: `App\Models\Tenants\Leave`
@@ -961,7 +1160,15 @@ ### leave_policys
### loans ### loans
**모델**: `App\Models\Tenants\Loan` **모델**: `App\Models\Tenants\Loan`
- **user()**: belongsTo → `users`
- **withdrawal()**: belongsTo → `withdrawals` - **withdrawal()**: belongsTo → `withdrawals`
- **creator()**: belongsTo → `users`
- **updater()**: belongsTo → `users`
### mail_logs
**모델**: `App\Models\Tenants\MailLog`
- **tenant()**: belongsTo → `tenants`
### payments ### payments
**모델**: `App\Models\Tenants\Payment` **모델**: `App\Models\Tenants\Payment`
@@ -1005,6 +1212,7 @@ ### receivings
**모델**: `App\Models\Tenants\Receiving` **모델**: `App\Models\Tenants\Receiving`
- **item()**: belongsTo → `items` - **item()**: belongsTo → `items`
- **certificateFile()**: belongsTo → `files`
- **creator()**: belongsTo → `users` - **creator()**: belongsTo → `users`
### salarys ### salarys
@@ -1040,15 +1248,23 @@ ### shipments
- **order()**: belongsTo → `orders` - **order()**: belongsTo → `orders`
- **workOrder()**: belongsTo → `work_orders` - **workOrder()**: belongsTo → `work_orders`
- **client()**: belongsTo → `clients`
- **creator()**: belongsTo → `users` - **creator()**: belongsTo → `users`
- **updater()**: belongsTo → `users` - **updater()**: belongsTo → `users`
- **items()**: hasMany → `shipment_items` - **items()**: hasMany → `shipment_items`
- **vehicleDispatches()**: hasMany → `shipment_vehicle_dispatches`
### shipment_items ### shipment_items
**모델**: `App\Models\Tenants\ShipmentItem` **모델**: `App\Models\Tenants\ShipmentItem`
- **shipment()**: belongsTo → `shipments` - **shipment()**: belongsTo → `shipments`
- **stockLot()**: belongsTo → `stock_lots` - **stockLot()**: belongsTo → `stock_lots`
- **orderItem()**: belongsTo → `order_items`
### shipment_vehicle_dispatchs
**모델**: `App\Models\Tenants\ShipmentVehicleDispatch`
- **shipment()**: belongsTo → `shipments`
### sites ### sites
**모델**: `App\Models\Tenants\Site` **모델**: `App\Models\Tenants\Site`
@@ -1120,6 +1336,11 @@ ### tenant_field_settings
- **fieldDef()**: belongsTo → `setting_field_defs` - **fieldDef()**: belongsTo → `setting_field_defs`
- **optionGroup()**: belongsTo → `tenant_option_groups` - **optionGroup()**: belongsTo → `tenant_option_groups`
### tenant_mail_configs
**모델**: `App\Models\Tenants\TenantMailConfig`
- **tenant()**: belongsTo → `tenants`
### tenant_option_groups ### tenant_option_groups
**모델**: `App\Models\Tenants\TenantOptionGroup` **모델**: `App\Models\Tenants\TenantOptionGroup`
@@ -1147,6 +1368,18 @@ ### tenant_user_profiles
### today_issues ### today_issues
**모델**: `App\Models\Tenants\TodayIssue` **모델**: `App\Models\Tenants\TodayIssue`
- **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 ### withdrawals
**모델**: `App\Models\Tenants\Withdrawal` **모델**: `App\Models\Tenants\Withdrawal`

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Console\Commands;
use App\Models\Quote\Quote;
use Illuminate\Console\Command;
class BackfillQuoteProductCodeCommand extends Command
{
protected $signature = 'data:backfill-quote-product-code {--dry-run : 실제 저장하지 않고 결과만 출력}';
protected $description = 'quotes.product_code가 비어있는 레코드에 calculation_inputs.items[0].productCode 값 보정';
public function handle(): int
{
$dryRun = $this->option('dry-run');
$quotes = Quote::whereNull('product_code')
->whereNotNull('calculation_inputs')
->get();
$this->info("대상: {$quotes->count()}".($dryRun ? ' (dry-run)' : ''));
$updated = 0;
$skipped = 0;
foreach ($quotes as $quote) {
$inputs = $quote->calculation_inputs;
if (! is_array($inputs)) {
$inputs = json_decode($inputs, true);
}
$productCode = $inputs['items'][0]['productCode'] ?? null;
if (! $productCode) {
$skipped++;
$this->line(" SKIP #{$quote->id} ({$quote->quote_number}) — productCode 없음");
continue;
}
if (! $dryRun) {
$quote->update(['product_code' => $productCode]);
}
$updated++;
$this->line(' '.($dryRun ? 'WOULD ' : '')."UPDATE #{$quote->id} ({$quote->quote_number}) → {$productCode}");
}
$this->info("완료: 보정 {$updated}건, 스킵 {$skipped}");
return self::SUCCESS;
}
}

View File

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

View File

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

@@ -40,8 +40,8 @@ public function handle(): int
foreach ($files as $file) { foreach ($files as $file) {
try { try {
// Delete physical file // Delete physical file
if (Storage::disk('tenant')->exists($file->file_path)) { if (Storage::disk('r2')->exists($file->file_path)) {
Storage::disk('tenant')->delete($file->file_path); Storage::disk('r2')->delete($file->file_path);
} }
// Force delete from DB // Force delete from DB

View File

@@ -60,8 +60,8 @@ private function permanentDelete(File $file): void
{ {
DB::transaction(function () use ($file) { DB::transaction(function () use ($file) {
// Delete physical file // Delete physical file
if (Storage::disk('tenant')->exists($file->file_path)) { if (Storage::disk('r2')->exists($file->file_path)) {
Storage::disk('tenant')->delete($file->file_path); Storage::disk('r2')->delete($file->file_path);
} }
// Update tenant storage usage // Update tenant storage usage

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

@@ -29,7 +29,7 @@ class RecordStorageUsage extends Command
*/ */
public function handle(): int public function handle(): int
{ {
$tenants = Tenant::where('status', 'active')->get(); $tenants = Tenant::active()->get();
$recorded = 0; $recorded = 0;
foreach ($tenants as $tenant) { foreach ($tenants as $tenant) {

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

@@ -0,0 +1,264 @@
<?php
namespace App\Console\Commands;
use App\Models\Commons\File;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\File as FileFacade;
class UploadLocalFilesToR2 extends Command
{
protected $signature = 'r2:upload-local
{--count=3 : Number of files to upload}
{--source=db : Source: "db" (latest DB records) or "disk" (latest local files)}
{--dry-run : Show files without uploading}
{--fix : Delete wrong-path files from R2 before re-uploading}';
protected $description = 'Upload local files to Cloudflare R2 (by DB records or local disk)';
public function handle(): int
{
$count = (int) $this->option('count');
$source = $this->option('source');
$dryRun = $this->option('dry-run');
$this->info("=== R2 Upload Tool ===");
$this->info("Source: {$source} | Count: {$count}");
return $source === 'db'
? $this->uploadFromDb($count, $dryRun)
: $this->uploadFromDisk($count, $dryRun);
}
/**
* Upload files based on DB records (latest by ID desc)
*/
private function uploadFromDb(int $count, bool $dryRun): int
{
$files = File::orderByDesc('id')->limit($count)->get();
if ($files->isEmpty()) {
$this->warn('No files in DB.');
return 0;
}
$this->newLine();
$headers = ['ID', 'Display Name', 'R2 Path', 'R2 Exists', 'Local Exists', 'Size'];
$rows = [];
foreach ($files as $f) {
$localPath = storage_path("app/tenants/{$f->file_path}");
$r2Exists = Storage::disk('r2')->exists($f->file_path);
$localExists = file_exists($localPath);
$rows[] = [
$f->id,
mb_strimwidth($f->display_name ?? '', 0, 25, '...'),
$f->file_path,
$r2Exists ? '✓ YES' : '✗ NO',
$localExists ? '✓ YES' : '✗ NO',
$f->file_size ? $this->formatSize($f->file_size) : '-',
];
}
$this->table($headers, $rows);
// Filter: local exists but R2 doesn't
$toUpload = $files->filter(function ($f) {
$localPath = storage_path("app/tenants/{$f->file_path}");
return file_exists($localPath) && !Storage::disk('r2')->exists($f->file_path);
});
$alreadyInR2 = $files->filter(function ($f) {
return Storage::disk('r2')->exists($f->file_path);
});
if ($alreadyInR2->isNotEmpty()) {
$this->info("Already in R2: {$alreadyInR2->count()} files (skipped)");
}
if ($toUpload->isEmpty()) {
$missingBoth = $files->filter(function ($f) {
$localPath = storage_path("app/tenants/{$f->file_path}");
return !file_exists($localPath) && !Storage::disk('r2')->exists($f->file_path);
});
if ($missingBoth->isNotEmpty()) {
$this->warn("Missing both locally and in R2: {$missingBoth->count()} files");
$this->warn("These files may exist on the dev server only.");
}
$this->info('Nothing to upload.');
return 0;
}
if ($dryRun) {
$this->warn("[DRY RUN] Would upload {$toUpload->count()} files.");
return 0;
}
// Test R2 connection
$this->info('Testing R2 connection...');
try {
Storage::disk('r2')->directories('/');
$this->info('✓ R2 connection OK');
} catch (\Exception $e) {
$this->error('✗ R2 connection failed: ' . $e->getMessage());
return 1;
}
// Upload
$bar = $this->output->createProgressBar($toUpload->count());
$bar->start();
$success = 0;
$failed = 0;
foreach ($toUpload as $f) {
$localPath = storage_path("app/tenants/{$f->file_path}");
try {
$content = FileFacade::get($localPath);
$mimeType = $f->mime_type ?: FileFacade::mimeType($localPath);
Storage::disk('r2')->put($f->file_path, $content, [
'ContentType' => $mimeType,
]);
$success++;
} catch (\Exception $e) {
$failed++;
$this->newLine();
$this->error(" ✗ ID {$f->id}: {$e->getMessage()}");
}
$bar->advance();
}
$bar->finish();
$this->newLine(2);
$this->info("=== Upload Complete ===");
$this->info("✓ Success: {$success}");
if ($failed > 0) {
$this->error("✗ Failed: {$failed}");
return 1;
}
return 0;
}
/**
* Upload files based on local disk (newest files by mtime)
*/
private function uploadFromDisk(int $count, bool $dryRun): int
{
$storagePath = storage_path('app/tenants');
if (!is_dir($storagePath)) {
$this->error("Path not found: {$storagePath}");
return 1;
}
$allFiles = $this->collectFiles($storagePath);
if (empty($allFiles)) {
$this->warn('No files found.');
return 0;
}
usort($allFiles, fn($a, $b) => filemtime($b) - filemtime($a));
$filesToUpload = array_slice($allFiles, 0, $count);
$this->info("Found " . count($allFiles) . " total files, uploading {$count} most recent:");
$this->newLine();
$headers = ['#', 'File', 'Size', 'Modified', 'R2 Path'];
$rows = [];
foreach ($filesToUpload as $i => $filePath) {
$r2Path = $this->toR2Path($filePath);
$rows[] = [$i + 1, basename($filePath), $this->formatSize(filesize($filePath)), date('Y-m-d H:i:s', filemtime($filePath)), $r2Path];
}
$this->table($headers, $rows);
if ($dryRun) {
$this->warn('[DRY RUN] No files uploaded.');
return 0;
}
$this->info('Testing R2 connection...');
try {
Storage::disk('r2')->directories('/');
$this->info('✓ R2 connection OK');
} catch (\Exception $e) {
$this->error('✗ R2 connection failed: ' . $e->getMessage());
return 1;
}
$bar = $this->output->createProgressBar(count($filesToUpload));
$bar->start();
$success = 0;
$failed = 0;
$fix = $this->option('fix');
foreach ($filesToUpload as $filePath) {
$r2Path = $this->toR2Path($filePath);
try {
if ($fix) {
$wrongPath = $this->toRelativePath($filePath);
if ($wrongPath !== $r2Path && Storage::disk('r2')->exists($wrongPath)) {
Storage::disk('r2')->delete($wrongPath);
}
}
$content = FileFacade::get($filePath);
$mimeType = FileFacade::mimeType($filePath);
Storage::disk('r2')->put($r2Path, $content, ['ContentType' => $mimeType]);
$success++;
} catch (\Exception $e) {
$failed++;
$this->newLine();
$this->error(" ✗ Failed: {$r2Path} - {$e->getMessage()}");
}
$bar->advance();
}
$bar->finish();
$this->newLine(2);
$this->info("=== Upload Complete ===");
$this->info("✓ Success: {$success}");
if ($failed > 0) {
$this->error("✗ Failed: {$failed}");
return 1;
}
return 0;
}
private function toR2Path(string $filePath): string
{
$relative = $this->toRelativePath($filePath);
return str_starts_with($relative, 'tenants/') ? substr($relative, strlen('tenants/')) : $relative;
}
private function toRelativePath(string $filePath): string
{
return str_replace(str_replace('\\', '/', storage_path('app/')), '', str_replace('\\', '/', $filePath));
}
private function collectFiles(string $dir): array
{
$files = [];
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
if ($file->isFile() && $file->getFilename() !== '.gitignore') {
$files[] = $file->getPathname();
}
}
return $files;
}
private function formatSize(int $bytes): string
{
if ($bytes >= 1048576) return round($bytes / 1048576, 1) . ' MB';
return round($bytes / 1024, 1) . ' KB';
}
}

View File

@@ -0,0 +1,232 @@
<?php
namespace App\Enums;
use App\Models\Commons\Holiday;
use Carbon\Carbon;
class InspectionCycle
{
const DAILY = 'daily';
const WEEKLY = 'weekly';
const MONTHLY = 'monthly';
const BIMONTHLY = 'bimonthly';
const QUARTERLY = 'quarterly';
const SEMIANNUAL = 'semiannual';
public static function all(): array
{
return [
self::DAILY => '일일',
self::WEEKLY => '주간',
self::MONTHLY => '월간',
self::BIMONTHLY => '2개월',
self::QUARTERLY => '분기',
self::SEMIANNUAL => '반년',
];
}
public static function label(string $cycle): string
{
return self::all()[$cycle] ?? $cycle;
}
public static function periodType(string $cycle): string
{
return $cycle === self::DAILY ? 'month' : 'year';
}
public static function columnLabels(string $cycle, ?string $period = null): array
{
return match ($cycle) {
self::DAILY => self::dailyLabels($period),
self::WEEKLY => self::weeklyLabels(),
self::MONTHLY => self::monthlyLabels(),
self::BIMONTHLY => self::bimonthlyLabels(),
self::QUARTERLY => self::quarterlyLabels(),
self::SEMIANNUAL => self::semiannualLabels(),
default => self::dailyLabels($period),
};
}
public static function resolveCheckDate(string $cycle, string $period, int $colIndex): string
{
return match ($cycle) {
self::DAILY => self::dailyCheckDate($period, $colIndex),
self::WEEKLY => self::weeklyCheckDate($period, $colIndex),
self::MONTHLY => self::monthlyCheckDate($period, $colIndex),
self::BIMONTHLY => self::bimonthlyCheckDate($period, $colIndex),
self::QUARTERLY => self::quarterlyCheckDate($period, $colIndex),
self::SEMIANNUAL => self::semiannualCheckDate($period, $colIndex),
default => self::dailyCheckDate($period, $colIndex),
};
}
public static function resolvePeriod(string $cycle, string $checkDate): string
{
$date = Carbon::parse($checkDate);
return match ($cycle) {
self::DAILY => $date->format('Y-m'),
self::WEEKLY => (string) $date->isoWeekYear,
default => $date->format('Y'),
};
}
public static function columnCount(string $cycle, ?string $period = null): int
{
return count(self::columnLabels($cycle, $period));
}
public static function isWeekend(string $period, int $colIndex): bool
{
$date = Carbon::createFromFormat('Y-m', $period)->startOfMonth()->addDays($colIndex - 1);
return in_array($date->dayOfWeek, [0, 6]);
}
public static function getHolidayDates(string $cycle, string $period, int $tenantId): array
{
if ($cycle === self::DAILY) {
$start = Carbon::createFromFormat('Y-m', $period)->startOfMonth();
$end = $start->copy()->endOfMonth();
} else {
$start = Carbon::create((int) $period, 1, 1);
$end = Carbon::create((int) $period, 12, 31);
}
$holidays = Holiday::where('tenant_id', $tenantId)
->where('start_date', '<=', $end->toDateString())
->where('end_date', '>=', $start->toDateString())
->get();
$dates = [];
foreach ($holidays as $holiday) {
$hStart = $holiday->start_date->copy()->max($start);
$hEnd = $holiday->end_date->copy()->min($end);
$current = $hStart->copy();
while ($current->lte($hEnd)) {
$dates[$current->format('Y-m-d')] = true;
$current->addDay();
}
}
return $dates;
}
public static function isNonWorkingDay(string $checkDate, array $holidayDates = []): bool
{
$date = Carbon::parse($checkDate);
return $date->isWeekend() || isset($holidayDates[$checkDate]);
}
// --- Daily ---
private static function dailyLabels(?string $period): array
{
$date = Carbon::createFromFormat('Y-m', $period ?? now()->format('Y-m'));
$days = $date->daysInMonth;
$labels = [];
for ($d = 1; $d <= $days; $d++) {
$labels[$d] = (string) $d;
}
return $labels;
}
private static function dailyCheckDate(string $period, int $colIndex): string
{
return Carbon::createFromFormat('Y-m', $period)->startOfMonth()->addDays($colIndex - 1)->format('Y-m-d');
}
// --- Weekly ---
private static function weeklyLabels(): array
{
$labels = [];
for ($w = 1; $w <= 52; $w++) {
$labels[$w] = $w.'주';
}
return $labels;
}
private static function weeklyCheckDate(string $year, int $colIndex): string
{
return Carbon::create((int) $year)->setISODate((int) $year, $colIndex, 1)->format('Y-m-d');
}
// --- Monthly ---
private static function monthlyLabels(): array
{
$labels = [];
for ($m = 1; $m <= 12; $m++) {
$labels[$m] = $m.'월';
}
return $labels;
}
private static function monthlyCheckDate(string $year, int $colIndex): string
{
return Carbon::create((int) $year, $colIndex, 1)->format('Y-m-d');
}
// --- Bimonthly ---
private static function bimonthlyLabels(): array
{
return [
1 => '1~2월',
2 => '3~4월',
3 => '5~6월',
4 => '7~8월',
5 => '9~10월',
6 => '11~12월',
];
}
private static function bimonthlyCheckDate(string $year, int $colIndex): string
{
$month = ($colIndex - 1) * 2 + 1;
return Carbon::create((int) $year, $month, 1)->format('Y-m-d');
}
// --- Quarterly ---
private static function quarterlyLabels(): array
{
return [
1 => '1분기',
2 => '2분기',
3 => '3분기',
4 => '4분기',
];
}
private static function quarterlyCheckDate(string $year, int $colIndex): string
{
$month = ($colIndex - 1) * 3 + 1;
return Carbon::create((int) $year, $month, 1)->format('Y-m-d');
}
// --- Semiannual ---
private static function semiannualLabels(): array
{
return [
1 => '상반기',
2 => '하반기',
];
}
private static function semiannualCheckDate(string $year, int $colIndex): string
{
$month = $colIndex === 1 ? 1 : 7;
return Carbon::create((int) $year, $month, 1)->format('Y-m-d');
}
}

View File

@@ -17,25 +17,13 @@
class Handler extends ExceptionHandler class Handler extends ExceptionHandler
{ {
/** /**
* 특정 IP에서 발생하는 예외를 슬랙/로그에서 무시할지 확인 * 슬랙 알림에서 무시할 예외인지 확인
*/ */
protected function shouldIgnoreException(Throwable $e): bool protected function shouldIgnoreException(Throwable $e): bool
{ {
$ignoredIps = array_filter( // 세션 만료로 인한 인증 실패는 슬랙 알림 제외 (API Key 검증 통과 후 발생하므로 정상 케이스)
array_map('trim', explode(',', env('EXCEPTION_IGNORED_IPS', ''))) if ($e instanceof AuthenticationException && $e->getMessage() === '회원정보 정보 없음') {
); return true;
if (empty($ignoredIps)) {
return false;
}
$currentIp = request()?->ip();
// 무시할 IP 목록에 있고, '회원정보 정보 없음' 예외인 경우
if (in_array($currentIp, $ignoredIps, true)) {
if ($e instanceof AuthenticationException && $e->getMessage() === '회원정보 정보 없음') {
return true;
}
} }
return false; return false;
@@ -107,7 +95,7 @@ public function render($request, Throwable $exception)
if ($exception instanceof BadRequestHttpException) { if ($exception instanceof BadRequestHttpException) {
return response()->json([ return response()->json([
'success' => false, 'success' => false,
'message' => '잘못된 요청', 'message' => $exception->getMessage() ?: '잘못된 요청',
'data' => null, 'data' => null,
], 400); ], 400);
} }

View File

@@ -7,6 +7,7 @@
use Maatwebsite\Excel\Concerns\WithHeadings; use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\WithStyles; use Maatwebsite\Excel\Concerns\WithStyles;
use Maatwebsite\Excel\Concerns\WithTitle; use Maatwebsite\Excel\Concerns\WithTitle;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
class DailyReportExport implements FromArray, ShouldAutoSize, WithHeadings, WithStyles, WithTitle class DailyReportExport implements FromArray, ShouldAutoSize, WithHeadings, WithStyles, WithTitle
@@ -31,10 +32,10 @@ public function headings(): array
return [ return [
['일일 일보 - '.$this->report['date']], ['일일 일보 - '.$this->report['date']],
[], [],
['전일 잔액', number_format($this->report['previous_balance']).'원'], ['전월 이월', number_format($this->report['previous_balance']).'원'],
['당 입금', number_format($this->report['daily_deposit']).'원'], ['당 입금', number_format($this->report['daily_deposit']).'원'],
['당 출금', number_format($this->report['daily_withdrawal']).'원'], ['당 출금', number_format($this->report['daily_withdrawal']).'원'],
['당일 잔액', number_format($this->report['current_balance']).'원'], ['잔액', number_format($this->report['current_balance']).'원'],
[], [],
['구분', '거래처명', '계정과목', '입금액', '출금액', '적요'], ['구분', '거래처명', '계정과목', '입금액', '출금액', '적요'],
]; ];
@@ -47,6 +48,7 @@ public function array(): array
{ {
$rows = []; $rows = [];
// ── 예금 입출금 내역 ──
foreach ($this->report['details'] as $detail) { foreach ($this->report['details'] as $detail) {
$rows[] = [ $rows[] = [
$detail['type_label'], $detail['type_label'],
@@ -58,7 +60,7 @@ public function array(): array
]; ];
} }
// 합계 행 추가 // 합계 행
$rows[] = []; $rows[] = [];
$rows[] = [ $rows[] = [
'합계', '합계',
@@ -69,6 +71,37 @@ public function array(): array
'', '',
]; ];
// ── 어음 및 외상매출채권 현황 ──
$noteReceivables = $this->report['note_receivables'] ?? [];
$rows[] = [];
$rows[] = [];
$rows[] = ['어음 및 외상매출채권 현황'];
$rows[] = ['No.', '내용', '금액', '발행일', '만기일'];
$noteTotal = 0;
$no = 1;
foreach ($noteReceivables as $item) {
$amount = $item['current_balance'] ?? 0;
$noteTotal += $amount;
$rows[] = [
$no++,
$item['content'] ?? '-',
$amount > 0 ? number_format($amount) : '',
$item['issue_date'] ?? '-',
$item['due_date'] ?? '-',
];
}
// 어음 합계
$rows[] = [
'합계',
'',
number_format($noteTotal),
'',
'',
];
return $rows; return $rows;
} }
@@ -77,7 +110,7 @@ public function array(): array
*/ */
public function styles(Worksheet $sheet): array public function styles(Worksheet $sheet): array
{ {
return [ $styles = [
1 => ['font' => ['bold' => true, 'size' => 14]], 1 => ['font' => ['bold' => true, 'size' => 14]],
3 => ['font' => ['bold' => true]], 3 => ['font' => ['bold' => true]],
4 => ['font' => ['bold' => true]], 4 => ['font' => ['bold' => true]],
@@ -86,10 +119,32 @@ public function styles(Worksheet $sheet): array
8 => [ 8 => [
'font' => ['bold' => true], 'font' => ['bold' => true],
'fill' => [ 'fill' => [
'fillType' => \PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID, 'fillType' => Fill::FILL_SOLID,
'startColor' => ['rgb' => 'E0E0E0'], 'startColor' => ['rgb' => 'E0E0E0'],
], ],
], ],
]; ];
// 어음 섹션 헤더 스타일 (동적 행 번호)
// headings 8행 + details 수 + 합계 2행 + 빈 2행 + 어음 제목 1행 + 어음 헤더 1행
$detailCount = count($this->report['details']);
$noteHeaderTitleRow = 8 + $detailCount + 2 + 2 + 1; // 어음 제목 행
$noteHeaderRow = $noteHeaderTitleRow + 1; // 어음 컬럼 헤더 행
$styles[$noteHeaderTitleRow] = ['font' => ['bold' => true, 'size' => 12]];
$styles[$noteHeaderRow] = [
'font' => ['bold' => true],
'fill' => [
'fillType' => Fill::FILL_SOLID,
'startColor' => ['rgb' => 'E0E0E0'],
],
];
// 어음 합계 행
$noteCount = count($this->report['note_receivables'] ?? []);
$noteTotalRow = $noteHeaderRow + $noteCount + 1;
$styles[$noteTotalRow] = ['font' => ['bold' => true]];
return $styles;
} }
} }

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, int $code = 400,
array $error = [] array $error = []
): JsonResponse { ): 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([ return response()->json([
'success' => false, 'success' => false,
'message' => "[{$code}] {$message}", 'message' => "[{$code}] {$message}",
'error' => [ 'error' => $errorBody,
'code' => $code,
'details' => $error['details'] ?? null,
],
], $code); ], $code);
} }
@@ -225,8 +234,16 @@ public static function handle(
$message = (string) ($result['message'] ?? ($result['error'] ?? '서버 에러')); $message = (string) ($result['message'] ?? ($result['error'] ?? '서버 에러'));
$details = $result['details'] ?? null; $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()가 처리하게 맡김 // 에러에도 쿼리 로그 포함되도록 error()가 처리하게 맡김
return self::error($message, $code, ['details' => $details]); return self::error($message, $code, $errorData);
} }
// 표준 박스( ['data'=>..., 'query'=>..., 'statusCode'=>...] ) 하위호환 // 표준 박스( ['data'=>..., 'query'=>..., 'statusCode'=>...] ) 하위호환

View File

@@ -14,7 +14,7 @@
* - 두께 매핑 (normalizeThickness) * - 두께 매핑 (normalizeThickness)
* - 면적 계산 (calculateArea) * - 면적 계산 (calculateArea)
* *
* @see docs/plans/5130-sam-data-migration-plan.md 섹션 4.5 * @see docs/dev_plans/5130-sam-data-migration-plan.md 섹션 4.5
*/ */
class Legacy5130Calculator class Legacy5130Calculator
{ {

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,87 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\V1\AccountSubject\StoreAccountSubjectRequest;
use App\Http\Requests\V1\AccountSubject\UpdateAccountSubjectRequest;
use App\Services\AccountCodeService;
use Illuminate\Http\Request;
class AccountSubjectController extends Controller
{
public function __construct(
private readonly AccountCodeService $service
) {}
/**
* 계정과목 목록 조회
*/
public function index(Request $request)
{
$params = $request->only([
'search', 'category', 'sub_category',
'department_type', 'depth', 'is_active', 'selectable',
]);
$subjects = $this->service->index($params);
return ApiResponse::success($subjects, __('message.fetched'));
}
/**
* 계정과목 등록
*/
public function store(StoreAccountSubjectRequest $request)
{
$subject = $this->service->store($request->validated());
return ApiResponse::success($subject, __('message.created'), [], 201);
}
/**
* 계정과목 수정
*/
public function update(int $id, UpdateAccountSubjectRequest $request)
{
$subject = $this->service->update($id, $request->validated());
return ApiResponse::success($subject, __('message.updated'));
}
/**
* 계정과목 활성/비활성 토글
*/
public function toggleStatus(int $id, Request $request)
{
$isActive = (bool) $request->input('is_active', true);
$subject = $this->service->toggleStatus($id, $isActive);
return ApiResponse::success($subject, __('message.toggled'));
}
/**
* 계정과목 삭제
*/
public function destroy(int $id)
{
$this->service->destroy($id);
return ApiResponse::success(null, __('message.deleted'));
}
/**
* 기본 계정과목표 일괄 생성 (더존 표준)
*/
public function seedDefaults()
{
$count = $this->service->seedDefaults();
return ApiResponse::success(
['inserted_count' => $count],
__('message.created')
);
}
}

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,8 +4,14 @@
use App\Helpers\ApiResponse; use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Approval\ApproveRequest;
use App\Http\Requests\Approval\CancelRequest;
use App\Http\Requests\Approval\DelegationStoreRequest;
use App\Http\Requests\Approval\DelegationUpdateRequest;
use App\Http\Requests\Approval\HoldRequest;
use App\Http\Requests\Approval\InboxIndexRequest; use App\Http\Requests\Approval\InboxIndexRequest;
use App\Http\Requests\Approval\IndexRequest; use App\Http\Requests\Approval\IndexRequest;
use App\Http\Requests\Approval\PreDecideRequest;
use App\Http\Requests\Approval\ReferenceIndexRequest; use App\Http\Requests\Approval\ReferenceIndexRequest;
use App\Http\Requests\Approval\RejectRequest; use App\Http\Requests\Approval\RejectRequest;
use App\Http\Requests\Approval\StoreRequest; use App\Http\Requests\Approval\StoreRequest;
@@ -133,10 +139,10 @@ public function submit(int $id, SubmitRequest $request): JsonResponse
* 결재 승인 * 결재 승인
* POST /v1/approvals/{id}/approve * POST /v1/approvals/{id}/approve
*/ */
public function approve(int $id, Request $request): JsonResponse public function approve(int $id, ApproveRequest $request): JsonResponse
{ {
return ApiResponse::handle(function () use ($id, $request) { return ApiResponse::handle(function () use ($id, $request) {
return $this->service->approve($id, $request->input('comment')); return $this->service->approve($id, $request->validated()['comment'] ?? null);
}, __('message.approval.approved')); }, __('message.approval.approved'));
} }
@@ -155,11 +161,99 @@ public function reject(int $id, RejectRequest $request): JsonResponse
* 결재 회수 (기안자만) * 결재 회수 (기안자만)
* POST /v1/approvals/{id}/cancel * POST /v1/approvals/{id}/cancel
*/ */
public function cancel(int $id): JsonResponse public function cancel(int $id, CancelRequest $request): JsonResponse
{
return ApiResponse::handle(function () use ($id, $request) {
return $this->service->cancel($id, $request->validated()['recall_reason'] ?? null);
}, __('message.approval.cancelled'));
}
/**
* 보류 (현재 결재자만)
* POST /v1/approvals/{id}/hold
*/
public function hold(int $id, HoldRequest $request): JsonResponse
{
return ApiResponse::handle(function () use ($id, $request) {
return $this->service->hold($id, $request->validated()['comment']);
}, __('message.approval.held'));
}
/**
* 보류 해제 (보류한 결재자만)
* POST /v1/approvals/{id}/release-hold
*/
public function releaseHold(int $id): JsonResponse
{ {
return ApiResponse::handle(function () use ($id) { return ApiResponse::handle(function () use ($id) {
return $this->service->cancel($id); return $this->service->releaseHold($id);
}, __('message.approval.cancelled')); }, __('message.approval.hold_released'));
}
/**
* 전결 (현재 결재자가 이후 모든 결재를 건너뛰고 최종 승인)
* POST /v1/approvals/{id}/pre-decide
*/
public function preDecide(int $id, PreDecideRequest $request): JsonResponse
{
return ApiResponse::handle(function () use ($id, $request) {
return $this->service->preDecide($id, $request->validated()['comment'] ?? null);
}, __('message.approval.pre_decided'));
}
/**
* 복사 재기안
* POST /v1/approvals/{id}/copy
*/
public function copyForRedraft(int $id): JsonResponse
{
return ApiResponse::handle(function () use ($id) {
return $this->service->copyForRedraft($id);
}, __('message.approval.copied'));
}
/**
* 완료함 목록
* GET /v1/approvals/completed
*/
public function completed(IndexRequest $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
return $this->service->completed($request->validated());
}, __('message.fetched'));
}
/**
* 완료함 현황 카드
* GET /v1/approvals/completed/summary
*/
public function completedSummary(): JsonResponse
{
return ApiResponse::handle(function () {
return $this->service->completedSummary();
}, __('message.fetched'));
}
/**
* 미처리 건수 (뱃지용)
* GET /v1/approvals/badge-counts
*/
public function badgeCounts(): JsonResponse
{
return ApiResponse::handle(function () {
return $this->service->badgeCounts();
}, __('message.fetched'));
}
/**
* 완료함 미읽음 일괄 읽음 처리
* POST /v1/approvals/completed/mark-read
*/
public function markCompletedAsRead(): JsonResponse
{
return ApiResponse::handle(function () {
return $this->service->markCompletedAsRead();
}, __('message.approval.marked_read'));
} }
/** /**
@@ -183,4 +277,52 @@ public function markUnread(int $id): JsonResponse
return $this->service->markUnread($id); return $this->service->markUnread($id);
}, __('message.approval.marked_unread')); }, __('message.approval.marked_unread'));
} }
// =========================================================================
// 위임 관리
// =========================================================================
/**
* 위임 목록
* GET /v1/approvals/delegations
*/
public function delegationIndex(Request $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
return $this->service->delegationIndex($request->all());
}, __('message.fetched'));
}
/**
* 위임 생성
* POST /v1/approvals/delegations
*/
public function delegationStore(DelegationStoreRequest $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
return $this->service->delegationStore($request->validated());
}, __('message.created'));
}
/**
* 위임 수정
* PATCH /v1/approvals/delegations/{id}
*/
public function delegationUpdate(int $id, DelegationUpdateRequest $request): JsonResponse
{
return ApiResponse::handle(function () use ($id, $request) {
return $this->service->delegationUpdate($id, $request->validated());
}, __('message.updated'));
}
/**
* 위임 삭제
* DELETE /v1/approvals/delegations/{id}
*/
public function delegationDestroy(int $id): JsonResponse
{
return ApiResponse::handle(function () use ($id) {
return $this->service->delegationDestroy($id);
}, __('message.deleted'));
}
} }

View File

@@ -0,0 +1,112 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\Qms\AuditChecklistStoreRequest;
use App\Http\Requests\Qms\AuditChecklistUpdateRequest;
use App\Services\AuditChecklistService;
use Illuminate\Http\Request;
class AuditChecklistController extends Controller
{
public function __construct(private AuditChecklistService $service) {}
/**
* 점검표 목록
*/
public function index(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->index($request->all());
}, __('message.fetched'));
}
/**
* 점검표 생성 (카테고리+항목 일괄)
*/
public function store(AuditChecklistStoreRequest $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->store($request->validated());
}, __('message.created'));
}
/**
* 점검표 상세
*/
public function show(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->show($id);
}, __('message.fetched'));
}
/**
* 점검표 수정
*/
public function update(AuditChecklistUpdateRequest $request, int $id)
{
return ApiResponse::handle(function () use ($request, $id) {
return $this->service->update($id, $request->validated());
}, __('message.updated'));
}
/**
* 점검표 완료 처리
*/
public function complete(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->complete($id);
}, __('message.updated'));
}
/**
* 항목 완료/미완료 토글
*/
public function toggleItem(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->toggleItem($id);
}, __('message.updated'));
}
/**
* 항목별 기준 문서 조회
*/
public function itemDocuments(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->itemDocuments($id);
}, __('message.fetched'));
}
/**
* 기준 문서 연결
*/
public function attachDocument(Request $request, int $id)
{
return ApiResponse::handle(function () use ($request, $id) {
return $this->service->attachDocument($id, $request->validate([
'title' => 'required|string|max:200',
'version' => 'nullable|string|max:20',
'date' => 'nullable|date',
'document_id' => 'nullable|integer|exists:documents,id',
]));
}, __('message.created'));
}
/**
* 기준 문서 연결 해제
*/
public function detachDocument(int $id, int $docId)
{
return ApiResponse::handle(function () use ($id, $docId) {
$this->service->detachDocument($id, $docId);
return null;
}, __('message.deleted'));
}
}

View File

@@ -0,0 +1,287 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Models\Tenants\JournalEntry;
use App\Services\BarobillBankTransactionService;
use App\Services\JournalSyncService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* 바로빌 은행 거래 API 컨트롤러 (React 연동용)
*
* MNG에서 동기화된 은행 거래 데이터를 React에서 조회/관리
*/
class BarobillBankTransactionController extends Controller
{
public function __construct(
protected BarobillBankTransactionService $service,
protected JournalSyncService $journalSyncService,
) {}
/**
* 은행 거래 목록 조회
*/
public function index(Request $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
$params = $request->validate([
'start_date' => 'nullable|date',
'end_date' => 'nullable|date|after_or_equal:start_date',
'bank_account_num' => 'nullable|string|max:50',
'search' => 'nullable|string|max:100',
'per_page' => 'nullable|integer|min:1|max:100',
'page' => 'nullable|integer|min:1',
]);
return $this->service->index($params);
}, __('message.fetched'));
}
/**
* 계좌 목록 (필터용)
*/
public function accounts(): JsonResponse
{
return ApiResponse::handle(function () {
return $this->service->accounts();
}, __('message.fetched'));
}
/**
* 잔액 요약
*/
public function balanceSummary(Request $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
$params = $request->validate([
'date' => 'nullable|date',
]);
return $this->service->balanceSummary($params);
}, __('message.fetched'));
}
// =========================================================================
// 분할 (Splits)
// =========================================================================
/**
* 거래 분할 조회
*/
public function getSplits(Request $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
$validated = $request->validate([
'unique_key' => 'required|string|max:500',
]);
return $this->service->getSplits($validated['unique_key']);
}, __('message.fetched'));
}
/**
* 거래 분할 저장
*/
public function saveSplits(Request $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
$validated = $request->validate([
'unique_key' => 'required|string|max:500',
'items' => 'required|array|min:1',
'items.*.split_amount' => 'required|numeric',
'items.*.account_code' => 'nullable|string|max:20',
'items.*.account_name' => 'nullable|string|max:100',
'items.*.deduction_type' => 'nullable|string|max:20',
'items.*.evidence_name' => 'nullable|string|max:100',
'items.*.description' => 'nullable|string|max:500',
'items.*.memo' => 'nullable|string|max:500',
'items.*.bank_account_num' => 'nullable|string|max:50',
'items.*.trans_dt' => 'nullable|string|max:20',
'items.*.trans_date' => 'nullable|date',
'items.*.original_deposit' => 'nullable|numeric',
'items.*.original_withdraw' => 'nullable|numeric',
'items.*.summary' => 'nullable|string|max:500',
]);
return $this->service->saveSplits($validated['unique_key'], $validated['items']);
}, __('message.created'));
}
/**
* 거래 분할 삭제
*/
public function deleteSplits(Request $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
$validated = $request->validate([
'unique_key' => 'required|string|max:500',
]);
return $this->service->deleteSplits($validated['unique_key']);
}, __('message.deleted'));
}
// =========================================================================
// 오버라이드 (Override)
// =========================================================================
/**
* 적요/분류 오버라이드 저장
*/
public function saveOverride(Request $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
$validated = $request->validate([
'unique_key' => 'required|string|max:500',
'modified_summary' => 'nullable|string|max:500',
'modified_cast' => 'nullable|string|max:100',
]);
return $this->service->saveOverride(
$validated['unique_key'],
$validated['modified_summary'] ?? null,
$validated['modified_cast'] ?? null
);
}, __('message.updated'));
}
// =========================================================================
// 수동 입력 (Manual)
// =========================================================================
/**
* 수동 은행 거래 등록
*/
public function storeManual(Request $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
$validated = $request->validate([
'bank_account_num' => 'required|string|max:50',
'bank_code' => 'nullable|string|max:10',
'bank_name' => 'nullable|string|max:50',
'trans_date' => 'required|date',
'trans_time' => 'nullable|string|max:10',
'trans_dt' => 'nullable|string|max:20',
'deposit' => 'nullable|numeric|min:0',
'withdraw' => 'nullable|numeric|min:0',
'balance' => 'nullable|numeric',
'summary' => 'nullable|string|max:500',
'cast' => 'nullable|string|max:100',
'memo' => 'nullable|string|max:500',
'trans_office' => 'nullable|string|max:100',
'account_code' => 'nullable|string|max:20',
'account_name' => 'nullable|string|max:100',
'client_code' => 'nullable|string|max:20',
'client_name' => 'nullable|string|max:200',
]);
return $this->service->storeManual($validated);
}, __('message.created'));
}
/**
* 수동 은행 거래 수정
*/
public function updateManual(Request $request, int $id): JsonResponse
{
return ApiResponse::handle(function () use ($request, $id) {
$validated = $request->validate([
'deposit' => 'nullable|numeric|min:0',
'withdraw' => 'nullable|numeric|min:0',
'balance' => 'nullable|numeric',
'summary' => 'nullable|string|max:500',
'cast' => 'nullable|string|max:100',
'memo' => 'nullable|string|max:500',
'account_code' => 'nullable|string|max:20',
'account_name' => 'nullable|string|max:100',
'client_code' => 'nullable|string|max:20',
'client_name' => 'nullable|string|max:200',
]);
return $this->service->updateManual($id, $validated);
}, __('message.updated'));
}
/**
* 수동 은행 거래 삭제
*/
public function destroyManual(int $id): JsonResponse
{
return ApiResponse::handle(function () use ($id) {
return $this->service->destroyManual($id);
}, __('message.deleted'));
}
// =========================================================================
// 분개 (Journal Entries)
// =========================================================================
/**
* 은행 거래 분개 조회
*/
public function getJournalEntries(int $id): JsonResponse
{
return ApiResponse::handle(function () use ($id) {
$sourceKey = "barobill_bank_{$id}";
return $this->journalSyncService->getForSource(
JournalEntry::SOURCE_BAROBILL_BANK,
$sourceKey
) ?? ['items' => []];
}, __('message.fetched'));
}
/**
* 은행 거래 분개 저장
*/
public function storeJournalEntries(Request $request, int $id): JsonResponse
{
return ApiResponse::handle(function () use ($request, $id) {
$validated = $request->validate([
'items' => 'required|array|min:1',
'items.*.side' => 'required|in:debit,credit',
'items.*.account_code' => 'required|string|max:20',
'items.*.account_name' => 'nullable|string|max:100',
'items.*.debit_amount' => 'required|integer|min:0',
'items.*.credit_amount' => 'required|integer|min:0',
'items.*.vendor_name' => 'nullable|string|max:200',
'items.*.memo' => 'nullable|string|max:500',
]);
$bankTx = \App\Models\Barobill\BarobillBankTransaction::find($id);
if (! $bankTx) {
throw new \Illuminate\Database\Eloquent\ModelNotFoundException;
}
$entryDate = $bankTx->trans_date ?? now()->format('Y-m-d');
$sourceKey = "barobill_bank_{$id}";
return $this->journalSyncService->saveForSource(
JournalEntry::SOURCE_BAROBILL_BANK,
$sourceKey,
$entryDate,
"바로빌 은행거래 분개 (#{$id})",
$validated['items'],
);
}, __('message.created'));
}
/**
* 은행 거래 분개 삭제
*/
public function deleteJournalEntries(int $id): JsonResponse
{
return ApiResponse::handle(function () use ($id) {
$sourceKey = "barobill_bank_{$id}";
return $this->journalSyncService->deleteForSource(
JournalEntry::SOURCE_BAROBILL_BANK,
$sourceKey
);
}, __('message.deleted'));
}
}

View File

@@ -0,0 +1,326 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Models\Tenants\JournalEntry;
use App\Services\BarobillCardTransactionService;
use App\Services\JournalSyncService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* 바로빌 카드 거래 API 컨트롤러 (React 연동용)
*
* MNG에서 동기화된 카드 거래 데이터를 React에서 조회/관리
*/
class BarobillCardTransactionController extends Controller
{
public function __construct(
protected BarobillCardTransactionService $service,
protected JournalSyncService $journalSyncService,
) {}
/**
* 카드 거래 목록 조회
*/
public function index(Request $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
$params = $request->validate([
'start_date' => 'nullable|date',
'end_date' => 'nullable|date|after_or_equal:start_date',
'card_num' => 'nullable|string|max:50',
'search' => 'nullable|string|max:100',
'include_hidden' => 'nullable|boolean',
'per_page' => 'nullable|integer|min:1|max:100',
'page' => 'nullable|integer|min:1',
]);
return $this->service->index($params);
}, __('message.fetched'));
}
/**
* 단일 카드 거래 상세
*/
public function show(int $id): JsonResponse
{
return ApiResponse::handle(function () use ($id) {
$tx = $this->service->show($id);
if (! $tx) {
throw new \Illuminate\Database\Eloquent\ModelNotFoundException;
}
return $tx;
}, __('message.fetched'));
}
/**
* 카드 번호 목록 (필터용)
*/
public function cardNumbers(): JsonResponse
{
return ApiResponse::handle(function () {
return $this->service->cardNumbers();
}, __('message.fetched'));
}
// =========================================================================
// 분할 (Splits)
// =========================================================================
/**
* 카드 거래 분할 조회
*/
public function getSplits(Request $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
$validated = $request->validate([
'unique_key' => 'required|string|max:500',
]);
return $this->service->getSplits($validated['unique_key']);
}, __('message.fetched'));
}
/**
* 카드 거래 분할 저장
*/
public function saveSplits(Request $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
$validated = $request->validate([
'unique_key' => 'required|string|max:500',
'items' => 'required|array|min:1',
'items.*.split_amount' => 'required|numeric',
'items.*.split_supply_amount' => 'nullable|numeric',
'items.*.split_tax' => 'nullable|numeric',
'items.*.account_code' => 'nullable|string|max:20',
'items.*.account_name' => 'nullable|string|max:100',
'items.*.deduction_type' => 'nullable|string|max:20',
'items.*.evidence_name' => 'nullable|string|max:100',
'items.*.description' => 'nullable|string|max:500',
'items.*.memo' => 'nullable|string|max:500',
'items.*.card_num' => 'nullable|string|max:50',
'items.*.use_dt' => 'nullable|string|max:20',
'items.*.use_date' => 'nullable|date',
'items.*.approval_num' => 'nullable|string|max:50',
'items.*.original_amount' => 'nullable|numeric',
'items.*.merchant_name' => 'nullable|string|max:200',
]);
return $this->service->saveSplits($validated['unique_key'], $validated['items']);
}, __('message.created'));
}
/**
* 카드 거래 분할 삭제
*/
public function deleteSplits(Request $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
$validated = $request->validate([
'unique_key' => 'required|string|max:500',
]);
return $this->service->deleteSplits($validated['unique_key']);
}, __('message.deleted'));
}
// =========================================================================
// 수동 입력 (Manual)
// =========================================================================
/**
* 수동 카드 거래 등록
*/
public function storeManual(Request $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
$validated = $request->validate([
'card_num' => 'required|string|max:50',
'card_company' => 'nullable|string|max:10',
'card_company_name' => 'nullable|string|max:50',
'use_dt' => 'required|string|max:20',
'use_date' => 'required|date',
'use_time' => 'nullable|string|max:10',
'approval_num' => 'nullable|string|max:50',
'approval_type' => 'nullable|string|max:10',
'approval_amount' => 'required|numeric',
'tax' => 'nullable|numeric',
'service_charge' => 'nullable|numeric',
'merchant_name' => 'required|string|max:200',
'merchant_biz_num' => 'nullable|string|max:20',
'account_code' => 'nullable|string|max:20',
'account_name' => 'nullable|string|max:100',
'deduction_type' => 'nullable|string|max:20',
'evidence_name' => 'nullable|string|max:100',
'description' => 'nullable|string|max:500',
'memo' => 'nullable|string|max:500',
]);
return $this->service->storeManual($validated);
}, __('message.created'));
}
/**
* 수동 카드 거래 수정
*/
public function updateManual(Request $request, int $id): JsonResponse
{
return ApiResponse::handle(function () use ($request, $id) {
$validated = $request->validate([
'approval_amount' => 'nullable|numeric',
'tax' => 'nullable|numeric',
'merchant_name' => 'nullable|string|max:200',
'account_code' => 'nullable|string|max:20',
'account_name' => 'nullable|string|max:100',
'deduction_type' => 'nullable|string|max:20',
'description' => 'nullable|string|max:500',
'memo' => 'nullable|string|max:500',
]);
return $this->service->updateManual($id, $validated);
}, __('message.updated'));
}
/**
* 수동 카드 거래 삭제
*/
public function destroyManual(int $id): JsonResponse
{
return ApiResponse::handle(function () use ($id) {
return $this->service->destroyManual($id);
}, __('message.deleted'));
}
// =========================================================================
// 숨김/복원 (Hide/Restore)
// =========================================================================
/**
* 카드 거래 숨김
*/
public function hide(int $id): JsonResponse
{
return ApiResponse::handle(function () use ($id) {
return $this->service->hide($id);
}, __('message.updated'));
}
/**
* 카드 거래 숨김 복원
*/
public function restore(int $id): JsonResponse
{
return ApiResponse::handle(function () use ($id) {
return $this->service->restore($id);
}, __('message.updated'));
}
/**
* 숨겨진 거래 목록
*/
public function hiddenList(Request $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
$params = $request->validate([
'start_date' => 'nullable|date',
'end_date' => 'nullable|date|after_or_equal:start_date',
]);
return $this->service->hiddenList($params);
}, __('message.fetched'));
}
// =========================================================================
// 금액 수정
// =========================================================================
/**
* 공급가액/세액 수정
*/
public function updateAmount(Request $request, int $id): JsonResponse
{
return ApiResponse::handle(function () use ($request, $id) {
$validated = $request->validate([
'supply_amount' => 'required|numeric',
'tax' => 'required|numeric',
'modified_by_name' => 'nullable|string|max:50',
]);
return $this->service->updateAmount($id, $validated);
}, __('message.updated'));
}
// =========================================================================
// 분개 (Journal Entries)
// =========================================================================
/**
* 카드 거래 분개 조회
*/
public function getJournalEntries(int $id): JsonResponse
{
return ApiResponse::handle(function () use ($id) {
$sourceKey = "barobill_card_{$id}";
return $this->journalSyncService->getForSource(
JournalEntry::SOURCE_BAROBILL_CARD,
$sourceKey
) ?? ['items' => []];
}, __('message.fetched'));
}
/**
* 카드 거래 분개 저장
*/
public function storeJournalEntries(Request $request, int $id): JsonResponse
{
return ApiResponse::handle(function () use ($request, $id) {
$validated = $request->validate([
'items' => 'required|array|min:1',
'items.*.side' => 'required|in:debit,credit',
'items.*.account_code' => 'required|string|max:20',
'items.*.account_name' => 'nullable|string|max:100',
'items.*.debit_amount' => 'required|integer|min:0',
'items.*.credit_amount' => 'required|integer|min:0',
'items.*.vendor_name' => 'nullable|string|max:200',
'items.*.memo' => 'nullable|string|max:500',
]);
$tx = $this->service->show($id);
if (! $tx) {
throw new \Illuminate\Database\Eloquent\ModelNotFoundException;
}
$entryDate = $tx->use_date ?? now()->format('Y-m-d');
$sourceKey = "barobill_card_{$id}";
return $this->journalSyncService->saveForSource(
JournalEntry::SOURCE_BAROBILL_CARD,
$sourceKey,
$entryDate,
"바로빌 카드거래 분개 (#{$id})",
$validated['items'],
);
}, __('message.created'));
}
/**
* 카드 거래 분개 삭제
*/
public function deleteJournalEntries(int $id): JsonResponse
{
return ApiResponse::handle(function () use ($id) {
$sourceKey = "barobill_card_{$id}";
return $this->journalSyncService->deleteForSource(
JournalEntry::SOURCE_BAROBILL_CARD,
$sourceKey
);
}, __('message.deleted'));
}
}

View File

@@ -0,0 +1,167 @@
<?php
namespace App\Http\Controllers\Api\V1;
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 BarobillSoapService $soapService,
) {}
/**
* 연동 현황 조회
*/
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' => $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),
];
}, __('message.fetched'));
}
/**
* 바로빌 로그인 정보 등록
*/
public function login(Request $request)
{
$data = $request->validate([
'barobill_id' => 'required|string',
'password' => 'required|string',
]);
return ApiResponse::handle(function () use ($data) {
return $this->barobillService->saveSetting([
'barobill_id' => $data['barobill_id'],
]);
}, __('message.saved'));
}
/**
* 바로빌 회원가입 정보 등록
*/
public function signup(Request $request)
{
$data = $request->validate([
'business_number' => 'required|string|size:10',
'company_name' => 'required|string',
'ceo_name' => 'required|string',
'business_type' => 'nullable|string',
'business_category' => 'nullable|string',
'address' => 'nullable|string',
'barobill_id' => 'required|string',
'password' => 'required|string',
'manager_name' => 'nullable|string',
'manager_phone' => 'nullable|string',
'manager_email' => 'nullable|email',
]);
return ApiResponse::handle(function () use ($data) {
return $this->barobillService->saveSetting([
'corp_num' => $data['business_number'],
'corp_name' => $data['company_name'],
'ceo_name' => $data['ceo_name'],
'biz_type' => $data['business_type'] ?? null,
'biz_class' => $data['business_category'] ?? null,
'addr' => $data['address'] ?? null,
'barobill_id' => $data['barobill_id'],
'contact_name' => $data['manager_name'] ?? null,
'contact_tel' => $data['manager_phone'] ?? null,
'contact_id' => $data['manager_email'] ?? null,
]);
}, __('message.saved'));
}
/**
* 바로빌 서비스 URL 조회 (공통)
*/
private function getServiceUrl(string $path): array
{
return ['url' => $this->barobillService->getBaseUrl().$path];
}
/**
* 은행 빠른조회 서비스 URL 조회
*/
public function bankServiceUrl()
{
return ApiResponse::handle(function () {
return $this->getServiceUrl('/BANKACCOUNT.asmx');
}, __('message.fetched'));
}
/**
* 계좌 연동 등록 URL 조회
*/
public function accountLinkUrl()
{
return ApiResponse::handle(function () {
return $this->getServiceUrl('/BANKACCOUNT.asmx');
}, __('message.fetched'));
}
/**
* 카드 연동 등록 URL 조회
*/
public function cardLinkUrl()
{
return ApiResponse::handle(function () {
return $this->getServiceUrl('/CARD.asmx');
}, __('message.fetched'));
}
/**
* 공인인증서 등록 URL 조회
*/
public function certificateUrl()
{
return ApiResponse::handle(function () {
return $this->getServiceUrl('/CORPSTATE.asmx');
}, __('message.fetched'));
}
}

View File

@@ -18,12 +18,9 @@ public function __construct(
*/ */
public function show() public function show()
{ {
$setting = $this->barobillService->getSetting(); return ApiResponse::handle(function () {
return $this->barobillService->getSetting();
return ApiResponse::handle( }, __('message.fetched'));
data: $setting,
message: __('message.fetched')
);
} }
/** /**
@@ -31,12 +28,9 @@ public function show()
*/ */
public function save(SaveBarobillSettingRequest $request) public function save(SaveBarobillSettingRequest $request)
{ {
$setting = $this->barobillService->saveSetting($request->validated()); return ApiResponse::handle(function () use ($request) {
return $this->barobillService->saveSetting($request->validated());
return ApiResponse::handle( }, __('message.saved'));
data: $setting,
message: __('message.saved')
);
} }
/** /**
@@ -44,11 +38,8 @@ public function save(SaveBarobillSettingRequest $request)
*/ */
public function testConnection() public function testConnection()
{ {
$result = $this->barobillService->testConnection(); return ApiResponse::handle(function () {
return $this->barobillService->testConnection();
return ApiResponse::handle( }, __('message.barobill.connection_success'));
data: $result,
message: __('message.barobill.connection_success')
);
} }
} }

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

@@ -51,4 +51,56 @@ public function summary(Request $request)
); );
}, __('message.fetched')); }, __('message.fetched'));
} }
/**
* 일정 등록
*/
public function store(Request $request)
{
$validated = $request->validate([
'title' => 'required|string|max:200',
'description' => 'nullable|string|max:1000',
'start_date' => 'required|date_format:Y-m-d',
'end_date' => 'required|date_format:Y-m-d|after_or_equal:start_date',
'start_time' => 'nullable|date_format:H:i',
'end_time' => 'nullable|date_format:H:i',
'is_all_day' => 'boolean',
'color' => 'nullable|string|max:20',
]);
return ApiResponse::handle(function () use ($validated) {
return $this->calendarService->createSchedule($validated);
}, __('message.created'));
}
/**
* 일정 수정
*/
public function update(Request $request, int $id)
{
$validated = $request->validate([
'title' => 'required|string|max:200',
'description' => 'nullable|string|max:1000',
'start_date' => 'required|date_format:Y-m-d',
'end_date' => 'required|date_format:Y-m-d|after_or_equal:start_date',
'start_time' => 'nullable|date_format:H:i',
'end_time' => 'nullable|date_format:H:i',
'is_all_day' => 'boolean',
'color' => 'nullable|string|max:20',
]);
return ApiResponse::handle(function () use ($id, $validated) {
return $this->calendarService->updateSchedule($id, $validated);
}, __('message.updated'));
}
/**
* 일정 삭제
*/
public function destroy(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->calendarService->deleteSchedule($id);
}, __('message.deleted'));
}
} }

View File

@@ -0,0 +1,133 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Services\CalendarScheduleService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class CalendarScheduleController extends Controller
{
public function __construct(
private readonly CalendarScheduleService $service
) {}
/**
* 일정 목록 조회
*/
public function index(Request $request): JsonResponse
{
$request->validate([
'year' => 'required|integer|min:2000|max:2100',
'type' => 'nullable|string',
]);
return ApiResponse::handle(
fn () => $this->service->list(
(int) $request->input('year'),
$request->input('type')
),
__('message.fetched')
);
}
/**
* 통계 조회
*/
public function stats(Request $request): JsonResponse
{
$request->validate([
'year' => 'required|integer|min:2000|max:2100',
]);
return ApiResponse::handle(
fn () => $this->service->stats((int) $request->input('year')),
__('message.fetched')
);
}
/**
* 단건 조회
*/
public function show(int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->show($id),
__('message.fetched')
);
}
/**
* 등록
*/
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'name' => 'required|string|max:100',
'start_date' => 'required|date',
'end_date' => 'required|date|after_or_equal:start_date',
'type' => 'required|string|in:public_holiday,temporary_holiday,substitute_holiday,tax_deadline,company_event',
'is_recurring' => 'boolean',
'memo' => 'nullable|string|max:500',
]);
return ApiResponse::handle(
fn () => $this->service->store($validated),
__('message.created')
);
}
/**
* 수정
*/
public function update(Request $request, int $id): JsonResponse
{
$validated = $request->validate([
'name' => 'required|string|max:100',
'start_date' => 'required|date',
'end_date' => 'required|date|after_or_equal:start_date',
'type' => 'required|string|in:public_holiday,temporary_holiday,substitute_holiday,tax_deadline,company_event',
'is_recurring' => 'boolean',
'memo' => 'nullable|string|max:500',
]);
return ApiResponse::handle(
fn () => $this->service->update($id, $validated),
__('message.updated')
);
}
/**
* 삭제
*/
public function destroy(int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->delete($id),
__('message.deleted')
);
}
/**
* 대량 등록
*/
public function bulkStore(Request $request): JsonResponse
{
$validated = $request->validate([
'schedules' => 'required|array|min:1',
'schedules.*.name' => 'required|string|max:100',
'schedules.*.start_date' => 'required|date',
'schedules.*.end_date' => 'required|date|after_or_equal:schedules.*.start_date',
'schedules.*.type' => 'required|string|in:public_holiday,temporary_holiday,substitute_holiday,tax_deadline,company_event',
'schedules.*.is_recurring' => 'boolean',
'schedules.*.memo' => 'nullable|string|max:500',
]);
return ApiResponse::handle(
fn () => $this->service->bulkStore($validated['schedules']),
__('message.created')
);
}
}

View File

@@ -4,7 +4,9 @@
use App\Helpers\ApiResponse; use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Tenants\JournalEntry;
use App\Services\CardTransactionService; use App\Services\CardTransactionService;
use App\Services\JournalSyncService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -14,7 +16,8 @@
class CardTransactionController extends Controller class CardTransactionController extends Controller
{ {
public function __construct( public function __construct(
protected CardTransactionService $service protected CardTransactionService $service,
protected JournalSyncService $journalSyncService,
) {} ) {}
/** /**
@@ -148,4 +151,105 @@ public function destroy(int $id): JsonResponse
return $this->service->destroy($id); return $this->service->destroy($id);
}, __('message.deleted')); }, __('message.deleted'));
} }
// =========================================================================
// 분개 (Journal Entries)
// =========================================================================
/**
* 카드 거래 분개 조회
*/
public function getJournalEntries(int $id): JsonResponse
{
return ApiResponse::handle(function () use ($id) {
$sourceKey = "card_{$id}";
$data = $this->journalSyncService->getForSource(
JournalEntry::SOURCE_CARD_TRANSACTION,
$sourceKey
);
if (! $data) {
return ['items' => []];
}
// 프론트엔드가 기대하는 items 형식으로 변환
$items = array_map(fn ($row) => [
'id' => $row['id'],
'supply_amount' => $row['debit_amount'],
'tax_amount' => 0,
'account_code' => $row['account_code'],
'deduction_type' => 'deductible',
'vendor_name' => $row['vendor_name'],
'description' => $row['memo'],
'memo' => '',
], $data['rows']);
return ['items' => $items];
}, __('message.fetched'));
}
/**
* 카드 거래 분개 저장
*/
public function storeJournalEntries(Request $request, int $id): JsonResponse
{
return ApiResponse::handle(function () use ($request, $id) {
$validated = $request->validate([
'items' => 'required|array|min:1',
'items.*.supply_amount' => 'required|integer|min:0',
'items.*.tax_amount' => 'required|integer|min:0',
'items.*.account_code' => 'required|string|max:20',
'items.*.deduction_type' => 'nullable|string|max:20',
'items.*.vendor_name' => 'nullable|string|max:200',
'items.*.description' => 'nullable|string|max:500',
'items.*.memo' => 'nullable|string|max:500',
]);
// 카드 거래 정보 조회 (날짜용)
$transaction = $this->service->show($id);
if (! $transaction) {
throw new \Illuminate\Database\Eloquent\ModelNotFoundException;
}
$entryDate = $transaction->used_at
? $transaction->used_at->format('Y-m-d')
: ($transaction->withdrawal_date?->format('Y-m-d') ?? now()->format('Y-m-d'));
// items → journal rows 변환 (각 item을 차변 행으로)
$rows = [];
foreach ($validated['items'] as $item) {
$amount = ($item['supply_amount'] ?? 0) + ($item['tax_amount'] ?? 0);
$rows[] = [
'side' => 'debit',
'account_code' => $item['account_code'],
'debit_amount' => $amount,
'credit_amount' => 0,
'vendor_name' => $item['vendor_name'] ?? '',
'memo' => $item['description'] ?? $item['memo'] ?? '',
];
}
// 대변 합계 행 (카드미지급금)
$totalAmount = array_sum(array_column($rows, 'debit_amount'));
$rows[] = [
'side' => 'credit',
'account_code' => '25300', // 미지급금 (표준 코드)
'account_name' => '미지급금',
'debit_amount' => 0,
'credit_amount' => $totalAmount,
'vendor_name' => $transaction->merchant_name ?? '',
'memo' => '카드결제',
];
$sourceKey = "card_{$id}";
return $this->journalSyncService->saveForSource(
JournalEntry::SOURCE_CARD_TRANSACTION,
$sourceKey,
$entryDate,
"카드거래 분개 (#{$id})",
$rows,
);
}, __('message.created'));
}
} }

View File

@@ -0,0 +1,92 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\Quality\SaveChecklistTemplateRequest;
use App\Services\ChecklistTemplateService;
use Illuminate\Http\Request;
class ChecklistTemplateController extends Controller
{
public function __construct(private ChecklistTemplateService $service) {}
/**
* 템플릿 조회 (type별)
*/
public function show(Request $request)
{
return ApiResponse::handle(function () use ($request) {
$type = $request->query('type', 'day1_audit');
return $this->service->getByType($type);
}, __('message.fetched'));
}
/**
* 템플릿 저장 (전체 덮어쓰기)
*/
public function update(SaveChecklistTemplateRequest $request, int $id)
{
return ApiResponse::handle(function () use ($request, $id) {
return $this->service->save($id, $request->validated());
}, __('message.updated'));
}
/**
* 항목 완료 토글
*/
public function toggleItem(int $id, string $subItemId)
{
return ApiResponse::handle(function () use ($id, $subItemId) {
return $this->service->toggleItem($id, $subItemId);
}, __('message.updated'));
}
/**
* 항목별 파일 목록 조회
*/
public function documents(Request $request)
{
return ApiResponse::handle(function () use ($request) {
$templateId = (int) $request->query('template_id');
$subItemId = $request->query('sub_item_id');
return $this->service->getDocuments($templateId, $subItemId);
}, __('message.fetched'));
}
/**
* 파일 업로드
*/
public function uploadDocument(Request $request)
{
$request->validate([
'template_id' => ['required', 'integer'],
'sub_item_id' => ['required', 'string', 'max:50'],
'file' => ['required', 'file', 'max:10240'], // 10MB
]);
return ApiResponse::handle(function () use ($request) {
return $this->service->uploadDocument(
(int) $request->input('template_id'),
$request->input('sub_item_id'),
$request->file('file')
);
}, __('message.created'));
}
/**
* 파일 삭제
*/
public function deleteDocument(int $id, Request $request)
{
return ApiResponse::handle(function () use ($id, $request) {
$replace = filter_var($request->query('replace', false), FILTER_VALIDATE_BOOLEAN);
$this->service->deleteDocument($id, $replace);
return 'success';
}, __('message.deleted'));
}
}

View File

@@ -20,6 +20,16 @@ public function index(Request $request)
}, __('message.client.fetched')); }, __('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) public function show(int $id)
{ {
return ApiResponse::handle(function () use ($id) { return ApiResponse::handle(function () use ($id) {

View File

@@ -2,11 +2,14 @@
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use App\Exports\DailyReportExport;
use App\Helpers\ApiResponse; use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Services\DailyReportService; use App\Services\DailyReportService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Maatwebsite\Excel\Facades\Excel;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
/** /**
* 일일 보고서 컨트롤러 * 일일 보고서 컨트롤러
@@ -58,4 +61,19 @@ public function summary(Request $request): JsonResponse
return $this->service->summary($params); return $this->service->summary($params);
}, __('message.fetched')); }, __('message.fetched'));
} }
/**
* 일일 보고서 엑셀 다운로드
*/
public function export(Request $request): BinaryFileResponse
{
$params = $request->validate([
'date' => 'nullable|date',
]);
$reportData = $this->service->exportData($params);
$filename = '일일일보_'.$reportData['date'].'.xlsx';
return Excel::download(new DailyReportExport($reportData), $filename);
}
} }

View File

@@ -0,0 +1,92 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Services\DashboardCeoService;
use Illuminate\Http\JsonResponse;
/**
* CEO 대시보드 섹션별 API 컨트롤러
*
* 6개 섹션: 매출, 매입, 생산, 미출고, 시공, 근태
*/
class DashboardCeoController extends Controller
{
public function __construct(
private readonly DashboardCeoService $service
) {}
/**
* 매출 현황 요약
* GET /api/v1/dashboard/sales/summary
*/
public function salesSummary(): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->salesSummary(),
__('message.fetched')
);
}
/**
* 매입 현황 요약
* GET /api/v1/dashboard/purchases/summary
*/
public function purchasesSummary(): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->purchasesSummary(),
__('message.fetched')
);
}
/**
* 생산 현황 요약
* GET /api/v1/dashboard/production/summary
*/
public function productionSummary(): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->productionSummary(),
__('message.fetched')
);
}
/**
* 미출고 내역 요약
* GET /api/v1/dashboard/unshipped/summary
*/
public function unshippedSummary(): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->unshippedSummary(),
__('message.fetched')
);
}
/**
* 시공 현황 요약
* GET /api/v1/dashboard/construction/summary
*/
public function constructionSummary(): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->constructionSummary(),
__('message.fetched')
);
}
/**
* 근태 현황 요약
* GET /api/v1/dashboard/attendance/summary
*/
public function attendanceSummary(): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->attendanceSummary(),
__('message.fetched')
);
}
}

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

@@ -74,6 +74,22 @@ public function destroy(int $id): JsonResponse
}, __('message.deleted')); }, __('message.deleted'));
} }
/**
* rendered_html 스냅샷 저장 (Lazy Snapshot)
* PATCH /v1/documents/{id}/snapshot
*/
public function patchSnapshot(int $id, UpdateRequest $request): JsonResponse
{
return ApiResponse::handle(function () use ($id, $request) {
$renderedHtml = $request->validated()['rendered_html'] ?? null;
if (! $renderedHtml) {
throw new \Symfony\Component\HttpKernel\Exception\BadRequestHttpException('rendered_html is required');
}
return $this->service->patchSnapshot($id, $renderedHtml);
}, __('message.updated'));
}
// ========================================================================= // =========================================================================
// FQC 일괄생성 (제품검사) // FQC 일괄생성 (제품검사)
// ========================================================================= // =========================================================================

View File

@@ -33,4 +33,20 @@ public function summary(Request $request): JsonResponse
return $this->entertainmentService->getSummary($limitType, $companyType, $year, $quarter); return $this->entertainmentService->getSummary($limitType, $companyType, $year, $quarter);
}, __('message.fetched')); }, __('message.fetched'));
} }
/**
* 접대비 상세 조회 (모달용)
*/
public function detail(Request $request): JsonResponse
{
$companyType = $request->query('company_type', 'medium');
$year = $request->query('year') ? (int) $request->query('year') : null;
$quarter = $request->query('quarter') ? (int) $request->query('quarter') : null;
$startDate = $request->query('start_date');
$endDate = $request->query('end_date');
return ApiResponse::handle(function () use ($companyType, $year, $quarter, $startDate, $endDate) {
return $this->entertainmentService->getDetail($companyType, $year, $quarter, $startDate, $endDate);
}, __('message.fetched'));
}
} }

View File

@@ -128,13 +128,16 @@ public function summary(Request $request)
/** /**
* 대시보드 상세 조회 (CEO 대시보드 당월 예상 지출내역 모달용) * 대시보드 상세 조회 (CEO 대시보드 당월 예상 지출내역 모달용)
* *
* @param Request $request transaction_type 쿼리 파라미터 (purchase, card, bill, null=전체) * @param Request $request transaction_type (purchase, card, bill, null=전체), start_date, end_date, search
*/ */
public function dashboardDetail(Request $request) public function dashboardDetail(Request $request)
{ {
$transactionType = $request->query('transaction_type'); $transactionType = $request->query('transaction_type');
$startDate = $request->query('start_date');
$endDate = $request->query('end_date');
$search = $request->query('search');
$data = $this->service->dashboardDetail($transactionType); $data = $this->service->dashboardDetail($transactionType, $startDate, $endDate, $search);
return ApiResponse::success($data, __('message.fetched')); return ApiResponse::success($data, __('message.fetched'));
} }

View File

@@ -12,6 +12,20 @@
class FileStorageController extends Controller 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 * Upload file to temp
*/ */
@@ -83,14 +97,29 @@ public function trash()
} }
/** /**
* Download file * Download file (attachment)
*/ */
public function download(int $id) public function download(int $id, Request $request)
{ {
$this->ensureContext($request);
$service = new FileStorageService; $service = new FileStorageService;
$file = $service->getFile($id); $file = $service->getFile($id);
return $file->download(); return $file->download(inline: false);
}
/**
* View file inline (이미지/PDF 브라우저에서 바로 표시)
*/
public function view(int $id, Request $request)
{
$this->ensureContext($request);
$service = new FileStorageService;
$file = $service->getFile($id);
return $file->download(inline: true);
} }
/** /**

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\V1\GeneralJournalEntry\StoreManualJournalRequest;
use App\Http\Requests\V1\GeneralJournalEntry\UpdateJournalRequest;
use App\Services\GeneralJournalEntryService;
use Illuminate\Http\Request;
class GeneralJournalEntryController extends Controller
{
public function __construct(
private readonly GeneralJournalEntryService $service
) {}
/**
* 일반전표 통합 목록 조회
*/
public function index(Request $request)
{
$params = $request->only([
'start_date', 'end_date', 'search', 'page', 'per_page',
]);
$result = $this->service->index($params);
return ApiResponse::success($result, __('message.fetched'));
}
/**
* 요약 통계
*/
public function summary(Request $request)
{
$params = $request->only([
'start_date', 'end_date', 'search',
]);
$summary = $this->service->summary($params);
return ApiResponse::success($summary, __('message.fetched'));
}
/**
* 수기전표 등록
*/
public function store(StoreManualJournalRequest $request)
{
$entry = $this->service->store($request->validated());
return ApiResponse::success($entry, __('message.created'), [], 201);
}
/**
* 전표 상세 조회 (분개 수정 모달용)
*/
public function show(int $id)
{
$detail = $this->service->show($id);
return ApiResponse::success($detail, __('message.fetched'));
}
/**
* 분개 수정
*/
public function updateJournal(int $id, UpdateJournalRequest $request)
{
$entry = $this->service->updateJournal($id, $request->validated());
return ApiResponse::success($entry, __('message.updated'));
}
/**
* 분개 삭제
*/
public function destroyJournal(int $id)
{
$this->service->destroyJournal($id);
return ApiResponse::success(null, __('message.deleted'));
}
}

View File

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

View File

@@ -0,0 +1,278 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Models\Tenants\JournalEntry;
use App\Services\HometaxInvoiceService;
use App\Services\JournalSyncService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* 홈택스 세금계산서 API 컨트롤러 (React 연동용)
*
* MNG에서 동기화된 홈택스 세금계산서를 React에서 조회/관리
*/
class HometaxInvoiceController extends Controller
{
public function __construct(
protected HometaxInvoiceService $service,
protected JournalSyncService $journalSyncService,
) {}
/**
* 매출 세금계산서 목록
*/
public function sales(Request $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
$params = $request->validate([
'start_date' => 'nullable|date',
'end_date' => 'nullable|date|after_or_equal:start_date',
'search' => 'nullable|string|max:100',
'per_page' => 'nullable|integer|min:1|max:100',
'page' => 'nullable|integer|min:1',
]);
return $this->service->sales($params);
}, __('message.fetched'));
}
/**
* 매입 세금계산서 목록
*/
public function purchases(Request $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
$params = $request->validate([
'start_date' => 'nullable|date',
'end_date' => 'nullable|date|after_or_equal:start_date',
'search' => 'nullable|string|max:100',
'per_page' => 'nullable|integer|min:1|max:100',
'page' => 'nullable|integer|min:1',
]);
return $this->service->purchases($params);
}, __('message.fetched'));
}
/**
* 세금계산서 상세 조회
*/
public function show(int $id): JsonResponse
{
return ApiResponse::handle(function () use ($id) {
$invoice = $this->service->show($id);
if (! $invoice) {
throw new \Illuminate\Database\Eloquent\ModelNotFoundException;
}
return $invoice;
}, __('message.fetched'));
}
/**
* 요약 통계 (매출/매입 합계)
*/
public function summary(Request $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
$params = $request->validate([
'start_date' => 'nullable|date',
'end_date' => 'nullable|date|after_or_equal:start_date',
]);
return $this->service->summary($params);
}, __('message.fetched'));
}
// =========================================================================
// 수동 입력 (Manual)
// =========================================================================
/**
* 수동 세금계산서 등록
*/
public function store(Request $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
$validated = $request->validate([
'invoice_type' => 'required|in:sales,purchase',
'nts_confirm_num' => 'nullable|string|max:50',
'write_date' => 'required|date',
'issue_date' => 'nullable|date',
'invoicer_corp_num' => 'nullable|string|max:20',
'invoicer_corp_name' => 'nullable|string|max:200',
'invoicer_ceo_name' => 'nullable|string|max:100',
'invoicee_corp_num' => 'nullable|string|max:20',
'invoicee_corp_name' => 'nullable|string|max:200',
'invoicee_ceo_name' => 'nullable|string|max:100',
'supply_amount' => 'required|integer',
'tax_amount' => 'required|integer',
'total_amount' => 'required|integer',
'tax_type' => 'nullable|string|max:10',
'purpose_type' => 'nullable|string|max:10',
'issue_type' => 'nullable|string|max:10',
'item_name' => 'nullable|string|max:200',
'account_code' => 'nullable|string|max:20',
'account_name' => 'nullable|string|max:100',
'deduction_type' => 'nullable|string|max:20',
'remark1' => 'nullable|string|max:500',
]);
return $this->service->storeManual($validated);
}, __('message.created'));
}
/**
* 수동 세금계산서 수정
*/
public function update(Request $request, int $id): JsonResponse
{
return ApiResponse::handle(function () use ($request, $id) {
$validated = $request->validate([
'write_date' => 'nullable|date',
'issue_date' => 'nullable|date',
'invoicer_corp_num' => 'nullable|string|max:20',
'invoicer_corp_name' => 'nullable|string|max:200',
'invoicee_corp_num' => 'nullable|string|max:20',
'invoicee_corp_name' => 'nullable|string|max:200',
'supply_amount' => 'nullable|integer',
'tax_amount' => 'nullable|integer',
'total_amount' => 'nullable|integer',
'item_name' => 'nullable|string|max:200',
'account_code' => 'nullable|string|max:20',
'account_name' => 'nullable|string|max:100',
'deduction_type' => 'nullable|string|max:20',
'remark1' => 'nullable|string|max:500',
]);
return $this->service->updateManual($id, $validated);
}, __('message.updated'));
}
/**
* 수동 세금계산서 삭제
*/
public function destroy(int $id): JsonResponse
{
return ApiResponse::handle(function () use ($id) {
return $this->service->destroyManual($id);
}, __('message.deleted'));
}
// =========================================================================
// 분개 (자체 테이블: hometax_invoice_journals)
// =========================================================================
/**
* 분개 조회
*/
public function getJournals(int $id): JsonResponse
{
return ApiResponse::handle(function () use ($id) {
return $this->service->getJournals($id);
}, __('message.fetched'));
}
/**
* 분개 저장
*/
public function saveJournals(Request $request, int $id): JsonResponse
{
return ApiResponse::handle(function () use ($request, $id) {
$validated = $request->validate([
'items' => 'required|array|min:1',
'items.*.dc_type' => 'required|in:debit,credit',
'items.*.account_code' => 'required|string|max:20',
'items.*.account_name' => 'nullable|string|max:100',
'items.*.debit_amount' => 'required|integer|min:0',
'items.*.credit_amount' => 'required|integer|min:0',
'items.*.description' => 'nullable|string|max:500',
]);
return $this->service->saveJournals($id, $validated['items']);
}, __('message.created'));
}
/**
* 분개 삭제
*/
public function deleteJournals(int $id): JsonResponse
{
return ApiResponse::handle(function () use ($id) {
return $this->service->deleteJournals($id);
}, __('message.deleted'));
}
// =========================================================================
// 통합 분개 (JournalSyncService - CEO 대시보드 연동)
// =========================================================================
/**
* 통합 분개 조회
*/
public function getJournalEntries(int $id): JsonResponse
{
return ApiResponse::handle(function () use ($id) {
$sourceKey = "hometax_{$id}";
return $this->journalSyncService->getForSource(
JournalEntry::SOURCE_HOMETAX_INVOICE,
$sourceKey
) ?? ['items' => []];
}, __('message.fetched'));
}
/**
* 통합 분개 저장
*/
public function storeJournalEntries(Request $request, int $id): JsonResponse
{
return ApiResponse::handle(function () use ($request, $id) {
$validated = $request->validate([
'items' => 'required|array|min:1',
'items.*.side' => 'required|in:debit,credit',
'items.*.account_code' => 'required|string|max:20',
'items.*.account_name' => 'nullable|string|max:100',
'items.*.debit_amount' => 'required|integer|min:0',
'items.*.credit_amount' => 'required|integer|min:0',
'items.*.vendor_name' => 'nullable|string|max:200',
'items.*.memo' => 'nullable|string|max:500',
]);
$invoice = $this->service->show($id);
if (! $invoice) {
throw new \Illuminate\Database\Eloquent\ModelNotFoundException;
}
$entryDate = $invoice->write_date?->format('Y-m-d') ?? now()->format('Y-m-d');
$sourceKey = "hometax_{$id}";
return $this->journalSyncService->saveForSource(
JournalEntry::SOURCE_HOMETAX_INVOICE,
$sourceKey,
$entryDate,
"홈택스 세금계산서 분개 (#{$id})",
$validated['items'],
);
}, __('message.created'));
}
/**
* 통합 분개 삭제
*/
public function deleteJournalEntries(int $id): JsonResponse
{
return ApiResponse::handle(function () use ($id) {
$sourceKey = "hometax_{$id}";
return $this->journalSyncService->deleteForSource(
JournalEntry::SOURCE_HOMETAX_INVOICE,
$sourceKey
);
}, __('message.deleted'));
}
}

View File

@@ -34,6 +34,16 @@ public function stats(Request $request)
}, __('message.inspection.fetched')); }, __('message.inspection.fetched'));
} }
/**
* 캘린더 스케줄 조회
*/
public function calendar(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->calendar($request->all());
}, __('message.inspection.fetched'));
}
/** /**
* 단건 조회 * 단건 조회
*/ */

View File

@@ -446,12 +446,13 @@ private function expandBomItems(array $bom): array
'child_item_type' => $childItem?->item_type, 'child_item_type' => $childItem?->item_type,
'unit' => $childItem?->unit, 'unit' => $childItem?->unit,
'quantity' => $entry['quantity'] ?? 1, 'quantity' => $entry['quantity'] ?? 1,
'category' => $entry['category'] ?? null,
]; ];
})->toArray(); })->toArray();
} }
/** /**
* BOM 트리 구조 빌드 (재귀) * BOM 트리 구조 빌드 (재귀, category 필드가 있으면 3단계 그룹화)
*/ */
private function buildBomTree(Item $item, int $maxDepth, int $currentDepth): array private function buildBomTree(Item $item, int $maxDepth, int $currentDepth): array
{ {
@@ -478,14 +479,47 @@ private function buildBomTree(Item $item, int $maxDepth, int $currentDepth): arr
->get() ->get()
->keyBy('id'); ->keyBy('id');
foreach ($bom as $entry) { // category 필드가 있으면 카테고리별 그룹 노드 생성 (3단계)
$childItemId = $entry['child_item_id'] ?? null; $hasCategory = collect($bom)->contains(fn ($b) => ! empty($b['category']));
$childItem = $childItems[$childItemId] ?? null;
if ($childItem) { if ($hasCategory) {
$childTree = $this->buildBomTree($childItem, $maxDepth, $currentDepth + 1); $grouped = [];
$childTree['quantity'] = $entry['quantity'] ?? 1; foreach ($bom as $entry) {
$result['children'][] = $childTree; $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;
}
} }
} }

View File

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

View File

@@ -26,6 +26,20 @@ class ItemsFileController extends Controller
*/ */
private const ITEM_GROUP_ID = '1'; 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,6 +47,7 @@ class ItemsFileController extends Controller
*/ */
public function index(int $id, Request $request) public function index(int $id, Request $request)
{ {
$this->ensureContext($request);
return ApiResponse::handle(function () use ($id, $request) { return ApiResponse::handle(function () use ($id, $request) {
$tenantId = app('tenant_id'); $tenantId = app('tenant_id');
$fieldKey = $request->input('field_key'); $fieldKey = $request->input('field_key');
@@ -69,6 +84,7 @@ public function index(int $id, Request $request)
*/ */
public function upload(int $id, ItemFileUploadRequest $request) public function upload(int $id, ItemFileUploadRequest $request)
{ {
$this->ensureContext($request);
return ApiResponse::handle(function () use ($id, $request) { return ApiResponse::handle(function () use ($id, $request) {
$tenantId = app('tenant_id'); $tenantId = app('tenant_id');
$userId = auth()->id() ?? app('api_user'); $userId = auth()->id() ?? app('api_user');
@@ -109,7 +125,7 @@ public function upload(int $id, ItemFileUploadRequest $request)
$filePath = $directory.'/'.$storedName; $filePath = $directory.'/'.$storedName;
// 파일 저장 (tenant 디스크) // 파일 저장 (tenant 디스크)
Storage::disk('tenant')->putFileAs($directory, $uploadedFile, $storedName); Storage::disk('r2')->putFileAs($directory, $uploadedFile, $storedName);
// file_type 자동 분류 (MIME 타입 기반) // file_type 자동 분류 (MIME 타입 기반)
$mimeType = $uploadedFile->getMimeType(); $mimeType = $uploadedFile->getMimeType();
@@ -152,6 +168,7 @@ public function upload(int $id, ItemFileUploadRequest $request)
*/ */
public function delete(int $id, mixed $fileId, Request $request) public function delete(int $id, mixed $fileId, Request $request)
{ {
$this->ensureContext($request);
$fileId = (int) $fileId; $fileId = (int) $fileId;
return ApiResponse::handle(function () use ($id, $fileId) { return ApiResponse::handle(function () use ($id, $fileId) {

View File

@@ -11,6 +11,7 @@
use App\Http\Requests\Loan\LoanUpdateRequest; use App\Http\Requests\Loan\LoanUpdateRequest;
use App\Services\LoanService; use App\Services\LoanService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class LoanController extends Controller class LoanController extends Controller
{ {
@@ -33,8 +34,10 @@ public function index(LoanIndexRequest $request): JsonResponse
*/ */
public function summary(LoanIndexRequest $request): JsonResponse public function summary(LoanIndexRequest $request): JsonResponse
{ {
$userId = $request->validated()['user_id'] ?? null; $validated = $request->validated();
$result = $this->loanService->summary($userId); $userId = $validated['user_id'] ?? null;
$category = $validated['category'] ?? null;
$result = $this->loanService->summary($userId, $category);
return ApiResponse::success($result, __('message.fetched')); return ApiResponse::success($result, __('message.fetched'));
} }
@@ -42,9 +45,12 @@ public function summary(LoanIndexRequest $request): JsonResponse
/** /**
* 가지급금 대시보드 * 가지급금 대시보드
*/ */
public function dashboard(): JsonResponse public function dashboard(Request $request): JsonResponse
{ {
$result = $this->loanService->dashboard(); $startDate = $request->query('start_date');
$endDate = $request->query('end_date');
$result = $this->loanService->dashboard($startDate, $endDate);
return ApiResponse::success($result, __('message.fetched')); return ApiResponse::success($result, __('message.fetched'));
} }

View File

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

View File

@@ -4,18 +4,24 @@
use App\Helpers\ApiResponse; use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\V1\Payroll\BulkGeneratePayrollRequest;
use App\Http\Requests\V1\Payroll\CalculatePayrollRequest; use App\Http\Requests\V1\Payroll\CalculatePayrollRequest;
use App\Http\Requests\V1\Payroll\CopyFromPreviousPayrollRequest;
use App\Http\Requests\V1\Payroll\PayPayrollRequest; use App\Http\Requests\V1\Payroll\PayPayrollRequest;
use App\Http\Requests\V1\Payroll\StorePayrollJournalRequest;
use App\Http\Requests\V1\Payroll\StorePayrollRequest; use App\Http\Requests\V1\Payroll\StorePayrollRequest;
use App\Http\Requests\V1\Payroll\UpdatePayrollRequest; use App\Http\Requests\V1\Payroll\UpdatePayrollRequest;
use App\Http\Requests\V1\Payroll\UpdatePayrollSettingRequest; use App\Http\Requests\V1\Payroll\UpdatePayrollSettingRequest;
use App\Services\ExportService;
use App\Services\PayrollService; use App\Services\PayrollService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class PayrollController extends Controller class PayrollController extends Controller
{ {
public function __construct( public function __construct(
private readonly PayrollService $service private readonly PayrollService $service,
private readonly ExportService $exportService
) {} ) {}
/** /**
@@ -28,6 +34,7 @@ public function index(Request $request)
'month', 'month',
'user_id', 'user_id',
'status', 'status',
'department_id',
'search', 'search',
'sort_by', 'sort_by',
'sort_dir', 'sort_dir',
@@ -103,6 +110,16 @@ public function confirm(int $id)
return ApiResponse::success($payroll, __('message.payroll.confirmed')); return ApiResponse::success($payroll, __('message.payroll.confirmed'));
} }
/**
* 급여 확정 취소
*/
public function unconfirm(int $id)
{
$payroll = $this->service->unconfirm($id);
return ApiResponse::success($payroll, __('message.payroll.unconfirmed'));
}
/** /**
* 급여 지급 처리 * 급여 지급 처리
*/ */
@@ -113,6 +130,16 @@ public function pay(int $id, PayPayrollRequest $request)
return ApiResponse::success($payroll, __('message.payroll.paid')); return ApiResponse::success($payroll, __('message.payroll.paid'));
} }
/**
* 급여 지급 취소 (슈퍼관리자)
*/
public function unpay(int $id)
{
$payroll = $this->service->unpay($id);
return ApiResponse::success($payroll, __('message.payroll.unpaid'));
}
/** /**
* 일괄 확정 * 일괄 확정
*/ */
@@ -127,13 +154,29 @@ public function bulkConfirm(Request $request)
} }
/** /**
* 급여명세서 조회 * 재직사원 일괄 생성
*/ */
public function payslip(int $id) public function bulkGenerate(BulkGeneratePayrollRequest $request)
{ {
$payslip = $this->service->payslip($id); $year = (int) $request->input('year');
$month = (int) $request->input('month');
return ApiResponse::success($payslip, __('message.fetched')); $result = $this->service->bulkGenerate($year, $month);
return ApiResponse::success($result, __('message.payroll.bulk_generated'));
}
/**
* 전월 급여 복사
*/
public function copyFromPrevious(CopyFromPreviousPayrollRequest $request)
{
$year = (int) $request->input('year');
$month = (int) $request->input('month');
$result = $this->service->copyFromPreviousMonth($year, $month);
return ApiResponse::success($result, __('message.payroll.copied'));
} }
/** /**
@@ -150,6 +193,76 @@ public function calculate(CalculatePayrollRequest $request)
return ApiResponse::success($payrolls, __('message.payroll.calculated')); return ApiResponse::success($payrolls, __('message.payroll.calculated'));
} }
/**
* 급여 계산 미리보기
*/
public function calculatePreview(Request $request)
{
$data = $request->only([
'user_id',
'base_salary',
'overtime_pay',
'bonus',
'allowances',
'deductions',
]);
$result = $this->service->calculatePreview($data);
return ApiResponse::success($result, __('message.calculated'));
}
/**
* 급여명세서 조회
*/
public function payslip(int $id)
{
$payslip = $this->service->payslip($id);
return ApiResponse::success($payslip, __('message.fetched'));
}
/**
* 급여 엑셀 내보내기
*/
public function export(Request $request): BinaryFileResponse
{
$params = $request->only([
'year',
'month',
'status',
'user_id',
'department_id',
'search',
'sort_by',
'sort_dir',
]);
$exportData = $this->service->getExportData($params);
$filename = '급여현황_'.date('Ymd_His');
return $this->exportService->download(
$exportData['data'],
$exportData['headings'],
$filename,
'급여현황'
);
}
/**
* 급여 전표 생성
*/
public function journalEntries(StorePayrollJournalRequest $request)
{
$year = (int) $request->input('year');
$month = (int) $request->input('month');
$entryDate = $request->input('entry_date');
$entry = $this->service->createJournalEntries($year, $month, $entryDate);
return ApiResponse::success($entry, __('message.payroll.journal_created'));
}
/** /**
* 급여 설정 조회 * 급여 설정 조회
*/ */

View File

@@ -0,0 +1,67 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\Quality\PerformanceReportConfirmRequest;
use App\Http\Requests\Quality\PerformanceReportMemoRequest;
use App\Services\PerformanceReportService;
use Illuminate\Http\Request;
class PerformanceReportController extends Controller
{
public function __construct(private PerformanceReportService $service) {}
public function index(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->index($request->all());
}, __('message.fetched'));
}
public function stats(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->stats($request->all());
}, __('message.fetched'));
}
public function confirm(PerformanceReportConfirmRequest $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->confirm($request->validated()['ids']);
}, __('message.updated'));
}
public function unconfirm(PerformanceReportConfirmRequest $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->unconfirm($request->validated()['ids']);
}, __('message.updated'));
}
public function updateMemo(PerformanceReportMemoRequest $request)
{
return ApiResponse::handle(function () use ($request) {
$data = $request->validated();
return $this->service->updateMemo($data['ids'], $data['memo']);
}, __('message.updated'));
}
public function missing(Request $request)
{
return ApiResponse::handle(function () use ($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

@@ -0,0 +1,50 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\ProductionOrder\ProductionOrderIndexRequest;
use App\Services\ProductionOrderService;
use Illuminate\Http\JsonResponse;
class ProductionOrderController extends Controller
{
public function __construct(
private readonly ProductionOrderService $service
) {}
/**
* 생산지시 목록 조회
*/
public function index(ProductionOrderIndexRequest $request): JsonResponse
{
$result = $this->service->index($request->validated());
return ApiResponse::success($result, __('message.fetched'));
}
/**
* 생산지시 상태별 통계
*/
public function stats(): JsonResponse
{
$stats = $this->service->stats();
return ApiResponse::success($stats, __('message.fetched'));
}
/**
* 생산지시 상세 조회
*/
public function show(int $orderId): JsonResponse
{
try {
$detail = $this->service->show($orderId);
return ApiResponse::success($detail, __('message.fetched'));
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
return ApiResponse::error(__('error.order.not_found'), 404);
}
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\Qms\QmsLotAuditConfirmRequest;
use App\Http\Requests\Qms\QmsLotAuditDocumentDetailRequest;
use App\Http\Requests\Qms\QmsLotAuditIndexRequest;
use App\Services\QmsLotAuditService;
class QmsLotAuditController extends Controller
{
public function __construct(private QmsLotAuditService $service) {}
/**
* 품질관리서 목록 (로트 추적 심사용)
*/
public function index(QmsLotAuditIndexRequest $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->index($request->validated());
}, __('message.fetched'));
}
/**
* 품질관리서 상세 — 수주/개소 목록
*/
public function show(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->show($id);
}, __('message.fetched'));
}
/**
* 수주 루트별 8종 서류 목록
*/
public function routeDocuments(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->routeDocuments($id);
}, __('message.fetched'));
}
/**
* 서류 상세 조회 (2단계 로딩)
*/
public function documentDetail(QmsLotAuditDocumentDetailRequest $request, string $type, int $id)
{
return ApiResponse::handle(function () use ($type, $id) {
return $this->service->documentDetail($type, $id);
}, __('message.fetched'));
}
/**
* 개소별 로트 심사 확인 토글
*/
public function confirm(QmsLotAuditConfirmRequest $request, int $id)
{
return ApiResponse::handle(function () use ($request, $id) {
return $this->service->confirm($id, $request->validated());
}, __('message.updated'));
}
}

View File

@@ -0,0 +1,147 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\Quality\QualityDocumentStoreRequest;
use App\Http\Requests\Quality\QualityDocumentUpdateRequest;
use App\Services\QualityDocumentService;
use Illuminate\Http\Request;
class QualityDocumentController extends Controller
{
public function __construct(private QualityDocumentService $service) {}
public function index(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->index($request->all());
}, __('message.fetched'));
}
public function stats(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->stats($request->all());
}, __('message.fetched'));
}
public function calendar(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->calendar($request->all());
}, __('message.fetched'));
}
public function availableOrders(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->availableOrders($request->all());
}, __('message.fetched'));
}
public function show(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->show($id);
}, __('message.fetched'));
}
public function store(QualityDocumentStoreRequest $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->store($request->validated());
}, __('message.created'));
}
public function update(QualityDocumentUpdateRequest $request, int $id)
{
return ApiResponse::handle(function () use ($request, $id) {
return $this->service->update($id, $request->validated());
}, __('message.updated'));
}
public function destroy(int $id)
{
return ApiResponse::handle(function () use ($id) {
$this->service->destroy($id);
return 'success';
}, __('message.deleted'));
}
public function complete(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->complete($id);
}, __('message.updated'));
}
public function attachOrders(Request $request, int $id)
{
$request->validate([
'order_ids' => ['required', 'array', 'min:1'],
'order_ids.*' => ['required', 'integer'],
]);
return ApiResponse::handle(function () use ($request, $id) {
return $this->service->attachOrders($id, $request->input('order_ids'));
}, __('message.updated'));
}
public function detachOrder(int $id, int $orderId)
{
return ApiResponse::handle(function () use ($id, $orderId) {
return $this->service->detachOrder($id, $orderId);
}, __('message.updated'));
}
public function inspectLocation(Request $request, int $id, int $locId)
{
$request->validate([
'post_width' => ['nullable', 'integer'],
'post_height' => ['nullable', 'integer'],
'change_reason' => ['nullable', 'string', 'max:500'],
'inspection_status' => ['nullable', 'string', 'in:pending,completed'],
]);
return ApiResponse::handle(function () use ($request, $id, $locId) {
return $this->service->inspectLocation($id, $locId, $request->all());
}, __('message.updated'));
}
public function requestDocument(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->requestDocument($id);
}, __('message.fetched'));
}
public function resultDocument(int $id)
{
return ApiResponse::handle(function () use ($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

@@ -8,13 +8,15 @@
use App\Http\Requests\Shipment\ShipmentUpdateRequest; use App\Http\Requests\Shipment\ShipmentUpdateRequest;
use App\Http\Requests\Shipment\ShipmentUpdateStatusRequest; use App\Http\Requests\Shipment\ShipmentUpdateStatusRequest;
use App\Services\ShipmentService; use App\Services\ShipmentService;
use App\Services\WorkOrderService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class ShipmentController extends Controller class ShipmentController extends Controller
{ {
public function __construct( public function __construct(
private readonly ShipmentService $service private readonly ShipmentService $service,
private readonly WorkOrderService $workOrderService
) {} ) {}
/** /**
@@ -83,7 +85,7 @@ public function store(ShipmentStoreRequest $request): JsonResponse
{ {
$shipment = $this->service->store($request->validated()); $shipment = $this->service->store($request->validated());
return ApiResponse::success($shipment, __('message.created'), 201); return ApiResponse::success($shipment, __('message.created'), [], 201);
} }
/** /**
@@ -132,6 +134,22 @@ public function destroy(int $id): JsonResponse
} }
} }
/**
* 수주 기반 출하 생성
*/
public function createFromOrder(int $orderId): JsonResponse
{
try {
$shipment = $this->workOrderService->createShipmentForOrder($orderId);
return ApiResponse::success($shipment, __('message.created'), [], 201);
} catch (\Symfony\Component\HttpKernel\Exception\BadRequestHttpException $e) {
return ApiResponse::error($e->getMessage(), 400);
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
return ApiResponse::error(__('error.order.not_found'), 404);
}
}
/** /**
* LOT 옵션 조회 * LOT 옵션 조회
*/ */

View File

@@ -4,6 +4,7 @@
use App\Helpers\ApiResponse; use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\V1\Stock\StoreStockAdjustmentRequest;
use App\Services\StockService; use App\Services\StockService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -29,6 +30,8 @@ public function index(Request $request): JsonResponse
'sort_dir', 'sort_dir',
'per_page', 'per_page',
'page', 'page',
'start_date',
'end_date',
]); ]);
$stocks = $this->service->index($params); $stocks = $this->service->index($params);
@@ -69,4 +72,32 @@ public function statsByItemType(): JsonResponse
return ApiResponse::success($stats, __('message.fetched')); 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\SubscriptionCancelRequest;
use App\Http\Requests\V1\Subscription\SubscriptionIndexRequest; use App\Http\Requests\V1\Subscription\SubscriptionIndexRequest;
use App\Http\Requests\V1\Subscription\SubscriptionStoreRequest; use App\Http\Requests\V1\Subscription\SubscriptionStoreRequest;
use App\Services\ExportService;
use App\Services\SubscriptionService; use App\Services\SubscriptionService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class SubscriptionController extends Controller class SubscriptionController extends Controller
{ {
public function __construct( 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 public function export(ExportStoreRequest $request): JsonResponse
{ {
return ApiResponse::handle( return ApiResponse::handle(
fn () => $this->subscriptionService->createExport($request->validated()), fn () => $this->subscriptionService->createExport($request->validated(), $this->exportService),
__('message.export.requested') __('message.export.requested')
); );
} }
@@ -137,4 +141,24 @@ public function exportStatus(int $id): JsonResponse
__('message.fetched') __('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

@@ -10,12 +10,33 @@
use App\Http\Requests\TaxInvoice\TaxInvoiceSummaryRequest; use App\Http\Requests\TaxInvoice\TaxInvoiceSummaryRequest;
use App\Http\Requests\TaxInvoice\UpdateTaxInvoiceRequest; use App\Http\Requests\TaxInvoice\UpdateTaxInvoiceRequest;
use App\Http\Requests\V1\TaxInvoice\BulkIssueRequest; use App\Http\Requests\V1\TaxInvoice\BulkIssueRequest;
use App\Models\Tenants\JournalEntry;
use App\Services\JournalSyncService;
use App\Services\TaxInvoiceService; use App\Services\TaxInvoiceService;
use App\Services\TenantSettingService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class TaxInvoiceController extends Controller 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( public function __construct(
private TaxInvoiceService $taxInvoiceService private TaxInvoiceService $taxInvoiceService,
private JournalSyncService $journalSyncService,
private TenantSettingService $tenantSettingService,
) {} ) {}
/** /**
@@ -23,12 +44,9 @@ public function __construct(
*/ */
public function index(TaxInvoiceListRequest $request) public function index(TaxInvoiceListRequest $request)
{ {
$taxInvoices = $this->taxInvoiceService->list($request->validated()); return ApiResponse::handle(function () use ($request) {
return $this->taxInvoiceService->list($request->validated());
return ApiResponse::handle( }, __('message.fetched'));
data: $taxInvoices,
message: __('message.fetched')
);
} }
/** /**
@@ -36,12 +54,9 @@ public function index(TaxInvoiceListRequest $request)
*/ */
public function show(int $id) public function show(int $id)
{ {
$taxInvoice = $this->taxInvoiceService->show($id); return ApiResponse::handle(function () use ($id) {
return $this->taxInvoiceService->show($id);
return ApiResponse::handle( }, __('message.fetched'));
data: $taxInvoice,
message: __('message.fetched')
);
} }
/** /**
@@ -49,13 +64,9 @@ public function show(int $id)
*/ */
public function store(CreateTaxInvoiceRequest $request) public function store(CreateTaxInvoiceRequest $request)
{ {
$taxInvoice = $this->taxInvoiceService->create($request->validated()); return ApiResponse::handle(function () use ($request) {
return $this->taxInvoiceService->create($request->validated());
return ApiResponse::handle( }, __('message.created'));
data: $taxInvoice,
message: __('message.created'),
status: 201
);
} }
/** /**
@@ -63,12 +74,9 @@ public function store(CreateTaxInvoiceRequest $request)
*/ */
public function update(UpdateTaxInvoiceRequest $request, int $id) public function update(UpdateTaxInvoiceRequest $request, int $id)
{ {
$taxInvoice = $this->taxInvoiceService->update($id, $request->validated()); return ApiResponse::handle(function () use ($request, $id) {
return $this->taxInvoiceService->update($id, $request->validated());
return ApiResponse::handle( }, __('message.updated'));
data: $taxInvoice,
message: __('message.updated')
);
} }
/** /**
@@ -76,12 +84,11 @@ public function update(UpdateTaxInvoiceRequest $request, int $id)
*/ */
public function destroy(int $id) public function destroy(int $id)
{ {
$this->taxInvoiceService->delete($id); return ApiResponse::handle(function () use ($id) {
$this->taxInvoiceService->delete($id);
return ApiResponse::handle( return null;
data: null, }, __('message.deleted'));
message: __('message.deleted')
);
} }
/** /**
@@ -89,12 +96,9 @@ public function destroy(int $id)
*/ */
public function issue(int $id) public function issue(int $id)
{ {
$taxInvoice = $this->taxInvoiceService->issue($id); return ApiResponse::handle(function () use ($id) {
return $this->taxInvoiceService->issue($id);
return ApiResponse::handle( }, __('message.tax_invoice.issued'));
data: $taxInvoice,
message: __('message.tax_invoice.issued')
);
} }
/** /**
@@ -102,12 +106,9 @@ public function issue(int $id)
*/ */
public function bulkIssue(BulkIssueRequest $request) public function bulkIssue(BulkIssueRequest $request)
{ {
$result = $this->taxInvoiceService->bulkIssue($request->getIds()); return ApiResponse::handle(function () use ($request) {
return $this->taxInvoiceService->bulkIssue($request->getIds());
return ApiResponse::handle( }, __('message.tax_invoice.bulk_issued'));
data: $result,
message: __('message.tax_invoice.bulk_issued')
);
} }
/** /**
@@ -115,12 +116,9 @@ public function bulkIssue(BulkIssueRequest $request)
*/ */
public function cancel(CancelTaxInvoiceRequest $request, int $id) public function cancel(CancelTaxInvoiceRequest $request, int $id)
{ {
$taxInvoice = $this->taxInvoiceService->cancel($id, $request->validated()['reason']); return ApiResponse::handle(function () use ($request, $id) {
return $this->taxInvoiceService->cancel($id, $request->validated()['reason']);
return ApiResponse::handle( }, __('message.tax_invoice.cancelled'));
data: $taxInvoice,
message: __('message.tax_invoice.cancelled')
);
} }
/** /**
@@ -128,12 +126,9 @@ public function cancel(CancelTaxInvoiceRequest $request, int $id)
*/ */
public function checkStatus(int $id) public function checkStatus(int $id)
{ {
$taxInvoice = $this->taxInvoiceService->checkStatus($id); return ApiResponse::handle(function () use ($id) {
return $this->taxInvoiceService->checkStatus($id);
return ApiResponse::handle( }, __('message.fetched'));
data: $taxInvoice,
message: __('message.fetched')
);
} }
/** /**
@@ -141,11 +136,121 @@ public function checkStatus(int $id)
*/ */
public function summary(TaxInvoiceSummaryRequest $request) public function summary(TaxInvoiceSummaryRequest $request)
{ {
$summary = $this->taxInvoiceService->summary($request->validated()); return ApiResponse::handle(function () use ($request) {
return $this->taxInvoiceService->summary($request->validated());
}, __('message.fetched'));
}
return ApiResponse::handle( // =========================================================================
data: $summary, // 공급자 설정 (Supplier Settings)
message: __('message.fetched') // =========================================================================
);
/**
* 공급자 설정 조회
*/
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)
// =========================================================================
/**
* 세금계산서 분개 조회
*/
public function getJournalEntries(int $id): JsonResponse
{
return ApiResponse::handle(function () use ($id) {
$sourceKey = "tax_invoice_{$id}";
$data = $this->journalSyncService->getForSource(
JournalEntry::SOURCE_TAX_INVOICE,
$sourceKey
);
return $data ?? ['rows' => []];
}, __('message.fetched'));
}
/**
* 세금계산서 분개 저장/수정
*/
public function storeJournalEntries(Request $request, int $id): JsonResponse
{
return ApiResponse::handle(function () use ($request, $id) {
$validated = $request->validate([
'rows' => 'required|array|min:1',
'rows.*.side' => 'required|in:debit,credit',
'rows.*.account_subject' => 'required|string|max:20',
'rows.*.debit_amount' => 'required|integer|min:0',
'rows.*.credit_amount' => 'required|integer|min:0',
]);
// 세금계산서 정보 조회 (entry_date용)
$taxInvoice = $this->taxInvoiceService->show($id);
$rows = array_map(fn ($row) => [
'side' => $row['side'],
'account_code' => $row['account_subject'],
'debit_amount' => $row['debit_amount'],
'credit_amount' => $row['credit_amount'],
], $validated['rows']);
$sourceKey = "tax_invoice_{$id}";
return $this->journalSyncService->saveForSource(
JournalEntry::SOURCE_TAX_INVOICE,
$sourceKey,
$taxInvoice->issue_date?->format('Y-m-d') ?? now()->format('Y-m-d'),
"세금계산서 분개 (#{$id})",
$rows,
);
}, __('message.created'));
}
/**
* 세금계산서 분개 삭제
*/
public function deleteJournalEntries(int $id): JsonResponse
{
return ApiResponse::handle(function () use ($id) {
$sourceKey = "tax_invoice_{$id}";
return $this->journalSyncService->deleteForSource(
JournalEntry::SOURCE_TAX_INVOICE,
$sourceKey
);
}, __('message.deleted'));
} }
} }

View File

@@ -20,9 +20,10 @@ public function __construct(
public function summary(Request $request): JsonResponse public function summary(Request $request): JsonResponse
{ {
$limit = (int) $request->input('limit', 30); $limit = (int) $request->input('limit', 30);
$date = $request->input('date'); // YYYY-MM-DD (이전 이슈 조회용)
return ApiResponse::handle(function () use ($limit) { return ApiResponse::handle(function () use ($limit, $date) {
return $this->todayIssueService->summary($limit); return $this->todayIssueService->summary($limit, null, $date);
}, __('message.fetched')); }, __('message.fetched'));
} }

View File

@@ -32,4 +32,18 @@ public function summary(Request $request): JsonResponse
return $this->vatService->getSummary($periodType, $year, $period); return $this->vatService->getSummary($periodType, $year, $period);
}, __('message.fetched')); }, __('message.fetched'));
} }
/**
* 부가세 상세 조회 (모달용)
*/
public function detail(Request $request): JsonResponse
{
$periodType = $request->query('period_type', 'quarter');
$year = $request->query('year') ? (int) $request->query('year') : null;
$period = $request->query('period') ? (int) $request->query('period') : null;
return ApiResponse::handle(function () use ($periodType, $year, $period) {
return $this->vatService->getDetail($periodType, $year, $period);
}, __('message.fetched'));
}
} }

View File

@@ -0,0 +1,74 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\VehicleDispatch\VehicleDispatchUpdateRequest;
use App\Services\VehicleDispatchService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class VehicleDispatchController extends Controller
{
public function __construct(
private readonly VehicleDispatchService $service
) {}
/**
* 배차차량 목록 조회
*/
public function index(Request $request): JsonResponse
{
$params = $request->only([
'search',
'status',
'start_date',
'end_date',
'per_page',
'page',
]);
$dispatches = $this->service->index($params);
return ApiResponse::success($dispatches, __('message.fetched'));
}
/**
* 배차차량 통계 조회
*/
public function stats(): JsonResponse
{
$stats = $this->service->stats();
return ApiResponse::success($stats, __('message.fetched'));
}
/**
* 배차차량 상세 조회
*/
public function show(int $id): JsonResponse
{
try {
$dispatch = $this->service->show($id);
return ApiResponse::success($dispatch, __('message.fetched'));
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
return ApiResponse::error(__('error.not_found'), 404);
}
}
/**
* 배차차량 수정
*/
public function update(VehicleDispatchUpdateRequest $request, int $id): JsonResponse
{
try {
$dispatch = $this->service->update($id, $request->validated());
return ApiResponse::success($dispatch, __('message.updated'));
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
return ApiResponse::error(__('error.not_found'), 404);
}
}
}

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

@@ -61,14 +61,18 @@ public function detail(Request $request): JsonResponse
: 0.05; : 0.05;
$year = $request->query('year') ? (int) $request->query('year') : null; $year = $request->query('year') ? (int) $request->query('year') : null;
$quarter = $request->query('quarter') ? (int) $request->query('quarter') : null; $quarter = $request->query('quarter') ? (int) $request->query('quarter') : null;
$startDate = $request->query('start_date');
$endDate = $request->query('end_date');
return ApiResponse::handle(function () use ($calculationType, $fixedAmountPerMonth, $ratio, $year, $quarter) { return ApiResponse::handle(function () use ($calculationType, $fixedAmountPerMonth, $ratio, $year, $quarter, $startDate, $endDate) {
return $this->welfareService->getDetail( return $this->welfareService->getDetail(
$calculationType, $calculationType,
$fixedAmountPerMonth, $fixedAmountPerMonth,
$ratio, $ratio,
$year, $year,
$quarter $quarter,
$startDate,
$endDate
); );
}, __('message.fetched')); }, __('message.fetched'));
} }

View File

@@ -230,6 +230,16 @@ public function inspectionReport(int $id)
}, __('message.work_order.fetched')); }, __('message.work_order.fetched'));
} }
/**
* 작업지시 검사 설정 조회 (공정 자동 판별 + 구성품 목록)
*/
public function inspectionConfig(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->getInspectionConfig($id);
}, __('message.work_order.fetched'));
}
/** /**
* 작업지시의 검사용 문서 템플릿 조회 * 작업지시의 검사용 문서 템플릿 조회
*/ */
@@ -310,7 +320,14 @@ public function materialsForItem(int $id, int $itemId)
public function registerMaterialInputForItem(MaterialInputForItemRequest $request, int $id, int $itemId) public function registerMaterialInputForItem(MaterialInputForItemRequest $request, int $id, int $itemId)
{ {
return ApiResponse::handle(function () use ($request, $id, $itemId) { return ApiResponse::handle(function () use ($request, $id, $itemId) {
return $this->service->registerMaterialInputForItem($id, $itemId, $request->validated()['inputs']); $validated = $request->validated();
return $this->service->registerMaterialInputForItem(
$id,
$itemId,
$validated['inputs'],
(bool) ($validated['replace'] ?? false)
);
}, __('message.work_order.material_input_registered')); }, __('message.work_order.material_input_registered'));
} }

View File

@@ -0,0 +1,91 @@
<?php
namespace App\Http\Controllers\V1\Equipment;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\V1\Equipment\StoreEquipmentRequest;
use App\Http\Requests\V1\Equipment\UpdateEquipmentRequest;
use App\Services\Equipment\EquipmentService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class EquipmentController extends Controller
{
public function __construct(private readonly EquipmentService $service) {}
public function index(Request $request): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->index($request->only([
'search', 'status', 'production_line', 'equipment_type',
'sort_by', 'sort_direction', 'per_page',
])),
__('message.fetched')
);
}
public function show(int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->show($id),
__('message.fetched')
);
}
public function store(StoreEquipmentRequest $request): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->store($request->validated()),
__('message.equipment.created')
);
}
public function update(UpdateEquipmentRequest $request, int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->update($id, $request->validated()),
__('message.equipment.updated')
);
}
public function destroy(int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->destroy($id),
__('message.equipment.deleted')
);
}
public function restore(int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->restore($id),
__('message.equipment.restored')
);
}
public function toggleActive(int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->toggleActive($id),
__('message.toggled')
);
}
public function stats(): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->stats(),
__('message.fetched')
);
}
public function options(): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->options(),
__('message.fetched')
);
}
}

View File

@@ -0,0 +1,130 @@
<?php
namespace App\Http\Controllers\V1\Equipment;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\V1\Equipment\StoreInspectionTemplateRequest;
use App\Http\Requests\V1\Equipment\ToggleInspectionDetailRequest;
use App\Http\Requests\V1\Equipment\UpdateInspectionNotesRequest;
use App\Services\Equipment\EquipmentInspectionService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class EquipmentInspectionController extends Controller
{
public function __construct(private readonly EquipmentInspectionService $service) {}
public function index(Request $request): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->getInspections(
$request->input('cycle', 'daily'),
$request->input('period', now()->format('Y-m')),
$request->input('production_line'),
$request->input('equipment_id') ? (int) $request->input('equipment_id') : null
),
__('message.fetched')
);
}
public function toggleDetail(ToggleInspectionDetailRequest $request): JsonResponse
{
$data = $request->validated();
return ApiResponse::handle(
fn () => $this->service->toggleDetail(
$data['equipment_id'],
$data['template_item_id'],
$data['check_date'],
$data['cycle'] ?? 'daily'
),
__('message.equipment.inspection_saved')
);
}
public function setResult(Request $request): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->setResult(
(int) $request->input('equipment_id'),
(int) $request->input('template_item_id'),
$request->input('check_date'),
$request->input('cycle', 'daily'),
$request->input('result')
),
__('message.equipment.inspection_saved')
);
}
public function updateNotes(UpdateInspectionNotesRequest $request): JsonResponse
{
$data = $request->validated();
return ApiResponse::handle(
fn () => $this->service->updateNotes(
$data['equipment_id'],
$data['year_month'],
collect($data)->only(['overall_judgment', 'inspector_id', 'repair_note', 'issue_note'])->toArray(),
$data['cycle'] ?? 'daily'
),
__('message.equipment.inspection_saved')
);
}
public function resetInspection(Request $request): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->resetInspection(
(int) $request->input('equipment_id'),
$request->input('cycle', 'daily'),
$request->input('period')
),
__('message.equipment.inspection_reset')
);
}
public function templates(Request $request, int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->getTemplatesByEquipment($id, $request->input('cycle')),
__('message.fetched')
);
}
public function storeTemplate(StoreInspectionTemplateRequest $request, int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->saveTemplate($id, $request->validated()),
__('message.equipment.template_created')
);
}
public function updateTemplate(Request $request, int $templateId): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->updateTemplate($templateId, $request->all()),
__('message.updated')
);
}
public function deleteTemplate(int $templateId): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->deleteTemplate($templateId),
__('message.deleted')
);
}
public function copyTemplates(Request $request, int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->copyTemplates(
$id,
$request->input('source_cycle'),
$request->input('target_cycles', [])
),
__('message.equipment.template_copied')
);
}
}

View File

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

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Http\Controllers\V1\Equipment;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\V1\Equipment\StoreEquipmentRepairRequest;
use App\Services\Equipment\EquipmentRepairService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class EquipmentRepairController extends Controller
{
public function __construct(private readonly EquipmentRepairService $service) {}
public function index(Request $request): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->index($request->only([
'equipment_id', 'repair_type', 'date_from', 'date_to', 'search', 'per_page',
])),
__('message.fetched')
);
}
public function store(StoreEquipmentRepairRequest $request): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->store($request->validated()),
__('message.equipment.repair_created')
);
}
public function update(StoreEquipmentRepairRequest $request, int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->update($id, $request->validated()),
__('message.updated')
);
}
public function destroy(int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->destroy($id),
__('message.deleted')
);
}
}

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

@@ -89,6 +89,7 @@ public function handle(Request $request, Closure $next)
// Bearer 인증 (Sanctum) // Bearer 인증 (Sanctum)
$user = []; $user = [];
$accessToken = null;
if ($token = $request->bearerToken()) { if ($token = $request->bearerToken()) {
$accessToken = PersonalAccessToken::findToken($token); $accessToken = PersonalAccessToken::findToken($token);
if ($accessToken && $accessToken->tokenable instanceof User) { if ($accessToken && $accessToken->tokenable instanceof User) {
@@ -114,9 +115,25 @@ 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 토큰 없이 접근 가능 // 화이트리스트(인증 예외 라우트) - Bearer 토큰 없이 접근 가능
$allowWithoutAuth = [ $allowWithoutAuth = [
'api/v1/login', 'api/v1/login',
'api/v1/token-login', // MNG → SAM 자동 로그인 (API Key만 필요)
'api/v1/signup', 'api/v1/signup',
'api/v1/register', 'api/v1/register',
'api/v1/refresh', 'api/v1/refresh',
@@ -124,6 +141,14 @@ public function handle(Request $request, Closure $next)
'api/v1/internal/exchange-token', // 내부 서버간 토큰 교환 (HMAC 인증 사용) 'api/v1/internal/exchange-token', // 내부 서버간 토큰 교환 (HMAC 인증 사용)
'api/v1/admin/fcm/*', // Admin FCM API (MNG에서 API Key만으로 접근) 'api/v1/admin/fcm/*', // Admin FCM API (MNG에서 API Key만으로 접근)
'api/v1/app/*', // 앱 버전 확인/다운로드 (API Key만 필요) 'api/v1/app/*', // 앱 버전 확인/다운로드 (API Key만 필요)
'api/v1/bending-items', // 절곡품 목록 (MNG에서 API Key만으로 접근)
'api/v1/bending-items/*', // 절곡품 상세/필터
'api/v1/guiderail-models', // 절곡품 모델 목록
'api/v1/guiderail-models/*', // 절곡품 모델 상세/필터
'api/v1/items/*/files', // 품목 파일 업로드/조회
'api/v1/files/*/view', // 파일 인라인 보기 (MNG 이미지 표시)
'api/v1/files/*/download', // 파일 다운로드
'api/v1/quotes/calculate/*', // 자동산출 (MNG에서 API Key + X-TENANT-ID로 접근)
]; ];
// 현재 라우트 확인 (경로 또는 이름) // 현재 라우트 확인 (경로 또는 이름)

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,27 @@
<?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',
'search' => 'nullable|string|max:100',
'page' => 'nullable|integer|min:1',
'size' => 'nullable|integer|min:1|max:200',
];
}
}

View File

@@ -0,0 +1,47 @@
<?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:100|unique:items,code',
'name' => 'required|string|max:200',
'unit' => 'nullable|string|max:20',
'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,20 @@
<?php
namespace App\Http\Requests\Approval;
use Illuminate\Foundation\Http\FormRequest;
class ApproveRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'comment' => 'nullable|string|max:1000',
];
}
}

View File

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

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Http\Requests\Approval;
use Illuminate\Foundation\Http\FormRequest;
class DelegationStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'delegate_id' => 'required|integer|exists:users,id',
'start_date' => 'required|date|after_or_equal:today',
'end_date' => 'required|date|after_or_equal:start_date',
'form_ids' => 'nullable|array',
'form_ids.*' => 'integer|exists:approval_forms,id',
'notify_delegator' => 'nullable|boolean',
'reason' => 'nullable|string|max:500',
];
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Requests\Approval;
use Illuminate\Foundation\Http\FormRequest;
class DelegationUpdateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'delegate_id' => 'nullable|integer|exists:users,id',
'start_date' => 'nullable|date',
'end_date' => 'nullable|date|after_or_equal:start_date',
'form_ids' => 'nullable|array',
'form_ids.*' => 'integer|exists:approval_forms,id',
'notify_delegator' => 'nullable|boolean',
'is_active' => 'nullable|boolean',
'reason' => 'nullable|string|max:500',
];
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Requests\Approval;
use Illuminate\Foundation\Http\FormRequest;
class HoldRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'comment' => 'required|string|max:1000',
];
}
public function messages(): array
{
return [
'comment.required' => __('error.approval.comment_required'),
];
}
}

View File

@@ -22,6 +22,8 @@ public function rules(): array
'sort_dir' => 'nullable|string|in:asc,desc', 'sort_dir' => 'nullable|string|in:asc,desc',
'per_page' => 'nullable|integer|min:1', 'per_page' => 'nullable|integer|min:1',
'page' => 'nullable|integer|min:1', 'page' => 'nullable|integer|min:1',
'start_date' => 'nullable|date_format:Y-m-d',
'end_date' => 'nullable|date_format:Y-m-d|after_or_equal:start_date',
]; ];
} }
} }

View File

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

View File

@@ -65,7 +65,7 @@ public function rules(): array
'mobile' => 'nullable|string|max:20', 'mobile' => 'nullable|string|max:20',
'fax' => 'nullable|string|max:20', 'fax' => 'nullable|string|max:20',
'email' => 'nullable|email|max:100', 'email' => 'nullable|email|max:100',
'address' => 'nullable|string|max:255', 'address' => 'nullable|string|max:500',
// 담당자 정보 // 담당자 정보
'manager_name' => 'nullable|string|max:50', 'manager_name' => 'nullable|string|max:50',
'manager_tel' => 'nullable|string|max:20', 'manager_tel' => 'nullable|string|max:20',

View File

@@ -65,7 +65,7 @@ public function rules(): array
'mobile' => 'nullable|string|max:20', 'mobile' => 'nullable|string|max:20',
'fax' => 'nullable|string|max:20', 'fax' => 'nullable|string|max:20',
'email' => 'nullable|email|max:100', 'email' => 'nullable|email|max:100',
'address' => 'nullable|string|max:255', 'address' => 'nullable|string|max:500',
// 담당자 정보 // 담당자 정보
'manager_name' => 'nullable|string|max:50', 'manager_name' => 'nullable|string|max:50',
'manager_tel' => 'nullable|string|max:20', 'manager_tel' => 'nullable|string|max:20',

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' => '유효하지 않은 프리셋입니다.',
];
}
}

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