Compare commits

..

56 Commits

Author SHA1 Message Date
b6d2b9942e chore: [인프라] Slack 채널 분리 + logging 권한 + 문서 갱신
- Slack 알림 채널: product_infra → deploy_api
- logging.php daily/api 채널 permission 0664 추가
- CLAUDE.md, INDEX.md, 변경이력 문서 갱신

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 02:59:49 +09:00
4208ca3010 feat: [HR/기타] 캘린더/배차/설비/재고 + DB 마이그레이션
- 캘린더 CRUD API, 배차차량 관리 API (CRUD + options)
- 배차정보 다중 행 시스템 (shipment_vehicle_dispatches)
- 설비 다중점검주기 + 부 담당자 스키마 추가
- TodayIssue 날짜 기반 조회, Stock/Client 날짜 필터
- i18n 메시지 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 02:59:30 +09:00
95371fd841 feat: [CEO 대시보드] 섹션별 API + 일일보고서 엑셀
- DashboardCeo 리스크 감지형 서비스 리팩토링
- 일일보고서 어음/외상매출채권 현황 섹션 추가
- 엑셀 내보내기 화면 데이터 기반 리팩토링
- 공정명 컬럼 및 근태 부서 조인 수정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 02:59:05 +09:00
1df34b2fa9 feat: [재무] 어음 V8 + 상품권 접대비 연동 + 일반전표/계정과목 API
- Bill 확장 필드 (V8), Loan 상품권 카테고리/접대비 자동 연동
- GeneralJournalEntry CRUD, AccountSubject API
- 접대비/복리후생비 날짜 필터, 매출채권 soft delete 제외
- 바로빌 연동 API 엔드포인트 추가
- 부가세 상세 조회 API

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 02:58:55 +09:00
3d12687a2d feat: [결재] 양식 마이그레이션 12종 + 반려이력/재상신
- 재직/경력/위촉증명서, 사직서, 사용인감계, 위임장
- 이사회의사록, 견적서, 공문서, 연차사용촉진 1차/2차
- 지출결의서 body_template 고도화
- rejection_history, resubmit_count, drafter_read_at 컬럼
- Document-Approval 브릿지 연동 (linkable)
- 수신함 날짜 범위 필터 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 02:58:32 +09:00
5e4cbc7742 feat: [문서] rendered_html 스냅샷 저장 + snapshot 엔드포인트
- Document upsert에 rendered_html 필드 추가
- Lazy Snapshot API (snapshot_document_id resolve)
- UpsertRequest rendered_html 검증 추가
- DocumentTemplateSection description 컬럼

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 02:58:06 +09:00
4dd38ab14d feat: [생산지시] 전용 API + 자재투입/공정 개선
- ProductionOrder 전용 엔드포인트 (목록/통계/상세)
- 재고생산 보조공정 일반 워크플로우에서 분리
- 자재투입 replace 모드 + bom_group_key 개별 저장
- 공정단계 options 컬럼 추가 (검사 설정/범위)
- 셔터박스 prefix isStandard 파라미터 제거

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 02:57:59 +09:00
f9cd219f67 feat: [품질관리] 품질관리서/실적신고/검사 API
- QualityDocument CRUD + 수주 연결 + 개소별 데이터 저장
- PerformanceReport 실적신고 확인/메모 API
- Inspection 검사 설정 + product_code 전파 수정
- 수주선택 API에 client_name 필드 추가
- 절곡 검사 프로파일 분리 (S1/S2/S3)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 02:57:48 +09:00
김보곤
091719e81b feat: [approval] 연차사용촉진 통지서 1차/2차 양식 마이그레이션 추가
- leave_promotion_1st: 연차사용촉진 통지서 (1차) - hr 카테고리
- leave_promotion_2nd: 연차사용촉진 통지서 (2차) - hr 카테고리
2026-03-07 00:30:00 +09:00
김보곤
b06438cc52 feat: [approval] 공문서 양식 마이그레이션 추가 2026-03-06 23:39:35 +09:00
김보곤
1e5cd70081 feat: [approval] 견적서 양식 마이그레이션 추가 2026-03-06 23:22:56 +09:00
김보곤
b21c9de6eb feat: [approval] 이사회의사록 양식 데이터 마이그레이션 추가 2026-03-06 23:01:03 +09:00
김보곤
91567c54bd feat: [approval] 위임장 양식 데이터 마이그레이션 추가
- 전체 테넌트에 delegation 양식 레코드 자동 삽입
2026-03-06 22:51:24 +09:00
김보곤
88eb507426 feat: [menu] 경조사비관리 메뉴 추가 마이그레이션
- 각 테넌트의 부가세관리 메뉴 하위에 경조사비관리 메뉴 자동 추가
- 중복 방지 (이미 존재하면 skip)
2026-03-06 21:45:49 +09:00
김보곤
0bf56931fa feat: [database] 경조사비 관리 테이블 생성
- condolence_expenses 테이블: 거래처 경조사비 관리대장
- 경조사일자, 지출일자, 거래처명, 내역, 구분(축의/부조)
- 부조금(여부/지출방법/금액), 선물(여부/종류/금액), 총금액
2026-03-06 21:39:38 +09:00
김보곤
c55a4a42e6 feat: [approvals] 사용인감계 양식 데이터 마이그레이션 추가
- 모든 테넌트에 seal_usage 양식 자동 등록
2026-03-06 20:53:34 +09:00
김보곤
428b2e2a12 feat: [departments] options JSON 컬럼 추가
- 조직도 숨기기 등 확장 속성 저장용
2026-03-06 20:28:03 +09:00
김보곤
64877869e6 feat: [menu] menu_favorites 테이블 마이그레이션 추가
- tenant_id, user_id, menu_id, sort_order 컬럼
- unique 제약: (tenant_id, user_id, menu_id)
- FK cascade delete: users, menus
2026-03-06 15:05:08 +09:00
김보곤
92efe2e83b feat: [approval] 위촉증명서 양식 데이터 마이그레이션 2026-03-05 23:58:35 +09:00
김보곤
78eb9363f4 feat: [approval] 경력증명서 양식 데이터 마이그레이션 2026-03-05 23:42:35 +09:00
김보곤
c611f551a6 feat: [approval] 재직증명서 양식 마이그레이션 추가
- approval_forms 테이블에 employment_cert 폼 삽입
2026-03-05 19:04:58 +09:00
김보곤
48889a7250 feat: [rd] CM송 저장 테이블 마이그레이션 추가
- cm_songs 테이블: tenant_id, user_id, company_name, industry, lyrics, audio_path, options
2026-03-05 14:37:22 +09:00
김보곤
18f39433ae feat: [approval] approvals 테이블에 rejection_history JSON 컬럼 추가 2026-03-05 13:51:09 +09:00
김보곤
3785d87df4 feat: [approval] approvals 테이블에 resubmit_count 컬럼 추가 2026-03-05 13:07:21 +09:00
김보곤
d9075e5da5 feat: [approval] approvals 테이블에 drafter_read_at 컬럼 추가
- 기안자가 완료 결과를 확인했는지 추적하는 타임스탬프
- 완료함 미읽음 뱃지 기능 지원
2026-03-05 11:37:56 +09:00
1d71b588cb chore: [infra] Slack 알림 채널 분리 — product_infra → deploy_api
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:33:05 +09:00
김보곤
521229adcf fix: [storage] RecordStorageUsage 명령어 tenants 테이블 컬럼명 오류 수정
- Tenant::where('status', 'active') → Tenant::active() 스코프 사용
- tenants 테이블에 status 컬럼 없음, tenant_st_code 사용
2026-03-05 09:16:28 +09:00
김보곤
5ce2d2fcbf feat: [approval] 지출결의서 body_template 고도화
- 참조 문서 기반으로 정형 양식 HTML 리디자인
- 지출형식/세금계산서 체크박스, 기본정보, 8열 내역 테이블, 합계, 첨부 섹션 포함
2026-03-04 22:00:40 +09:00
김보곤
5f5b5db59f feat: [approval] body_template 컬럼 추가 및 지출결의서 양식 등록
- approval_forms 테이블에 body_template TEXT 컬럼 추가
- 지출결의서(expense) 양식 데이터 등록 (HTML 테이블 본문 템플릿 포함)
2026-03-04 22:00:40 +09:00
김보곤
814b965748 fix: [address] 주소 필드 255자 → 500자 확장
- DB 마이그레이션: clients, tenants, site_briefings, sites 테이블 address 컬럼 varchar(500)
- FormRequest 8개 파일 max:255 → max:500 변경
2026-03-04 11:29:18 +09:00
김보곤
c55380f1d2 fix: [cards] cards/stats → card-transactions/dashboard 리다이렉트 추가 2026-03-04 11:10:12 +09:00
김보곤
4870b7e6eb fix: [models] User 모델 import 누락/오류 수정
- Loan.php: User import 누락 → App\Models\Members\User 추가
- TodayIssue.php: App\Models\Users\User → App\Models\Members\User 수정
- Tenants 네임스페이스에서 User::class가 App\Models\Tenants\User로 잘못 해석되는 문제 해결
2026-03-04 11:06:26 +09:00
김보곤
88ef6a8490 feat: [hr] Leave 모델 확장 + 결재양식 마이그레이션 추가
- Leave 타입 6개 추가: business_trip, remote, field_work, early_leave, late_reason, absent_reason
- 그룹 상수 추가: VACATION_TYPES, ATTENDANCE_REQUEST_TYPES, REASON_REPORT_TYPES
- FORM_CODE_MAP: 유형 → 결재양식코드 매핑 상수
- ATTENDANCE_STATUS_MAP: 유형 → 근태상태 매핑 상수
- 결재양식 2개 추가: attendance_request(근태신청), reason_report(사유서)
2026-03-03 23:53:11 +09:00
김보곤
2fd122feba feat: [ai-quotation] 제조 견적서 마이그레이션 추가
- ai_quotations: quote_mode, quote_number, product_category 컬럼 추가
- ai_quotation_items: specification, unit, quantity, unit_price, total_price, item_category, floor_code 컬럼 추가
- ai_quote_price_tables 테이블 신규 생성
2026-03-03 15:58:15 +09:00
김보곤
5a0deddb58 feat: [hr] 사업소득자 임금대장 display_name/business_reg_number 컬럼 추가
- user_id nullable 변경 (직접 입력 대상자 지원)
- display_name, business_reg_number 컬럼 추가
- 기존 데이터 earner 프로필에서 자동 채움
2026-03-03 14:33:27 +09:00
d68fd56232 fix: [deploy] 배포 시 .env 권한 640 보장 추가
- Stage/Production 배포 스크립트에 chmod 640 추가
- vi 편집으로 인한 .env 권한 변경(600) 방지
- 2026-03-03 장애 재발 방지 (PHP-FPM이 .env 읽기 실패 → 500)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 10:21:12 +09:00
김보곤
d8abc57271 feat: [rd] AI 견적 엔진 테이블 생성 + 모듈 카탈로그 시더
- ai_quotation_modules: SAM 모듈 카탈로그 (18개 모듈)
- ai_quotations: AI 견적 요청/결과
- ai_quotation_items: AI 추천 모듈 목록
- AiQuotationModuleSeeder: customer-pricing 기반 초기 데이터
2026-03-02 18:15:40 +09:00
김보곤
d7dd6cdbc5 feat: [roadmap] 중장기 계획 테이블 마이그레이션 추가
- admin_roadmap_plans: 계획 테이블 (제목, 카테고리, 상태, Phase, 진행률 등)
- admin_roadmap_milestones: 마일스톤 테이블 (plan_id FK, 상태, 예정일 등)
2026-03-02 15:51:17 +09:00
김보곤
2bb3a2872a feat: [interview] 마스터 질문 데이터 시드 마이그레이션 추가
- 8개 도메인, 16개 템플릿, 80개 마스터 질문 INSERT
- idempotent 처리: 이미 도메인 카테고리 존재 시 스킵
- Jenkins 자동 배포로 운영서버 데이터 반영 목적
2026-02-28 22:06:13 +09:00
김보곤
6df1da9e42 feat: [interview] 인터뷰 시나리오 고도화 마이그레이션
- interview_projects 테이블 신규 (회사별 프로젝트)
- interview_attachments 테이블 신규 (첨부파일 + AI 분석)
- interview_knowledge 테이블 신규 (AI 추출 지식)
- interview_categories에 project_id, domain 컬럼 추가
- interview_questions에 ai_hint, expected_format, depends_on, domain 추가
- interview_answers에 answer_data, attachments JSON 추가
- interview_sessions에 project_id, session_type, voice_recording_id 추가
2026-02-28 21:49:07 +09:00
김보곤
93e94901b7 feat: [interview] 카테고리 계층 구조 parent_id 마이그레이션 추가
- interview_categories 테이블에 parent_id 컬럼 추가
- self-referencing FK, nullOnDelete
2026-02-28 21:46:29 +09:00
김보곤
7028e27517 feat: [document] 블록 빌더 지원 마이그레이션 추가
- document_templates: builder_type, schema, page_config 컬럼 추가
- documents: data JSON, rendered_html, pdf_path 컬럼 추가
2026-02-28 19:32:48 +09:00
김보곤
1d2876d90c feat: [leaves] 휴가-결재 연동을 위한 DB 변경
- leaves 테이블에 approval_id 컬럼 추가 (마이그레이션)
- 휴가신청 결재 양식(approval_forms) 등록 (마이그레이션)
- Leave 모델 fillable에 approval_id 추가
2026-02-28 15:55:30 +09:00
김보곤
bfb821698a feat: [approval] Phase 2 마이그레이션 추가
- approval_steps: parallel_group, acted_by, approval_type 컬럼 추가
- approvals: recall_reason, parent_doc_id 컬럼 추가
- approval_delegations 테이블 생성 (위임/대결)
2026-02-28 12:16:33 +09:00
김보곤
b80f4a0392 feat: [approval] 결재관리 Phase 1 마이그레이션
- approvals 테이블: line_id, body, is_urgent, department_id 컬럼 추가
- approval_steps 테이블: approver_name, approver_department, approver_position 스냅샷 컬럼 추가
2026-02-27 23:27:00 +09:00
김보곤
2ed90dc6db feat: [hr] 사업소득자 임금대장 테이블 마이그레이션 추가
- business_income_payments 테이블 생성
- 소득세(3%)/지방소득세(0.3%) 고정세율 구조
- (tenant_id, user_id, pay_year, pay_month) 유니크 제약
2026-02-27 20:28:01 +09:00
김보곤
347d351d9d feat: [esign] esign_contracts 테이블에 completion_template_name 컬럼 추가
- 완료 알림톡 템플릿명을 저장하기 위한 nullable string 컬럼
2026-02-27 16:32:52 +09:00
김보곤
87a8930c00 feat: [payroll] 근로소득세 간이세액표 DB 테이블 및 시더 추가
- income_tax_brackets 테이블 마이그레이션 생성
- 2024년 국세청 간이세액표 데이터 시더 (7,117건)
- salary_from/salary_to(천원), family_count(1~11), tax_amount(원)
2026-02-27 14:05:54 +09:00
김보곤
bbcb0205fe feat: [hr] 사업소득자관리 worker_type 컬럼 추가
- tenant_user_profiles 테이블에 worker_type 컬럼 추가 (employee/business_income)
- TenantUserProfile 모델 fillable에 worker_type 추가
2026-02-27 13:58:25 +09:00
9bf0cc8df2 fix: [cicd] 배포 승인 비활성화 + storage 권한 수정
- Production Approval stage 주석처리 (런칭 후 활성화)
- 배포 시 storage/bootstrap chown www-data:webservice + chmod 775 추가
- Stage/Production 모두 적용
2026-02-27 10:44:54 +09:00
김보곤
255fad99e7 feat: [payroll] payrolls 테이블에 long_term_care 컬럼 추가 2026-02-27 10:11:17 +09:00
김보곤
08b07c724a feat: [attendance] attendance_requests 테이블 마이그레이션 추가
- 근태 승인 워크플로우용 신청 테이블
- tenant_id, user_id, request_type, start_date, end_date, status 등
2026-02-27 09:36:21 +09:00
김보곤
91cdfe9917 feat: [equipment] files 테이블에 GCS 컬럼 추가
- gcs_object_name, gcs_uri 컬럼 추가
- 설비 사진 멀티 업로드 기능 지원
2026-02-27 09:36:11 +09:00
김보곤
10c09b9fea feat: [equipment] 설비관리 테이블 마이그레이션 6개 생성
- equipments (설비 마스터)
- equipment_inspection_templates (점검항목 템플릿)
- equipment_inspections (월간 점검 헤더)
- equipment_inspection_details (일자별 점검 결과)
- equipment_repairs (수리이력)
- equipment_process (설비-공정 피봇)
2026-02-27 09:36:11 +09:00
김보곤
04bb990045 feat: [calendar] 달력 일정 관리 API 구현
- GET /api/v1/calendar-schedules — 연도별 일정 목록 조회
- GET /api/v1/calendar-schedules/stats — 통계 조회
- GET /api/v1/calendar-schedules/{id} — 단건 조회
- POST /api/v1/calendar-schedules — 등록
- PUT /api/v1/calendar-schedules/{id} — 수정
- DELETE /api/v1/calendar-schedules/{id} — 삭제
- POST /api/v1/calendar-schedules/bulk — 대량 등록
2026-02-26 14:38:00 +09:00
7543054df3 fix: 세션 만료 예외를 슬랙 알림에서 제외
- '회원정보 정보 없음' AuthenticationException은 API Key 검증 통과 후 발생하므로 세션 만료 정상 케이스
- IP 기반 필터링(EXCEPTION_IGNORED_IPS) 대신 예외 자체를 무조건 제외하도록 단순화

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 21:29:57 +09:00
408 changed files with 4325 additions and 39881 deletions

1
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

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

130
Jenkinsfile vendored
View File

@@ -1,12 +1,6 @@
pipeline { pipeline {
agent any agent any
parameters {
choice(name: 'ACTION', choices: ['deploy', 'rollback'], description: '배포 또는 롤백')
choice(name: 'ROLLBACK_TARGET', choices: ['production', 'stage'], description: '롤백 대상 환경')
string(name: 'ROLLBACK_RELEASE', defaultValue: '', description: '롤백할 릴리스 ID (예: 20260310_120000). 비워두면 직전 릴리스로 롤백')
}
options { options {
disableConcurrentBuilds() disableConcurrentBuilds()
} }
@@ -14,73 +8,10 @@ pipeline {
environment { environment {
DEPLOY_USER = 'hskwon' DEPLOY_USER = 'hskwon'
RELEASE_ID = new Date().format('yyyyMMdd_HHmmss') RELEASE_ID = new Date().format('yyyyMMdd_HHmmss')
PROD_SERVER = '211.117.60.189'
} }
stages { stages {
// ── 롤백: 릴리스 목록 조회 ──
stage('Rollback: List Releases') {
when { expression { params.ACTION == 'rollback' } }
steps {
script {
def basePath = params.ROLLBACK_TARGET == 'production' ? '/home/webservice/api' : '/home/webservice/api-stage'
sshagent(credentials: ['deploy-ssh-key']) {
def releases = sh(script: "ssh ${DEPLOY_USER}@${PROD_SERVER} 'ls -1dt ${basePath}/releases/*/ | head -6 | xargs -I{} basename {}'", returnStdout: true).trim()
def current = sh(script: "ssh ${DEPLOY_USER}@${PROD_SERVER} 'basename \$(readlink -f ${basePath}/current)'", returnStdout: true).trim()
echo "=== ${params.ROLLBACK_TARGET} 릴리스 목록 ==="
echo "현재 활성: ${current}"
echo "사용 가능:\n${releases}"
}
}
}
}
// ── 롤백: symlink 전환 ──
stage('Rollback: Switch Release') {
when { expression { params.ACTION == 'rollback' } }
steps {
script {
def basePath = params.ROLLBACK_TARGET == 'production' ? '/home/webservice/api' : '/home/webservice/api-stage'
sshagent(credentials: ['deploy-ssh-key']) {
def targetRelease = params.ROLLBACK_RELEASE
if (!targetRelease?.trim()) {
// 비워두면 직전 릴리스로 롤백
targetRelease = sh(script: "ssh ${DEPLOY_USER}@${PROD_SERVER} 'ls -1dt ${basePath}/releases/*/ | sed -n 2p | xargs basename'", returnStdout: true).trim()
}
// 릴리스 존재 여부 확인
sh "ssh ${DEPLOY_USER}@${PROD_SERVER} 'test -d ${basePath}/releases/${targetRelease}'"
slackSend channel: '#deploy_api', color: '#FF9800', tokenCredentialId: 'slack-token',
message: "🔄 *api* ${params.ROLLBACK_TARGET} 롤백 시작 → ${targetRelease}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
sh """
ssh ${DEPLOY_USER}@${PROD_SERVER} '
ln -sfn ${basePath}/releases/${targetRelease} ${basePath}/current &&
cd ${basePath}/current &&
php artisan config:cache &&
php artisan route:cache &&
php artisan view:cache &&
sudo systemctl reload php8.4-fpm
'
"""
if (params.ROLLBACK_TARGET == 'production') {
sh "ssh ${DEPLOY_USER}@${PROD_SERVER} 'sudo supervisorctl restart sam-queue-worker:*'"
}
slackSend channel: '#deploy_api', color: 'good', tokenCredentialId: 'slack-token',
message: "✅ *api* ${params.ROLLBACK_TARGET} 롤백 완료 → ${targetRelease}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
}
}
}
}
// ── 일반 배포: Checkout ──
stage('Checkout') { stage('Checkout') {
when { expression { params.ACTION == 'deploy' } }
steps { steps {
checkout scm checkout scm
script { script {
@@ -93,22 +24,17 @@ pipeline {
// ── main → 운영서버 Stage 배포 ── // ── main → 운영서버 Stage 배포 ──
stage('Deploy Stage') { stage('Deploy Stage') {
when { when { branch 'main' }
allOf {
branch 'main'
expression { params.ACTION == 'deploy' }
}
}
steps { steps {
sshagent(credentials: ['deploy-ssh-key']) { sshagent(credentials: ['deploy-ssh-key']) {
sh """ sh """
ssh ${DEPLOY_USER}@${PROD_SERVER} 'mkdir -p /home/webservice/api-stage/releases/${RELEASE_ID}' ssh ${DEPLOY_USER}@211.117.60.189 'mkdir -p /home/webservice/api-stage/releases/${RELEASE_ID}'
rsync -az --delete \ rsync -az --delete \
--exclude='.git' --exclude='.env' \ --exclude='.git' --exclude='.env' \
--exclude='storage/app' --exclude='storage/logs' \ --exclude='storage/app' --exclude='storage/logs' \
--exclude='storage/framework/sessions' --exclude='storage/framework/cache' \ --exclude='storage/framework/sessions' --exclude='storage/framework/cache' \
. ${DEPLOY_USER}@${PROD_SERVER}:/home/webservice/api-stage/releases/${RELEASE_ID}/ . ${DEPLOY_USER}@211.117.60.189:/home/webservice/api-stage/releases/${RELEASE_ID}/
ssh ${DEPLOY_USER}@${PROD_SERVER} ' ssh ${DEPLOY_USER}@211.117.60.189 '
cd /home/webservice/api-stage/releases/${RELEASE_ID} && cd /home/webservice/api-stage/releases/${RELEASE_ID} &&
mkdir -p bootstrap/cache storage/framework/{views,cache/data,sessions} storage/logs && mkdir -p bootstrap/cache storage/framework/{views,cache/data,sessions} storage/logs &&
sudo chown -R www-data:webservice storage bootstrap/cache && sudo chown -R www-data:webservice storage bootstrap/cache &&
@@ -145,22 +71,17 @@ pipeline {
// ── main → 운영서버 Production 배포 ── // ── main → 운영서버 Production 배포 ──
stage('Deploy Production') { stage('Deploy Production') {
when { when { branch 'main' }
allOf {
branch 'main'
expression { params.ACTION == 'deploy' }
}
}
steps { steps {
sshagent(credentials: ['deploy-ssh-key']) { sshagent(credentials: ['deploy-ssh-key']) {
sh """ sh """
ssh ${DEPLOY_USER}@${PROD_SERVER} 'mkdir -p /home/webservice/api/releases/${RELEASE_ID}' ssh ${DEPLOY_USER}@211.117.60.189 'mkdir -p /home/webservice/api/releases/${RELEASE_ID}'
rsync -az --delete \ rsync -az --delete \
--exclude='.git' --exclude='.env' \ --exclude='.git' --exclude='.env' \
--exclude='storage/app' --exclude='storage/logs' \ --exclude='storage/app' --exclude='storage/logs' \
--exclude='storage/framework/sessions' --exclude='storage/framework/cache' \ --exclude='storage/framework/sessions' --exclude='storage/framework/cache' \
. ${DEPLOY_USER}@${PROD_SERVER}:/home/webservice/api/releases/${RELEASE_ID}/ . ${DEPLOY_USER}@211.117.60.189:/home/webservice/api/releases/${RELEASE_ID}/
ssh ${DEPLOY_USER}@${PROD_SERVER} ' ssh ${DEPLOY_USER}@211.117.60.189 '
cd /home/webservice/api/releases/${RELEASE_ID} && cd /home/webservice/api/releases/${RELEASE_ID} &&
mkdir -p bootstrap/cache storage/framework/{views,cache/data,sessions} storage/logs && mkdir -p bootstrap/cache storage/framework/{views,cache/data,sessions} storage/logs &&
sudo chown -R www-data:webservice storage bootstrap/cache && sudo chown -R www-data:webservice storage bootstrap/cache &&
@@ -188,32 +109,23 @@ pipeline {
post { post {
success { success {
script { slackSend channel: '#deploy_api', color: 'good', tokenCredentialId: 'slack-token',
if (params.ACTION == 'deploy') { message: "✅ *api* 배포 성공 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
slackSend channel: '#deploy_api', color: 'good', tokenCredentialId: 'slack-token',
message: "✅ *api* 배포 성공 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
}
}
} }
failure { failure {
slackSend channel: '#deploy_api', color: 'danger', tokenCredentialId: 'slack-token',
message: "❌ *api* 배포 실패 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
script { script {
if (params.ACTION == 'deploy') { if (env.BRANCH_NAME == 'main') {
slackSend channel: '#deploy_api', color: 'danger', tokenCredentialId: 'slack-token', sshagent(credentials: ['deploy-ssh-key']) {
message: "❌ *api* 배포 실패 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>" sh """
if (env.BRANCH_NAME == 'main') { ssh ${DEPLOY_USER}@211.117.60.189 '
sshagent(credentials: ['deploy-ssh-key']) { PREV=\$(ls -1dt /home/webservice/api/releases/*/ | sed -n "2p" | xargs basename 2>/dev/null) &&
sh """ [ -n "\$PREV" ] && ln -sfn /home/webservice/api/releases/\$PREV /home/webservice/api/current &&
ssh ${DEPLOY_USER}@${PROD_SERVER} ' sudo systemctl reload php8.4-fpm
PREV=\$(ls -1dt /home/webservice/api/releases/*/ | sed -n "2p" | xargs basename 2>/dev/null) && ' || true
[ -n "\$PREV" ] && ln -sfn /home/webservice/api/releases/\$PREV /home/webservice/api/current && """
sudo systemctl reload php8.4-fpm
' || true
"""
}
} }
} else {
slackSend channel: '#deploy_api', color: 'danger', tokenCredentialId: 'slack-token',
message: "❌ *api* ${params.ROLLBACK_TARGET} 롤백 실패\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
} }
} }
} }

1214
LOGICAL_RELATIONSHIPS.md Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

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

View File

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

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

View File

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

View File

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

View File

@@ -1,438 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\Items\Item;
use App\Models\Qualitys\Inspection;
use App\Models\Tenants\Receiving;
use App\Models\Tenants\Stock;
use App\Models\Tenants\StockLot;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* 원자재 입고 테스트 데이터 생성 커맨드
*
* 서비스 준비 단계에서 절곡 데이터 정합성, 작업일지, 검사 테스트를 위한 데이터 생성
*/
class GenerateReceivingTestDataCommand extends Command
{
protected $signature = 'receiving:generate-test-data
{--tenant=287 : 테넌트 ID}
{--date=2026-03-21 : 입고일자 (YYYY-MM-DD)}
{--dry-run : 실제 DB에 넣지 않고 미리보기만}';
protected $description = '원자재(RM) 품목 기반 입고 테스트 데이터 생성 (Receiving + Stock + StockLot + IQC Inspection)';
private const USER_ID = 33;
private const ITEM_TYPE_MAP = [
'FG' => 'purchased_part',
'PT' => 'bent_part',
'SM' => 'sub_material',
'RM' => 'raw_material',
'CS' => 'consumable',
];
/** 철강 관련 공급업체 (업체명 => 제조사) */
private const STEEL_SUPPLIERS = [
'(주)포스코' => '포스코',
'현대제철(주)' => '현대제철',
'동국제강(주)' => '동국제강',
'(주)세아제강' => '세아제강',
'한국철강(주)' => '한국철강',
];
/** 원단/기타 공급업체 */
private const FABRIC_SUPPLIERS = [
'(주)대한스틸' => '대한스틸',
'(주)한국소재' => '한국소재',
'삼화산업(주)' => '삼화산업',
];
/** 창고 위치 */
private const LOCATIONS = ['A-01-01', 'A-01-02', 'A-02-01', 'B-01-01', 'B-02-01', 'C-01-01'];
public function handle(): int
{
$tenantId = (int) $this->option('tenant');
$date = $this->option('date');
$dryRun = $this->option('dry-run');
$this->info('=== 원자재 입고 테스트 데이터 생성 ===');
$this->info("테넌트: {$tenantId} | 일자: {$date}".($dryRun ? ' [DRY-RUN]' : ''));
// Step 1: RM 품목 조회
$items = Item::withoutGlobalScopes()
->where('tenant_id', $tenantId)
->where('item_type', 'RM')
->whereNull('deleted_at')
->orderBy('code')
->get();
if ($items->isEmpty()) {
$this->error("테넌트 {$tenantId}에 원자재(RM) 품목이 없습니다.");
return self::FAILURE;
}
$this->info("대상 품목: {$items->count()}");
$this->newLine();
if ($dryRun) {
$this->table(
['코드', '품목명', '단위', '예상 공급업체', '예상 수량'],
$items->map(fn ($item) => [
$item->code,
$item->name,
$item->unit,
$this->getSupplierForItem($item->name)[0],
'10~100 (10단위)',
])->toArray()
);
return self::SUCCESS;
}
// 채번 시퀀스 초기화
$datePrefix = date('Ymd', strtotime($date));
$dateShort = date('ymd', strtotime($date));
$receivingSeq = $this->getNextReceivingSeq($tenantId, $datePrefix);
$lotSeq = $this->getNextLotSeq($tenantId, $dateShort);
$inspectionSeq = $this->getNextInspectionSeq($tenantId, $dateShort);
$orderSeq = 1;
$created = ['receiving' => 0, 'stock' => 0, 'stock_lot' => 0, 'inspection' => 0];
DB::transaction(function () use ($items, $tenantId, $date, $datePrefix, $dateShort, &$receivingSeq, &$lotSeq, &$inspectionSeq, &$orderSeq, &$created) {
foreach ($items as $item) {
$qty = rand(1, 10) * 10; // 10~100 (10단위)
[$supplierName, $manufacturer] = $this->getSupplierForItem($item->name);
$location = self::LOCATIONS[array_rand(self::LOCATIONS)];
// Step 2: Receiving 생성
$receivingNumber = 'RV'.$datePrefix.str_pad($receivingSeq++, 4, '0', STR_PAD_LEFT);
$lotNo = $dateShort.'-'.str_pad($lotSeq++, 2, '0', STR_PAD_LEFT);
$orderNo = 'PO-'.$dateShort.'-'.str_pad($orderSeq++, 3, '0', STR_PAD_LEFT);
$receiving = Receiving::withoutGlobalScopes()->create([
'tenant_id' => $tenantId,
'receiving_number' => $receivingNumber,
'order_no' => $orderNo,
'order_date' => $date,
'item_id' => $item->id,
'item_code' => $item->code,
'item_name' => $item->name,
'supplier' => $supplierName,
'order_qty' => $qty,
'order_unit' => $item->unit ?: 'EA',
'due_date' => $date,
'receiving_qty' => $qty,
'receiving_date' => $date,
'lot_no' => $lotNo,
'supplier_lot' => 'SUP-'.rand(1000, 9999),
'receiving_location' => $location,
'receiving_manager' => '관리자',
'status' => 'completed',
'remark' => '테스트 데이터 (서비스 준비)',
'options' => [
'manufacturer' => $manufacturer,
'inspection_status' => '적',
'inspection_date' => $date,
'inspection_result' => '합격',
],
'created_by' => self::USER_ID,
'updated_by' => self::USER_ID,
]);
$created['receiving']++;
// Step 3: Stock 생성/갱신 (item_id 또는 item_code로 조회)
$stockType = self::ITEM_TYPE_MAP[$item->item_type] ?? 'raw_material';
$stock = Stock::withoutGlobalScopes()
->where('tenant_id', $tenantId)
->where(function ($q) use ($item) {
$q->where('item_id', $item->id)
->orWhere('item_code', $item->code);
})
->first();
if ($stock) {
$stock->stock_qty += $qty;
$stock->available_qty += $qty;
$stock->lot_count += 1;
$stock->last_receipt_date = $date;
$stock->status = 'normal';
$stock->updated_by = self::USER_ID;
$stock->save();
} else {
$stock = Stock::withoutGlobalScopes()->create([
'tenant_id' => $tenantId,
'item_id' => $item->id,
'item_code' => $item->code,
'item_name' => $item->name,
'item_type' => $stockType,
'unit' => $item->unit ?: 'EA',
'stock_qty' => $qty,
'safety_stock' => 10,
'reserved_qty' => 0,
'available_qty' => $qty,
'lot_count' => 1,
'oldest_lot_date' => $date,
'location' => $location,
'status' => 'normal',
'last_receipt_date' => $date,
'created_by' => self::USER_ID,
]);
$created['stock']++;
}
// Step 4: StockLot 생성
$nextFifo = StockLot::withoutGlobalScopes()
->where('stock_id', $stock->id)
->max('fifo_order') ?? 0;
StockLot::withoutGlobalScopes()->create([
'tenant_id' => $tenantId,
'stock_id' => $stock->id,
'lot_no' => $lotNo,
'fifo_order' => $nextFifo + 1,
'receipt_date' => $date,
'qty' => $qty,
'reserved_qty' => 0,
'available_qty' => $qty,
'unit' => $item->unit ?: 'EA',
'supplier' => $supplierName,
'supplier_lot' => $receiving->supplier_lot,
'po_number' => $orderNo,
'location' => $location,
'status' => 'available',
'receiving_id' => $receiving->id,
'created_by' => self::USER_ID,
]);
$created['stock_lot']++;
// Step 5: IQC 수입검사 생성
$inspectionNo = 'IQC-'.$dateShort.'-'.str_pad($inspectionSeq++, 4, '0', STR_PAD_LEFT);
Inspection::withoutGlobalScopes()->create([
'tenant_id' => $tenantId,
'inspection_no' => $inspectionNo,
'inspection_type' => 'IQC',
'status' => 'completed',
'result' => 'pass',
'request_date' => $date,
'inspection_date' => $date,
'item_id' => $item->id,
'lot_no' => $lotNo,
'meta' => [
'quantity' => $qty,
'unit' => $item->unit ?: 'EA',
'supplier_name' => $supplierName,
'manufacturer_name' => $manufacturer,
'item_code' => $item->code,
'item_name' => $item->name,
],
'items' => $this->buildInspectionItems($item->name),
'extra' => [
'remarks' => '입고 시 수입검사 합격',
'opinion' => '양호',
],
'created_by' => self::USER_ID,
'updated_by' => self::USER_ID,
]);
$created['inspection']++;
$this->line(" [{$item->code}] {$item->name} → 수량:{$qty} | {$receivingNumber} | {$lotNo} | {$supplierName}");
}
});
$this->newLine();
$this->info('=== 생성 완료 ===');
$this->table(
['항목', '건수'],
[
['Receiving (입고)', $created['receiving']],
['Stock (재고, 신규)', $created['stock']],
['StockLot (재고LOT)', $created['stock_lot']],
['Inspection (수입검사)', $created['inspection']],
]
);
return self::SUCCESS;
}
/**
* 품목명에 따른 공급업체/제조사 반환
*
* @return array [업체명, 제조사]
*/
private function getSupplierForItem(string $itemName): array
{
$suppliers = self::STEEL_SUPPLIERS;
// SUS 품목 → 현대제철 우선
if (str_contains($itemName, 'SUS')) {
$pool = [
['현대제철(주)', '현대제철'],
['동국제강(주)', '동국제강'],
['(주)세아제강', '세아제강'],
];
return $pool[array_rand($pool)];
}
// EGI 품목 → 포스코 우선
if (str_contains($itemName, 'EGI')) {
$pool = [
['(주)포스코', '포스코'],
['한국철강(주)', '한국철강'],
['동국제강(주)', '동국제강'],
];
return $pool[array_rand($pool)];
}
// 원단류
$fabricPool = array_map(
fn ($name, $mfr) => [$name, $mfr],
array_keys(self::FABRIC_SUPPLIERS),
array_values(self::FABRIC_SUPPLIERS)
);
return $fabricPool[array_rand($fabricPool)];
}
/**
* 품목에 맞는 검사항목 생성
*/
private function buildInspectionItems(string $itemName): array
{
$items = [
[
'name' => '외관검사',
'type' => 'visual',
'spec' => '표면 흠집, 녹, 이물질 없음',
'unit' => '-',
'result' => '합격',
'measured_value' => '양호',
'judgment' => 'pass',
],
];
// 철판류 (SUS/EGI) 전용 검사항목
if (str_contains($itemName, 'SUS') || str_contains($itemName, 'EGI')) {
// 두께 추출
$thickness = $this->extractThickness($itemName);
$items[] = [
'name' => '두께검사',
'type' => 'measurement',
'spec' => $thickness ? "{$thickness}mm ±0.05" : '규격 ±0.05mm',
'unit' => 'mm',
'result' => '적합',
'measured_value' => $thickness ?: '-',
'judgment' => 'pass',
];
$items[] = [
'name' => '치수검사',
'type' => 'measurement',
'spec' => '가로/세로 규격 ±1mm',
'unit' => 'mm',
'result' => '적합',
'measured_value' => '규격 이내',
'judgment' => 'pass',
];
$items[] = [
'name' => '재질확인',
'type' => 'certificate',
'spec' => str_contains($itemName, 'SUS') ? 'STS304' : 'SECC',
'unit' => '-',
'result' => '적합',
'measured_value' => str_contains($itemName, 'SUS') ? 'STS304' : 'SECC',
'judgment' => 'pass',
];
} else {
// 원단류 기본 검사
$items[] = [
'name' => '규격검사',
'type' => 'measurement',
'spec' => '제품규격 확인',
'unit' => '-',
'result' => '적합',
'measured_value' => '규격 이내',
'judgment' => 'pass',
];
}
return $items;
}
/**
* 품목명에서 두께(mm) 추출
*/
private function extractThickness(string $itemName): ?string
{
// "EGI1.2*..." → 1.2, "SUS1.5*..." → 1.5, "EGI1.6T" → 1.6
if (preg_match('/(SUS|EGI)\s*(\d+\.?\d*)/i', $itemName, $m)) {
return $m[2];
}
return null;
}
/**
* 다음 입고번호 시퀀스 조회
*/
private function getNextReceivingSeq(int $tenantId, string $datePrefix): int
{
$prefix = 'RV'.$datePrefix;
$last = Receiving::withoutGlobalScopes()
->where('tenant_id', $tenantId)
->where('receiving_number', 'like', $prefix.'%')
->orderBy('receiving_number', 'desc')
->first(['receiving_number']);
if ($last) {
return ((int) substr($last->receiving_number, -4)) + 1;
}
return 1;
}
/**
* 다음 LOT번호 시퀀스 조회
*/
private function getNextLotSeq(int $tenantId, string $dateShort): int
{
$last = Receiving::withoutGlobalScopes()
->where('tenant_id', $tenantId)
->where('lot_no', 'like', $dateShort.'-%')
->orderBy('lot_no', 'desc')
->first(['lot_no']);
if ($last) {
return ((int) substr($last->lot_no, -2)) + 1;
}
return 1;
}
/**
* 다음 수입검사번호 시퀀스 조회
*/
private function getNextInspectionSeq(int $tenantId, string $dateShort): int
{
$prefix = 'IQC-'.$dateShort.'-';
$last = Inspection::withoutGlobalScopes()
->where('tenant_id', $tenantId)
->where('inspection_no', 'like', $prefix.'%')
->orderBy('inspection_no', 'desc')
->first(['inspection_no']);
if ($last) {
return ((int) substr($last->inspection_no, -4)) + 1;
}
return 1;
}
}

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

@@ -1,31 +0,0 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Services\AccountLedgerService;
use Illuminate\Http\Request;
class AccountLedgerController extends Controller
{
public function __construct(
private readonly AccountLedgerService $service
) {}
/**
* 계정별원장 조회
*/
public function index(Request $request)
{
$request->validate([
'start_date' => 'required|date',
'end_date' => 'required|date|after_or_equal:start_date',
'account_code' => 'required|string|max:10',
]);
return ApiResponse::handle(function () use ($request) {
return $this->service->index($request->only(['start_date', 'end_date', 'account_code']));
}, __('message.fetched'));
}
}

View File

@@ -5,7 +5,6 @@
use App\Helpers\ApiResponse; use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\V1\AccountSubject\StoreAccountSubjectRequest; use App\Http\Requests\V1\AccountSubject\StoreAccountSubjectRequest;
use App\Http\Requests\V1\AccountSubject\UpdateAccountSubjectRequest;
use App\Services\AccountCodeService; use App\Services\AccountCodeService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -20,10 +19,7 @@ public function __construct(
*/ */
public function index(Request $request) public function index(Request $request)
{ {
$params = $request->only([ $params = $request->only(['search', 'category']);
'search', 'category', 'sub_category',
'department_type', 'depth', 'is_active', 'selectable',
]);
$subjects = $this->service->index($params); $subjects = $this->service->index($params);
@@ -40,16 +36,6 @@ public function store(StoreAccountSubjectRequest $request)
return ApiResponse::success($subject, __('message.created'), [], 201); return ApiResponse::success($subject, __('message.created'), [], 201);
} }
/**
* 계정과목 수정
*/
public function update(int $id, UpdateAccountSubjectRequest $request)
{
$subject = $this->service->update($id, $request->validated());
return ApiResponse::success($subject, __('message.updated'));
}
/** /**
* 계정과목 활성/비활성 토글 * 계정과목 활성/비활성 토글
*/ */
@@ -71,17 +57,4 @@ public function destroy(int $id)
return ApiResponse::success(null, __('message.deleted')); return ApiResponse::success(null, __('message.deleted'));
} }
/**
* 기본 계정과목표 일괄 생성 (더존 표준)
*/
public function seedDefaults()
{
$count = $this->service->seedDefaults();
return ApiResponse::success(
['inserted_count' => $count],
__('message.created')
);
}
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,18 +4,13 @@
use App\Helpers\ApiResponse; use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller; 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 App\Services\BarobillService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class BarobillController extends Controller class BarobillController extends Controller
{ {
public function __construct( public function __construct(
private BarobillService $barobillService, private BarobillService $barobillService
private BarobillSoapService $soapService,
) {} ) {}
/** /**
@@ -24,43 +19,17 @@ public function __construct(
public function status() public function status()
{ {
return ApiResponse::handle(function () { return ApiResponse::handle(function () {
$tenantId = app('tenant_id');
$setting = $this->barobillService->getSetting(); $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 [ return [
'bank_service_count' => $accountCount, 'bank_service_count' => 0,
'account_link_count' => $accountCount, 'account_link_count' => 0,
'card_count' => $cardCount, 'member' => $setting ? [
'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, 'barobill_id' => $setting->barobill_id,
'biz_no' => $setting->corp_num, 'biz_no' => $setting->corp_num,
'status' => $setting->isVerified() ? 'active' : 'inactive', 'status' => $setting->isVerified() ? 'active' : 'inactive',
'server_mode' => $this->barobillService->isTestMode() ? 'test' : 'production', 'server_mode' => config('services.barobill.test_mode', true) ? 'test' : 'production',
] : null), ] : null,
]; ];
}, __('message.fetched')); }, __('message.fetched'));
} }
@@ -117,21 +86,17 @@ public function signup(Request $request)
}, __('message.saved')); }, __('message.saved'));
} }
/**
* 바로빌 서비스 URL 조회 (공통)
*/
private function getServiceUrl(string $path): array
{
return ['url' => $this->barobillService->getBaseUrl().$path];
}
/** /**
* 은행 빠른조회 서비스 URL 조회 * 은행 빠른조회 서비스 URL 조회
*/ */
public function bankServiceUrl() public function bankServiceUrl(Request $request)
{ {
return ApiResponse::handle(function () { return ApiResponse::handle(function () {
return $this->getServiceUrl('/BANKACCOUNT.asmx'); $baseUrl = config('services.barobill.test_mode', true)
? 'https://testws.barobill.co.kr'
: 'https://ws.barobill.co.kr';
return ['url' => $baseUrl.'/Bank/BankAccountService'];
}, __('message.fetched')); }, __('message.fetched'));
} }
@@ -141,7 +106,11 @@ public function bankServiceUrl()
public function accountLinkUrl() public function accountLinkUrl()
{ {
return ApiResponse::handle(function () { return ApiResponse::handle(function () {
return $this->getServiceUrl('/BANKACCOUNT.asmx'); $baseUrl = config('services.barobill.test_mode', true)
? 'https://testws.barobill.co.kr'
: 'https://ws.barobill.co.kr';
return ['url' => $baseUrl.'/Bank/AccountLink'];
}, __('message.fetched')); }, __('message.fetched'));
} }
@@ -151,7 +120,11 @@ public function accountLinkUrl()
public function cardLinkUrl() public function cardLinkUrl()
{ {
return ApiResponse::handle(function () { return ApiResponse::handle(function () {
return $this->getServiceUrl('/CARD.asmx'); $baseUrl = config('services.barobill.test_mode', true)
? 'https://testws.barobill.co.kr'
: 'https://ws.barobill.co.kr';
return ['url' => $baseUrl.'/Card/CardLink'];
}, __('message.fetched')); }, __('message.fetched'));
} }
@@ -161,7 +134,11 @@ public function cardLinkUrl()
public function certificateUrl() public function certificateUrl()
{ {
return ApiResponse::handle(function () { return ApiResponse::handle(function () {
return $this->getServiceUrl('/CORPSTATE.asmx'); $baseUrl = config('services.barobill.test_mode', true)
? 'https://testws.barobill.co.kr'
: 'https://ws.barobill.co.kr';
return ['url' => $baseUrl.'/Certificate/Register'];
}, __('message.fetched')); }, __('message.fetched'));
} }
} }

View File

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

View File

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

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

View File

@@ -1,118 +0,0 @@
<?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 prefixes(Request $request): JsonResponse
{
$this->ensureContext($request);
return ApiResponse::handle(
fn () => [
'prefixes' => $this->service->prefixes(),
'labels' => BendingItemService::PREFIX_LABELS,
],
__('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 duplicate(Request $request, int $id): JsonResponse
{
$this->ensureContext($request);
return ApiResponse::handle(
fn () => new BendingItemResource($this->service->duplicate($id)),
__('message.created')
);
}
public function destroy(Request $request, int $id): JsonResponse
{
$this->ensureContext($request);
return ApiResponse::handle(
fn () => $this->service->delete($id),
__('message.deleted')
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -1,46 +0,0 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Services\IncomeStatementService;
use Illuminate\Http\Request;
class IncomeStatementController extends Controller
{
public function __construct(
private readonly IncomeStatementService $service
) {}
/**
* 손익계산서 조회
*/
public function index(Request $request)
{
$request->validate([
'start_date' => 'required|date',
'end_date' => 'required|date|after_or_equal:start_date',
'unit' => 'nullable|in:won,thousand,million',
]);
return ApiResponse::handle(function () use ($request) {
return $this->service->data($request->only(['start_date', 'end_date', 'unit']));
}, __('message.fetched'));
}
/**
* 손익계산서 월별 조회
*/
public function monthly(Request $request)
{
$request->validate([
'year' => 'required|integer|min:2020|max:2100',
'unit' => 'nullable|in:won,thousand,million',
]);
return ApiResponse::handle(function () use ($request) {
return $this->service->monthly($request->only(['year', 'unit']));
}, __('message.fetched'));
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,82 +0,0 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\OrgChart\AssignRequest;
use App\Http\Requests\OrgChart\ReorderDepartmentsRequest;
use App\Http\Requests\OrgChart\ReorderEmployeesRequest;
use App\Http\Requests\OrgChart\ToggleHideRequest;
use App\Http\Requests\OrgChart\UnassignRequest;
use App\Services\OrgChartService;
use Illuminate\Http\Request;
class OrgChartController extends Controller
{
public function __construct(private OrgChartService $service) {}
// GET /v1/org-chart
public function index(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->getOrgChart($request->all());
}, __('message.fetched'));
}
// GET /v1/org-chart/stats
public function stats()
{
return ApiResponse::handle(function () {
return $this->service->getStats();
}, __('message.fetched'));
}
// GET /v1/org-chart/unassigned
public function unassigned(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->getUnassigned($request->all());
}, __('message.fetched'));
}
// POST /v1/org-chart/assign
public function assign(AssignRequest $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->assign($request->validated());
}, __('message.updated'));
}
// POST /v1/org-chart/unassign
public function unassign(UnassignRequest $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->unassign($request->validated());
}, __('message.updated'));
}
// PUT /v1/org-chart/reorder-employees
public function reorderEmployees(ReorderEmployeesRequest $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->reorderEmployees($request->validated());
}, __('message.reordered'));
}
// PUT /v1/org-chart/reorder-departments
public function reorderDepartments(ReorderDepartmentsRequest $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->reorderDepartments($request->validated());
}, __('message.reordered'));
}
// PATCH /v1/org-chart/departments/{id}/toggle-hide
public function toggleHide(int $id, ToggleHideRequest $request)
{
return ApiResponse::handle(function () use ($id, $request) {
return $this->service->toggleHide($id, $request->validated());
}, __('message.updated'));
}
}

View File

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

View File

@@ -56,12 +56,4 @@ public function missing(Request $request)
return $this->service->missing($request->all()); return $this->service->missing($request->all());
}, __('message.fetched')); }, __('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

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

View File

@@ -124,24 +124,4 @@ public function resultDocument(int $id)
return $this->service->resultDocument($id); return $this->service->resultDocument($id);
}, __('message.fetched')); }, __('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

@@ -7,14 +7,8 @@
use App\Http\Requests\V1\Receiving\ProcessReceivingRequest; use App\Http\Requests\V1\Receiving\ProcessReceivingRequest;
use App\Http\Requests\V1\Receiving\StoreReceivingRequest; use App\Http\Requests\V1\Receiving\StoreReceivingRequest;
use App\Http\Requests\V1\Receiving\UpdateReceivingRequest; use App\Http\Requests\V1\Receiving\UpdateReceivingRequest;
use App\Models\Items\Item;
use App\Models\Qualitys\Inspection;
use App\Models\Tenants\Receiving;
use App\Models\Tenants\Stock;
use App\Models\Tenants\StockLot;
use App\Services\ReceivingService; use App\Services\ReceivingService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class ReceivingController extends Controller class ReceivingController extends Controller
{ {
@@ -102,264 +96,4 @@ public function process(int $id, ProcessReceivingRequest $request)
return ApiResponse::success($receiving, __('message.receiving.processed')); return ApiResponse::success($receiving, __('message.receiving.processed'));
} }
/**
* [개발전용] 입고 강제 생성 (원자재 입고 + 재공품 재고 + 수입검사 한번에)
*
* POST /api/v1/dev/force-receiving
* Body: { item_id: number, qty?: number }
*
* PT(재공품) 품목이면 → material 속성에서 대응 RM(원자재) 찾아 입고 생성
* + 원래 PT 품목의 재고도 함께 생성 (자재투입 매칭용)
*/
public function forceCreate(Request $request)
{
$request->validate([
'item_id' => 'required|integer',
'qty' => 'nullable|integer|min:1|max:10000',
]);
$tenantId = app('tenant_id');
$itemId = $request->input('item_id');
$qty = $request->input('qty', 100);
$userId = auth()->id() ?? 33;
$date = now()->toDateString();
$item = Item::withoutGlobalScopes()
->where('tenant_id', $tenantId)
->where('id', $itemId)
->first();
if (! $item) {
return ApiResponse::error(__('error.not_found'), 404);
}
$result = DB::transaction(function () use ($item, $tenantId, $qty, $userId, $date) {
$datePrefix = date('Ymd', strtotime($date));
$dateShort = date('ymd', strtotime($date));
// 채번
$receivingSeq = (Receiving::withoutGlobalScopes()->where('tenant_id', $tenantId)
->where('receiving_number', 'LIKE', "RV{$datePrefix}%")->count()) + 1;
$lotSeq = (StockLot::withoutGlobalScopes()->where('tenant_id', $tenantId)
->where('lot_no', 'LIKE', "{$dateShort}-%")->count()) + 1;
$inspSeq = (Inspection::withoutGlobalScopes()->where('tenant_id', $tenantId)
->where('inspection_no', 'LIKE', "IQC-{$dateShort}-%")->count()) + 1;
$receivingNumber = 'RV'.$datePrefix.str_pad($receivingSeq, 4, '0', STR_PAD_LEFT);
$lotNo = $dateShort.'-'.str_pad($lotSeq, 2, '0', STR_PAD_LEFT);
$orderNo = 'PO-'.$dateShort.'-DEV'.str_pad(rand(1, 999), 3, '0', STR_PAD_LEFT);
// PT(재공품)이면 material 속성에서 RM(원자재) 찾기
$rmItem = null;
$receivingItem = $item; // 기본: 원본 품목으로 입고
$itemOptions = is_array($item->options) ? $item->options : (json_decode($item->options ?? '{}', true) ?: []);
$materialSpec = $itemOptions['material'] ?? null;
if ($item->item_type === 'PT' && $materialSpec) {
// "EGI 1.55T" → type=EGI, thickness=1.55
// "SUS 1.2T" → type=SUS, thickness=1.2
$cleanMat = preg_replace('/T$/i', '', trim($materialSpec));
if (preg_match('/^([A-Za-z]+)\s*([\d.]+)/', $cleanMat, $m)) {
$matType = $m[1];
$thickness = (float) $m[2];
// 품목명에서 길이 추출: "... 2438mm" → 2438
$length = null;
if (preg_match('/(\d{3,5})\s*mm/i', $item->name, $lm)) {
$length = (int) $lm[1];
}
// RM 품목 검색: 재질+두께 일치, 길이 일치 (있으면)
$rmQuery = Item::withoutGlobalScopes()
->where('tenant_id', $tenantId)
->where('item_type', 'RM')
->whereNull('deleted_at')
->whereRaw("JSON_UNQUOTE(JSON_EXTRACT(options, '$.attributes.material_type')) = ?", [$matType]);
// 두께 정확 매칭 시도 → 근사 매칭 폴백
$rmExact = (clone $rmQuery)
->whereRaw("CAST(JSON_UNQUOTE(JSON_EXTRACT(options, '$.attributes.thickness')) AS DECIMAL(5,2)) = ?", [$thickness]);
if ($length) {
$rmExact = $rmExact->whereRaw("CAST(JSON_UNQUOTE(JSON_EXTRACT(options, '$.attributes.length')) AS UNSIGNED) = ?", [$length]);
}
$rmItem = $rmExact->first();
// 두께 근사 매칭 (±0.1)
if (! $rmItem) {
$rmApprox = (clone $rmQuery)
->whereRaw("ABS(CAST(JSON_UNQUOTE(JSON_EXTRACT(options, '$.attributes.thickness')) AS DECIMAL(5,2)) - ?) <= 0.1", [$thickness]);
if ($length) {
$rmApprox = $rmApprox->whereRaw("CAST(JSON_UNQUOTE(JSON_EXTRACT(options, '$.attributes.length')) AS UNSIGNED) = ?", [$length]);
}
$rmItem = $rmApprox->first();
}
// 길이 무관 매칭 (두께만)
if (! $rmItem) {
$rmItem = (clone $rmQuery)
->whereRaw("ABS(CAST(JSON_UNQUOTE(JSON_EXTRACT(options, '$.attributes.thickness')) AS DECIMAL(5,2)) - ?) <= 0.1", [$thickness])
->first();
}
if ($rmItem) {
$receivingItem = $rmItem;
}
}
}
// 공급업체 결정
$supplier = str_contains($receivingItem->name, 'SUS') ? '현대제철(주)' : '(주)포스코';
// 1. Receiving 생성 (RM 원자재 기준)
$receiving = Receiving::withoutGlobalScopes()->create([
'tenant_id' => $tenantId,
'receiving_number' => $receivingNumber,
'order_no' => $orderNo,
'order_date' => $date,
'item_id' => $receivingItem->id,
'item_code' => $receivingItem->code,
'item_name' => $receivingItem->name,
'supplier' => $supplier,
'order_qty' => $qty,
'order_unit' => $receivingItem->unit ?: 'EA',
'due_date' => $date,
'receiving_qty' => $qty,
'receiving_date' => $date,
'lot_no' => $lotNo,
'supplier_lot' => 'SUP-'.rand(1000, 9999),
'receiving_location' => 'A-01-01',
'receiving_manager' => '관리자',
'status' => 'inspection_completed',
'remark' => '[개발전용] 강제 생성 입고'.($rmItem ? ' (원자재 '.$rmItem->code.' → 재공품 '.$item->code.')' : ''),
'options' => [
'manufacturer' => str_replace('(주)', '', $supplier),
'inspection_status' => '적',
'inspection_date' => $date,
'inspection_result' => '합격',
'force_created' => true,
'original_pt_item_id' => $item->id,
'original_pt_item_code' => $item->code,
],
'created_by' => $userId,
'updated_by' => $userId,
]);
// 2-A. 원자재(RM) Stock/StockLot 생성 (입고 이력용)
if ($rmItem && $rmItem->id !== $item->id) {
$this->createStockAndLot($rmItem, $tenantId, $qty, $userId, $date, $lotNo, $receiving, $orderNo, $supplier);
}
// 2-B. 재공품(PT) Stock/StockLot 생성 (자재투입 매칭용)
$ptStock = $this->createStockAndLot($item, $tenantId, $qty, $userId, $date, $lotNo, $receiving, $orderNo, $supplier);
// 3. IQC 수입검사 생성 (합격)
$inspectionNo = 'IQC-'.$dateShort.'-'.str_pad($inspSeq, 4, '0', STR_PAD_LEFT);
Inspection::withoutGlobalScopes()->create([
'tenant_id' => $tenantId,
'inspection_no' => $inspectionNo,
'inspection_type' => 'IQC',
'status' => 'completed',
'result' => 'pass',
'request_date' => $date,
'inspection_date' => $date,
'item_id' => $receivingItem->id,
'lot_no' => $lotNo,
'meta' => [
'quantity' => $qty,
'unit' => $receivingItem->unit ?: 'EA',
'supplier_name' => $supplier,
'item_code' => $receivingItem->code,
'item_name' => $receivingItem->name,
'force_created' => true,
],
'items' => [
['item' => '외관검사', 'standard' => '이상 없을 것', 'result' => '양호', 'judgment' => '적'],
['item' => '치수검사', 'standard' => '규격 일치', 'result' => '양호', 'judgment' => '적'],
],
'extra' => ['remarks' => '[개발전용] 자동 합격', 'opinion' => '양호'],
'created_by' => $userId,
'updated_by' => $userId,
]);
return [
'receiving_number' => $receivingNumber,
'lot_no' => $lotNo,
'rm_item_code' => $receivingItem->code,
'rm_item_name' => $receivingItem->name,
'pt_item_code' => $item->code,
'pt_item_name' => $item->name,
'qty' => $qty,
'available_qty' => $ptStock->available_qty,
'matched_rm' => $rmItem ? true : false,
];
});
return ApiResponse::success($result, '입고 데이터가 강제 생성되었습니다.');
}
/**
* Stock + StockLot 생성/갱신 헬퍼
*/
private function createStockAndLot(Item $item, int $tenantId, int $qty, int $userId, string $date, string $lotNo, Receiving $receiving, string $orderNo, string $supplier): Stock
{
// items.item_type 코드(FG/PT/SM/RM/CS)를 그대로 사용 (common_codes 매핑과 일치)
$stockType = $item->item_type ?: 'RM';
$stock = Stock::withoutGlobalScopes()
->where('tenant_id', $tenantId)
->where('item_id', $item->id)
->first();
if ($stock) {
$stock->stock_qty += $qty;
$stock->available_qty += $qty;
$stock->lot_count += 1;
$stock->last_receipt_date = $date;
$stock->status = 'normal';
$stock->updated_by = $userId;
$stock->save();
} else {
$stock = Stock::withoutGlobalScopes()->create([
'tenant_id' => $tenantId,
'item_id' => $item->id,
'item_code' => $item->code,
'item_name' => $item->name,
'item_type' => $stockType,
'unit' => $item->unit ?: 'EA',
'stock_qty' => $qty,
'safety_stock' => 10,
'reserved_qty' => 0,
'available_qty' => $qty,
'lot_count' => 1,
'oldest_lot_date' => $date,
'location' => 'A-01-01',
'status' => 'normal',
'last_receipt_date' => $date,
'created_by' => $userId,
]);
}
$nextFifo = (StockLot::withoutGlobalScopes()->where('stock_id', $stock->id)->max('fifo_order') ?? 0) + 1;
StockLot::withoutGlobalScopes()->create([
'tenant_id' => $tenantId,
'stock_id' => $stock->id,
'lot_no' => $lotNo,
'fifo_order' => $nextFifo,
'receipt_date' => $date,
'qty' => $qty,
'reserved_qty' => 0,
'available_qty' => $qty,
'unit' => $item->unit ?: 'EA',
'supplier' => $supplier,
'supplier_lot' => $receiving->supplier_lot,
'po_number' => $orderNo,
'location' => 'A-01-01',
'status' => 'available',
'receiving_id' => $receiving->id,
'created_by' => $userId,
]);
return $stock;
}
} }

View File

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

View File

@@ -4,11 +4,9 @@
use App\Helpers\ApiResponse; use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\V1\Stock\StoreStockAdjustmentRequest;
use App\Services\StockService; use App\Services\StockService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class StockController extends Controller class StockController extends Controller
{ {
@@ -73,149 +71,4 @@ public function statsByItemType(): JsonResponse
return ApiResponse::success($stats, __('message.fetched')); return ApiResponse::success($stats, __('message.fetched'));
} }
/**
* 재고 수정 (안전재고, 최대재고, 사용상태)
*/
public function update(int $id, Request $request): JsonResponse
{
try {
$data = $request->validate([
'safety_stock' => 'nullable|numeric|min:0',
'max_stock' => 'nullable|numeric|min:0',
'is_active' => 'nullable|boolean',
]);
// 최대재고가 설정된 경우 안전재고 이상이어야 함
if (isset($data['max_stock']) && $data['max_stock'] > 0
&& isset($data['safety_stock']) && $data['safety_stock'] > $data['max_stock']) {
return ApiResponse::error('최대재고는 안전재고 이상이어야 합니다.', 422);
}
$stock = $this->service->updateStock($id, $data);
return ApiResponse::success($stock, __('message.updated'));
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
return ApiResponse::error(__('error.stock.not_found'), 404);
}
}
/**
* 재고 조정 이력 조회
*/
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);
}
}
/**
* 재고 거래이력 (사용현황)
* GET /api/v1/stocks/{id}/transactions
*/
public function transactions(int $id): JsonResponse
{
$tenantId = app('tenant_id');
$stock = DB::table('stocks')
->where('tenant_id', $tenantId)
->where('item_id', $id)
->first();
if (! $stock) {
// item_id로 직접 검색
$stock = DB::table('stocks')
->where('tenant_id', $tenantId)
->where('id', $id)
->first();
}
$stockId = $stock?->id;
$itemCode = $stock?->item_code;
$transactions = DB::table('stock_transactions')
->where('tenant_id', $tenantId)
->where(function ($q) use ($stockId, $itemCode) {
if ($stockId) {
$q->where('stock_id', $stockId);
}
if ($itemCode) {
$q->orWhere('item_code', $itemCode);
}
})
->orderByDesc('created_at')
->limit(100)
->get([
'id', 'type', 'qty', 'balance_qty', 'reference_type', 'reference_id',
'lot_no', 'reason', 'remark', 'item_code', 'item_name', 'created_by', 'created_at',
]);
// 참조 정보 보강 (work_order 번호 등)
$woIds = $transactions->where('reference_type', 'work_order_input')
->pluck('reference_id')->unique()->values();
$woMap = [];
if ($woIds->isNotEmpty()) {
$woMap = DB::table('work_orders')
->whereIn('id', $woIds)
->pluck('work_order_no', 'id')
->toArray();
}
$data = $transactions->map(function ($tx) use ($woMap) {
$refLabel = match ($tx->reference_type) {
'work_order_input' => '자재투입',
'work_order_input_cancel' => '자재투입 취소',
'work_order_input_replace' => '자재투입 교체',
'receiving' => '입고',
'adjustment' => '재고조정',
'shipment' => '출하',
default => $tx->reference_type ?? '-',
};
$refNo = $woMap[$tx->reference_id] ?? null;
return [
'id' => $tx->id,
'type' => $tx->type,
'type_label' => $refLabel,
'qty' => (float) $tx->qty,
'balance_qty' => (float) $tx->balance_qty,
'reference_type' => $tx->reference_type,
'reference_id' => $tx->reference_id,
'reference_no' => $refNo,
'lot_no' => $tx->lot_no,
'reason' => $tx->reason,
'remark' => $tx->remark,
'item_code' => $tx->item_code,
'item_name' => $tx->item_name,
'created_at' => $tx->created_at,
];
});
return ApiResponse::success([
'item_code' => $stock?->item_code,
'item_name' => $stock?->item_name,
'current_qty' => (float) ($stock?->stock_qty ?? 0),
'available_qty' => (float) ($stock?->available_qty ?? 0),
'transactions' => $data,
]);
}
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -95,17 +95,6 @@ public function toggleActive(int $id): JsonResponse
); );
} }
/**
* 공정 복제
*/
public function duplicate(int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->processService->duplicate($id),
'message.created'
);
}
/** /**
* 공정 옵션 목록 (드롭다운용) * 공정 옵션 목록 (드롭다운용)
*/ */

View File

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

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

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

@@ -16,20 +16,18 @@ public function handle(Request $request, Closure $next)
{ {
// 화이트리스트(인증 예외 라우트) - API Key 검증 제외 // 화이트리스트(인증 예외 라우트) - API Key 검증 제외
$publicRoutes = [ $publicRoutes = [
'/', // Root (Swagger redirect) '/', // Root (Swagger redirect)
'api/v1/signup', 'api/v1/signup',
'api/v1/register', 'api/v1/register',
'api/v1/refresh', 'api/v1/refresh',
'api/v1/debug-apikey', 'api/v1/debug-apikey',
'api/v1/internal/*', // 내부 서버간 통신 (HMAC 인증 사용) 'api/v1/internal/*', // 내부 서버간 통신 (HMAC 인증 사용)
'api-docs', // Swagger UI (정적) 'api-docs', // Swagger UI
'api-docs/*', // Swagger 하위 경로 'api-docs/*', // Swagger 하위 경로
'docs', // L5-Swagger UI 'docs', // L5-Swagger UI
'docs/*', // L5-Swagger 하위 경로 (에셋 등) 'docs/*', // L5-Swagger 하위 경로 (에셋 등)
'docs/api-docs.json', // Swagger JSON (기본) 'docs/api-docs.json', // Swagger JSON
'docs/api-docs-v1.json', // Swagger JSON (v1) 'up', // Health check
'api/documentation/*', // L5-Swagger v1 문서
'up', // Health check
]; ];
$currentRoute = $request->route()?->uri() ?? $request->path(); $currentRoute = $request->route()?->uri() ?? $request->path();
@@ -91,7 +89,6 @@ public function handle(Request $request, Closure $next)
// Bearer 인증 (Sanctum) // Bearer 인증 (Sanctum)
$user = []; $user = [];
$accessToken = null;
if ($token = $request->bearerToken()) { if ($token = $request->bearerToken()) {
$accessToken = PersonalAccessToken::findToken($token); $accessToken = PersonalAccessToken::findToken($token);
if ($accessToken && $accessToken->tokenable instanceof User) { if ($accessToken && $accessToken->tokenable instanceof User) {
@@ -117,25 +114,9 @@ public function handle(Request $request, Closure $next)
} }
} }
// MNG 내부 통신: X-TENANT-ID 헤더로 테넌트 컨텍스트 설정
$headerTenantId = $request->header('X-TENANT-ID');
if ($headerTenantId && $validApiKey) {
if ($accessToken && $accessToken->name === 'mng_session') {
// Bearer 토큰(mng_session)이 있으면 테넌트 컨텍스트 재설정
$overrideTenantId = (int) $headerTenantId;
$request->attributes->set('tenant_id', $overrideTenantId);
app()->instance('tenant_id', $overrideTenantId);
} elseif (! app()->bound('tenant_id')) {
// Bearer 토큰 없이 API Key + X-TENANT-ID만 있으면 tenant 컨텍스트만 설정
$request->attributes->set('tenant_id', (int) $headerTenantId);
app()->instance('tenant_id', (int) $headerTenantId);
}
}
// 화이트리스트(인증 예외 라우트) - Bearer 토큰 없이 접근 가능 // 화이트리스트(인증 예외 라우트) - Bearer 토큰 없이 접근 가능
$allowWithoutAuth = [ $allowWithoutAuth = [
'api/v1/login', 'api/v1/login',
'api/v1/token-login', // MNG → SAM 자동 로그인 (API Key만 필요)
'api/v1/signup', 'api/v1/signup',
'api/v1/register', 'api/v1/register',
'api/v1/refresh', 'api/v1/refresh',
@@ -143,13 +124,6 @@ public function handle(Request $request, Closure $next)
'api/v1/internal/exchange-token', // 내부 서버간 토큰 교환 (HMAC 인증 사용) 'api/v1/internal/exchange-token', // 내부 서버간 토큰 교환 (HMAC 인증 사용)
'api/v1/admin/fcm/*', // Admin FCM API (MNG에서 API Key만으로 접근) 'api/v1/admin/fcm/*', // Admin FCM API (MNG에서 API Key만으로 접근)
'api/v1/app/*', // 앱 버전 확인/다운로드 (API Key만 필요) 'api/v1/app/*', // 앱 버전 확인/다운로드 (API Key만 필요)
'api/v1/bending-items', // 절곡 기초관리 (MNG에서 API Key + X-TENANT-ID로 접근)
'api/v1/bending-items/*', // 절곡 기초관리 상세/수정/삭제
'api/v1/guiderail-models', // 절곡품 가이드레일 (MNG에서 API Key + X-TENANT-ID로 접근)
'api/v1/guiderail-models/*', // 절곡품 가이드레일 상세
'api/v1/items/*/files', // 품목 파일 (절곡품 이미지 업로드/조회)
'api/v1/files/*/presigned-url', // 파일 presigned URL (이미지 표시)
'api/v1/files/presigned-url-by-path', // 파일 경로 기반 presigned URL (문서양식 섹션 이미지)
]; ];
// 현재 라우트 확인 (경로 또는 이름) // 현재 라우트 확인 (경로 또는 이름)

View File

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

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

View File

@@ -1,49 +0,0 @@
<?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:10',
'lot_no' => [
'nullable', 'string', 'max:50',
\Illuminate\Validation\Rule::unique('bending_items', 'lot_no')->where('tenant_id', request()->header('X-TENANT-ID', app()->bound('tenant_id') ? app('tenant_id') : 1)),
],
'item_name' => 'required|string|max:50',
'item_sep' => 'required|in:스크린,철재',
'item_bending' => 'required|string|max:50',
'material' => 'required|string|max:50',
'model_UA' => 'nullable|in:인정,비인정',
'item_spec' => 'nullable|string|max:50',
'model_name' => 'nullable|string|max:30',
'search_keyword' => 'nullable|string|max:100',
'rail_width' => 'nullable|integer',
'memo' => 'nullable|string|max:500',
'author' => 'nullable|string|max:50',
'registration_date' => 'nullable|date',
// 케이스 전용
'exit_direction' => 'nullable|string|max:30',
'front_bottom_width' => 'nullable|integer',
'box_width' => 'nullable|integer',
'box_height' => 'nullable|integer',
// 전개도
'bendingData' => 'nullable|array',
'bendingData.*.no' => 'required|integer',
'bendingData.*.input' => 'required|numeric',
'bendingData.*.rate' => 'nullable|string',
'bendingData.*.sum' => 'required|numeric',
'bendingData.*.color' => 'required|boolean',
'bendingData.*.aAngle' => 'required|boolean',
];
}
}

View File

@@ -1,47 +0,0 @@
<?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:10',
'lot_no' => 'nullable|string|max:50',
'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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,53 +0,0 @@
<?php
namespace App\Http\Requests\Equipment;
use App\Models\Commons\File;
use Illuminate\Foundation\Http\FormRequest;
class StoreEquipmentPhotoRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$equipmentId = $this->route('id');
$currentCount = File::where('document_id', $equipmentId)
->where('document_type', 'equipment')
->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.equipment.photo_limit_exceeded'),
'files.*.mimes' => __('error.file.invalid_type'),
'files.*.max' => __('error.file.size_exceeded'),
];
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,9 +2,7 @@
namespace App\Http\Requests\ItemMaster; namespace App\Http\Requests\ItemMaster;
use App\Models\ItemMaster\ItemPage;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ItemPageStoreRequest extends FormRequest class ItemPageStoreRequest extends FormRequest
{ {
@@ -15,26 +13,10 @@ public function authorize(): bool
public function rules(): array public function rules(): array
{ {
$tenantId = app('tenant_id');
return [ return [
'page_name' => 'required|string|max:255', 'page_name' => 'required|string|max:255',
'item_type' => [ 'item_type' => 'required|in:FG,PT,SM,RM,CS',
'required',
'in:FG,PT,SM,RM,CS',
Rule::unique((new ItemPage)->getTable(), 'item_type')
->where('tenant_id', $tenantId)
->where('is_active', true)
->whereNull('deleted_at'),
],
'absolute_path' => 'nullable|string|max:500', 'absolute_path' => 'nullable|string|max:500',
]; ];
} }
public function messages(): array
{
return [
'item_type.unique' => __('error.item_page_duplicate_item_type'),
];
}
} }

View File

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

View File

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

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