Compare commits

61 Commits

Author SHA1 Message Date
김보곤
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
166 changed files with 13391 additions and 421 deletions

View File

@@ -1,6 +1,6 @@
# 논리적 데이터베이스 관계 문서
> **자동 생성**: 2026-03-12 13:58:25
> **자동 생성**: 2026-03-17 15:29:06
> **소스**: Eloquent 모델 관계 분석
## 📊 모델별 관계 현황
@@ -374,6 +374,8 @@ ### esign_signers
### equipments
**모델**: `App\Models\Equipment\Equipment`
- **manager()**: belongsTo → `users`
- **subManager()**: belongsTo → `users`
- **inspectionTemplates()**: hasMany → `equipment_inspection_templates`
- **inspections()**: hasMany → `equipment_inspections`
- **repairs()**: hasMany → `equipment_repairs`
@@ -384,6 +386,7 @@ ### equipment_inspections
**모델**: `App\Models\Equipment\EquipmentInspection`
- **equipment()**: belongsTo → `equipments`
- **inspector()**: belongsTo → `users`
- **details()**: hasMany → `equipment_inspection_details`
### equipment_inspection_details
@@ -407,6 +410,7 @@ ### equipment_repairs
**모델**: `App\Models\Equipment\EquipmentRepair`
- **equipment()**: belongsTo → `equipments`
- **repairer()**: belongsTo → `users`
### estimates
**모델**: `App\Models\Estimate\Estimate`
@@ -429,6 +433,12 @@ ### file_share_links
- **file()**: belongsTo → `files`
- **tenant()**: belongsTo → `tenants`
### corporate_vehicles
**모델**: `App\Models\Tenants\CorporateVehicle`
- **logs()**: hasMany → `vehicle_logs`
- **maintenances()**: hasMany → `vehicle_maintenances`
### folders
**모델**: `App\Models\Folder`
@@ -713,6 +723,11 @@ ### process_steps
- **process()**: belongsTo → `processes`
### bending_item_mappings
**모델**: `App\Models\Production\BendingItemMapping`
- **item()**: belongsTo → `items`
### work_orders
**모델**: `App\Models\Production\WorkOrder`
@@ -898,6 +913,7 @@ ### quality_documents
- **documentOrders()**: hasMany → `quality_document_orders`
- **locations()**: hasMany → `quality_document_locations`
- **performanceReport()**: hasOne → `performance_reports`
- **file()**: hasOne → `files`
### quality_document_locations
**모델**: `App\Models\Qualitys\QualityDocumentLocation`
@@ -1232,6 +1248,7 @@ ### shipments
- **order()**: belongsTo → `orders`
- **workOrder()**: belongsTo → `work_orders`
- **client()**: belongsTo → `clients`
- **creator()**: belongsTo → `users`
- **updater()**: belongsTo → `users`
- **items()**: hasMany → `shipment_items`
@@ -1242,6 +1259,7 @@ ### shipment_items
- **shipment()**: belongsTo → `shipments`
- **stockLot()**: belongsTo → `stock_lots`
- **orderItem()**: belongsTo → `order_items`
### shipment_vehicle_dispatchs
**모델**: `App\Models\Tenants\ShipmentVehicleDispatch`
@@ -1353,6 +1371,16 @@ ### today_issues
- **reader()**: belongsTo → `users`
- **targetUser()**: belongsTo → `users`
### vehicle_logs
**모델**: `App\Models\Tenants\VehicleLog`
- **vehicle()**: belongsTo → `corporate_vehicles`
### vehicle_maintenances
**모델**: `App\Models\Tenants\VehicleMaintenance`
- **vehicle()**: belongsTo → `corporate_vehicles`
### withdrawals
**모델**: `App\Models\Tenants\Withdrawal`

View File

@@ -0,0 +1,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' => 'FG',
'item_category' => $category,
'unit' => 'SET',
'options' => json_encode($options, JSON_UNESCAPED_UNICODE),
'is_active' => true,
'created_at' => now(),
'updated_at' => now(),
]);
}
$created++;
$this->line("{$code} ({$name}) — 부품 ".count($components).'개');
}
$this->info(" 생성: {$created}건 | 스킵: {$skipped}");
return $created;
}
private function buildCode(object $row, string $category): string
{
if ($category === 'SHUTTERBOX_MODEL') {
$size = ($row->box_width ?? '').
'*'.($row->box_height ?? '');
$exit = match ($row->exit_direction ?? '') {
'양면 점검구' => '양면',
'밑면 점검구' => '밑면',
'후면 점검구' => '후면',
default => $row->exit_direction ?? '',
};
return "SB-{$size}-{$exit}";
}
// BOTTOMBAR_MODEL
$model = $row->model_name ?? 'UNKNOWN';
$finish = str_contains($row->finishing_type ?? '', 'SUS') ? 'SUS' : 'EGI';
return "BB-{$model}-{$finish}";
}
private function buildName(object $row, string $category): string
{
if ($category === 'SHUTTERBOX_MODEL') {
return "케이스 {$row->box_width}*{$row->box_height} {$row->exit_direction}";
}
return "하단마감재 {$row->model_name} {$row->firstitem}";
}
private function buildOptions(object $row, string $category, array $components, array $materialSummary): array
{
$base = [
'author' => $row->author ?? null,
'registration_date' => $row->registration_date ?? null,
'search_keyword' => $row->search_keyword ?? null,
'memo' => $row->remark ?? null,
'components' => $components,
'material_summary' => $materialSummary,
'source' => 'chandj_'.(strtolower($category)),
'legacy_num' => $row->num,
];
if ($category === 'SHUTTERBOX_MODEL') {
return array_merge($base, [
'box_width' => (int) ($row->box_width ?? 0),
'box_height' => (int) ($row->box_height ?? 0),
'exit_direction' => $row->exit_direction ?? null,
'front_bottom_width' => $row->front_bottom_width ?? null,
'rail_width' => $row->rail_width ?? null,
]);
}
// BOTTOMBAR_MODEL
return array_merge($base, [
'model_name' => $row->model_name ?? null,
'item_sep' => $row->firstitem ?? null,
'model_UA' => $row->model_UA ?? null,
'finishing_type' => $row->finishing_type ?? null,
'bar_width' => $row->bar_width ?? null,
'bar_height' => $row->bar_height ?? null,
]);
}
private function convertComponents(array $legacyComps): array
{
return array_map(function ($c, $idx) {
$inputs = $c['inputList'] ?? [];
$rates = $c['bendingrateList'] ?? [];
$sums = $c['sumList'] ?? [];
$colors = $c['colorList'] ?? [];
$angles = $c['AList'] ?? [];
$bendingData = [];
for ($i = 0; $i < count($inputs); $i++) {
$bendingData[] = [
'no' => $i + 1,
'input' => (float) ($inputs[$i] ?? 0),
'rate' => (string) ($rates[$i] ?? ''),
'sum' => (float) ($sums[$i] ?? 0),
'color' => (bool) ($colors[$i] ?? false),
'aAngle' => (bool) ($angles[$i] ?? false),
];
}
$lastSum = ! empty($sums) ? (float) end($sums) : ($c['widthsum'] ?? 0);
return [
'orderNumber' => $idx + 1,
'itemName' => $c['itemName'] ?? '',
'material' => $c['material'] ?? '',
'quantity' => (int) ($c['quantity'] ?? 1),
'width_sum' => (float) $lastSum,
'bendingData' => $bendingData,
'legacy_bending_num' => $c['source_num'] ?? $c['num'] ?? null,
];
}, $legacyComps, array_keys($legacyComps));
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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

@@ -95,7 +95,7 @@ public function render($request, Throwable $exception)
if ($exception instanceof BadRequestHttpException) {
return response()->json([
'success' => false,
'message' => '잘못된 요청',
'message' => $exception->getMessage() ?: '잘못된 요청',
'data' => null,
], 400);
}

View File

@@ -0,0 +1,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

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

View File

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

View File

@@ -0,0 +1,119 @@
<?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 파라미터가 필요합니다.'];
}
$item = $this->service->resolveItem($prodCode, $specCode, $lengthCode);
if (! $item) {
return ['error' => 'NOT_MAPPED', 'code' => 404, 'message' => '해당 조합에 매핑된 품목이 없습니다.'];
}
return $item;
}, __('message.fetched'));
}
/**
* 원자재 LOT 목록 조회 (수입검사 완료 입고 기준)
*
* 재질(material)이 일치하는 입고 LOT 목록 반환
*/
public function materialLots(Request $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
$material = $request->query('material');
$query = Receiving::where('status', 'completed')
->whereNotNull('lot_no')
->where('lot_no', '!=', '');
// 재질(item_name 또는 specification)으로 필터링
if ($material) {
$query->where(function ($q) use ($material) {
$q->where('item_name', 'LIKE', "%{$material}%")
->orWhere('specification', 'LIKE', "%{$material}%");
});
}
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 번호 생성 (프리뷰 + 일련번호 확정)
*/
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가 필요합니다.'];
}
$dateCode = BendingCodeService::generateDateCode($regDate);
$lotBase = "{$prodCode}{$specCode}{$dateCode}-{$lengthCode}";
$lotNumber = $this->service->generateLotNumber($lotBase);
$material = BendingCodeService::getMaterial($prodCode, $specCode);
return [
'lot_base' => $lotBase,
'lot_number' => $lotNumber,
'date_code' => $dateCode,
'material' => $material,
];
}, __('message.fetched'));
}
}

View File

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

View File

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

View File

@@ -0,0 +1,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

@@ -12,6 +12,20 @@
class FileStorageController extends Controller
{
/**
* Bearer 토큰 없이 X-TENANT-ID 헤더로 컨텍스트 설정 (MNG 프록시 호출용)
*/
private function ensureContext(Request $request): void
{
if (! app()->bound('tenant_id') || ! app('tenant_id')) {
$tenantId = (int) ($request->header('X-TENANT-ID') ?: 287);
app()->instance('tenant_id', $tenantId);
}
if (! app()->bound('api_user') || ! app('api_user')) {
app()->instance('api_user', 1);
}
}
/**
* Upload file to temp
*/
@@ -85,8 +99,10 @@ public function trash()
/**
* Download file (attachment)
*/
public function download(int $id)
public function download(int $id, Request $request)
{
$this->ensureContext($request);
$service = new FileStorageService;
$file = $service->getFile($id);
@@ -96,8 +112,10 @@ public function download(int $id)
/**
* View file inline (이미지/PDF 브라우저에서 바로 표시)
*/
public function view(int $id)
public function view(int $id, Request $request)
{
$this->ensureContext($request);
$service = new FileStorageService;
$file = $service->getFile($id);

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

@@ -28,6 +28,7 @@ public function index(Request $request)
'category_id' => $request->input('category_id'),
'item_type' => $request->input('type') ?? $request->input('item_type'),
'item_category' => $request->input('item_category'),
'bom_category' => $request->input('bom_category'),
'group_id' => $request->input('group_id'),
'active' => $request->input('is_active') ?? $request->input('active'),
'has_bom' => $request->input('has_bom'),

View File

@@ -26,6 +26,20 @@ class ItemsFileController extends Controller
*/
private const ITEM_GROUP_ID = '1';
/**
* Bearer 토큰 없이 X-TENANT-ID 헤더로 컨텍스트 설정 (MNG 프록시 호출용)
*/
private function ensureContext(Request $request): void
{
if (! app()->bound('tenant_id') || ! app('tenant_id')) {
$tenantId = (int) ($request->header('X-TENANT-ID') ?: 287);
app()->instance('tenant_id', $tenantId);
}
if (! app()->bound('api_user') || ! app('api_user')) {
app()->instance('api_user', 1);
}
}
/**
* 파일 목록 조회
*
@@ -33,6 +47,7 @@ class ItemsFileController extends Controller
*/
public function index(int $id, Request $request)
{
$this->ensureContext($request);
return ApiResponse::handle(function () use ($id, $request) {
$tenantId = app('tenant_id');
$fieldKey = $request->input('field_key');
@@ -69,6 +84,7 @@ public function index(int $id, Request $request)
*/
public function upload(int $id, ItemFileUploadRequest $request)
{
$this->ensureContext($request);
return ApiResponse::handle(function () use ($id, $request) {
$tenantId = app('tenant_id');
$userId = auth()->id() ?? app('api_user');
@@ -152,6 +168,7 @@ public function upload(int $id, ItemFileUploadRequest $request)
*/
public function delete(int $id, mixed $fileId, Request $request)
{
$this->ensureContext($request);
$fileId = (int) $fileId;
return ApiResponse::handle(function () use ($id, $fileId) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -125,6 +125,13 @@ public function handle(Request $request, Closure $next)
'api/v1/internal/exchange-token', // 내부 서버간 토큰 교환 (HMAC 인증 사용)
'api/v1/admin/fcm/*', // Admin FCM API (MNG에서 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', // 파일 다운로드
];
// 현재 라우트 확인 (경로 또는 이름)

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,72 @@
<?php
namespace App\Http\Resources\Api\V1;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class BendingItemResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'code' => $this->code,
'name' => $this->name,
'item_type' => $this->item_type,
'item_category' => $this->item_category,
'unit' => $this->unit,
'is_active' => $this->is_active,
// options → 최상위로 노출
'item_name' => $this->getOption('item_name'),
'item_sep' => $this->getOption('item_sep'),
'item_bending' => $this->getOption('item_bending'),
'item_spec' => $this->getOption('item_spec'),
'material' => $this->getOption('material'),
'model_name' => $this->getOption('model_name'),
'model_UA' => $this->getOption('model_UA'),
'search_keyword' => $this->getOption('search_keyword'),
'rail_width' => $this->getOption('rail_width'),
'registration_date' => $this->getOption('registration_date'),
'author' => $this->getOption('author'),
'memo' => $this->getOption('memo'),
// 케이스 전용
'exit_direction' => $this->getOption('exit_direction'),
'front_bottom_width' => $this->getOption('front_bottom_width'),
'box_width' => $this->getOption('box_width'),
'box_height' => $this->getOption('box_height'),
// 전개도
'bendingData' => $this->getOption('bendingData'),
// PREFIX 관련
'prefix' => $this->getOption('prefix'),
'length_code' => $this->getOption('length_code'),
'length_mm' => $this->getOption('length_mm'),
// 추적
'legacy_bending_num' => $this->getOption('legacy_bending_num'),
// 계산값
'width_sum' => $this->getWidthSum(),
'bend_count' => $this->getBendCount(),
// 메타
'created_at' => $this->created_at?->format('Y-m-d H:i:s'),
'updated_at' => $this->updated_at?->format('Y-m-d H:i:s'),
];
}
private function getWidthSum(): ?int
{
$data = $this->getOption('bendingData', []);
if (empty($data)) {
return null;
}
$last = end($data);
return isset($last['sum']) ? (int) $last['sum'] : null;
}
private function getBendCount(): int
{
$data = $this->getOption('bendingData', []);
return count(array_filter($data, fn ($d) => ($d['rate'] ?? '') !== ''));
}
}

View File

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

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Jobs\Barobill;
use App\Models\Barobill\BarobillMember;
use App\Services\Barobill\BarobillBankSyncService;
use App\Services\Barobill\BarobillCardSyncService;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
/**
* 바로빌 데이터 자동 동기화 Job
*
* 스케줄러에서 매일 실행하여 활성 회원의 은행/카드 거래내역을 동기화한다.
*/
class SyncBarobillDataJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $timeout = 300;
public function __construct(
private string $syncType = 'all',
) {}
public function handle(
BarobillBankSyncService $bankSyncService,
BarobillCardSyncService $cardSyncService,
): void {
$members = BarobillMember::withoutGlobalScopes()
->where('status', 'active')
->where('server_mode', 'production')
->get();
if ($members->isEmpty()) {
Log::info('[SyncBarobill] 활성 회원 없음, 스킵');
return;
}
$yesterday = Carbon::yesterday()->format('Ymd');
$today = Carbon::today()->format('Ymd');
foreach ($members as $member) {
try {
if (in_array($this->syncType, ['all', 'bank'])) {
$result = $bankSyncService->syncIfNeeded(
$member->tenant_id,
$yesterday,
$today
);
Log::info('[SyncBarobill] 은행 동기화 완료', [
'tenant_id' => $member->tenant_id,
'result' => $result,
]);
}
if (in_array($this->syncType, ['all', 'card'])) {
$result = $cardSyncService->syncCardTransactions(
$member->tenant_id,
$yesterday,
$today
);
Log::info('[SyncBarobill] 카드 동기화 완료', [
'tenant_id' => $member->tenant_id,
'result' => $result,
]);
}
} catch (\Throwable $e) {
Log::error('[SyncBarobill] 동기화 실패', [
'tenant_id' => $member->tenant_id,
'sync_type' => $this->syncType,
'error' => $e->getMessage(),
]);
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Models\Production;
use App\Models\Items\Item;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BendingItemMapping extends Model
{
use BelongsToTenant;
protected $table = 'bending_item_mappings';
protected $fillable = [
'tenant_id',
'prod_code',
'spec_code',
'length_code',
'item_id',
'is_active',
];
protected $casts = [
'is_active' => 'boolean',
];
public function item(): BelongsTo
{
return $this->belongsTo(Item::class);
}
}

View File

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

View File

@@ -331,11 +331,21 @@ public function scopeSearch($query, ?string $keyword)
/**
* 수정 가능 여부 확인
* - 모든 상태에서 수정 가능 (finalized, converted 포함)
* - 수주 전환된 견적 수정 시 연결된 수주도 함께 동기화됨
* - 생산지시가 존재하는 수주에 연결된 견적은 수정 불가
* - 그 외 모든 상태에서 수정 가능 (finalized, converted 포함)
*/
public function isEditable(): bool
{
if ($this->order_id) {
$hasWorkOrders = Order::where('id', $this->order_id)
->whereHas('workOrders')
->exists();
if ($hasWorkOrders) {
return false;
}
}
return true;
}

View File

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

View File

@@ -147,7 +147,7 @@ public function vehicleDispatches(): HasMany
*/
public function client(): BelongsTo
{
return $this->belongsTo(\App\Models\Clients\Client::class);
return $this->belongsTo(\App\Models\Orders\Client::class);
}
/**

View File

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

View File

@@ -50,6 +50,8 @@ class StockTransaction extends Model
public const REASON_PRODUCTION_OUTPUT = 'production_output';
public const REASON_ADJUSTMENT = 'adjustment';
public const REASONS = [
self::REASON_RECEIVING => '입고',
self::REASON_WORK_ORDER_INPUT => '생산투입',
@@ -57,6 +59,7 @@ class StockTransaction extends Model
self::REASON_ORDER_CONFIRM => '수주확정',
self::REASON_ORDER_CANCEL => '수주취소',
self::REASON_PRODUCTION_OUTPUT => '생산입고',
self::REASON_ADJUSTMENT => '재고조정',
];
protected $fillable = [

View File

@@ -18,6 +18,32 @@ class Tenant extends Model
{
use ModelTrait, SoftDeletes;
// 데모 테넌트 유형 상수
const TYPE_STD = 'STD';
const TYPE_TPL = 'TPL';
const TYPE_HQ = 'HQ';
const TYPE_DEMO_SHOWCASE = 'DEMO_SHOWCASE';
const TYPE_DEMO_PARTNER = 'DEMO_PARTNER';
const TYPE_DEMO_TRIAL = 'DEMO_TRIAL';
const DEMO_TYPES = [
self::TYPE_DEMO_SHOWCASE,
self::TYPE_DEMO_PARTNER,
self::TYPE_DEMO_TRIAL,
];
// 데모 options 키 상수
const OPTION_DEMO_PRESET = 'demo_preset';
const OPTION_DEMO_LIMITS = 'demo_limits';
const OPTION_DEMO_READ_ONLY = 'demo_read_only';
protected $fillable = [
'company_name',
'code',
@@ -47,6 +73,7 @@ class Tenant extends Model
protected $casts = [
'trial_ends_at' => 'datetime',
'demo_expires_at' => 'datetime',
'expires_at' => 'datetime',
'last_paid_at' => 'datetime',
'max_users' => 'integer',
@@ -244,4 +271,133 @@ private function formatBytes(int $bytes): string
return round($bytes, 2).' '.$units[$pow];
}
// ─── options 헬퍼 메서드 ───
public function getOption(string $key, mixed $default = null): mixed
{
return data_get($this->options, $key, $default);
}
public function setOption(string $key, mixed $value): self
{
$options = $this->options ?? [];
data_set($options, $key, $value);
$this->options = $options;
return $this;
}
// ─── 데모 테넌트 관련 메서드 ───
/**
* 데모 테넌트인지 확인 (모든 데모 유형)
*/
public function isDemoTenant(): bool
{
return in_array($this->tenant_type, self::DEMO_TYPES);
}
/**
* 데모 쇼케이스(공용 읽기전용)인지 확인
*/
public function isDemoShowcase(): bool
{
return $this->tenant_type === self::TYPE_DEMO_SHOWCASE;
}
/**
* 파트너 데모인지 확인
*/
public function isDemoPartner(): bool
{
return $this->tenant_type === self::TYPE_DEMO_PARTNER;
}
/**
* 고객 체험 데모인지 확인
*/
public function isDemoTrial(): bool
{
return $this->tenant_type === self::TYPE_DEMO_TRIAL;
}
/**
* 데모 만료 여부 확인
*/
public function isDemoExpired(): bool
{
if (! $this->isDemoTenant()) {
return false;
}
// 쇼케이스는 만료 없음
if ($this->isDemoShowcase()) {
return false;
}
return $this->demo_expires_at && now()->gt($this->demo_expires_at);
}
/**
* 데모 읽기전용인지 확인
*/
public function isDemoReadOnly(): bool
{
return $this->isDemoShowcase()
|| $this->getOption(self::OPTION_DEMO_READ_ONLY, false);
}
/**
* 데모 테넌트만 조회하는 스코프
*/
public function scopeDemo($query)
{
return $query->whereIn('tenant_type', self::DEMO_TYPES);
}
/**
* 데모 프리셋 조회
*/
public function getDemoPreset(): ?string
{
return $this->getOption(self::OPTION_DEMO_PRESET);
}
/**
* 데모 수량 제한 조회
*/
public function getDemoLimits(): array
{
return $this->getOption(self::OPTION_DEMO_LIMITS, [
'max_items' => 100,
'max_orders' => 50,
'max_productions' => 30,
'max_users' => 5,
'max_storage_gb' => 1,
'max_ai_tokens' => 100000,
]);
}
/**
* 데모 → 정식 테넌트로 전환
* fillable 밖의 컬럼이므로 forceFill 사용
*/
public function convertToProduction(): void
{
$this->forceFill([
'tenant_type' => self::TYPE_STD,
'demo_expires_at' => null,
'demo_source_partner_id' => null,
])->save();
// options에서 데모 관련 키 제거
$options = $this->options ?? [];
unset(
$options[self::OPTION_DEMO_PRESET],
$options[self::OPTION_DEMO_LIMITS],
$options[self::OPTION_DEMO_READ_ONLY]
);
$this->update(['options' => $options ?: null]);
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Models\Tenants;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class VehicleLog extends Model
{
use BelongsToTenant, SoftDeletes;
protected $fillable = [
'tenant_id',
'vehicle_id',
'log_date',
'department',
'driver_name',
'trip_type',
'departure_type',
'departure_name',
'departure_address',
'arrival_type',
'arrival_name',
'arrival_address',
'distance_km',
'note',
];
protected $casts = [
'vehicle_id' => 'integer',
'distance_km' => 'integer',
];
public function vehicle(): BelongsTo
{
return $this->belongsTo(CorporateVehicle::class, 'vehicle_id');
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Models\Tenants;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class VehicleMaintenance extends Model
{
use BelongsToTenant, SoftDeletes;
protected $fillable = [
'tenant_id',
'vehicle_id',
'date',
'category',
'description',
'amount',
'mileage',
'vendor',
'memo',
];
protected $casts = [
'vehicle_id' => 'integer',
'amount' => 'integer',
'mileage' => 'integer',
];
public function vehicle(): BelongsTo
{
return $this->belongsTo(CorporateVehicle::class, 'vehicle_id');
}
}

View File

@@ -1536,7 +1536,7 @@ private function generateDocumentNumber(int $tenantId): string
$prefix = 'AP';
$date = now()->format('Ymd');
$lastNumber = Approval::query()
$lastNumber = Approval::withTrashed()
->where('tenant_id', $tenantId)
->where('document_number', 'like', "{$prefix}-{$date}-%")
->orderByDesc('document_number')

View File

@@ -0,0 +1,293 @@
<?php
namespace App\Services\Barobill;
use App\Models\Barobill\BarobillBankSyncStatus;
use App\Models\Barobill\BarobillMember;
use App\Services\Service;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* 바로빌 은행 거래내역 동기화 서비스 (API 독립 구현)
*
* MNG의 BarobillBankSyncService 패턴을 참고하여 독립 작성.
* SOAP API를 호출하여 은행 거래내역을 DB에 캐시/동기화한다.
*/
class BarobillBankSyncService extends Service
{
public function __construct(
protected BarobillSoapService $soapService
) {}
/**
* 지정 기간의 거래내역이 최신인지 확인하고, 필요 시 바로빌 API에서 동기화
*/
public function syncIfNeeded(int $tenantId, string $startDateYmd, string $endDateYmd): array
{
$member = BarobillMember::withoutGlobalScopes()
->where('tenant_id', $tenantId)
->first();
if (! $member || empty($member->barobill_id)) {
return ['success' => false, 'error' => '바로빌 회원 정보 없음'];
}
$this->soapService->initForMember($member);
$accounts = $this->getRegisteredAccounts($member);
if (empty($accounts)) {
return ['success' => true, 'message' => '등록된 계좌 없음', 'synced' => 0];
}
$currentYearMonth = Carbon::now()->format('Ym');
$chunks = $this->splitDateRangeMonthly($startDateYmd, $endDateYmd);
$totalSynced = 0;
foreach ($accounts as $acc) {
$accNum = $acc['bankAccountNum'];
foreach ($chunks as $chunk) {
$yearMonth = substr($chunk['start'], 0, 6);
$isCurrentMonth = ($yearMonth === $currentYearMonth);
$syncStatus = BarobillBankSyncStatus::withoutGlobalScopes()
->where('tenant_id', $tenantId)
->where('bank_account_num', $accNum)
->where('synced_year_month', $yearMonth)
->first();
if ($syncStatus) {
if (! $isCurrentMonth) {
continue;
}
if ($syncStatus->synced_at && $syncStatus->synced_at->diffInMinutes(now()) < 10) {
continue;
}
}
$count = $this->fetchAndCache(
$tenantId,
$member->barobill_id,
$accNum,
$acc['bankName'],
$acc['bankCode'],
$chunk['start'],
$chunk['end'],
$yearMonth
);
$totalSynced += $count;
}
}
return [
'success' => true,
'synced' => $totalSynced,
'accounts' => count($accounts),
];
}
/**
* 바로빌 등록 계좌 목록 조회 (SOAP)
*/
public function getRegisteredAccounts(BarobillMember $member): array
{
$result = $this->soapService->getBankAccounts($member->biz_no, false);
if (! $result['success']) {
return [];
}
$data = $result['data'];
$accountList = [];
if (isset($data->BankAccount)) {
$accountList = is_array($data->BankAccount) ? $data->BankAccount : [$data->BankAccount];
} elseif (isset($data->BankAccountEx)) {
$accountList = is_array($data->BankAccountEx) ? $data->BankAccountEx : [$data->BankAccountEx];
}
$accounts = [];
foreach ($accountList as $acc) {
if (! is_object($acc)) {
continue;
}
$bankAccountNum = $acc->BankAccountNum ?? '';
if (empty($bankAccountNum) || (is_numeric($bankAccountNum) && $bankAccountNum < 0)) {
continue;
}
$accounts[] = [
'bankAccountNum' => $bankAccountNum,
'bankCode' => $acc->BankCode ?? '',
'bankName' => $acc->BankName ?? '',
];
}
return $accounts;
}
/**
* 바로빌 API에서 거래내역을 가져와 DB에 캐시
*/
protected function fetchAndCache(
int $tenantId,
string $userId,
string $accNum,
string $bankName,
string $bankCode,
string $startDate,
string $endDate,
string $yearMonth
): int {
$result = $this->soapService->call('bankaccount', 'GetPeriodBankAccountTransLog', [
'ID' => $userId,
'BankAccountNum' => $accNum,
'StartDate' => $startDate,
'EndDate' => $endDate,
'TransDirection' => 1,
'CountPerPage' => 1000,
'CurrentPage' => 1,
'OrderDirection' => 2,
]);
if (! $result['success']) {
$errorCode = $result['error_code'] ?? 0;
if (in_array($errorCode, [-25005, -25001])) {
$this->updateSyncStatus($tenantId, $accNum, $yearMonth);
}
return 0;
}
$chunkData = $result['data'];
if (is_numeric($chunkData) && $chunkData < 0) {
if (in_array((int) $chunkData, [-25005, -25001])) {
$this->updateSyncStatus($tenantId, $accNum, $yearMonth);
}
return 0;
}
$rawLogs = [];
if (isset($chunkData->BankAccountLogList) && isset($chunkData->BankAccountLogList->BankAccountTransLog)) {
$logs = $chunkData->BankAccountLogList->BankAccountTransLog;
$rawLogs = is_array($logs) ? $logs : [$logs];
}
$count = 0;
if (! empty($rawLogs)) {
$count = $this->cacheTransactions($tenantId, $accNum, $bankName, $bankCode, $rawLogs);
Log::debug("[BankSync] 캐시 저장 ({$startDate}~{$endDate}): {$count}");
}
$this->updateSyncStatus($tenantId, $accNum, $yearMonth);
return $count;
}
/**
* API 응답을 DB에 배치 저장
*/
protected function cacheTransactions(int $tenantId, string $accNum, string $bankName, string $bankCode, array $rawLogs): int
{
$rows = [];
$now = now();
foreach ($rawLogs as $log) {
$transDT = $log->TransDT ?? '';
$transDate = strlen($transDT) >= 8 ? substr($transDT, 0, 8) : '';
$transTime = strlen($transDT) >= 14 ? substr($transDT, 8, 6) : '';
$deposit = floatval($log->Deposit ?? 0);
$withdraw = floatval($log->Withdraw ?? 0);
$balance = floatval($log->Balance ?? 0);
$summary = $log->TransRemark1 ?? $log->Summary ?? '';
$remark2 = $log->TransRemark2 ?? '';
$cleanSummary = $this->cleanSummary($summary, $remark2);
$rows[] = [
'tenant_id' => $tenantId,
'bank_account_num' => $log->BankAccountNum ?? $accNum,
'bank_code' => $log->BankCode ?? $bankCode,
'bank_name' => $log->BankName ?? $bankName,
'trans_date' => $transDate,
'trans_time' => $transTime,
'trans_dt' => $transDT,
'deposit' => $deposit,
'withdraw' => $withdraw,
'balance' => $balance,
'summary' => $cleanSummary,
'cast' => $remark2,
'memo' => $log->Memo ?? '',
'trans_office' => $log->TransOffice ?? '',
'is_manual' => false,
'created_at' => $now,
'updated_at' => $now,
];
}
$inserted = 0;
foreach (array_chunk($rows, 100) as $batch) {
$inserted += DB::table('barobill_bank_transactions')->insertOrIgnore($batch);
}
return $inserted;
}
protected function updateSyncStatus(int $tenantId, string $accNum, string $yearMonth): void
{
BarobillBankSyncStatus::withoutGlobalScopes()->updateOrCreate(
[
'tenant_id' => $tenantId,
'bank_account_num' => $accNum,
'synced_year_month' => $yearMonth,
],
['synced_at' => now()]
);
}
/**
* 기간을 월별 청크로 분할
*/
protected function splitDateRangeMonthly(string $startDate, string $endDate): array
{
$start = Carbon::createFromFormat('Ymd', $startDate)->startOfDay();
$end = Carbon::createFromFormat('Ymd', $endDate)->endOfDay();
$chunks = [];
$cursor = $start->copy();
while ($cursor->lte($end)) {
$chunkStart = $cursor->copy();
$chunkEnd = $cursor->copy()->endOfMonth()->startOfDay();
if ($chunkEnd->gt($end)) {
$chunkEnd = $end->copy()->startOfDay();
}
$chunks[] = [
'start' => $chunkStart->format('Ymd'),
'end' => $chunkEnd->format('Ymd'),
];
$cursor = $chunkStart->copy()->addMonth()->startOfMonth();
}
return $chunks;
}
/**
* 요약 정리 (중복 정보 제거)
*/
protected function cleanSummary(string $summary, string $remark): string
{
$summary = trim($summary);
$remark = trim($remark);
if (! empty($remark) && str_contains($summary, $remark)) {
$summary = trim(str_replace($remark, '', $summary));
}
return $summary;
}
}

View File

@@ -0,0 +1,186 @@
<?php
namespace App\Services\Barobill;
use App\Models\Barobill\BarobillMember;
use App\Services\Service;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* 바로빌 카드 거래내역 동기화 서비스 (API 독립 구현)
*
* MNG의 EcardController 카드 조회 패턴을 참고하여 독립 작성.
* SOAP API를 호출하여 카드 거래내역을 DB에 캐시/동기화한다.
*/
class BarobillCardSyncService extends Service
{
public function __construct(
protected BarobillSoapService $soapService
) {}
/**
* 카드 거래내역 동기화
*/
public function syncCardTransactions(int $tenantId, string $startDateYmd, string $endDateYmd): array
{
$member = BarobillMember::withoutGlobalScopes()
->where('tenant_id', $tenantId)
->first();
if (! $member || empty($member->barobill_id)) {
return ['success' => false, 'error' => '바로빌 회원 정보 없음'];
}
$this->soapService->initForMember($member);
$cards = $this->getRegisteredCards($member);
if (empty($cards)) {
return ['success' => true, 'message' => '등록된 카드 없음', 'synced' => 0];
}
$totalSynced = 0;
foreach ($cards as $card) {
$cardNum = $card['cardNum'];
$result = $this->soapService->getPeriodCardLog(
$member->biz_no,
$member->barobill_id,
$cardNum,
$startDateYmd,
$endDateYmd
);
if (! $result['success']) {
Log::warning("[CardSync] 카드 조회 실패: {$cardNum}", [
'error' => $result['error'] ?? '',
]);
continue;
}
$data = $result['data'];
if (is_numeric($data) && $data < 0) {
continue;
}
$rawLogs = [];
if (isset($data->CardLogList) && isset($data->CardLogList->CardLog)) {
$logs = $data->CardLogList->CardLog;
$rawLogs = is_array($logs) ? $logs : [$logs];
}
if (! empty($rawLogs)) {
$count = $this->cacheTransactions(
$tenantId,
$cardNum,
$card['cardCompany'],
$card['cardCompanyName'],
$rawLogs
);
$totalSynced += $count;
Log::debug("[CardSync] 카드 {$cardNum}: {$count}건 저장");
}
}
return [
'success' => true,
'synced' => $totalSynced,
'cards' => count($cards),
];
}
/**
* 바로빌 등록 카드 목록 조회 (SOAP)
*/
public function getRegisteredCards(BarobillMember $member): array
{
$result = $this->soapService->getCards($member->biz_no, false);
if (! $result['success']) {
return [];
}
$data = $result['data'];
$cardList = [];
if (isset($data->CardEx2)) {
$cardList = is_array($data->CardEx2) ? $data->CardEx2 : [$data->CardEx2];
} elseif (isset($data->Card)) {
$cardList = is_array($data->Card) ? $data->Card : [$data->Card];
}
$cards = [];
foreach ($cardList as $card) {
if (! is_object($card)) {
continue;
}
$cardNum = $card->CardNum ?? '';
if (empty($cardNum) || (is_numeric($cardNum) && $cardNum < 0)) {
continue;
}
$cards[] = [
'cardNum' => $cardNum,
'cardCompany' => $card->CardCompany ?? '',
'cardCompanyName' => BarobillSoapService::$cardCompanyCodes[$card->CardCompany ?? ''] ?? '',
'alias' => $card->Alias ?? '',
];
}
return $cards;
}
/**
* API 응답을 DB에 배치 저장
*/
protected function cacheTransactions(
int $tenantId,
string $cardNum,
string $cardCompany,
string $cardCompanyName,
array $rawLogs
): int {
$rows = [];
$now = now();
foreach ($rawLogs as $log) {
$useDT = $log->UseDT ?? '';
$useDate = strlen($useDT) >= 8 ? substr($useDT, 0, 8) : '';
$useTime = strlen($useDT) >= 14 ? substr($useDT, 8, 6) : '';
$rows[] = [
'tenant_id' => $tenantId,
'card_num' => $log->CardNum ?? $cardNum,
'card_company' => $log->CardCompany ?? $cardCompany,
'card_company_name' => $cardCompanyName,
'use_dt' => $useDT,
'use_date' => $useDate,
'use_time' => $useTime,
'approval_num' => $log->ApprovalNum ?? '',
'approval_type' => $log->ApprovalType ?? '',
'approval_amount' => floatval($log->ApprovalAmount ?? 0),
'tax' => floatval($log->Tax ?? 0),
'service_charge' => floatval($log->ServiceCharge ?? 0),
'payment_plan' => $log->PaymentPlan ?? '',
'currency_code' => $log->CurrencyCode ?? '',
'merchant_name' => $log->MerchantName ?? '',
'merchant_biz_num' => $log->MerchantBizNum ?? '',
'merchant_addr' => $log->MerchantAddr ?? '',
'merchant_ceo' => '',
'merchant_biz_type' => '',
'merchant_tel' => '',
'use_key' => $log->UseKey ?? '',
'is_manual' => false,
'created_at' => $now,
'updated_at' => $now,
];
}
$inserted = 0;
foreach (array_chunk($rows, 100) as $batch) {
$inserted += DB::table('barobill_card_transactions')->insertOrIgnore($batch);
}
return $inserted;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,116 @@
<?php
namespace App\Services\Barobill;
use App\Models\Barobill\HometaxInvoice;
use App\Services\Service;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* 홈택스 세금계산서 동기화 서비스 (API 독립 구현)
*
* MNG의 HometaxSyncService 패턴을 참고하여 독립 작성.
* 바로빌 API 응답 데이터를 로컬 DB에 upsert한다.
*/
class HometaxSyncService extends Service
{
/**
* API 응답 데이터를 로컬 DB에 동기화
*
* @param array $invoices API에서 받은 세금계산서 목록
* @param int $tenantId 테넌트 ID
* @param string $invoiceType 'sales' 또는 'purchase'
* @return array 동기화 결과
*/
public function syncInvoices(array $invoices, int $tenantId, string $invoiceType): array
{
$result = [
'inserted' => 0,
'updated' => 0,
'failed' => 0,
'total' => count($invoices),
];
if (empty($invoices)) {
return $result;
}
DB::beginTransaction();
try {
foreach ($invoices as $apiData) {
if (empty($apiData['ntsConfirmNum'])) {
$result['failed']++;
continue;
}
$modelData = HometaxInvoice::fromApiData($apiData, $tenantId, $invoiceType);
$existing = HometaxInvoice::withoutGlobalScopes()
->where('tenant_id', $tenantId)
->where('nts_confirm_num', $modelData['nts_confirm_num'])
->where('invoice_type', $invoiceType)
->first();
if ($existing) {
$existing->update([
'write_date' => $modelData['write_date'],
'issue_date' => $modelData['issue_date'],
'invoicer_corp_num' => $modelData['invoicer_corp_num'],
'invoicer_corp_name' => $modelData['invoicer_corp_name'],
'invoicer_ceo_name' => $modelData['invoicer_ceo_name'],
'invoicee_corp_num' => $modelData['invoicee_corp_num'],
'invoicee_corp_name' => $modelData['invoicee_corp_name'],
'invoicee_ceo_name' => $modelData['invoicee_ceo_name'],
'supply_amount' => $modelData['supply_amount'],
'tax_amount' => $modelData['tax_amount'],
'total_amount' => $modelData['total_amount'],
'tax_type' => $modelData['tax_type'],
'purpose_type' => $modelData['purpose_type'],
'item_name' => $modelData['item_name'],
'remark' => $modelData['remark'],
'synced_at' => now(),
]);
$result['updated']++;
} else {
HometaxInvoice::create($modelData);
$result['inserted']++;
}
}
DB::commit();
Log::info('[HometaxSync] 동기화 완료', [
'tenant_id' => $tenantId,
'invoice_type' => $invoiceType,
'result' => $result,
]);
} catch (\Throwable $e) {
DB::rollBack();
Log::error('[HometaxSync] 동기화 실패', [
'tenant_id' => $tenantId,
'invoice_type' => $invoiceType,
'error' => $e->getMessage(),
]);
throw $e;
}
return $result;
}
/**
* 마지막 동기화 시간 조회
*/
public function getLastSyncTime(int $tenantId, string $invoiceType): ?string
{
$lastSync = HometaxInvoice::withoutGlobalScopes()
->where('tenant_id', $tenantId)
->where('invoice_type', $invoiceType)
->orderByDesc('synced_at')
->value('synced_at');
return $lastSync?->format('Y-m-d H:i:s');
}
}

View File

@@ -0,0 +1,182 @@
<?php
namespace App\Services;
use App\Models\Orders\Order;
use App\Models\Production\BendingItemMapping;
class BendingCodeService extends Service
{
// =========================================================================
// 제품 코드 (7종)
// =========================================================================
public const PRODUCTS = [
['code' => 'R', 'name' => '가이드레일(벽면형)'],
['code' => 'S', 'name' => '가이드레일(측면형)'],
['code' => 'G', 'name' => '연기차단재'],
['code' => 'B', 'name' => '하단마감재(스크린)'],
['code' => 'T', 'name' => '하단마감재(철재)'],
['code' => 'L', 'name' => 'L-Bar'],
['code' => 'C', 'name' => '케이스'],
];
// =========================================================================
// 종류 코드 + 사용 가능 제품
// =========================================================================
public const SPECS = [
['code' => 'M', 'name' => '본체', 'products' => ['R', 'S']],
['code' => 'T', 'name' => '본체(철재)', 'products' => ['R', 'S']],
['code' => 'C', 'name' => 'C형', 'products' => ['R', 'S']],
['code' => 'D', 'name' => 'D형', 'products' => ['R', 'S']],
['code' => 'S', 'name' => 'SUS(마감)', 'products' => ['R', 'S', 'B', 'T']],
['code' => 'U', 'name' => 'SUS(마감)2', 'products' => ['S']],
['code' => 'E', 'name' => 'EGI(마감)', 'products' => ['R', 'S', 'B', 'T']],
['code' => 'I', 'name' => '화이바원단', 'products' => ['G']],
['code' => 'A', 'name' => '스크린용', 'products' => ['L']],
['code' => 'F', 'name' => '전면부', 'products' => ['C']],
['code' => 'P', 'name' => '점검구', 'products' => ['C']],
['code' => 'L', 'name' => '린텔부', 'products' => ['C']],
['code' => 'B', 'name' => '후면코너부', 'products' => ['C']],
];
// =========================================================================
// 모양&길이 코드
// =========================================================================
public const LENGTHS_SMOKE_BARRIER = [
['code' => '53', 'name' => 'W50 × 3000'],
['code' => '54', 'name' => 'W50 × 4000'],
['code' => '83', 'name' => 'W80 × 3000'],
['code' => '84', 'name' => 'W80 × 4000'],
];
public const LENGTHS_GENERAL = [
['code' => '12', 'name' => '1219'],
['code' => '24', 'name' => '2438'],
['code' => '30', 'name' => '3000'],
['code' => '35', 'name' => '3500'],
['code' => '40', 'name' => '4000'],
['code' => '41', 'name' => '4150'],
['code' => '42', 'name' => '4200'],
['code' => '43', 'name' => '4300'],
];
// =========================================================================
// 제품+종류 → 원자재(재질) 매핑
// =========================================================================
public const MATERIAL_MAP = [
'G:I' => '화이바원단',
'B:S' => 'SUS 1.2T',
'B:E' => 'EGI 1.55T',
'T:S' => 'SUS 1.2T',
'T:E' => 'EGI 1.55T',
'L:A' => 'EGI 1.55T',
'R:M' => 'EGI 1.55T',
'R:T' => 'EGI 1.55T',
'R:C' => 'EGI 1.55T',
'R:D' => 'EGI 1.55T',
'R:S' => 'SUS 1.2T',
'R:E' => 'EGI 1.55T',
'S:M' => 'EGI 1.55T',
'S:T' => 'EGI 1.55T',
'S:C' => 'EGI 1.55T',
'S:D' => 'EGI 1.55T',
'S:S' => 'SUS 1.2T',
'S:U' => 'SUS 1.2T',
'S:E' => 'EGI 1.55T',
'C:F' => 'EGI 1.55T',
'C:P' => 'EGI 1.55T',
'C:L' => 'EGI 1.55T',
'C:B' => 'EGI 1.55T',
];
/**
* 코드맵 전체 반환 (프론트엔드 드롭다운 구성용)
*/
public function getCodeMap(): array
{
return [
'products' => self::PRODUCTS,
'specs' => self::SPECS,
'lengths' => [
'smoke_barrier' => self::LENGTHS_SMOKE_BARRIER,
'general' => self::LENGTHS_GENERAL,
],
'material_map' => self::MATERIAL_MAP,
];
}
/**
* 드롭다운 선택 조합 → items 테이블 품목 매핑 조회
*/
public function resolveItem(string $prodCode, string $specCode, string $lengthCode): ?array
{
$mapping = BendingItemMapping::where('tenant_id', $this->tenantId())
->where('prod_code', $prodCode)
->where('spec_code', $specCode)
->where('length_code', $lengthCode)
->where('is_active', true)
->with('item:id,code,name,specification,unit')
->first();
if (! $mapping || ! $mapping->item) {
return null;
}
return [
'item_id' => $mapping->item->id,
'item_code' => $mapping->item->code,
'item_name' => $mapping->item->name,
'specification' => $mapping->item->specification,
'unit' => $mapping->item->unit ?? 'EA',
];
}
/**
* LOT 번호 생성 (일련번호 suffix 포함)
*
* base: 'GI6317-53' → 결과: 'GI6317-53-001'
*/
public function generateLotNumber(string $lotBase): string
{
$tenantId = $this->tenantId();
// 같은 base로 시작하는 기존 LOT 수 조회
$count = Order::withoutGlobalScopes()
->where('tenant_id', $tenantId)
->where('order_type_code', Order::TYPE_STOCK)
->where('options->bending_lot->lot_number', 'LIKE', $lotBase.'-%')
->count();
$seq = str_pad($count + 1, 3, '0', STR_PAD_LEFT);
return "{$lotBase}-{$seq}";
}
/**
* 날짜 → 4자리 날짜코드
*
* 2026-03-17 → '6317'
* 2026-10-05 → '6A05'
*/
public static function generateDateCode(string $date): string
{
$dt = \Carbon\Carbon::parse($date);
$year = $dt->year % 10;
$month = $dt->month;
$day = $dt->day;
$monthCode = $month >= 10
? chr(55 + $month) // 10=A, 11=B, 12=C
: (string) $month;
return $year.$monthCode.str_pad($day, 2, '0', STR_PAD_LEFT);
}
/**
* 제품+종류 → 원자재(재질) 반환
*/
public static function getMaterial(string $prodCode, string $specCode): ?string
{
return self::MATERIAL_MAP["{$prodCode}:{$specCode}"] ?? null;
}
}

View File

@@ -0,0 +1,119 @@
<?php
namespace App\Services;
use App\Models\Items\Item;
use Illuminate\Pagination\LengthAwarePaginator;
class BendingItemService extends Service
{
public function list(array $params): LengthAwarePaginator
{
return Item::where('item_category', 'BENDING')
->when($params['item_sep'] ?? null, fn ($q, $v) => $q->where('options->item_sep', $v))
->when($params['item_bending'] ?? null, fn ($q, $v) => $q->where('options->item_bending', $v))
->when($params['material'] ?? null, fn ($q, $v) => $q->where('options->material', 'like', "%{$v}%"))
->when($params['model_UA'] ?? null, fn ($q, $v) => $q->where('options->model_UA', $v))
->when($params['model_name'] ?? null, fn ($q, $v) => $q->where('options->model_name', $v))
->when($params['search'] ?? null, fn ($q, $v) => $q->where(
fn ($q2) => $q2
->where('name', 'like', "%{$v}%")
->orWhere('code', 'like', "%{$v}%")
->orWhere('options->search_keyword', 'like', "%{$v}%")
->orWhere('options->item_spec', 'like', "%{$v}%")
))
->orderBy('code')
->paginate($params['size'] ?? 50);
}
public function filters(): array
{
$items = Item::where('item_category', 'BENDING')
->select('options')
->get();
return [
'item_sep' => $items->pluck('options.item_sep')->filter()->unique()->sort()->values(),
'item_bending' => $items->pluck('options.item_bending')->filter()->unique()->sort()->values(),
'material' => $items->pluck('options.material')->filter()->unique()->sort()->values(),
'model_UA' => $items->pluck('options.model_UA')->filter()->unique()->sort()->values(),
'model_name' => $items->pluck('options.model_name')->filter()->unique()->sort()->values(),
];
}
public function find(int $id): Item
{
return Item::where('item_category', 'BENDING')->findOrFail($id);
}
public function create(array $data): Item
{
$options = $this->buildOptions($data);
return Item::create([
'tenant_id' => $this->tenantId(),
'item_type' => 'PT',
'item_category' => 'BENDING',
'code' => $data['code'],
'name' => $data['name'],
'unit' => $data['unit'] ?? 'EA',
'options' => $options,
'is_active' => true,
'created_by' => $this->apiUserId(),
]);
}
public function update(int $id, array $data): Item
{
$item = Item::where('item_category', 'BENDING')->findOrFail($id);
if (isset($data['code'])) {
$item->code = $data['code'];
}
if (isset($data['name'])) {
$item->name = $data['name'];
}
$optionKeys = self::OPTION_KEYS;
foreach ($optionKeys as $key) {
if (array_key_exists($key, $data)) {
$item->setOption($key, $data[$key]);
}
}
$item->updated_by = $this->apiUserId();
$item->save();
return $item;
}
public function delete(int $id): bool
{
$item = Item::where('item_category', 'BENDING')->findOrFail($id);
$item->deleted_by = $this->apiUserId();
$item->save();
return $item->delete();
}
private function buildOptions(array $data): array
{
$options = [];
foreach (self::OPTION_KEYS as $key) {
if (isset($data[$key])) {
$options[$key] = $data[$key];
}
}
return $options;
}
private const OPTION_KEYS = [
'item_name', 'item_sep', 'item_bending', 'item_spec',
'material', 'model_name', 'model_UA', 'search_keyword',
'rail_width', 'registration_date', 'author', 'memo',
'parent_num', 'exit_direction', 'front_bottom_width',
'box_width', 'box_height', 'bendingData',
'prefix', 'length_code', 'length_mm',
];
}

View File

@@ -2,6 +2,7 @@
namespace App\Services\Calculation;
use App\Helpers\SafeMathEvaluator;
use Illuminate\Support\Facades\Log;
class FormulaParser
@@ -230,8 +231,8 @@ protected function executeSimpleMath(string $formula, array $variables): float
throw new \InvalidArgumentException("안전하지 않은 수학 표현식: {$expression}");
}
// 계산 실행
return eval("return {$expression};");
// 안전한 산술 파서로 계산 실행
return SafeMathEvaluator::calculate($expression);
}
/**
@@ -276,7 +277,7 @@ protected function evaluateCondition(string $condition, array $variables): bool
throw new \InvalidArgumentException("안전하지 않은 조건식: {$expression}");
}
return eval("return {$expression};");
return SafeMathEvaluator::compare($expression);
}
/**

View File

@@ -53,7 +53,7 @@ public function index(array $params)
$query->whereDate('created_at', '<=', $endDate);
}
$query->orderBy('client_code')->orderBy('id');
$query->orderBy('id', 'desc');
$paginator = $query->paginate($size, ['*'], 'page', $page);
@@ -113,6 +113,22 @@ public function index(array $params)
return $paginator;
}
/**
* 거래처 간단 목록 (id, name만 반환) - vendors 엔드포인트용
*/
public function vendors(array $params): array
{
$tenantId = $this->tenantId();
$perPage = (int) ($params['per_page'] ?? 9999);
return Client::where('tenant_id', $tenantId)
->where('is_active', true)
->orderBy('name')
->limit($perPage)
->get(['id', 'name'])
->toArray();
}
/** 단건 */
public function show(int $id)
{
@@ -304,10 +320,14 @@ public function stats(): array
{
$tenantId = $this->tenantId();
$total = Client::where('tenant_id', $tenantId)->count();
$base = Client::where('tenant_id', $tenantId);
$total = (clone $base)->count();
$active = (clone $base)->where('is_active', true)->count();
$inactive = $total - $active;
// 거래처 유형별 통계
$typeCounts = Client::where('tenant_id', $tenantId)
$typeCounts = (clone $base)
->selectRaw('client_type, COUNT(*) as count')
->groupBy('client_type')
->pluck('count', 'client_type')
@@ -321,12 +341,14 @@ public function stats(): array
->distinct('client_id')
->pluck('client_id');
$badDebtCount = Client::where('tenant_id', $tenantId)
$badDebtCount = (clone $base)
->whereIn('id', $badDebtClientIds)
->count();
return [
'total' => $total,
'active' => $active,
'inactive' => $inactive,
'sales' => $typeCounts['SALES'] ?? 0,
'purchase' => $typeCounts['PURCHASE'] ?? 0,
'both' => $typeCounts['BOTH'] ?? 0,

View File

@@ -0,0 +1,271 @@
<?php
namespace App\Services\Demo;
use App\Models\Tenants\Tenant;
use App\Services\Service;
use Illuminate\Support\Facades\DB;
/**
* 데모 테넌트 분석 서비스
*
* - 전환율 분석 (데모 → 정식)
* - 활동 모니터링 (데모 테넌트별 사용량)
* - 파트너별 영업 성과 분석
*
* 기존 코드 영향 없음: 데모 전용 분석 로직만 포함
*
* @see docs/features/sales/demo-tenant-policy.md
*/
class DemoAnalyticsService extends Service
{
/**
* 전환율 분석 (전체 또는 파트너별)
*/
public function conversionFunnel(array $params = []): array
{
$partnerId = $params['partner_id'] ?? null;
$period = $params['period'] ?? 'all'; // all, monthly, quarterly
$baseQuery = Tenant::withoutGlobalScopes();
if ($partnerId) {
$baseQuery->where('demo_source_partner_id', $partnerId);
}
// Tier 3 체험 테넌트 전체 (현재 + 전환 완료)
$totalTrials = (clone $baseQuery)
->where(function ($q) {
$q->where('tenant_type', Tenant::TYPE_DEMO_TRIAL)
->orWhere(function ($q2) {
// 정식 전환된 테넌트 (demo_source_partner_id가 있으면서 STD)
$q2->where('tenant_type', Tenant::TYPE_STD)
->whereNotNull('demo_source_partner_id');
});
})
->count();
// 활성 체험 중
$activeTrials = (clone $baseQuery)
->where('tenant_type', Tenant::TYPE_DEMO_TRIAL)
->where(function ($q) {
$q->whereNull('demo_expires_at')
->orWhere('demo_expires_at', '>', now());
})
->count();
// 만료된 체험
$expiredTrials = (clone $baseQuery)
->where('tenant_type', Tenant::TYPE_DEMO_TRIAL)
->whereNotNull('demo_expires_at')
->where('demo_expires_at', '<', now())
->count();
// 정식 전환 완료
$converted = (clone $baseQuery)
->where('tenant_type', Tenant::TYPE_STD)
->whereNotNull('demo_source_partner_id')
->count();
$conversionRate = $totalTrials > 0
? round($converted / $totalTrials * 100, 1) : 0;
// 평균 전환 기간 (정식 전환된 건의 생성일 → 전환일 차이)
$avgConversionDays = Tenant::withoutGlobalScopes()
->where('tenant_type', Tenant::TYPE_STD)
->whereNotNull('demo_source_partner_id')
->when($partnerId, fn ($q) => $q->where('demo_source_partner_id', $partnerId))
->selectRaw('AVG(DATEDIFF(updated_at, created_at)) as avg_days')
->value('avg_days');
return [
'funnel' => [
'total_trials' => $totalTrials,
'active_trials' => $activeTrials,
'expired_trials' => $expiredTrials,
'converted' => $converted,
],
'conversion_rate' => $conversionRate,
'avg_conversion_days' => $avgConversionDays ? (int) round($avgConversionDays) : null,
];
}
/**
* 파트너별 성과 분석
*/
public function partnerPerformance(array $params = []): array
{
$partners = DB::table('sales_partners as sp')
->leftJoin('users as u', 'sp.user_id', '=', 'u.id')
->where('sp.status', 'active')
->select('sp.id', 'sp.partner_code', 'u.name as partner_name')
->get();
$results = [];
foreach ($partners as $partner) {
$demos = Tenant::withoutGlobalScopes()
->where('demo_source_partner_id', $partner->id)
->get();
$trials = $demos->where('tenant_type', Tenant::TYPE_DEMO_TRIAL);
$convertedCount = Tenant::withoutGlobalScopes()
->where('tenant_type', Tenant::TYPE_STD)
->where('demo_source_partner_id', $partner->id)
->count();
$totalTrials = $trials->count() + $convertedCount;
$conversionRate = $totalTrials > 0
? round($convertedCount / $totalTrials * 100, 1) : 0;
$results[] = [
'partner_id' => $partner->id,
'partner_code' => $partner->partner_code,
'partner_name' => $partner->partner_name,
'demo_count' => $demos->count(),
'active_trials' => $trials->filter(fn ($t) => ! $t->isDemoExpired())->count(),
'expired_trials' => $trials->filter(fn ($t) => $t->isDemoExpired())->count(),
'converted' => $convertedCount,
'conversion_rate' => $conversionRate,
];
}
// 전환율 내림차순 정렬
usort($results, fn ($a, $b) => $b['conversion_rate'] <=> $a['conversion_rate']);
return $results;
}
/**
* 데모 테넌트 활동 현황
*/
public function activityReport(array $params = []): array
{
$demos = Tenant::withoutGlobalScopes()
->whereIn('tenant_type', Tenant::DEMO_TYPES)
->when(
! empty($params['partner_id']),
fn ($q) => $q->where('demo_source_partner_id', $params['partner_id'])
)
->get();
$report = [];
foreach ($demos as $tenant) {
// 각 데모 테넌트의 데이터 입력량 조회
$dataCounts = $this->getDataCounts($tenant->id);
$totalRecords = array_sum($dataCounts);
// 최근 활동 시점 (가장 최근 레코드의 updated_at)
$lastActivity = $this->getLastActivity($tenant->id);
$daysSinceActivity = $lastActivity
? (int) now()->diffInDays($lastActivity) : null;
$report[] = [
'tenant_id' => $tenant->id,
'company_name' => $tenant->company_name,
'tenant_type' => $tenant->tenant_type,
'demo_expires_at' => $tenant->demo_expires_at?->toDateString(),
'is_expired' => $tenant->isDemoExpired(),
'data_counts' => $dataCounts,
'total_records' => $totalRecords,
'last_activity' => $lastActivity?->toDateString(),
'days_since_activity' => $daysSinceActivity,
'activity_status' => $this->classifyActivity($daysSinceActivity),
];
}
return $report;
}
/**
* 전체 요약 (대시보드용)
*/
public function summary(): array
{
$funnel = $this->conversionFunnel();
$allDemos = Tenant::withoutGlobalScopes()
->whereIn('tenant_type', Tenant::DEMO_TYPES)
->get();
$inactiveCount = 0;
foreach ($allDemos as $tenant) {
$lastActivity = $this->getLastActivity($tenant->id);
if ($lastActivity && now()->diffInDays($lastActivity) >= 7) {
$inactiveCount++;
}
}
return [
'funnel' => $funnel['funnel'],
'conversion_rate' => $funnel['conversion_rate'],
'avg_conversion_days' => $funnel['avg_conversion_days'],
'total_demos' => $allDemos->count(),
'inactive_count' => $inactiveCount,
'by_type' => [
'showcase' => $allDemos->where('tenant_type', Tenant::TYPE_DEMO_SHOWCASE)->count(),
'partner' => $allDemos->where('tenant_type', Tenant::TYPE_DEMO_PARTNER)->count(),
'trial' => $allDemos->where('tenant_type', Tenant::TYPE_DEMO_TRIAL)->count(),
],
];
}
// ──────────────────────────────────────────────
// Private Helpers
// ──────────────────────────────────────────────
private function getDataCounts(int $tenantId): array
{
$tables = ['departments', 'clients', 'items', 'quotes', 'orders'];
$counts = [];
foreach ($tables as $table) {
if (\Schema::hasTable($table) && \Schema::hasColumn($table, 'tenant_id')) {
$counts[$table] = DB::table($table)->where('tenant_id', $tenantId)->count();
}
}
return $counts;
}
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;
}
private function classifyActivity(?int $daysSinceActivity): string
{
if ($daysSinceActivity === null) {
return 'no_data';
}
return match (true) {
$daysSinceActivity <= 1 => 'active',
$daysSinceActivity <= 3 => 'normal',
$daysSinceActivity <= 7 => 'low',
default => 'inactive',
};
}
}

View File

@@ -0,0 +1,469 @@
<?php
namespace App\Services\Demo;
use App\Models\Tenants\Tenant;
use App\Services\Service;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* 데모 테넌트 생성/관리 서비스
*
* 기존 코드 영향 없음: 데모 전용 로직만 포함
*
* @see docs/features/sales/demo-tenant-policy.md
*/
class DemoTenantService extends Service
{
/**
* 기본 데모 수량 제한
*/
private const DEFAULT_DEMO_LIMITS = [
'max_items' => 100,
'max_orders' => 50,
'max_productions' => 30,
'max_users' => 5,
'max_storage_gb' => 1,
'max_ai_tokens' => 100000,
];
/**
* 사용 가능한 프리셋 목록
*/
private const AVAILABLE_PRESETS = [
'manufacturing', // 제조업 기본
// 향후 추가 예정:
// 'blinds', // 블라인드/스크린
// 'construction', // 시공/건설
// 'distribution', // 유통/도소매
];
/**
* 파트너 데모 테넌트 생성 (Tier 2)
* 파트너 승인 시 호출
*/
public function createPartnerDemo(int $partnerId, string $preset = 'manufacturing'): Tenant
{
$tenant = new Tenant;
$tenant->forceFill([
'company_name' => '파트너데모_'.$partnerId,
'code' => 'DEMO_P_'.$partnerId,
'email' => 'demo-partner-'.$partnerId.'@codebridge-x.com',
'tenant_st_code' => 'active',
'tenant_type' => Tenant::TYPE_DEMO_PARTNER,
'demo_source_partner_id' => $partnerId,
'options' => [
Tenant::OPTION_DEMO_PRESET => $preset,
Tenant::OPTION_DEMO_LIMITS => self::DEFAULT_DEMO_LIMITS,
],
]);
$tenant->save();
Log::info('파트너 데모 테넌트 생성', [
'tenant_id' => $tenant->id,
'partner_id' => $partnerId,
'preset' => $preset,
]);
return $tenant;
}
/**
* 고객 체험 테넌트 생성 (Tier 3)
* 파트너가 요청 → 본사 승인 후 호출
*/
public function createTrialDemo(
int $partnerId,
string $companyName,
string $email,
int $durationDays = 30,
string $preset = 'manufacturing'
): Tenant {
$tenant = new Tenant;
$tenant->forceFill([
'company_name' => $companyName,
'code' => 'DEMO_T_'.strtoupper(substr(md5(uniqid()), 0, 8)),
'email' => $email,
'tenant_st_code' => 'trial',
'tenant_type' => Tenant::TYPE_DEMO_TRIAL,
'demo_expires_at' => now()->addDays($durationDays),
'demo_source_partner_id' => $partnerId,
'options' => [
Tenant::OPTION_DEMO_PRESET => $preset,
Tenant::OPTION_DEMO_LIMITS => self::DEFAULT_DEMO_LIMITS,
],
]);
$tenant->save();
Log::info('고객 체험 테넌트 생성', [
'tenant_id' => $tenant->id,
'partner_id' => $partnerId,
'company_name' => $companyName,
'expires_at' => $tenant->demo_expires_at->toDateString(),
]);
return $tenant;
}
/**
* 체험 기간 연장 (최대 1회, 30일)
*/
public function extendTrial(Tenant $tenant, int $days = 30): bool
{
if (! $tenant->isDemoTrial()) {
return false;
}
// 이미 연장한 이력이 있는지 체크 (options에 기록)
if ($tenant->getOption('demo_extended', false)) {
return false;
}
$newExpiry = ($tenant->demo_expires_at ?? now())->addDays($days);
$tenant->forceFill(['demo_expires_at' => $newExpiry]);
$tenant->setOption('demo_extended', true);
$tenant->save();
Log::info('고객 체험 기간 연장', [
'tenant_id' => $tenant->id,
'new_expires_at' => $newExpiry->toDateString(),
]);
return true;
}
/**
* 데모 → 정식 전환
*/
public function convertToProduction(Tenant $tenant): void
{
$tenant->convertToProduction();
Log::info('데모 → 정식 테넌트 전환', [
'tenant_id' => $tenant->id,
'company_name' => $tenant->company_name,
]);
}
/**
* 사용 가능한 프리셋 목록
*/
public function getAvailablePresets(): array
{
return self::AVAILABLE_PRESETS;
}
/**
* 만료된 체험 테넌트 조회
*/
public function getExpiredTrials(): \Illuminate\Database\Eloquent\Collection
{
return Tenant::withoutGlobalScopes()
->where('tenant_type', Tenant::TYPE_DEMO_TRIAL)
->whereNotNull('demo_expires_at')
->where('demo_expires_at', '<', now())
->get();
}
// ──────────────────────────────────────────────
// Phase 3: API 엔드포인트용 메서드
// ──────────────────────────────────────────────
/**
* 내가 생성한 데모 테넌트 목록
*/
public function index(array $params): array
{
$userId = $this->apiUserId();
// 현재 사용자의 파트너 ID 조회
$partnerId = DB::table('sales_partners')
->where('user_id', $userId)
->value('id');
$query = Tenant::withoutGlobalScopes()
->whereIn('tenant_type', Tenant::DEMO_TYPES);
// 파트너인 경우 자기가 생성한 데모만 조회
if ($partnerId) {
$query->where('demo_source_partner_id', $partnerId);
}
// 상태 필터
if (! empty($params['status'])) {
match ($params['status']) {
'active' => $query->where(function ($q) {
$q->whereNull('demo_expires_at')
->orWhere('demo_expires_at', '>', now());
}),
'expired' => $query->whereNotNull('demo_expires_at')
->where('demo_expires_at', '<', now()),
default => null,
};
}
// 타입 필터
if (! empty($params['type'])) {
$query->where('tenant_type', $params['type']);
}
$tenants = $query->orderByDesc('created_at')->get();
return $tenants->map(function (Tenant $t) {
return $this->formatTenantResponse($t);
})->toArray();
}
/**
* 데모 테넌트 상세 조회
*/
public function show(int $id): array
{
$tenant = $this->findDemoTenant($id);
$response = $this->formatTenantResponse($tenant);
// 상세 조회 시 데이터 현황도 포함
$response['data_counts'] = $this->getDataCounts($tenant->id);
return $response;
}
/**
* API에서 고객 체험 테넌트 생성
*/
public function createTrialFromApi(array $data): array
{
$userId = $this->apiUserId();
$partnerId = DB::table('sales_partners')
->where('user_id', $userId)
->value('id');
// 파트너가 아닌 경우 관리자 권한으로 생성 (partnerId = 0)
$partnerId = $partnerId ?? 0;
$preset = $data['preset'] ?? 'manufacturing';
$durationDays = $data['duration_days'] ?? 30;
$tenant = $this->createTrialDemo(
$partnerId,
$data['company_name'],
$data['email'],
$durationDays,
$preset
);
// 샘플 데이터 시드
try {
$seeder = new \Database\Seeders\Demo\ManufacturingPresetSeeder;
$seeder->run($tenant->id);
} catch (\Exception $e) {
Log::warning('데모 샘플 데이터 시드 실패', [
'tenant_id' => $tenant->id,
'error' => $e->getMessage(),
]);
}
return $this->formatTenantResponse($tenant);
}
/**
* API에서 데모 데이터 리셋
*/
public function resetFromApi(int $id): array
{
$tenant = $this->findDemoTenant($id);
$this->checkOwnership($tenant);
// 리셋 커맨드의 테이블 목록 사용
$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',
'departments', 'audit_logs',
];
DB::beginTransaction();
try {
$totalDeleted = 0;
foreach ($tables as $table) {
if (! \Schema::hasTable($table) || ! \Schema::hasColumn($table, 'tenant_id')) {
continue;
}
$totalDeleted += DB::table($table)->where('tenant_id', $tenant->id)->delete();
}
DB::commit();
} catch (\Exception $e) {
DB::rollBack();
throw $e;
}
// 재시드
$preset = $tenant->getDemoPreset() ?? 'manufacturing';
try {
$seeder = new \Database\Seeders\Demo\ManufacturingPresetSeeder;
$seeder->run($tenant->id);
} catch (\Exception $e) {
Log::warning('리셋 후 재시드 실패', [
'tenant_id' => $tenant->id,
'error' => $e->getMessage(),
]);
}
Log::info('데모 데이터 API 리셋', [
'tenant_id' => $tenant->id,
'deleted_count' => $totalDeleted,
]);
return [
'tenant_id' => $tenant->id,
'deleted_count' => $totalDeleted,
'data_counts' => $this->getDataCounts($tenant->id),
];
}
/**
* API에서 체험 기간 연장
*/
public function extendFromApi(int $id, int $days = 30): array
{
$tenant = $this->findDemoTenant($id);
$this->checkOwnership($tenant);
if (! $tenant->isDemoTrial()) {
return ['error' => __('error.demo_tenant.not_trial'), 'code' => 400];
}
if (! $this->extendTrial($tenant, $days)) {
return ['error' => __('error.demo_tenant.already_extended'), 'code' => 400];
}
return $this->formatTenantResponse($tenant->fresh());
}
/**
* API에서 데모 → 정식 전환
*/
public function convertFromApi(int $id): array
{
$tenant = $this->findDemoTenant($id);
$this->checkOwnership($tenant);
$this->convertToProduction($tenant);
return $this->formatTenantResponse($tenant->fresh());
}
/**
* 데모 현황 통계
*/
public function stats(): array
{
$userId = $this->apiUserId();
$partnerId = DB::table('sales_partners')
->where('user_id', $userId)
->value('id');
$baseQuery = Tenant::withoutGlobalScopes()
->whereIn('tenant_type', Tenant::DEMO_TYPES);
if ($partnerId) {
$baseQuery->where('demo_source_partner_id', $partnerId);
}
$all = (clone $baseQuery)->get();
$active = $all->filter(fn (Tenant $t) => ! $t->isDemoExpired());
$expired = $all->filter(fn (Tenant $t) => $t->isDemoExpired());
$converted = Tenant::withoutGlobalScopes()
->where('tenant_type', Tenant::TYPE_STD)
->when($partnerId, fn ($q) => $q->where('demo_source_partner_id', $partnerId))
->whereNotNull('demo_source_partner_id')
->count();
return [
'total' => $all->count(),
'active' => $active->count(),
'expired' => $expired->count(),
'converted' => $converted,
'by_type' => [
'showcase' => $all->where('tenant_type', Tenant::TYPE_DEMO_SHOWCASE)->count(),
'partner' => $all->where('tenant_type', Tenant::TYPE_DEMO_PARTNER)->count(),
'trial' => $all->where('tenant_type', Tenant::TYPE_DEMO_TRIAL)->count(),
],
];
}
// ──────────────────────────────────────────────
// Private Helpers
// ──────────────────────────────────────────────
private function findDemoTenant(int $id): Tenant
{
$tenant = Tenant::withoutGlobalScopes()->find($id);
if (! $tenant || ! $tenant->isDemoTenant()) {
throw new NotFoundHttpException(__('error.demo_tenant.not_found'));
}
return $tenant;
}
private function checkOwnership(Tenant $tenant): void
{
$userId = $this->apiUserId();
$partnerId = DB::table('sales_partners')
->where('user_id', $userId)
->value('id');
// 파트너가 아닌 경우 (관리자) → 통과
if (! $partnerId) {
return;
}
// 파트너인 경우 → 자기가 생성한 데모만 접근 가능
if ($tenant->demo_source_partner_id !== $partnerId) {
throw new NotFoundHttpException(__('error.demo_tenant.not_owned'));
}
}
private function formatTenantResponse(Tenant $tenant): array
{
return [
'id' => $tenant->id,
'company_name' => $tenant->company_name,
'code' => $tenant->code,
'email' => $tenant->email,
'tenant_type' => $tenant->tenant_type,
'tenant_st_code' => $tenant->tenant_st_code,
'demo_preset' => $tenant->getDemoPreset(),
'demo_limits' => $tenant->getDemoLimits(),
'demo_expires_at' => $tenant->demo_expires_at?->toDateString(),
'demo_source_partner_id' => $tenant->demo_source_partner_id,
'is_expired' => $tenant->isDemoExpired(),
'is_extended' => (bool) $tenant->getOption('demo_extended', false),
'created_at' => $tenant->created_at?->toDateString(),
];
}
private function getDataCounts(int $tenantId): array
{
$tables = ['departments', 'clients', 'items', 'quotes', 'orders'];
$counts = [];
foreach ($tables as $table) {
if (\Schema::hasTable($table) && \Schema::hasColumn($table, 'tenant_id')) {
$counts[$table] = DB::table($table)->where('tenant_id', $tenantId)->count();
}
}
return $counts;
}
}

View File

@@ -982,6 +982,7 @@ public function formatTemplateForReact(DocumentTemplate $template): array
'name' => $section->title,
'title' => $section->title,
'image_path' => $section->image_path,
'file_id' => $section->file_id,
'sort_order' => $section->sort_order,
'items' => $section->items->map(function ($item) use ($methodCodes) {
// method 코드를 한글 이름으로 변환
@@ -1034,7 +1035,7 @@ private function formatDocumentForReact(Document $document): array
'submitted_at' => $document->submitted_at?->toIso8601String(),
'completed_at' => $document->completed_at?->toIso8601String(),
'created_at' => $document->created_at?->toIso8601String(),
'data' => $document->data->map(fn ($d) => [
'data' => ($document->getRelation('data') ?? collect())->map(fn ($d) => [
'section_id' => $d->section_id,
'column_id' => $d->column_id,
'row_index' => $d->row_index,

View File

@@ -224,6 +224,15 @@ public function update(int $id, array $data): TenantUserProfile
if (! empty($profileUpdates)) {
$profile->update($profileUpdates);
// 퇴직/복직 시 user_tenants.is_active 동기화
if (isset($profileUpdates['employee_status'])) {
$isActive = $profileUpdates['employee_status'] !== 'resigned';
DB::table('user_tenants')
->where('user_id', $profile->user_id)
->where('tenant_id', $profile->tenant_id)
->update(['is_active' => $isActive]);
}
}
// 3. json_extra 사원 정보 업데이트
@@ -275,6 +284,12 @@ public function destroy(int $id): array
// 또는 employee_status를 resigned로 변경
$profile->update(['employee_status' => 'resigned']);
// 해당 테넌트 접근 차단 (다른 테넌트는 영향 없음)
DB::table('user_tenants')
->where('user_id', $profile->user_id)
->where('tenant_id', $tenantId)
->update(['is_active' => false]);
return [
'id' => $id,
'deleted_at' => now()->toDateTimeString(),
@@ -288,11 +303,25 @@ public function bulkDelete(array $ids): array
{
$tenantId = $this->tenantId();
// 퇴직 처리 대상의 user_id 추출
$userIds = TenantUserProfile::query()
->where('tenant_id', $tenantId)
->whereIn('id', $ids)
->pluck('user_id');
$updated = TenantUserProfile::query()
->where('tenant_id', $tenantId)
->whereIn('id', $ids)
->update(['employee_status' => 'resigned']);
// 해당 테넌트 접근 일괄 차단
if ($userIds->isNotEmpty()) {
DB::table('user_tenants')
->whereIn('user_id', $userIds)
->where('tenant_id', $tenantId)
->update(['is_active' => false]);
}
return [
'processed' => count($ids),
'updated' => $updated,

View File

@@ -34,6 +34,9 @@ public function getInspections(string $cycle, string $period, ?string $productio
$labels = InspectionCycle::columnLabels($cycle, $period);
$userId = $this->apiUserId();
$tenantId = $this->tenantId();
$holidayDates = InspectionCycle::getHolidayDates($cycle, $period, $tenantId);
$result = [];
foreach ($equipments as $equipment) {
@@ -68,6 +71,7 @@ public function getInspections(string $cycle, string $period, ?string $productio
'details' => $details,
'labels' => $labels,
'can_inspect' => $equipment->canInspect($userId),
'non_working_days' => array_keys($holidayDates),
];
}
@@ -365,6 +369,15 @@ public function copyTemplates(int $equipmentId, string $sourceCycle, array $targ
});
}
public function getTemplatesByEquipment(int $equipmentId, ?string $cycle = null): \Illuminate\Database\Eloquent\Collection
{
return EquipmentInspectionTemplate::where('equipment_id', $equipmentId)
->when($cycle, fn ($q) => $q->byCycle($cycle))
->active()
->orderBy('sort_order')
->get();
}
public function getActiveCycles(int $equipmentId): array
{
return EquipmentInspectionTemplate::where('equipment_id', $equipmentId)

View File

@@ -3,6 +3,7 @@
namespace App\Services\Equipment;
use App\Models\Equipment\Equipment;
use App\Models\Equipment\EquipmentInspection;
use App\Services\Service;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
@@ -126,7 +127,37 @@ public function stats(): array
$idle = Equipment::where('status', 'idle')->count();
$disposed = Equipment::where('status', 'disposed')->count();
return compact('total', 'active', 'idle', 'disposed');
// 설비 유형별 현황
$typeDistribution = Equipment::where('status', '!=', 'disposed')
->whereNotNull('equipment_type')
->selectRaw('equipment_type, count(*) as count')
->groupBy('equipment_type')
->orderByDesc('count')
->get()
->toArray();
// 이번달 점검 현황
$yearMonth = now()->format('Y-m');
$targetCount = Equipment::where('status', 'active')->count();
$completedCount = EquipmentInspection::where('year_month', $yearMonth)->distinct('equipment_id')->count('equipment_id');
$issueCount = EquipmentInspection::where('year_month', $yearMonth)
->where(function ($q) {
$q->whereNotNull('issue_note')->where('issue_note', '!=', '');
})
->count();
return [
'total' => $total,
'active' => $active,
'idle' => $idle,
'disposed' => $disposed,
'type_distribution' => $typeDistribution,
'inspection_stats' => [
'target_count' => $targetCount,
'completed_count' => $completedCount,
'issue_count' => $issueCount,
],
];
}
public function options(): array

View File

@@ -0,0 +1,124 @@
<?php
namespace App\Services\Finance;
use App\Models\Commons\File;
use App\Models\Finance\CorporateVehicle;
use App\Services\Service;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class VehiclePhotoService extends Service
{
private const MAX_PHOTOS = 10;
public function index(int $vehicleId): array
{
$vehicle = $this->getVehicle($vehicleId);
return $vehicle->photos->map(fn ($file) => $this->formatFileResponse($file))->values()->toArray();
}
/**
* @param UploadedFile[] $files
*/
public function store(int $vehicleId, array $files): array
{
$vehicle = $this->getVehicle($vehicleId);
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$currentCount = File::where('document_id', $vehicleId)
->where('document_type', 'corporate_vehicle')
->whereNull('deleted_at')
->count();
if ($currentCount + count($files) > self::MAX_PHOTOS) {
throw new \Exception(__('error.vehicle.photo_limit_exceeded'));
}
$uploaded = [];
foreach ($files as $uploadedFile) {
$extension = $uploadedFile->getClientOriginalExtension();
$storedName = bin2hex(random_bytes(8)).'.'.$extension;
$displayName = $uploadedFile->getClientOriginalName();
$year = date('Y');
$month = date('m');
$directory = sprintf('%d/corporate-vehicles/%s/%s', $tenantId, $year, $month);
$filePath = $directory.'/'.$storedName;
Storage::disk('r2')->putFileAs($directory, $uploadedFile, $storedName);
$mimeType = $uploadedFile->getMimeType();
$file = File::create([
'tenant_id' => $tenantId,
'display_name' => $displayName,
'stored_name' => $storedName,
'file_path' => $filePath,
'file_size' => $uploadedFile->getSize(),
'mime_type' => $mimeType,
'file_type' => 'image',
'document_id' => $vehicleId,
'document_type' => 'corporate_vehicle',
'is_temp' => false,
'uploaded_by' => $userId,
'created_by' => $userId,
]);
$uploaded[] = $this->formatFileResponse($file);
}
return $uploaded;
}
public function destroy(int $vehicleId, int $fileId): array
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$file = File::where('tenant_id', $tenantId)
->where('document_id', $vehicleId)
->where('document_type', 'corporate_vehicle')
->where('id', $fileId)
->first();
if (! $file) {
throw new NotFoundHttpException(__('error.file.not_found'));
}
$file->softDeleteFile($userId);
return [
'file_id' => $fileId,
'deleted' => true,
];
}
private function getVehicle(int $vehicleId): CorporateVehicle
{
$vehicle = CorporateVehicle::find($vehicleId);
if (! $vehicle) {
throw new NotFoundHttpException(__('error.vehicle.not_found'));
}
return $vehicle;
}
private function formatFileResponse(File $file): array
{
return [
'id' => $file->id,
'file_name' => $file->display_name,
'file_path' => $file->file_path,
'file_url' => url("/api/v1/files/{$file->id}/download"),
'file_size' => $file->file_size,
'mime_type' => $file->mime_type,
'created_at' => $file->created_at?->format('Y-m-d H:i:s'),
];
}
}

View File

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

View File

@@ -357,6 +357,7 @@ public function index(array $params): LengthAwarePaginator
$categoryId = $params['category_id'] ?? null;
$itemType = $params['item_type'] ?? null;
$itemCategory = $params['item_category'] ?? null;
$bomCategory = $params['bom_category'] ?? null;
$groupId = $params['group_id'] ?? null;
$active = $params['active'] ?? null;
$hasBom = $params['has_bom'] ?? null;
@@ -410,6 +411,11 @@ public function index(array $params): LengthAwarePaginator
$query->where('item_category', $itemCategory);
}
// BOM 카테고리 (options->bom_category)
if ($bomCategory) {
$query->where('options->bom_category', $bomCategory);
}
// 활성 상태
if ($active !== null && $active !== '') {
$query->where('is_active', (bool) $active);
@@ -743,6 +749,9 @@ public function update(int $id, array $data): Model
$data['attributes'] = array_merge($existingAttributes, $data['attributes']);
}
// 변경 전 스냅샷 (감사 로그용)
$before = $item->toArray();
// 테이블 업데이트
$itemData = array_intersect_key($data, array_flip([
'item_type', 'code', 'name', 'unit', 'category_id',
@@ -768,7 +777,19 @@ public function update(int $id, array $data): Model
$item->load('details');
}
return $item->refresh();
$item->refresh();
// 감사 로그
app(\App\Services\Audit\AuditLogger::class)->log(
tenantId: $tenantId,
targetType: 'item',
targetId: $item->id,
action: 'updated',
before: $before,
after: $item->toArray()
);
return $item;
}
/**

View File

@@ -109,17 +109,22 @@ public function index(array $params)
/**
* 통계 조회
*/
public function stats(): array
public function stats(?string $orderType = null): array
{
$tenantId = $this->tenantId();
$counts = Order::where('tenant_id', $tenantId)
$baseQuery = Order::where('tenant_id', $tenantId);
if ($orderType !== null) {
$baseQuery->where('order_type_code', $orderType);
}
$counts = (clone $baseQuery)
->select('status_code', DB::raw('count(*) as count'))
->groupBy('status_code')
->pluck('count', 'status_code')
->toArray();
$amounts = Order::where('tenant_id', $tenantId)
$amounts = (clone $baseQuery)
->select('status_code', DB::raw('sum(total_amount) as total'))
->groupBy('status_code')
->pluck('total', 'status_code')
@@ -162,10 +167,13 @@ public function store(array $data)
$userId = $this->apiUserId();
return DB::transaction(function () use ($data, $tenantId, $userId) {
// 수주번호 자동 생성
// 수주번호 자동 생성 (재고생산은 STK 접두사)
$pairCode = $data['pair_code'] ?? null;
unset($data['pair_code']);
$data['order_no'] = $this->generateOrderNo($tenantId, $pairCode);
$isStock = ($data['order_type_code'] ?? null) === Order::TYPE_STOCK;
$data['order_no'] = $isStock
? $this->generateStockOrderNo($tenantId)
: $this->generateOrderNo($tenantId, $pairCode);
$data['tenant_id'] = $tenantId;
$data['created_by'] = $userId;
$data['updated_by'] = $userId;
@@ -174,6 +182,21 @@ public function store(array $data)
$data['status_code'] = $data['status_code'] ?? Order::STATUS_DRAFT;
$data['order_type_code'] = $data['order_type_code'] ?? Order::TYPE_ORDER;
// 재고생산: 현장명 + 담당자 자동 설정
if ($isStock) {
$data['site_name'] = '재고생산';
// 담당자(manager_name)가 비어 있으면 로그인 사용자 이름으로 설정
$options = $data['options'] ?? [];
if (empty($options['manager_name'])) {
$user = \App\Models\Members\User::find($userId);
if ($user) {
$options['manager_name'] = $user->name;
$data['options'] = $options;
}
}
}
$items = $data['items'] ?? [];
unset($data['items']);
@@ -293,6 +316,14 @@ public function store(array $data)
$order->refresh();
$order->recalculateTotals()->save();
// 견적 연결: Quote.order_id 동기화
if ($order->quote_id) {
Quote::withoutGlobalScopes()
->where('id', $order->quote_id)
->whereNull('order_id')
->update(['order_id' => $order->id]);
}
return $this->loadDetailRelations($order);
});
}
@@ -629,8 +660,8 @@ public function updateStatus(int $id, string $status)
$createdSale = null;
$previousStatus = $order->status_code;
// 수주확정 시 매출 자동 생성 (sales_recognition = on_order_confirm인 경우)
if ($status === Order::STATUS_CONFIRMED && $order->shouldCreateSaleOnConfirm()) {
// 수주확정 시 매출 자동 생성 (재고생산은 매출 생성 불필요)
if ($status === Order::STATUS_CONFIRMED && $order->order_type_code !== Order::TYPE_STOCK && $order->shouldCreateSaleOnConfirm()) {
$createdSale = $this->createSaleFromOrder($order, $userId);
$order->sale_id = $createdSale->id;
}
@@ -776,6 +807,29 @@ private function generateOrderNoLegacy(int $tenantId): string
return sprintf('%s%s%04d', $prefix, $date, $seq);
}
/**
* 재고생산 번호 생성 (STK{YYYYMMDD}{NNNN})
*/
private function generateStockOrderNo(int $tenantId): string
{
$prefix = 'STK';
$date = now()->format('Ymd');
$lastNo = Order::withoutGlobalScopes()
->where('tenant_id', $tenantId)
->where('order_no', 'like', "{$prefix}{$date}%")
->orderByDesc('order_no')
->value('order_no');
if ($lastNo) {
$seq = (int) substr($lastNo, -4) + 1;
} else {
$seq = 1;
}
return sprintf('%s%s%04d', $prefix, $date, $seq);
}
/**
* 견적에서 수주 생성
*/
@@ -803,54 +857,99 @@ public function createFromQuote(int $quoteId, array $data = [])
}
return DB::transaction(function () use ($quote, $data, $tenantId, $userId) {
// 수주번호 생성
$pairCode = $data['pair_code'] ?? null;
$orderNo = $this->generateOrderNo($tenantId, $pairCode);
// Order 모델의 createFromQuote 사용
// calculation_inputs에서 제품 정보 추출
$calculationInputs = $quote->calculation_inputs ?? [];
$productItems = $calculationInputs['items'] ?? [];
$bomResults = $calculationInputs['bomResults'] ?? [];
$locationCount = count($productItems);
// 품목→개소 매핑 사전 계산
$hasFormulaSource = $quote->items->contains(fn ($item) => ! empty($item->formula_source));
$itemsPerLocation = (! $hasFormulaSource && $locationCount > 1)
? intdiv($quote->items->count(), $locationCount)
: 0;
// 견적 품목을 개소별로 그룹핑
$itemsByLocation = [];
foreach ($quote->items as $index => $quoteItem) {
$locIdx = $this->resolveQuoteItemLocationIndex($quoteItem, $productItems, $itemsPerLocation, $index, $locationCount);
$itemsByLocation[$locIdx][] = $quoteItem;
}
// 개소 × 수량 → 노드 목록 확장 (qty=10 → 노드 10개, 각 qty=1)
$expandedNodes = [];
foreach ($productItems as $idx => $locItem) {
$qty = (int) ($locItem['quantity'] ?? 1);
for ($q = 0; $q < $qty; $q++) {
$expandedNodes[] = [
'locItem' => $locItem,
'bomResult' => $bomResults[$idx] ?? null,
'origIdx' => $idx,
'seqNo' => $q + 1,
];
}
}
// 수주 1건 생성
$orderNo = $this->generateOrderNo($tenantId, $pairCode);
$order = Order::createFromQuote($quote, $orderNo);
$order->created_by = $userId;
$order->updated_by = $userId;
// 추가 데이터 병합 (납품일, 메모 등)
if (! empty($data['delivery_date'])) {
$order->delivery_date = $data['delivery_date'];
}
if (! empty($data['memo'])) {
$order->memo = $data['memo'];
}
if (! empty($data['delivery_method_code'])) {
$order->delivery_method_code = $data['delivery_method_code'];
}
// options 병합 (수신자, 수신처, 운임 등)
if (! empty($data['options'])) {
$order->options = array_merge($order->options ?? [], $data['options']);
}
$order->save();
// calculation_inputs에서 제품 정보 추출
$calculationInputs = $quote->calculation_inputs ?? [];
$productItems = $calculationInputs['items'] ?? [];
$bomResults = $calculationInputs['bomResults'] ?? [];
// OrderNode 생성 (개소별)
$nodeMap = [];
foreach ($productItems as $idx => $locItem) {
$bomResult = $bomResults[$idx] ?? null;
// 확장된 노드별로 OrderNode + OrderItem 생성
foreach ($expandedNodes as $nodeIdx => $expanded) {
$locItem = $expanded['locItem'];
$bomResult = $expanded['bomResult'];
$origIdx = $expanded['origIdx'];
$bomVars = $bomResult['variables'] ?? [];
$grandTotal = $bomResult['grand_total'] ?? 0;
$qty = (int) ($locItem['quantity'] ?? 1);
$floor = $locItem['floor'] ?? '';
$symbol = $locItem['code'] ?? '';
$nodeMap[$idx] = OrderNode::create([
// 노드명 = 제품명, 코드/부호에 수량 번호 부여
$productName = $locItem['productName'] ?? '';
$nodeCode = trim("{$floor}-{$symbol}", '-') ?: "LOC-{$nodeIdx}";
$nodeSymbol = $symbol;
$totalQty = (int) ($locItem['quantity'] ?? 1);
if ($totalQty > 1) {
$nodeCode .= '-'.$expanded['seqNo'];
$nodeSymbol .= ' #'.$expanded['seqNo'];
}
$nodeName = $productName ?: trim("{$floor} {$nodeSymbol}") ?: '개소 '.($nodeIdx + 1);
$node = OrderNode::create([
'tenant_id' => $tenantId,
'order_id' => $order->id,
'parent_id' => null,
'node_type' => 'location',
'code' => trim("{$floor}-{$symbol}", '-') ?: "LOC-{$idx}",
'name' => trim("{$floor} {$symbol}") ?: '개소 '.($idx + 1),
'code' => $nodeCode,
'name' => $nodeName,
'status_code' => OrderNode::STATUS_PENDING,
'quantity' => $qty,
'quantity' => 1,
'unit_price' => $grandTotal,
'total_price' => $grandTotal * $qty,
'total_price' => $grandTotal,
'options' => [
'floor' => $floor,
'symbol' => $symbol,
'symbol' => $nodeSymbol,
'product_code' => $locItem['productCode'] ?? null,
'product_name' => $locItem['productName'] ?? null,
'open_width' => $locItem['openWidth'] ?? null,
@@ -866,68 +965,19 @@ public function createFromQuote(int $quoteId, array $data = [])
'slat_info' => $this->extractSlatInfoFromBom($bomResult, $locItem),
],
'depth' => 0,
'sort_order' => $idx,
'sort_order' => $nodeIdx,
'created_by' => $userId,
]);
}
// 견적 품목을 수주 품목으로 변환 (노드 연결 포함)
// formula_source 없는 레거시 데이터용: sort_order 기반 분배 준비
$locationCount = count($productItems);
$hasFormulaSource = $quote->items->contains(fn ($item) => ! empty($item->formula_source));
$itemsPerLocation = (! $hasFormulaSource && $locationCount > 1)
? intdiv($quote->items->count(), $locationCount)
: 0;
foreach ($quote->items as $index => $quoteItem) {
$floorCode = null;
$symbolCode = null;
$locIdx = 0;
// 1순위: formula_source에서 인덱스 추출
$formulaSource = $quoteItem->formula_source ?? '';
if (preg_match('/product_(\d+)/', $formulaSource, $matches)) {
$locIdx = (int) $matches[1];
}
// 2순위: sort_order 기반 분배 (formula_source 없는 레거시 데이터)
if ($locIdx === 0 && $itemsPerLocation > 0) {
$locIdx = min(intdiv($index, $itemsPerLocation), $locationCount - 1);
}
// calculation_inputs에서 floor/code 가져오기
if (isset($productItems[$locIdx])) {
$floorCode = $productItems[$locIdx]['floor'] ?? null;
$symbolCode = $productItems[$locIdx]['code'] ?? null;
} elseif (count($productItems) === 1) {
$floorCode = $productItems[0]['floor'] ?? null;
$symbolCode = $productItems[0]['code'] ?? null;
}
// calculation_inputs에서 못 찾은 경우 note에서 파싱 시도
if (empty($floorCode) && empty($symbolCode)) {
$note = trim($quoteItem->note ?? '');
if ($note !== '' && $note !== '-' && $note !== '- -') {
$parts = preg_split('/\s+/', $note, 2);
$floorCode = ($parts[0] ?? null) !== '-' ? ($parts[0] ?? null) : null;
$symbolCode = ($parts[1] ?? null) !== '-' ? ($parts[1] ?? null) : null;
}
}
// note 파싱으로 locIdx 결정 (formula_source 없는 경우)
if ($locIdx === 0 && ! $itemsPerLocation && ! empty($floorCode) && ! empty($symbolCode)) {
foreach ($productItems as $pidx => $pItem) {
if (($pItem['floor'] ?? '') === $floorCode && ($pItem['code'] ?? '') === $symbolCode) {
$locIdx = $pidx;
break;
}
}
}
// 해당 개소 소속 품목 → OrderItem 복제
foreach ($itemsByLocation[$origIdx] ?? [] as $serialIdx => $quoteItem) {
$floorCode = $locItem['floor'] ?? null;
$symbolCode = $locItem['code'] ?? null;
$order->items()->create([
'tenant_id' => $tenantId,
'order_node_id' => $nodeMap[$locIdx]->id ?? null,
'serial_no' => $index + 1,
'order_node_id' => $node->id,
'serial_no' => $serialIdx + 1,
'item_id' => $quoteItem->item_id,
'item_code' => $quoteItem->item_code,
'item_name' => $quoteItem->item_name,
@@ -941,9 +991,10 @@ public function createFromQuote(int $quoteId, array $data = [])
'tax_amount' => round($quoteItem->total_price * 0.1, 2),
'total_amount' => round($quoteItem->total_price * 1.1, 2),
'note' => $quoteItem->formula_category,
'sort_order' => $index,
'sort_order' => $serialIdx,
]);
}
}
// 합계 재계산
$order->refresh();
@@ -959,6 +1010,48 @@ public function createFromQuote(int $quoteId, array $data = [])
});
}
/**
* 견적 품목이 속하는 개소 인덱스 결정
*/
private function resolveQuoteItemLocationIndex(
$quoteItem,
array $productItems,
int $itemsPerLocation,
int $itemIndex,
int $locationCount
): int {
$locIdx = 0;
// 1순위: formula_source에서 인덱스 추출
$formulaSource = $quoteItem->formula_source ?? '';
if (preg_match('/product_(\d+)/', $formulaSource, $matches)) {
return (int) $matches[1];
}
// 2순위: sort_order 기반 분배
if ($itemsPerLocation > 0) {
return min(intdiv($itemIndex, $itemsPerLocation), $locationCount - 1);
}
// 3순위: note에서 floor/code 매칭
$note = trim($quoteItem->note ?? '');
if ($note !== '' && $note !== '-' && $note !== '- -') {
$parts = preg_split('/\s+/', $note, 2);
$floorCode = ($parts[0] ?? null) !== '-' ? ($parts[0] ?? null) : null;
$symbolCode = ($parts[1] ?? null) !== '-' ? ($parts[1] ?? null) : null;
if (! empty($floorCode) && ! empty($symbolCode)) {
foreach ($productItems as $pidx => $pItem) {
if (($pItem['floor'] ?? '') === $floorCode && ($pItem['code'] ?? '') === $symbolCode) {
return $pidx;
}
}
}
}
return $locIdx;
}
/**
* 견적 변경사항을 수주에 동기화
*
@@ -1202,9 +1295,29 @@ public function createProductionOrder(int $orderId, array $data)
throw new BadRequestHttpException(__('error.order.production_order_already_exists'));
}
// order_nodes의 BOM 결과를 기반으로 공정별 자동 분류
// 재고생산(STOCK): 절곡 공정에 모든 품목 직접 배정 (BOM 매칭 스킵)
$isStock = $order->order_type_code === Order::TYPE_STOCK;
$nodesBomMap = [];
if ($isStock) {
$bendingProcess = \App\Models\Process::where('tenant_id', $tenantId)
->where('process_name', '절곡')
->where('is_active', true)
->first();
if (! $bendingProcess) {
throw new BadRequestHttpException(__('error.order.bending_process_not_found'));
}
$itemsByProcess = [
$bendingProcess->id => [
'process_id' => $bendingProcess->id,
'items' => $order->items->all(),
],
];
} else {
// 기존 로직: order_nodes의 BOM 결과를 기반으로 공정별 자동 분류
$bomItemIds = [];
$nodesBomMap = []; // node_id => [item_name => bom_item]
foreach ($order->rootNodes as $node) {
$bomResult = $node->options['bom_result'] ?? [];
@@ -1236,7 +1349,7 @@ public function createProductionOrder(int $orderId, array $data)
}
}
// item_code → item_id 매핑 구축 (fallback용)
// item_code → item_id 매핑 구축 (fallback용 — N+1 방지를 위해 사전 일괄 조회)
$codeToIdMap = [];
if (! empty($bomItemIds)) {
$codeToIdRows = DB::table('items')
@@ -1250,6 +1363,38 @@ public function createProductionOrder(int $orderId, array $data)
}
}
// order_items의 item_code로 추가 매핑 사전 구축 (루프 내 DB 조회 방지)
$orderItemCodes = $order->items->pluck('item_code')->filter()->unique()->values()->all();
$unmappedCodes = array_diff($orderItemCodes, array_keys($codeToIdMap));
if (! empty($unmappedCodes)) {
$extraRows = DB::table('items')
->where('tenant_id', $tenantId)
->whereIn('code', $unmappedCodes)
->whereNull('deleted_at')
->select('id', 'code')
->get();
foreach ($extraRows as $row) {
$codeToIdMap[$row->code] = $row->id;
}
}
// 사전 매핑된 item_id에 대한 process_items도 일괄 조회
$allResolvedIds = array_values(array_unique(array_merge(
array_keys($itemProcessMap),
array_values($codeToIdMap)
)));
$unmappedProcessIds = array_diff($allResolvedIds, array_keys($itemProcessMap));
if (! empty($unmappedProcessIds)) {
$extraProcessItems = DB::table('process_items')
->whereIn('item_id', $unmappedProcessIds)
->where('is_active', true)
->select('item_id', 'process_id')
->get();
foreach ($extraProcessItems as $pi) {
$itemProcessMap[$pi->item_id] = $pi->process_id;
}
}
// order_items를 공정별로 그룹화 (BOM item_id → process 매핑 활용)
$itemsByProcess = [];
foreach ($order->items as $orderItem) {
@@ -1268,31 +1413,11 @@ public function createProductionOrder(int $orderId, array $data)
}
}
// 3. fallback: item_code로 items 마스터 조회 → process_items 매핑
// 3. fallback: 사전 구축된 맵에서 item_code → process 매핑 (N+1 제거)
if ($processId === null && $orderItem->item_code) {
$resolvedId = $codeToIdMap[$orderItem->item_code] ?? null;
if (! $resolvedId) {
$resolvedId = DB::table('items')
->where('tenant_id', $tenantId)
->where('code', $orderItem->item_code)
->whereNull('deleted_at')
->value('id');
if ($resolvedId) {
$codeToIdMap[$orderItem->item_code] = $resolvedId;
}
}
if ($resolvedId && isset($itemProcessMap[$resolvedId])) {
$processId = $itemProcessMap[$resolvedId];
} elseif ($resolvedId) {
// process_items에서도 조회
$pi = DB::table('process_items')
->where('item_id', $resolvedId)
->where('is_active', true)
->value('process_id');
if ($pi) {
$processId = $pi;
$itemProcessMap[$resolvedId] = $pi;
}
}
}
@@ -1306,8 +1431,9 @@ public function createProductionOrder(int $orderId, array $data)
}
$itemsByProcess[$key]['items'][] = $orderItem;
}
}
return DB::transaction(function () use ($order, $data, $tenantId, $userId, $itemsByProcess, $nodesBomMap) {
return DB::transaction(function () use ($order, $data, $tenantId, $userId, $itemsByProcess, $nodesBomMap, $isStock) {
$workOrders = [];
// 담당자 ID 배열 처리 (assignee_ids 우선, fallback으로 assignee_id)
@@ -1327,6 +1453,7 @@ public function createProductionOrder(int $orderId, array $data)
// 공정 옵션 초기화 (보조 공정 플래그 포함)
$workOrderOptions = null;
$process = null;
if ($processId) {
$process = \App\Models\Process::find($processId);
if ($process && ! empty($process->options['is_auxiliary'])) {
@@ -1346,17 +1473,33 @@ public function createProductionOrder(int $orderId, array $data)
}
}
// team_id 결정: 명시적 전달값 > 공정 담당부서 자동 매핑
$teamId = $data['team_id'] ?? null;
if (! $teamId && $process && $process->department) {
$teamId = DB::table('departments')
->where('tenant_id', $tenantId)
->where('name', $process->department)
->value('id');
}
// priority 결정: 문자열 → 숫자 변환 (urgent=1, high=4, normal=7)
$priorityMap = ['urgent' => 1, 'high' => 4, 'normal' => 7];
$priority = is_numeric($data['priority'] ?? null)
? (int) $data['priority']
: ($priorityMap[$data['priority'] ?? 'normal'] ?? 7);
// 작업지시 생성
$workOrder = WorkOrder::create([
'tenant_id' => $tenantId,
'work_order_no' => $workOrderNo,
'sales_order_id' => $order->id,
'project_name' => $order->site_name ?? $order->client_name,
'project_name' => $isStock ? '재고생산' : ($order->site_name ?? $order->client_name),
'process_id' => $processId,
'status' => ! empty($assigneeIds) ? WorkOrder::STATUS_PENDING : WorkOrder::STATUS_UNASSIGNED,
'status' => (! empty($assigneeIds) || $teamId) ? WorkOrder::STATUS_WAITING : WorkOrder::STATUS_UNASSIGNED,
'priority' => $priority,
'assignee_id' => $primaryAssigneeId,
'team_id' => $data['team_id'] ?? null,
'scheduled_date' => $data['scheduled_date'] ?? $order->delivery_date,
'team_id' => $teamId,
'scheduled_date' => $data['scheduled_date'] ?? ($isStock ? now()->toDateString() : $order->delivery_date),
'memo' => $data['memo'] ?? null,
'options' => $workOrderOptions,
'is_active' => true,
@@ -1445,7 +1588,7 @@ public function createProductionOrder(int $orderId, array $data)
'item_id' => $itemId,
'item_name' => $orderItem->item_name,
'specification' => $orderItem->specification,
'quantity' => $orderItem->quantity,
'quantity' => (int) $orderItem->quantity,
'unit' => $orderItem->unit,
'sort_order' => $sortOrder++,
'status' => 'pending',
@@ -1877,16 +2020,22 @@ public function checkBendingStockForOrder(int $orderId): array
return [];
}
$stockService = app(StockService::class);
// 배치 조회로 N+1 방지 (루프 내 개별 Stock 조회 제거)
$bendingItemIds = $bendingItems->pluck('id')->all();
$stocksMap = \App\Models\Tenants\Stock::where('tenant_id', $tenantId)
->whereIn('item_id', $bendingItemIds)
->get()
->keyBy('item_id');
$result = [];
foreach ($bendingItems as $item) {
$neededQty = $itemQtyMap[$item->id];
$stockInfo = $stockService->getAvailableStock($item->id);
$stock = $stocksMap->get($item->id);
$availableQty = $stockInfo ? (float) $stockInfo['available_qty'] : 0;
$reservedQty = $stockInfo ? (float) $stockInfo['reserved_qty'] : 0;
$stockQty = $stockInfo ? (float) $stockInfo['stock_qty'] : 0;
$availableQty = $stock ? (float) $stock->available_qty : 0;
$reservedQty = $stock ? (float) $stock->reserved_qty : 0;
$stockQty = $stock ? (float) $stock->stock_qty : 0;
$shortfallQty = max(0, $neededQty - $availableQty);
$result[] = [

View File

@@ -0,0 +1,462 @@
<?php
namespace App\Services;
use App\Models\Qualitys\PerformanceReport;
use App\Models\Qualitys\QualityDocumentLocation;
use App\Models\Tenants\Tenant;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Symfony\Component\HttpFoundation\StreamedResponse;
class PerformanceReportExcelService extends Service
{
// 카테고리별 배경색
private const COLOR_MATERIAL = 'DAEEF3';
private const COLOR_SITE = 'E2EFDA';
private const COLOR_SUPERVISOR = 'FCE4D6';
private const COLOR_CONTRACTOR = 'EDEDED';
private const COLOR_DISTRIBUTOR = 'FFF2CC';
// 병합 대상 컬럼 (같은 품질관리서 내 개소가 여러개일 때)
private const MERGE_COLS = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'AA'];
// 비병합 컬럼 (개소별 데이터)
private const NO_MERGE_COLS = ['J', 'K', 'L'];
/**
* 확정건 엑셀 생성 및 스트림 응답
*/
public function generate(int $year, int $quarter): StreamedResponse
{
$tenantId = $this->tenantId();
$tenant = Tenant::find($tenantId);
$reports = $this->getConfirmedReports($tenantId, $year, $quarter);
$spreadsheet = new Spreadsheet;
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('판매실적대장');
// 시트 구성
$this->setColumnWidths($sheet);
$this->writeTitle($sheet, $year, $quarter);
$this->writeCompanyInfo($sheet, $tenant);
$this->writeCategoryHeaders($sheet);
$this->writeColumnHeaders($sheet);
$dataStartRow = 13;
$lastDataRow = $this->writeDataRows($sheet, $reports, $dataStartRow);
// 데이터 영역 테두리
if ($lastDataRow >= $dataStartRow) {
$this->applyDataBorders($sheet, $dataStartRow, $lastDataRow);
}
// 파일명
$companyName = $tenant?->company_name ?? 'SAM';
$filename = "{$companyName}_품질인정자재등의_판매실적_대장_{$year}년_{$quarter}분기.xlsx";
return $this->createStreamedResponse($spreadsheet, $filename);
}
/**
* 확정건 데이터 조회
*/
private function getConfirmedReports(int $tenantId, int $year, int $quarter)
{
return PerformanceReport::where('tenant_id', $tenantId)
->where('year', $year)
->where('quarter', $quarter)
->where('confirmation_status', PerformanceReport::STATUS_CONFIRMED)
->with([
'qualityDocument.locations.orderItem',
'qualityDocument.locations.qualityDocumentOrder.order',
'qualityDocument.client',
])
->orderBy('id')
->get();
}
/**
* 컬럼 너비 설정
*/
private function setColumnWidths($sheet): void
{
$widths = [
'A' => 6, 'B' => 16, 'C' => 12,
'D' => 10, 'E' => 14, 'F' => 10, 'G' => 12, 'H' => 10, 'I' => 10,
'J' => 10, 'K' => 14, 'L' => 8,
'M' => 20, 'N' => 18, 'O' => 10,
'P' => 14, 'Q' => 18, 'R' => 10, 'S' => 14,
'T' => 14, 'U' => 18, 'V' => 10, 'W' => 14,
'X' => 14, 'Y' => 18, 'Z' => 10, 'AA' => 14,
];
foreach ($widths as $col => $width) {
$sheet->getColumnDimension($col)->setWidth($width);
}
}
/**
* Row 1, 3: 제목/부제목
*/
private function writeTitle($sheet, int $year, int $quarter): void
{
// Row 1: 제목
$sheet->mergeCells('A1:AA1');
$sheet->setCellValue('A1', '품질인정자재등의 판매실적 제출서식');
$sheet->getStyle('A1')->applyFromArray([
'font' => ['name' => '돋움', 'size' => 24, 'bold' => true],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER],
]);
$sheet->getRowDimension(1)->setRowHeight(40);
// Row 3: 부제목
$sheet->mergeCells('A3:AA3');
$sheet->setCellValue('A3', "품질인정자재등의 판매실적 대장({$year}{$quarter}분기)");
$sheet->getStyle('A3')->applyFromArray([
'font' => ['name' => '돋움', 'size' => 18, 'bold' => true],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER],
]);
$sheet->getRowDimension(3)->setRowHeight(32);
}
/**
* Row 5~9: 회사 정보
*/
private function writeCompanyInfo($sheet, ?Tenant $tenant): void
{
$infoRows = [
5 => ['label' => '회사명', 'value' => $tenant?->company_name ?? ''],
6 => ['label' => '대표자', 'value' => $tenant?->ceo_name ?? ''],
7 => ['label' => '사업자등록번호', 'value' => $tenant?->business_num ?? ''],
8 => ['label' => '주소', 'value' => $tenant?->address ?? ''],
9 => ['label' => '연락처', 'value' => $tenant?->phone ?? ''],
];
foreach ($infoRows as $row => $info) {
$sheet->mergeCells("A{$row}:C{$row}");
$sheet->mergeCells("D{$row}:AA{$row}");
$sheet->setCellValue("A{$row}", $info['label']);
$sheet->setCellValue("D{$row}", $info['value']);
$sheet->getStyle("A{$row}:C{$row}")->applyFromArray([
'font' => ['name' => '돋움', 'size' => 11, 'bold' => true],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_RIGHT, 'vertical' => Alignment::VERTICAL_CENTER],
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => 'F2F2F2']],
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]],
]);
$sheet->getStyle("D{$row}:AA{$row}")->applyFromArray([
'font' => ['name' => '돋움', 'size' => 11],
'alignment' => ['vertical' => Alignment::VERTICAL_CENTER],
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]],
]);
}
}
/**
* Row 11: 카테고리 헤더 (건축자재내역, 건축공사장, 공사감리자, 공사시공자, 자재유통업자)
*/
private function writeCategoryHeaders($sheet): void
{
$categories = [
['range' => 'A11:L11', 'label' => '건축자재내역', 'color' => self::COLOR_MATERIAL],
['range' => 'M11:O11', 'label' => '건축공사장', 'color' => self::COLOR_SITE],
['range' => 'P11:S11', 'label' => '공사감리자', 'color' => self::COLOR_SUPERVISOR],
['range' => 'T11:W11', 'label' => '공사시공자', 'color' => self::COLOR_CONTRACTOR],
['range' => 'X11:AA11', 'label' => '자재유통업자', 'color' => self::COLOR_DISTRIBUTOR],
];
foreach ($categories as $cat) {
$sheet->mergeCells($cat['range']);
$startCell = explode(':', $cat['range'])[0];
$sheet->setCellValue($startCell, $cat['label']);
$sheet->getStyle($cat['range'])->applyFromArray([
'font' => ['name' => '돋움', 'size' => 11, 'bold' => true],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER],
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => $cat['color']]],
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]],
]);
}
$sheet->getRowDimension(11)->setRowHeight(28);
}
/**
* Row 12: 컬럼 헤더
*/
private function writeColumnHeaders($sheet): void
{
$headers = [
'A' => '일련번호', 'B' => '품질관리서번호', 'C' => '작성일',
'D' => '인정품목', 'E' => '규격(품명)', 'F' => '규격(종류)',
'G' => '제품검사일', 'H' => '내화성능시간', 'I' => '사용부위',
'J' => '로트번호', 'K' => '규격(치수)', 'L' => '수량',
'M' => '공사명칭', 'N' => '소재지', 'O' => '번지',
'P' => '사무소명', 'Q' => '사무소주소', 'R' => '성명', 'S' => '연락처',
'T' => '업체명', 'U' => '업체주소', 'V' => '성명', 'W' => '연락처',
'X' => '업체명', 'Y' => '업체주소', 'Z' => '대표자명', 'AA' => '연락처',
];
// 카테고리별 컬럼 색상 매핑
$colColors = [];
foreach (['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L'] as $c) {
$colColors[$c] = self::COLOR_MATERIAL;
}
foreach (['M', 'N', 'O'] as $c) {
$colColors[$c] = self::COLOR_SITE;
}
foreach (['P', 'Q', 'R', 'S'] as $c) {
$colColors[$c] = self::COLOR_SUPERVISOR;
}
foreach (['T', 'U', 'V', 'W'] as $c) {
$colColors[$c] = self::COLOR_CONTRACTOR;
}
foreach (['X', 'Y', 'Z', 'AA'] as $c) {
$colColors[$c] = self::COLOR_DISTRIBUTOR;
}
foreach ($headers as $col => $label) {
$cell = "{$col}12";
$sheet->setCellValue($cell, $label);
$sheet->getStyle($cell)->applyFromArray([
'font' => ['name' => '돋움', 'size' => 10, 'bold' => true],
'alignment' => [
'horizontal' => Alignment::HORIZONTAL_CENTER,
'vertical' => Alignment::VERTICAL_CENTER,
'wrapText' => true,
],
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => $colColors[$col]]],
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]],
]);
}
$sheet->getRowDimension(12)->setRowHeight(32);
}
/**
* 데이터 행 쓰기 (Row 13+)
*
* @return int 마지막 데이터 행 번호
*/
private function writeDataRows($sheet, $reports, int $startRow): int
{
$currentRow = $startRow;
$serialNo = 1;
foreach ($reports as $report) {
$doc = $report->qualityDocument;
if (! $doc) {
continue;
}
$locations = $doc->locations ?? collect();
$locationCount = $locations->count();
if ($locationCount === 0) {
// 개소 없는 경우에도 1행 출력
$this->writeDocumentRow($sheet, $currentRow, $serialNo, $doc, null);
$currentRow++;
} else {
$firstRow = $currentRow;
foreach ($locations as $idx => $location) {
$this->writeDocumentRow(
$sheet,
$currentRow,
$idx === 0 ? $serialNo : null,
$idx === 0 ? $doc : null,
$location
);
$currentRow++;
}
// 같은 품질관리서의 여러 개소 → 병합
if ($locationCount > 1) {
$lastRow = $currentRow - 1;
foreach (self::MERGE_COLS as $col) {
$sheet->mergeCells("{$col}{$firstRow}:{$col}{$lastRow}");
$sheet->getStyle("{$col}{$firstRow}")->getAlignment()
->setVertical(Alignment::VERTICAL_CENTER);
}
}
}
$serialNo++;
}
return $currentRow - 1;
}
/**
* 한 행 쓰기
*/
private function writeDocumentRow($sheet, int $row, ?int $serialNo, ?object $doc, ?QualityDocumentLocation $location): void
{
$options = $doc?->options ?? [];
$orderItem = $location?->orderItem;
// === 병합 컬럼 (문서 수준 - 첫 행에만 기록) ===
if ($doc !== null) {
$sheet->setCellValue("A{$row}", $serialNo);
$sheet->setCellValue("B{$row}", $doc->quality_doc_number ?? '');
$sheet->setCellValue("C{$row}", $doc->received_date?->format('Y-m-d') ?? '');
// 자재 정보 (D~I)
$sheet->setCellValue("D{$row}", $this->getProductCategory($location));
$sheet->setCellValue("E{$row}", $orderItem?->item_name ?? '');
$sheet->setCellValue("F{$row}", $orderItem?->specification ?? '');
$sheet->setCellValue("G{$row}", $this->getInspectionDate($doc));
$sheet->setCellValue("H{$row}", $this->getFireResistanceTime($location));
$sheet->setCellValue("I{$row}", $this->getUsagePart($location));
// 건축공사장 (M~O)
$site = $options['construction_site'] ?? [];
$sheet->setCellValue("M{$row}", $site['name'] ?? '');
$sheet->setCellValue("N{$row}", $site['land_location'] ?? '');
$sheet->setCellValue("O{$row}", $site['lot_number'] ?? '');
// 공사감리자 (P~S)
$supervisor = $options['supervisor'] ?? [];
$sheet->setCellValue("P{$row}", $supervisor['office'] ?? '');
$sheet->setCellValue("Q{$row}", $supervisor['address'] ?? '');
$sheet->setCellValue("R{$row}", $supervisor['name'] ?? '');
$sheet->setCellValue("S{$row}", $supervisor['phone'] ?? '');
// 공사시공자 (T~W)
$contractor = $options['contractor'] ?? [];
$sheet->setCellValue("T{$row}", $contractor['company'] ?? '');
$sheet->setCellValue("U{$row}", $contractor['address'] ?? '');
$sheet->setCellValue("V{$row}", $contractor['name'] ?? '');
$sheet->setCellValue("W{$row}", $contractor['phone'] ?? '');
// 자재유통업자 (X~AA)
$distributor = $options['material_distributor'] ?? [];
$sheet->setCellValue("X{$row}", $distributor['company'] ?? '');
$sheet->setCellValue("Y{$row}", $distributor['address'] ?? '');
$sheet->setCellValue("Z{$row}", $distributor['ceo'] ?? '');
$sheet->setCellValue("AA{$row}", $distributor['phone'] ?? '');
}
// === 비병합 컬럼 (개소별 - 매 행 기록) ===
if ($location !== null) {
$sheet->setCellValue("J{$row}", $this->getLotNumber($location));
$sheet->setCellValue("K{$row}", $this->formatDimension($location));
$sheet->setCellValue("L{$row}", $this->getQuantity($location));
}
// 행 스타일
$sheet->getStyle("A{$row}:AA{$row}")->applyFromArray([
'font' => ['name' => '돋움', 'size' => 10],
'alignment' => ['vertical' => Alignment::VERTICAL_CENTER, 'wrapText' => true],
]);
$sheet->getStyle("A{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
$sheet->getStyle("L{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
}
/**
* 데이터 영역 테두리 적용
*/
private function applyDataBorders($sheet, int $startRow, int $endRow): void
{
$sheet->getStyle("A{$startRow}:AA{$endRow}")->applyFromArray([
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]],
]);
}
/**
* 스트림 응답 생성
*/
private function createStreamedResponse(Spreadsheet $spreadsheet, string $filename): StreamedResponse
{
$encodedFilename = rawurlencode($filename);
return new StreamedResponse(function () use ($spreadsheet) {
$writer = new Xlsx($spreadsheet);
$writer->save('php://output');
$spreadsheet->disconnectWorksheets();
}, 200, [
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition' => "attachment; filename*=UTF-8''{$encodedFilename}",
'Cache-Control' => 'max-age=0',
]);
}
// ========================================
// 미확정 필드 (추후 데이터 매핑)
// ========================================
/** 인정품목 (추후 구현) */
private function getProductCategory(?QualityDocumentLocation $location): string
{
return '';
}
/** 내화성능시간 (추후 구현) */
private function getFireResistanceTime(?QualityDocumentLocation $location): string
{
return '';
}
/** 사용부위 (추후 구현) */
private function getUsagePart(?QualityDocumentLocation $location): string
{
return '';
}
/** 로트번호 (추후 구현) */
private function getLotNumber(?QualityDocumentLocation $location): string
{
return '';
}
// ========================================
// 헬퍼 메서드
// ========================================
/** 제품검사일 (품질관리서의 검사 완료일) */
private function getInspectionDate(?object $doc): string
{
if (! $doc) {
return '';
}
$options = $doc->options ?? [];
$endDate = $options['inspection']['end_date'] ?? '';
return $endDate ?: '';
}
/** 규격(치수): 너비 × 높이 */
private function formatDimension(?QualityDocumentLocation $location): string
{
if (! $location) {
return '';
}
$w = $location->post_width;
$h = $location->post_height;
if (! $w && ! $h) {
return '';
}
return "{$w} × {$h}";
}
/** 수량 (개소당 1 또는 orderItem 수량) */
private function getQuantity(?QualityDocumentLocation $location): int
{
if (! $location) {
return 0;
}
return (int) ($location->orderItem?->quantity ?? 1);
}
}

View File

@@ -13,9 +13,18 @@ class PerformanceReportService extends Service
public function __construct(
private readonly AuditLogger $auditLogger,
private readonly QualityDocumentService $qualityDocumentService
private readonly QualityDocumentService $qualityDocumentService,
private readonly PerformanceReportExcelService $excelService
) {}
/**
* 확정건 엑셀 다운로드
*/
public function exportConfirmed(int $year, int $quarter)
{
return $this->excelService->generate($year, $quarter);
}
/**
* 목록 조회
*/

View File

@@ -24,7 +24,7 @@ public function index(array $params): array
{
$query = QualityDocument::with([
'documentOrders.order.item',
'locations',
'documentOrders.locations',
'performanceReport',
])
->where('status', QualityDocument::STATUS_COMPLETED);
@@ -142,8 +142,18 @@ public function routeDocuments(int $qualityDocumentOrderId): array
);
$documents[] = $this->formatDocument('product', '제품검사 성적서', $locationsWithInspection);
// 8. 품질관리서
$documents[] = $this->formatDocument('quality', '품질관리서', collect([$qualityDoc]));
// 8. 품질관리서 (파일 정보 포함)
$qualityDoc->loadMissing('file');
$qualityDocFormatted = $this->formatDocument('quality', '품질관리서', collect([$qualityDoc]));
// 파일 정보 추가
if ($qualityDoc->file) {
$qualityDocFormatted['file_id'] = $qualityDoc->file->id;
$qualityDocFormatted['file_name'] = $qualityDoc->file->display_name ?? $qualityDoc->file->original_name;
$qualityDocFormatted['file_size'] = $qualityDoc->file->file_size;
}
$documents[] = $qualityDocFormatted;
return $documents;
}
@@ -200,8 +210,18 @@ public function confirm(int $locationId, array $data): array
private function transformReportToFrontend(QualityDocument $doc): array
{
$performanceReport = $doc->performanceReport;
$confirmedCount = $doc->locations->filter(function ($loc) {
return data_get($loc->options, 'lot_audit_confirmed', false);
// 수주로트 건수 = documentOrders 수
$totalRoutes = $doc->documentOrders->count();
// 확인 완료 수주로트 = 해당 주문의 모든 개소가 확인된 건수
$confirmedRoutes = $doc->documentOrders->filter(function ($docOrder) {
$locations = $docOrder->locations;
if ($locations->isEmpty()) {
return false;
}
return $locations->every(fn ($loc) => data_get($loc->options, 'lot_audit_confirmed', false));
})->count();
return [
@@ -209,8 +229,8 @@ private function transformReportToFrontend(QualityDocument $doc): array
'code' => $doc->quality_doc_number,
'site_name' => $doc->site_name,
'item' => $this->getFgProductName($doc),
'route_count' => $confirmedCount,
'total_routes' => $doc->locations->count(),
'route_count' => $confirmedRoutes,
'total_routes' => $totalRoutes,
'quarter' => $performanceReport
? $performanceReport->year.'년 '.$performanceReport->quarter.'분기'
: '',
@@ -415,21 +435,175 @@ private function getInspectionDetail(int $id, string $type): array
private function getOrderDetail(int $id): array
{
$order = Order::with(['nodes' => fn ($q) => $q->whereNull('parent_id')])->findOrFail($id);
$order = Order::with([
'client',
'nodes' => fn ($q) => $q->whereNull('parent_id')->orderBy('id'),
])->findOrFail($id);
$rootNodes = $order->nodes;
$options = $order->options ?? [];
// 개소별 제품 정보
$products = $rootNodes->map(function ($node, $index) {
$opts = $node->options ?? [];
$vars = data_get($opts, 'bom_result.variables', []);
return [
'no' => $index + 1,
'floor' => $opts['floor'] ?? '-',
'symbol' => $opts['symbol'] ?? '-',
'product_name' => $opts['product_name'] ?? '-',
'product_code' => $opts['product_code'] ?? null,
'open_width' => $opts['open_width'] ?? null,
'open_height' => $opts['open_height'] ?? null,
'made_width' => $opts['width'] ?? null,
'made_height' => $opts['height'] ?? null,
'guide_rail' => $vars['installation_type'] ?? '-',
'shaft' => $vars['bracket_inch'] ?? '-',
'case_inch' => $vars['bracket_inch'] ?? '-',
'bracket' => $vars['BRACKET_SIZE'] ?? '-',
'capacity' => $vars['MOTOR_CAPACITY'] ?? '-',
'finish' => $vars['finishing_type'] ?? '-',
'product_type' => $vars['product_type'] ?? null,
'joint_bar' => null, // 철재 전용 — 아래에서 bom_items에서 보강
];
})->values()->toArray();
// BOM items 집계 (모든 노드에서)
$allBomItems = $rootNodes->flatMap(function ($node) {
return collect(data_get($node->options, 'bom_result.items', []));
});
// 철재 제품의 조인트바 수량 보강
foreach ($rootNodes->values() as $index => $node) {
$bomItems = collect(data_get($node->options, 'bom_result.items', []));
$jointBar = $bomItems->first(fn ($i) => str_contains($i['item_name'] ?? '', '조인트바'));
if ($jointBar) {
$products[$index]['joint_bar'] = $jointBar['quantity'] ?? null;
}
}
// 모터 정보 (category: motor, controller)
$motorItems = $allBomItems->filter(fn ($i) => in_array($i['item_category'] ?? '', ['motor', 'controller']));
$motorLeft = [];
$motorRight = [];
foreach ($motorItems->groupBy('item_name') as $name => $group) {
$item = $group->first();
$totalQty = $group->sum('quantity');
$row = [
'item' => $item['item_name'],
'type' => $item['specification'] ?? '-',
'spec' => $item['item_code'] ?? '-',
'qty' => $totalQty,
];
// 모터/브라켓 → 좌, 제어기/전동개폐기 → 우
if (in_array($item['item_category'], ['controller'])) {
$motorRight[] = $row;
} else {
$motorLeft[] = $row;
}
}
// 절곡물 (category: steel)
$steelItems = $allBomItems->filter(fn ($i) => ($i['item_category'] ?? '') === 'steel');
$bendingParts = $this->groupBendingParts($steelItems);
// 부자재 (category: parts)
$partItems = $allBomItems->filter(fn ($i) => ($i['item_category'] ?? '') === 'parts');
$subsidiaryParts = [];
foreach ($partItems->groupBy('item_name') as $name => $group) {
$item = $group->first();
$subsidiaryParts[] = [
'name' => $item['item_name'],
'spec' => $item['specification'] ?? '-',
'qty' => $group->sum('quantity'),
];
}
return [
'type' => 'order',
'data' => [
'id' => $order->id,
'order_no' => $order->order_no,
'status' => $order->status,
'status_code' => $order->status_code,
'category_code' => $order->category_code,
'received_at' => $order->received_at?->toDateString(),
'delivery_date' => $order->delivery_date?->toDateString(),
'delivery_method_code' => $order->delivery_method_code,
'site_name' => $order->site_name,
'nodes_count' => $order->nodes->count(),
'client_name' => $order->client_name ?? $order->client?->name,
'client_contact' => $order->client_contact,
'manager_name' => $options['manager_name'] ?? null,
'receiver' => $options['receiver'] ?? null,
'receiver_contact' => $options['receiver_contact'] ?? null,
'shipping_address' => $options['shipping_address'] ?? null,
'shipping_address_detail' => $options['shipping_address_detail'] ?? null,
'shipping_cost_code' => $options['shipping_cost_code'] ?? null,
'quantity' => $order->quantity,
'supply_amount' => $order->supply_amount,
'tax_amount' => $order->tax_amount,
'total_amount' => $order->total_amount,
'remarks' => $order->remarks,
'nodes_count' => $rootNodes->count(),
'products' => $products,
'motors' => [
'left' => $motorLeft,
'right' => $motorRight,
],
'bending_parts' => $bendingParts,
'subsidiary_parts' => $subsidiaryParts,
],
];
}
/**
* 절곡물 BOM items를 그룹별로 분류
*/
private function groupBendingParts($steelItems): array
{
$groups = [
'가이드레일' => [],
'케이스' => [],
'하단마감' => [],
'연기차단재' => [],
'기타' => [],
];
foreach ($steelItems->groupBy('item_name') as $name => $group) {
$item = $group->first();
$totalQty = $group->sum('quantity');
$row = [
'name' => $item['item_name'],
'spec' => $item['specification'] ?? '-',
'qty' => $totalQty,
];
if (str_contains($name, '연기차단재')) {
$groups['연기차단재'][] = $row;
} elseif (str_contains($name, '가이드레일')) {
$groups['가이드레일'][] = $row;
} elseif (str_contains($name, '케이스') || str_contains($name, '마구리')) {
$groups['케이스'][] = $row;
} elseif (str_contains($name, '하장바') || str_contains($name, 'L-BAR') || str_contains($name, '보강평철')) {
$groups['하단마감'][] = $row;
} else {
$groups['기타'][] = $row;
}
}
$result = [];
foreach ($groups as $groupName => $items) {
if (! empty($items)) {
$result[] = [
'group' => $groupName,
'items' => $items,
];
}
}
return $result;
}
private function getWorkOrderLogDetail(int $id): array
{
$workOrder = WorkOrder::with('process')->findOrFail($id);
@@ -449,22 +623,65 @@ private function getWorkOrderLogDetail(int $id): array
private function getShipmentDetail(int $id): array
{
$shipment = Shipment::findOrFail($id);
$shipment = Shipment::with([
'vehicleDispatches',
'items',
'order.nodes' => fn ($q) => $q->whereNull('parent_id'),
])->findOrFail($id);
// 배차정보
$vehicleDispatches = $shipment->vehicleDispatches->map(fn ($d) => [
'logistics_company' => $d->logistics_company,
'arrival_datetime' => $d->arrival_datetime,
'tonnage' => $d->tonnage,
'vehicle_no' => $d->vehicle_no,
'driver_contact' => $d->driver_contact,
'remarks' => $d->remarks,
])->values()->toArray();
// 출하 품목 → 제품 그룹별 분류
$productGroups = [];
$otherParts = [];
foreach ($shipment->items as $item) {
$row = [
'item_name' => $item->item_name,
'specification' => $item->specification,
'quantity' => $item->quantity,
'unit' => $item->unit,
'lot_no' => $item->lot_no,
'floor_unit' => $item->floor_unit,
];
// floor_unit가 있으면 해당 제품 그룹에, 없으면 기타 부품
if ($item->floor_unit) {
$productGroups[$item->floor_unit][] = $row;
} else {
$otherParts[] = $row;
}
}
return [
'type' => 'shipping',
'data' => [
'id' => $shipment->id,
'shipment_no' => $shipment->shipment_no,
'lot_no' => $shipment->lot_no,
'status' => $shipment->status,
'scheduled_date' => $shipment->scheduled_date?->toDateString(),
'customer_name' => $shipment->customer_name,
'customer_grade' => $shipment->customer_grade,
'site_name' => $shipment->site_name,
'delivery_address' => $shipment->delivery_address,
'delivery_method' => $shipment->delivery_method,
'shipping_cost' => $shipment->shipping_cost,
'receiver' => $shipment->receiver,
'receiver_contact' => $shipment->receiver_contact,
'vehicle_no' => $shipment->vehicle_no,
'driver_name' => $shipment->driver_name,
'driver_contact' => $shipment->driver_contact,
'remarks' => $shipment->remarks,
'vehicle_dispatches' => $vehicleDispatches,
'product_groups' => $productGroups,
'other_parts' => $otherParts,
],
];
}

View File

@@ -2,6 +2,7 @@
namespace App\Services;
use App\Models\Commons\File;
use App\Models\Documents\Document;
use App\Models\Documents\DocumentData;
use App\Models\Documents\DocumentTemplate;
@@ -13,7 +14,9 @@
use App\Models\Qualitys\QualityDocumentLocation;
use App\Models\Qualitys\QualityDocumentOrder;
use App\Services\Audit\AuditLogger;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@@ -1248,4 +1251,78 @@ private function formatInspectionPeriod(array $options): string
return $start ?: $end ?: '';
}
// =========================================================================
// 파일 업로드/삭제
// =========================================================================
/**
* 품질관리서 파일 업로드 (1건당 1파일, 기존 파일 있으면 교체)
*/
public function uploadFile(int $id, UploadedFile $uploadedFile): array
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$doc = QualityDocument::where('tenant_id', $tenantId)->findOrFail($id);
// 기존 파일이 있으면 물리 삭제 (교체)
$existingFile = $doc->file;
if ($existingFile) {
$existingFile->permanentDelete();
}
// 저장 경로: {tenant_id}/quality-documents/{year}/{month}/{stored_name}
$date = now();
$storedName = bin2hex(random_bytes(16)).'.'.$uploadedFile->getClientOriginalExtension();
$filePath = sprintf(
'%d/quality-documents/%s/%s/%s',
$tenantId,
$date->format('Y'),
$date->format('m'),
$storedName
);
// R2에 파일 저장
Storage::disk('r2')->put($filePath, file_get_contents($uploadedFile->getPathname()));
// DB 레코드 생성
$file = File::create([
'tenant_id' => $tenantId,
'document_type' => QualityDocument::class,
'document_id' => $doc->id,
'display_name' => $uploadedFile->getClientOriginalName(),
'stored_name' => $storedName,
'file_path' => $filePath,
'file_size' => $uploadedFile->getSize(),
'mime_type' => $uploadedFile->getClientMimeType(),
'uploaded_by' => $userId,
'created_by' => $userId,
]);
return [
'id' => $file->id,
'display_name' => $file->display_name,
'file_size' => $file->file_size,
'mime_type' => $file->mime_type,
'created_at' => $file->created_at?->toIso8601String(),
];
}
/**
* 품질관리서 파일 삭제
*/
public function deleteFile(int $id): void
{
$tenantId = $this->tenantId();
$doc = QualityDocument::where('tenant_id', $tenantId)->findOrFail($id);
$file = $doc->file;
if (! $file) {
throw new NotFoundHttpException(__('error.not_found'));
}
$file->softDeleteFile($this->apiUserId());
}
}

View File

@@ -312,8 +312,7 @@ private function calculateExpression(string $expression): float
}
try {
// TODO: 프로덕션에서는 symfony/expression-language 등 안전한 라이브러리 사용 권장
return (float) eval("return {$expression};");
return \App\Helpers\SafeMathEvaluator::calculate($expression);
} catch (\Throwable $e) {
$this->errors[] = __('error.formula_calculation_error', ['expression' => $expression]);
@@ -1849,7 +1848,8 @@ private function calculateTenantBom(
'formulas' => $itemFormulas,
]);
// Step 8: 카테고리별 그룹화
// Step 8: 카테고리별 그룹화 (고정 순서: 주자재→모터→제어기→절곡품→부자재→검사비→기타)
$categoryOrder = ['material', 'motor', 'controller', 'steel', 'parts', 'inspection'];
$groupedItems = [];
foreach ($calculatedItems as $item) {
$category = $item['category_group'];
@@ -1863,6 +1863,19 @@ private function calculateTenantBom(
$groupedItems[$category]['items'][] = $item;
$groupedItems[$category]['subtotal'] += $item['total_price'];
}
// 고정 순서로 정렬 (미정의 카테고리는 뒤에 배치)
$sorted = [];
foreach ($categoryOrder as $cat) {
if (isset($groupedItems[$cat])) {
$sorted[$cat] = $groupedItems[$cat];
}
}
foreach ($groupedItems as $cat => $group) {
if (! isset($sorted[$cat])) {
$sorted[$cat] = $group;
}
}
$groupedItems = $sorted;
$this->addDebugStep(8, '카테고리그룹화', [
'groups' => array_map(fn ($g) => [
@@ -1928,6 +1941,7 @@ private function getTenantCategoryName(string $category): string
'controller' => '제어기',
'steel' => '절곡품',
'parts' => '부자재',
'inspection' => '검사비',
default => $category,
};
}

View File

@@ -1090,11 +1090,11 @@ public function calculateDynamicItems(array $inputs): array
'total_price' => $motorPrice * $quantity,
], $motorCode);
// 3. 제어기 (5130: 매립형×col15 + 노출형×col16 + 뒷박스×col17)
// 5130: 제어기 = price_매립 × col15 + price_노출 × col16 + price_뒷박스 × col17
// col15/col16/col17은 고정 수량 (QTY와 무관, $su를 곱하지 않음)
// 3. 제어기 — 셔터 수량(QTY)만큼 필요
// 5130 원본은 col15/col16/col17을 QTY와 무관하게 처리했으나,
// SAM에서는 개소별 수량(QTY)에 비례하여 계산
$controllerType = $inputs['controller_type'] ?? '매립형';
$controllerQty = (int) ($inputs['controller_qty'] ?? 1);
$controllerQty = (int) ($inputs['controller_qty'] ?? 1) * $quantity;
$controllerPrice = $this->getControllerPrice($controllerType);
if ($controllerPrice > 0 && $controllerQty > 0) {
$ctrlCode = "EST-CTRL-{$controllerType}";
@@ -1109,8 +1109,8 @@ public function calculateDynamicItems(array $inputs): array
], $ctrlCode);
}
// 뒷박스 (5130: col17 수량, QTY와 무관)
$backboxQty = (int) ($inputs['backbox_qty'] ?? 1);
// 뒷박스 — 제어기와 동일하게 수량(QTY) 반영
$backboxQty = (int) ($inputs['backbox_qty'] ?? 1) * $quantity;
if ($backboxQty > 0) {
$backboxPrice = $this->getControllerPrice('뒷박스');
if ($backboxPrice > 0) {

View File

@@ -52,10 +52,7 @@ public function index(array $params): LengthAwarePaginator
// 수주 전환용 조회: 아직 수주가 생성되지 않은 견적만
if ($forOrder) {
// 1. Quote.order_id가 null인 것 (빠른 체크)
$query->whereNull('order_id');
// 2. Orders 테이블에 해당 quote_id가 없는 것 (이중 체크, 인덱스 있음)
$query->whereDoesntHave('orders');
}
// items 포함 (수주 전환용)
@@ -77,7 +74,10 @@ public function index(array $params): LengthAwarePaginator
if ($status === Quote::STATUS_CONVERTED) {
$query->whereNotNull('order_id');
} elseif ($status) {
$query->where('status', $status)->whereNull('order_id');
$query->where('status', $status);
if (! $forOrder) {
$query->whereNull('order_id');
}
}
// 제품 카테고리 필터
@@ -196,6 +196,13 @@ public function show(int $id): Quote
$quote->setAttribute('bom_materials', $bomMaterials);
}
// 프론트 제어용 플래그
$quote->setAttribute('is_editable', $quote->isEditable());
$quote->setAttribute('has_work_orders', $quote->order_id
? Order::where('id', $quote->order_id)->whereHas('workOrders')->exists()
: false
);
return $quote;
}
@@ -634,39 +641,86 @@ public function convertToOrder(int $id): Quote
}
return DB::transaction(function () use ($quote, $userId, $tenantId) {
// 수주번호 생성
$orderNo = $this->generateOrderNumber($tenantId);
// 수주 마스터 생성
$order = Order::createFromQuote($quote, $orderNo);
$order->created_by = $userId;
$order->save();
// calculation_inputs에서 개소(제품) 정보 추출
$calculationInputs = $quote->calculation_inputs ?? [];
$productItems = $calculationInputs['items'] ?? [];
$bomResults = $calculationInputs['bomResults'] ?? [];
$locationCount = count($productItems);
// OrderNode 생성 (개소별)
$nodeMap = []; // productIndex → OrderNode
// 품목→개소 매핑 사전 계산
$hasFormulaSource = $quote->items->contains(fn ($item) => ! empty($item->formula_source));
$itemsPerLocation = (! $hasFormulaSource && $locationCount > 1)
? intdiv($quote->items->count(), $locationCount)
: 0;
// 견적 품목을 개소별로 그룹핑
$itemsByLocation = [];
foreach ($quote->items as $index => $quoteItem) {
$locIdx = $this->resolveLocationIndex($quoteItem, $productItems);
// sort_order 기반 분배 (formula_source/note 매칭 모두 실패 시)
if ($locIdx === 0 && $itemsPerLocation > 0) {
$locIdx = min(intdiv($index, $itemsPerLocation), $locationCount - 1);
}
$itemsByLocation[$locIdx][] = [
'quoteItem' => $quoteItem,
'mapping' => $this->resolveLocationMapping($quoteItem, $productItems),
];
}
// 개소(items) × 수량(quantity) = 총 수주 건수 계산
// 예: items 1건(qty=10) → 10건, items 3건(각 qty=1) → 3건
$expandedLocations = [];
foreach ($productItems as $idx => $locItem) {
$bomResult = $bomResults[$idx] ?? null;
$grandTotal = $bomResult['grand_total'] ?? 0;
$qty = (int) ($locItem['quantity'] ?? 1);
for ($q = 0; $q < $qty; $q++) {
$expandedLocations[] = [
'locItem' => $locItem,
'bomResult' => $bomResult,
'origIdx' => $idx,
'unitIndex' => $q,
];
}
}
$totalOrders = count($expandedLocations);
$orderNumbers = $this->generateOrderNumbers($tenantId, max($totalOrders, 1));
// 개소×수량별로 독립 수주 생성
$firstOrderId = null;
foreach ($expandedLocations as $orderIdx => $expanded) {
$locItem = $expanded['locItem'];
$bomResult = $expanded['bomResult'];
$origIdx = $expanded['origIdx'];
$grandTotal = $bomResult['grand_total'] ?? 0;
$floor = $locItem['floor'] ?? '';
$symbol = $locItem['code'] ?? '';
// 수주 마스터 생성 (qty=1 단위)
$unitLocItem = array_merge($locItem, ['quantity' => 1]);
$order = Order::createFromQuoteLocation($quote, $orderNumbers[$orderIdx], $unitLocItem, $bomResult);
$order->created_by = $userId;
$order->save();
if ($firstOrderId === null) {
$firstOrderId = $order->id;
}
// OrderNode 생성 (1수주 = 1노드)
$node = OrderNode::create([
'tenant_id' => $tenantId,
'order_id' => $order->id,
'parent_id' => null,
'node_type' => 'location',
'code' => trim("{$floor}-{$symbol}", '-') ?: "LOC-{$idx}",
'name' => trim("{$floor} {$symbol}") ?: '개소 '.($idx + 1),
'code' => trim("{$floor}-{$symbol}", '-') ?: "LOC-{$orderIdx}",
'name' => trim("{$floor} {$symbol}") ?: '개소 '.($orderIdx + 1),
'status_code' => OrderNode::STATUS_PENDING,
'quantity' => $qty,
'quantity' => 1,
'unit_price' => $grandTotal,
'total_price' => $grandTotal * $qty,
'total_price' => $grandTotal,
'options' => [
'floor' => $floor,
'symbol' => $symbol,
@@ -682,33 +736,17 @@ public function convertToOrder(int $id): Quote
'bom_result' => $bomResult,
],
'depth' => 0,
'sort_order' => $idx,
'sort_order' => 0,
'created_by' => $userId,
]);
$nodeMap[$idx] = $node;
}
// 수주 상세 품목 생성 (노드 연결 포함)
// formula_source 없는 레거시 데이터용: sort_order 기반 분배 준비
$locationCount = count($productItems);
$hasFormulaSource = $quote->items->contains(fn ($item) => ! empty($item->formula_source));
$itemsPerLocation = (! $hasFormulaSource && $locationCount > 1)
? intdiv($quote->items->count(), $locationCount)
: 0;
// 해당 개소 소속 품목 → OrderItem 복제 (모든 수량 분할 건에 동일 품목)
$serialIndex = 1;
foreach ($quote->items as $index => $quoteItem) {
$productMapping = $this->resolveLocationMapping($quoteItem, $productItems);
$locIdx = $this->resolveLocationIndex($quoteItem, $productItems);
foreach ($itemsByLocation[$origIdx] ?? [] as $entry) {
$mapping = $entry['mapping'];
$mapping['order_node_id'] = $node->id;
// sort_order 기반 분배 (formula_source/note 매칭 모두 실패 시)
if ($locIdx === 0 && $itemsPerLocation > 0) {
$locIdx = min(intdiv($index, $itemsPerLocation), $locationCount - 1);
}
$productMapping['order_node_id'] = isset($nodeMap[$locIdx]) ? $nodeMap[$locIdx]->id : null;
$orderItem = OrderItem::createFromQuoteItem($quoteItem, $order->id, $serialIndex, $productMapping);
$orderItem = OrderItem::createFromQuoteItem($entry['quoteItem'], $order->id, $serialIndex, $mapping);
$orderItem->created_by = $userId;
$orderItem->save();
$serialIndex++;
@@ -718,14 +756,15 @@ public function convertToOrder(int $id): Quote
$order->load('items');
$order->recalculateTotals();
$order->save();
}
// 견적에 수주 연결 (status는 accessor가 자동으로 'converted' 반환)
$quote->update([
'order_id' => $order->id,
'order_id' => $firstOrderId,
'updated_by' => $userId,
]);
return $quote->refresh()->load(['items', 'client', 'order']);
return $quote->refresh()->load(['items', 'client', 'orders']);
});
}
@@ -816,10 +855,10 @@ private function extractProductCodeFromInputs(array $data): ?string
}
/**
* 수주번호 생성
* 형식: ORD-YYMMDD-NNN (예: ORD-260105-001)
* 수주번호 N개 연속 생성
* 형식: ORD-YYMMDD-NNN (예: ORD-260105-001, -002, -003)
*/
private function generateOrderNumber(int $tenantId): string
private function generateOrderNumbers(int $tenantId, int $count = 1): array
{
$dateStr = now()->format('ymd');
$prefix = "ORD-{$dateStr}-";
@@ -839,9 +878,13 @@ private function generateOrderNumber(int $tenantId): string
}
}
$seqStr = str_pad((string) $sequence, 3, '0', STR_PAD_LEFT);
$numbers = [];
for ($i = 0; $i < $count; $i++) {
$seqStr = str_pad((string) ($sequence + $i), 3, '0', STR_PAD_LEFT);
$numbers[] = "{$prefix}{$seqStr}";
}
return "{$prefix}{$seqStr}";
return $numbers;
}
/**

View File

@@ -427,16 +427,47 @@ private function generateReceivingNumber(int $tenantId): string
/**
* LOT번호 자동 생성
*
* 채번규칙이 있으면 NumberingService 사용, 없으면 레거시 로직 (YYMMDD-NN)
*/
private function generateLotNo(): string
{
$now = now();
$year = $now->format('y');
$month = $now->format('m');
$day = $now->format('d');
$seq = str_pad(rand(1, 99), 2, '0', STR_PAD_LEFT);
$numberingService = app(NumberingService::class);
$numberingService->setContext($this->tenantId(), $this->apiUserId());
return "{$year}{$month}{$day}-{$seq}";
$number = $numberingService->generate('material_receipt');
if ($number !== null) {
return $number;
}
return $this->generateLotNoLegacy();
}
/**
* LOT번호 레거시 생성
*
* 5130 레거시 차용: YYMMDD-NN (일별 시퀀스, 01부터 시작)
*/
private function generateLotNoLegacy(): string
{
$tenantId = $this->tenantId();
$prefix = now()->format('ymd');
$lastReceiving = Receiving::query()
->where('tenant_id', $tenantId)
->where('lot_no', 'like', $prefix.'-%')
->orderBy('lot_no', 'desc')
->first(['lot_no']);
if ($lastReceiving) {
$lastSeq = (int) substr($lastReceiving->lot_no, -2);
$newSeq = $lastSeq + 1;
} else {
$newSeq = 1;
}
return $prefix.'-'.str_pad($newSeq, 2, '0', STR_PAD_LEFT);
}
/**

View File

@@ -309,6 +309,13 @@ public function updateStatus(int $id, string $status, ?array $additionalData = n
$shipment = Shipment::where('tenant_id', $tenantId)->findOrFail($id);
// 출하 가능 여부 검증 (scheduled → ready 이상 전환 시)
if (in_array($status, ['ready', 'shipping', 'completed']) && ! $shipment->can_ship) {
throw new \Symfony\Component\HttpKernel\Exception\BadRequestHttpException(
__('error.shipment.cannot_ship')
);
}
$updateData = [
'status' => $status,
'updated_by' => $userId,
@@ -344,10 +351,8 @@ public function updateStatus(int $id, string $status, ?array $additionalData = n
$previousStatus = $shipment->status;
$shipment->update($updateData);
// 🆕 출하완료 시 재고 차감 (FIFO)
if ($status === 'completed' && $previousStatus !== 'completed') {
$this->decreaseStockForShipment($shipment);
}
// 재고 차감 비활성화: 수주생산은 재고 미경유, 선생산 완성품은 자재 투입 시 차감됨
// TODO: 선생산 로직 검증 후 재검토 (decreaseStockForShipment)
// 연결된 수주(Order) 상태 동기화
$this->syncOrderStatus($shipment, $tenantId);
@@ -357,10 +362,21 @@ public function updateStatus(int $id, string $status, ?array $additionalData = n
/**
* 출하 완료 시 재고 차감
*
* 수주 연결 출하(order_id 있음)는 재고를 거치지 않으므로 차감 skip.
* 재고 출고(order_id 없음)만 재고 차감 수행.
*
* @return array 실패 내역 (빈 배열이면 전체 성공)
*/
private function decreaseStockForShipment(Shipment $shipment): void
private function decreaseStockForShipment(Shipment $shipment): array
{
// 수주 연결 출하는 재고 입고 없이 바로 출하하므로 차감하지 않음
if ($shipment->order_id) {
return [];
}
$stockService = app(StockService::class);
$failures = [];
// 출하 품목 조회
$items = $shipment->items;
@@ -389,15 +405,23 @@ private function decreaseStockForShipment(Shipment $shipment): void
stockLotId: $item->stock_lot_id
);
} catch (\Exception $e) {
// 재고 부족 등의 에러는 로그만 기록하고 계속 진행
\Illuminate\Support\Facades\Log::warning('Failed to decrease stock for shipment item', [
'shipment_id' => $shipment->id,
'item_code' => $item->item_code,
'quantity' => $item->quantity,
'error' => $e->getMessage(),
]);
$failures[] = [
'item_code' => $item->item_code,
'item_name' => $item->item_name,
'quantity' => $item->quantity,
'reason' => $e->getMessage(),
];
}
}
return $failures;
}
/**

View File

@@ -191,6 +191,77 @@ public function show(int $id): Item
->findOrFail($id);
}
/**
* 재고 조정 이력 조회
*/
public function adjustments(int $stockId): array
{
$tenantId = $this->tenantId();
$stock = Stock::where('tenant_id', $tenantId)->findOrFail($stockId);
$transactions = StockTransaction::where('tenant_id', $tenantId)
->where('stock_id', $stock->id)
->where('reason', StockTransaction::REASON_ADJUSTMENT)
->with('creator:id,name')
->orderByDesc('created_at')
->get();
return $transactions->map(fn ($t) => [
'id' => $t->id,
'adjusted_at' => $t->created_at->format('Y-m-d H:i'),
'quantity' => (float) $t->qty,
'balance_qty' => (float) $t->balance_qty,
'remark' => $t->remark,
'inspector' => $t->creator?->name ?? '-',
])->toArray();
}
/**
* 재고 조정 등록
*/
public function createAdjustment(int $stockId, array $data): array
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
return DB::transaction(function () use ($stockId, $data, $tenantId, $userId) {
$stock = Stock::where('tenant_id', $tenantId)->findOrFail($stockId);
$qty = (float) $data['quantity'];
// 재고량 직접 조정
$stock->stock_qty += $qty;
$stock->available_qty += $qty;
$stock->status = $stock->calculateStatus();
$stock->updated_by = $userId;
$stock->save();
// 거래 유형: 양수 → IN, 음수 → OUT
$type = $qty >= 0 ? StockTransaction::TYPE_IN : StockTransaction::TYPE_OUT;
// 거래 이력 기록
$this->recordTransaction(
stock: $stock,
type: $type,
qty: $qty,
reason: StockTransaction::REASON_ADJUSTMENT,
referenceType: 'stock',
referenceId: $stock->id,
remark: $data['remark'] ?? null
);
return [
'id' => $stock->id,
'adjusted_at' => now()->format('Y-m-d H:i'),
'quantity' => $qty,
'balance_qty' => (float) $stock->stock_qty,
'remark' => $data['remark'] ?? null,
'inspector' => \App\Models\Members\User::find($userId)?->name ?? '-',
];
});
}
/**
* 품목코드로 재고 조회 (Item 기준)
*/

View File

@@ -66,7 +66,7 @@ public function run(int $tenantId): void
'template' => json_encode([
'fields' => [
['name' => 'user_name', 'type' => 'text', 'label' => '신청자', 'required' => true],
['name' => 'request_type', 'type' => 'select', 'label' => '신청유형', 'required' => true],
['name' => 'request_type', 'type' => 'select', 'label' => '신청유형', 'required' => true, 'options' => ['휴가', '출장', '재택근무', '외근']],
['name' => 'period', 'type' => 'daterange', 'label' => '기간', 'required' => true],
['name' => 'days', 'type' => 'number', 'label' => '일수', 'required' => true],
['name' => 'reason', 'type' => 'textarea', 'label' => '사유', 'required' => true],
@@ -80,7 +80,7 @@ public function run(int $tenantId): void
'template' => json_encode([
'fields' => [
['name' => 'user_name', 'type' => 'text', 'label' => '작성자', 'required' => true],
['name' => 'report_type', 'type' => 'select', 'label' => '사유유형', 'required' => true],
['name' => 'report_type', 'type' => 'select', 'label' => '사유유형', 'required' => true, 'options' => ['지각', '조퇴', '결근', '외출', '기타']],
['name' => 'target_date', 'type' => 'date', 'label' => '대상일', 'required' => true],
['name' => 'reason', 'type' => 'textarea', 'label' => '사유', 'required' => true],
],

View File

@@ -0,0 +1,97 @@
<?php
namespace App\Services\Vehicle;
use App\Models\Tenants\CorporateVehicle;
use App\Services\Service;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class CorporateVehicleService extends Service
{
public function index(array $filters = []): LengthAwarePaginator
{
$query = CorporateVehicle::query();
if (! empty($filters['search'])) {
$search = $filters['search'];
$query->where(function ($q) use ($search) {
$q->where('plate_number', 'like', "%{$search}%")
->orWhere('model', 'like', "%{$search}%")
->orWhere('driver', 'like', "%{$search}%");
});
}
if (! empty($filters['ownership_type'])) {
$query->where('ownership_type', $filters['ownership_type']);
}
if (! empty($filters['status'])) {
$query->where('status', $filters['status']);
}
$query->orderByDesc('id');
return $query->paginate($filters['per_page'] ?? 20);
}
public function show(int $id): CorporateVehicle
{
$vehicle = CorporateVehicle::find($id);
if (! $vehicle) {
throw new NotFoundHttpException('차량을 찾을 수 없습니다.');
}
return $vehicle;
}
public function store(array $data): CorporateVehicle
{
return DB::transaction(function () use ($data) {
$data['tenant_id'] = $this->tenantId();
return CorporateVehicle::create($data);
});
}
public function update(int $id, array $data): CorporateVehicle
{
return DB::transaction(function () use ($id, $data) {
$vehicle = CorporateVehicle::find($id);
if (! $vehicle) {
throw new NotFoundHttpException('차량을 찾을 수 없습니다.');
}
$vehicle->update($data);
return $vehicle->fresh();
});
}
public function destroy(int $id): bool
{
return DB::transaction(function () use ($id) {
$vehicle = CorporateVehicle::find($id);
if (! $vehicle) {
throw new NotFoundHttpException('차량을 찾을 수 없습니다.');
}
return $vehicle->delete();
});
}
/**
* 드롭다운 목록 (차량일지, 정비이력에서 사용)
*/
public function dropdown(): array
{
return CorporateVehicle::where('status', '!=', 'disposed')
->orderBy('plate_number')
->get(['id', 'plate_number', 'model'])
->toArray();
}
}

View File

@@ -0,0 +1,129 @@
<?php
namespace App\Services\Vehicle;
use App\Models\Tenants\VehicleLog;
use App\Services\Service;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class VehicleLogService extends Service
{
public function index(array $filters = []): LengthAwarePaginator
{
$query = VehicleLog::query()->with(['vehicle:id,plate_number,model']);
if (! empty($filters['search'])) {
$search = $filters['search'];
$query->where(function ($q) use ($search) {
$q->where('driver_name', 'like', "%{$search}%")
->orWhere('departure_name', 'like', "%{$search}%")
->orWhere('arrival_name', 'like', "%{$search}%");
});
}
if (! empty($filters['vehicle_id'])) {
$query->where('vehicle_id', $filters['vehicle_id']);
}
if (! empty($filters['year']) && ! empty($filters['month'])) {
$query->whereYear('log_date', $filters['year'])
->whereMonth('log_date', $filters['month']);
}
if (! empty($filters['trip_type'])) {
$query->where('trip_type', $filters['trip_type']);
}
$query->orderByDesc('log_date')->orderByDesc('id');
return $query->paginate($filters['per_page'] ?? 20);
}
public function show(int $id): VehicleLog
{
$log = VehicleLog::with('vehicle:id,plate_number,model')->find($id);
if (! $log) {
throw new NotFoundHttpException('운행기록을 찾을 수 없습니다.');
}
return $log;
}
public function store(array $data): VehicleLog
{
return DB::transaction(function () use ($data) {
$data['tenant_id'] = $this->tenantId();
return VehicleLog::create($data);
});
}
public function update(int $id, array $data): VehicleLog
{
return DB::transaction(function () use ($id, $data) {
$log = VehicleLog::find($id);
if (! $log) {
throw new NotFoundHttpException('운행기록을 찾을 수 없습니다.');
}
$log->update($data);
return $log->fresh(['vehicle:id,plate_number,model']);
});
}
public function destroy(int $id): bool
{
return DB::transaction(function () use ($id) {
$log = VehicleLog::find($id);
if (! $log) {
throw new NotFoundHttpException('운행기록을 찾을 수 없습니다.');
}
return $log->delete();
});
}
/**
* 월별 통계
*/
public function summary(array $filters = []): array
{
$query = VehicleLog::query();
if (! empty($filters['vehicle_id'])) {
$query->where('vehicle_id', $filters['vehicle_id']);
}
if (! empty($filters['year']) && ! empty($filters['month'])) {
$query->whereYear('log_date', $filters['year'])
->whereMonth('log_date', $filters['month']);
}
$totalDistance = (clone $query)->sum('distance_km');
$totalCount = (clone $query)->count();
$commuteToQuery = (clone $query)->whereIn('trip_type', ['commute_to', 'commute_round']);
$commuteFromQuery = (clone $query)->whereIn('trip_type', ['commute_from', 'commute_round']);
$businessQuery = (clone $query)->whereIn('trip_type', ['business', 'business_round']);
$personalQuery = (clone $query)->whereIn('trip_type', ['personal', 'personal_round']);
return [
'total_distance' => (int) $totalDistance,
'total_count' => $totalCount,
'commute_to_distance' => (int) $commuteToQuery->sum('distance_km'),
'commute_to_count' => $commuteToQuery->count(),
'commute_from_distance' => (int) $commuteFromQuery->sum('distance_km'),
'commute_from_count' => $commuteFromQuery->count(),
'business_distance' => (int) $businessQuery->sum('distance_km'),
'business_count' => $businessQuery->count(),
'personal_distance' => (int) $personalQuery->sum('distance_km'),
'personal_count' => $personalQuery->count(),
];
}
}

View File

@@ -0,0 +1,93 @@
<?php
namespace App\Services\Vehicle;
use App\Models\Tenants\VehicleMaintenance;
use App\Services\Service;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class VehicleMaintenanceService extends Service
{
public function index(array $filters = []): LengthAwarePaginator
{
$query = VehicleMaintenance::query()->with(['vehicle:id,plate_number,model']);
if (! empty($filters['search'])) {
$search = $filters['search'];
$query->where(function ($q) use ($search) {
$q->where('description', 'like', "%{$search}%")
->orWhere('vendor', 'like', "%{$search}%");
});
}
if (! empty($filters['vehicle_id'])) {
$query->where('vehicle_id', $filters['vehicle_id']);
}
if (! empty($filters['category'])) {
$query->where('category', $filters['category']);
}
if (! empty($filters['start_date'])) {
$query->where('date', '>=', $filters['start_date']);
}
if (! empty($filters['end_date'])) {
$query->where('date', '<=', $filters['end_date']);
}
$query->orderByDesc('date')->orderByDesc('id');
return $query->paginate($filters['per_page'] ?? 20);
}
public function show(int $id): VehicleMaintenance
{
$item = VehicleMaintenance::with('vehicle:id,plate_number,model')->find($id);
if (! $item) {
throw new NotFoundHttpException('정비이력을 찾을 수 없습니다.');
}
return $item;
}
public function store(array $data): VehicleMaintenance
{
return DB::transaction(function () use ($data) {
$data['tenant_id'] = $this->tenantId();
return VehicleMaintenance::create($data);
});
}
public function update(int $id, array $data): VehicleMaintenance
{
return DB::transaction(function () use ($id, $data) {
$item = VehicleMaintenance::find($id);
if (! $item) {
throw new NotFoundHttpException('정비이력을 찾을 수 없습니다.');
}
$item->update($data);
return $item->fresh(['vehicle:id,plate_number,model']);
});
}
public function destroy(int $id): bool
{
return DB::transaction(function () use ($id) {
$item = VehicleMaintenance::find($id);
if (! $item) {
throw new NotFoundHttpException('정비이력을 찾을 수 없습니다.');
}
return $item->delete();
});
}
}

View File

@@ -51,6 +51,8 @@ public function index(array $params)
$query = WorkOrder::query()
->where('tenant_id', $tenantId)
->whereNotNull('process_id')
->where(fn ($q) => $q->whereNull('options->is_auxiliary')->orWhere('options->is_auxiliary', false))
->with([
'assignee:id,name',
'assignees.user:id,name',
@@ -80,15 +82,10 @@ public function index(array $params)
}
// 공정 필터 (process_id)
// - 'none' 또는 '0': 공정 미지정 (process_id IS NULL)
// - 숫자: 해당 공정 ID로 필터
if ($processId !== null) {
if ($processId === 'none' || $processId === '0' || $processId === 0) {
$query->whereNull('process_id');
} else {
// 기본 조건으로 process_id IS NOT NULL이므로 'none'은 무의미
if ($processId !== null && $processId !== 'none' && $processId !== '0' && $processId !== 0) {
$query->where('process_id', $processId);
}
}
// 공정 코드 필터 (process_code) - 대시보드용
if ($processCode !== null) {
@@ -163,30 +160,30 @@ public function stats(): array
{
$tenantId = $this->tenantId();
$counts = WorkOrder::where('tenant_id', $tenantId)
// 실제 작업건만 카운트 (공정 배정 + 보조공정 제외)
$baseQuery = WorkOrder::where('tenant_id', $tenantId)
->whereNotNull('process_id')
->where(fn ($q) => $q->whereNull('options->is_auxiliary')->orWhere('options->is_auxiliary', false));
$counts = (clone $baseQuery)
->select('status', DB::raw('count(*) as count'))
->groupBy('status')
->pluck('count', 'status')
->toArray();
// 공정별 카운트 (탭 숫자 표시용)
$byProcess = WorkOrder::where('tenant_id', $tenantId)
$byProcess = (clone $baseQuery)
->select('process_id', DB::raw('count(*) as count'))
->groupBy('process_id')
->pluck('count', 'process_id')
->toArray();
$total = array_sum($counts);
$noneCount = $byProcess[''] ?? $byProcess[0] ?? 0;
// null 키는 빈 문자열로 변환되므로 별도 처리
// process_id IS NOT NULL 기본 필터 적용으로 null 키 처리 불필요
$processedByProcess = [];
foreach ($byProcess as $key => $count) {
if ($key === '' || $key === 0 || $key === null) {
$processedByProcess['none'] = $count;
} else {
$processedByProcess[(string) $key] = $count;
}
}
return [
'total' => $total,
@@ -579,8 +576,8 @@ public function updateStatus(int $id, string $status, ?array $resultData = null)
// Fast-track 완료의 경우 started_at도 설정 (중간 상태 생략)
$workOrder->started_at = $workOrder->started_at ?? now();
$workOrder->completed_at = now();
// 모든 품목에 결과 데이터 저장
$this->saveItemResults($workOrder, $resultData, $userId);
// 모든 품목에 결과 데이터 저장 (LOT 번호 반환)
$lotNo = $this->saveItemResults($workOrder, $resultData, $userId);
break;
case WorkOrder::STATUS_SHIPPED:
$workOrder->shipped_at = now();
@@ -607,7 +604,14 @@ public function updateStatus(int $id, string $status, ?array $resultData = null)
$this->stockInFromProduction($workOrder);
}
return $workOrder->load(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'process:id,process_name,process_code']);
$result = $workOrder->load(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'process:id,process_name,process_code']);
// 완료 시 LOT 번호를 응답에 포함
if (isset($lotNo)) {
$result->setAttribute('lot_no', $lotNo);
}
return $result;
});
}
@@ -767,6 +771,8 @@ private function createShipmentFromOrder(Order $order, $mainWorkOrders, int $ten
'quantity' => $result['good_qty'] ?? $woItem->quantity,
'unit' => $woItem->unit,
'lot_no' => $lotNo,
'order_item_id' => $woItem->source_order_item_id,
'work_order_item_id' => $woItem->id,
'remarks' => null,
]);
}
@@ -787,6 +793,8 @@ private function createShipmentFromOrder(Order $order, $mainWorkOrders, int $ten
'quantity' => $orderItem->quantity,
'unit' => $orderItem->unit,
'lot_no' => null,
'order_item_id' => $orderItem->id,
'work_order_item_id' => null,
'remarks' => null,
]);
}
@@ -1105,7 +1113,7 @@ private function autoStartWorkOrderOnMaterialInput(WorkOrder $workOrder, int $te
/**
* 작업지시 품목에 결과 데이터 저장
*/
private function saveItemResults(WorkOrder $workOrder, ?array $resultData, int $userId): void
private function saveItemResults(WorkOrder $workOrder, ?array $resultData, int $userId): string
{
$items = $workOrder->items;
$lotNo = $this->generateLotNo($workOrder);
@@ -1140,6 +1148,8 @@ private function saveItemResults(WorkOrder $workOrder, ?array $resultData, int $
$item->options = $options;
$item->save();
}
return $lotNo;
}
/**
@@ -1476,6 +1486,35 @@ public function getMaterials(int $workOrderId): array
->keyBy('id');
}
// ── Step 1.5: 기존 BOM용 item_id 일괄 사전 로드 (N+1 방지) ──
$bomParentItemIds = $workOrder->items->pluck('item_id')->filter()->unique()->values()->all();
$bomItemsMap = collect();
$bomChildItemsMap = collect();
if (! empty($bomParentItemIds)) {
$bomItemsMap = \App\Models\Items\Item::where('tenant_id', $tenantId)
->whereIn('id', $bomParentItemIds)
->get()
->keyBy('id');
// BOM 자식 item_id 수집 및 배치 조회
$allChildIds = [];
foreach ($bomItemsMap as $parentItem) {
if (! empty($parentItem->bom)) {
foreach ($parentItem->bom as $bomEntry) {
if (! empty($bomEntry['child_item_id'])) {
$allChildIds[] = $bomEntry['child_item_id'];
}
}
}
}
if (! empty($allChildIds)) {
$bomChildItemsMap = \App\Models\Items\Item::where('tenant_id', $tenantId)
->whereIn('id', array_unique($allChildIds))
->get()
->keyBy('id');
}
}
// ── Step 2: 유니크 자재 목록 수집 ──
// 키: dynamic_bom → "{item_id}_{woItem_id}", 기존 BOM → "{item_id}"
$uniqueMaterials = [];
@@ -1517,12 +1556,11 @@ public function getMaterials(int $workOrderId): array
continue; // dynamic_bom이 있으면 기존 BOM fallback 건너뜀
}
// 기존 BOM 로직 (하위 호환)
// 기존 BOM 로직 (하위 호환) — 사전 로드된 맵 사용
$materialItems = [];
if ($woItem->item_id) {
$item = \App\Models\Items\Item::where('tenant_id', $tenantId)
->find($woItem->item_id);
$item = $bomItemsMap[$woItem->item_id] ?? null;
if ($item && ! empty($item->bom)) {
foreach ($item->bom as $bomItem) {
@@ -1533,8 +1571,7 @@ public function getMaterials(int $workOrderId): array
continue;
}
$childItem = \App\Models\Items\Item::where('tenant_id', $tenantId)
->find($childItemId);
$childItem = $bomChildItemsMap[$childItemId] ?? null;
if (! $childItem) {
continue;

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