Compare commits

...

379 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
김보곤
13ba753b7f merge: develop를 main에 머지 (Jenkinsfile 충돌 해결) 2026-02-25 15:40:33 +09:00
김보곤
433f3ee4ad Merge branch 'develop' of http://114.203.209.83:3000/SamProject/sam-api into develop 2026-02-25 14:10:53 +09:00
fcb110a7ed chore: Slack 알림에 커밋 메시지 추가
- Checkout 단계에서 GIT_COMMIT_MSG 캡처 (git log -1 --pretty=format:'%s')
- checkout scm을 slackSend 이전으로 이동 (커밋 정보 먼저 획득)
- 빌드 시작, 승인 대기, 성공, 실패 모든 Slack 메시지에 커밋 제목 포함

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 12:52:23 +09:00
bb0615693e chore: Slack 알림에 커밋 메시지 추가
- Checkout 단계에서 GIT_COMMIT_MSG 캡처 (git log -1 --pretty=format:'%s')
- checkout scm을 slackSend 이전으로 이동 (커밋 정보 먼저 획득)
- 빌드 시작, 승인 대기, 성공, 실패 모든 Slack 메시지에 커밋 제목 포함

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 12:52:17 +09:00
0802bc172e ci:동시 빌드 방지 + 운영 배포 승인 Slack 알림 (#product_deploy)
- disableConcurrentBuilds() 추가
- Production Approval 단계에 #product_deploy 채널 알림 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 11:30:57 +09:00
c4bcab07c1 ci:운영 배포 승인 대기 Slack 알림 추가 (#product_deploy)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 11:30:49 +09:00
0a461c9209 ci:Jenkinsfile 동시 빌드 방지 옵션 추가
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 11:24:39 +09:00
김보곤
3da8a16bfc feat: [business-card] ordered_by, ordered_at 컬럼 추가
- 3단계 워크플로우: 요청 → 제작의뢰 → 처리완료
2026-02-25 05:41:25 +09:00
97f61e24bc ci: Jenkinsfile slackSend 알림 복구
- Checkout: slackSend 빌드 시작 알림 추가 (tokenCredentialId)
- Post success/failure: echo → slackSend 교체 (tokenCredentialId)
- 이전 rebase 과정에서 소실된 slackSend 복구

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 23:53:48 +09:00
3f5a942939 fix: 배포 시 storage/framework 디렉터리 생성 추가
- mkdir -p storage/framework/{views,cache/data,sessions} storage/logs 추가
- .gitignore로 누락되는 Laravel 필수 디렉터리 생성
- Blade 캐시 경로 에러 해결

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 23:29:39 +09:00
e9ffd3bc38 fix: 배포 시 storage/framework 디렉터리 생성 추가
- mkdir -p storage/framework/{views,cache/data,sessions} storage/logs 추가
- .gitignore로 누락되는 Laravel 필수 디렉터리 생성
- Blade 캐시 경로 에러 해결

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 23:00:32 +09:00
6a4485134a fix: 배포 시 bootstrap/cache 디렉터리 생성 + slackSend 복구
- Stage/Production 배포에 mkdir -p bootstrap/cache 추가
- .gitignore로 누락되는 디렉터리 → composer install 시 package:discover 실패 해결
- rebase 중 사라진 slackSend 알림 복구 (Checkout, success, failure)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 22:56:34 +09:00
6f77eb564d fix: 배포 시 bootstrap/cache 디렉터리 생성 + slackSend 복구
- Stage/Production 배포에 mkdir -p bootstrap/cache 추가
- .gitignore로 누락되는 디렉터리 → composer install 시 package:discover 실패 해결
- rebase 중 사라진 slackSend 알림 복구 (Checkout, success, failure)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 22:53:38 +09:00
cb9c905698 fix: AppServiceProvider CLI 컨텍스트에서 request() 호출 에러 수정
- runningInConsole() 체크 추가로 artisan 명령어 실행 시 request 바인딩 에러 방지
- composer install 후 package:discover 실패 해결

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 22:40:35 +09:00
dc42be5256 fix: AppServiceProvider CLI 컨텍스트에서 request() 호출 에러 수정
- runningInConsole() 체크 추가로 artisan 명령어 실행 시 request 바인딩 에러 방지
- composer install 후 package:discover 실패 해결

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 22:40:24 +09:00
f67feaf82f chore: Jenkins Slack 알림 재테스트 (글로벌 설정 수정)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 22:28:12 +09:00
3f51447b22 chore:Jenkins Slack 알림 연동 테스트
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 22:28:12 +09:00
147f25ce48 fix: Checkout 단계 slackSend에 tokenCredentialId 추가
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 22:16:00 +09:00
80bd0dcb36 chore:Jenkins Slack 알림 연동 테스트
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 22:13:24 +09:00
8426bdcd73 fix:slackSend에 tokenCredentialId 추가 (credential null 에러 수정)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 21:53:48 +09:00
김보곤
eb6bd5e03e feat: [business-card] 명함신청 테이블 마이그레이션 추가
- business_card_requests 테이블 생성
- 신청자 정보 (name, phone, title, email, quantity, memo)
- 처리 상태 관리 (status, processed_by, processed_at, process_memo)
2026-02-24 21:44:59 +09:00
59f5253512 ci:Jenkinsfile 빌드 시작 Slack 알림 추가
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 21:00:05 +09:00
d1a09935e2 ci:Jenkinsfile Slack 알림 추가 (slackSend #product_infra)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 20:45:50 +09:00
8096514e93 ci:Jenkinsfile 2-branch 전략으로 전환 (stage 브랜치 제거, main에서 Stage→승인→Production)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 17:43:30 +09:00
4f4fa2dd04 refactor: stage 브랜치 제거, main에서 Stage→승인→Production 배포 흐름으로 변경
- main: Stage 자동 배포 → Jenkins 승인(24h) → Production 배포
- stage 브랜치 더 이상 사용 안함
- Production 실패 시 자동 롤백
2026-02-24 13:22:38 +09:00
0ded7897a4 refactor: stage 브랜치 제거, main에서 Stage→승인→Production 배포 흐름으로 변경
- main: Stage 자동 배포 → Jenkins 승인(24h) → Production 배포
- stage 브랜치 더 이상 사용 안함
- Production 실패 시 자동 롤백
2026-02-24 13:22:22 +09:00
b49f66ed57 ci: add Jenkinsfile for CI/CD pipeline (stage/main) 2026-02-24 08:15:05 +09:00
김보곤
03a1a0ab3d fix: [email] 이메일 발신자명을 (주)코드브릿지엑스로 변경 2026-02-24 08:08:45 +09:00
김보곤
240199af9d chore: [env] .env.example 업데이트 및 .gitignore 정리
- .env.example을 SAM 프로젝트 실제 키 구조로 업데이트
- .gitignore에 !.env.example 예외 추가
- GCS_* 중복 키 제거, Gemini/Claude/Vertex 키 섹션 추가
2026-02-23 10:17:37 +09:00
7eb5825d41 fix(WEB): 입고관리 저장 오류 3건 수정
- FormRequest에 manufacturer, material_no 규칙 추가 (validated()에서 누락 방지)
- store() 시 lot_no 자동 생성 (generateLotNo() 폴백)
- getOrCreateStock()에서 item_code 기반 2차 검색 추가 (unique key 충돌 방지)
  - 동일 item_code, 다른 item_id인 Stock 존재 시 item_id 업데이트
  - SoftDeletes 복원 로직 포함

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 04:19:48 +09:00
16b8dbcc6f fix: 절곡 자재투입 dynamic_bom 수량 보정 및 개소당 수량 산출
- getMaterialsForItem(): dynamic_bom 우선 체크 추가 (정적 BOM만 확인하던 문제)
- dynamic_bom.qty를 woItem.quantity로 나눠 개소당 수량 산출 (작업일지 bendingInfo와 일치)
- getMaterials(): 동일하게 개소당 수량으로 변환
- 응답에 lot_prefix, part_type, category 필드 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 04:19:48 +09:00
7c117bb29f chore: 작업현황 정리 및 관계 문서 갱신
- CURRENT_WORKS.md 이전 작업 이력 정리
- LOGICAL_RELATIONSHIPS.md stock_lots.workOrder 관계 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 04:19:48 +09:00
855e806e42 refactor: 절곡 재고 마이그레이션 커맨드 리팩토링 및 검증/시더 추가
- Migrate5130BendingStock: BD-* 품목 초기 재고 셋팅으로 목적 변경, --min-stock 옵션 추가
- ValidateBendingItems: BD-* 품목 존재 여부 검증 커맨드 신규
- BendingItemSeeder: 경동 절곡 품목 시딩 신규

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 04:19:47 +09:00
b00fa0502a fix: 견적→수주 변환 시 레거시 데이터 개소 분배 보완
- formula_source 없는 레거시 견적에서 sort_order 기반 개소 분배 로직 추가
- resolveLocationMapping/resolveLocationIndex 실패 시 index÷itemsPerLocation 폴백
- 기존 formula_source 매칭 로직은 그대로 유지

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 04:19:47 +09:00
5a3d6c2243 feat(WEB): 절곡 자재투입 LOT 매핑 파이프라인 구현
- PrefixResolver: 제품코드×마감재질→LOT prefix 결정 + BD-XX-NN 코드 생성
- DynamicBomEntry DTO: dynamic_bom JSON 항목 타입 안전 관리
- BendingInfoBuilder 확장: build() 리턴 변경 + buildDynamicBomForItem() 추가
- OrderService: 작업지시 생성 시 per-item dynamic_bom 자동 저장
- WorkOrderService.getMaterials(): dynamic_bom 우선 체크 + N+1 배치 최적화
- WorkOrderService.registerMaterialInput(): work_order_item_id 분기 라우팅 통일
- 단위 테스트 58개 + 통합 테스트 6개 (64 tests / 293 assertions)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 04:19:47 +09:00
9c88138de8 feat(WEB): 5130 레거시 절곡품 재고 마이그레이션 커맨드 추가
- php artisan migrate:5130-bending-stock 커맨드 생성
- 5130 lot 테이블 → SAM stocks + stock_lots 마이그레이션
- 5130 bending_work_log → SAM stock_transactions(OUT) 마이그레이션
- prod+spec+slength 3코드 → BD-{PROD}{SPEC}-{SLENGTH} 아이템 코드 매핑
- --dry-run 시뮬레이션, --rollback 롤백 지원
- 기존 BD- 아이템 item_category='BENDING' 자동 업데이트
- FIFO 기반 LOT 수량 차감 및 Stock 집계 갱신

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 04:19:47 +09:00
4f777d8cf9 feat(WEB): 절곡품 선생산→재고적재 Phase 3 - 수주 절곡 재고 확인 API
- OrderService: checkBendingStockForOrder() 메서드 추가
  - order_items에서 item_category='BENDING'인 품목 추출
  - 각 품목의 가용재고/부족수량 계산 후 반환
- OrderController: checkBendingStock() 엔드포인트 추가
- Route: GET /api/v1/orders/{id}/bending-stock

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 04:19:47 +09:00
25e21ee6d7 feat(WEB): 절곡품 선생산→재고적재 Phase 2 - 품목 카테고리 필터 추가
- StockController: item_category 파라미터 수용
- StockService: items.item_category 기반 필터링 로직 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 04:19:47 +09:00
8be54c3b8b feat(WEB): 절곡품 선생산→재고적재 Phase 1 - 생산입고 기반 구축
- StockTransaction: REASON_PRODUCTION_OUTPUT 상수 및 '생산입고' 라벨 추가
- StockLot: work_order_id FK 컬럼 마이그레이션 + 모델 fillable/casts/relation 추가
- StockService: increaseFromProduction() 메서드 구현 (increaseFromReceiving 기반)
- WorkOrderService: 완료 시 sales_order_id 유무에 따라 출하/재고입고 분기
  - stockInFromProduction(): 품목별 양품 재고 입고 처리
  - shouldStockIn(): items.options 기반 입고 대상 판단

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 04:19:47 +09:00
ba49313ffa fix(WEB): 수주 완전삭제(force) 시 생산지시완료 상태 처리 및 skip 응답 반영
- bulkDestroy force=true일 때 상태 체크 bypass, 연관 작업지시 데이터 모두 삭제
- forceDeleteWorkOrders() 헬퍼: 자재투입 재고복구, 문서, 부속데이터 정리 후 hard delete

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 04:19:47 +09:00
f8858cf1b7 fix(WEB): 수주 상태변경 API 응답에 공통 relations 로딩 적용
- loadDetailRelations() 공통 메서드 추가 (show()와 동일한 relations 보장)
- store/update/updateStatus/createFromQuote/revert 등 11곳 일괄 적용
- 수주확정/되돌리기 시 제품내용이 기타부품으로 매핑되던 문제 해결
- 원인: updateStatus 등에서 quote relation 미로딩 → products 빈 배열

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 04:19:47 +09:00
c637dd38eb docs(WEB): 수주 Swagger 문서 추가 - bulkDestroy, revertProductionOrder
- OrderBulkDeleteRequest 스키마 추가 (ids, force)
- OrderRevertProductionRequest 스키마 추가 (force, reason)
- DELETE /api/v1/orders/bulk 엔드포인트 문서 추가
- POST /api/v1/orders/{id}/revert-production 엔드포인트 문서 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 04:19:47 +09:00
37424b9cef feat(WEB): 수주 Bulk Delete API + 작업지시 Revert Force 통합
- 수주 일괄 삭제 API 추가 (DELETE /orders/bulk)
  - OrderBulkDeleteRequest (ids, force 검증)
  - force=true: hard delete (운영환경 차단), force=false: soft delete
  - 삭제 불가 건(상태/작업지시/출하) skip 처리 + skipped_ids 반환

- 작업지시 되돌리기 force/운영 모드 분기
  - force=true (개발): 기존 hard delete 로직 유지
  - force=false (운영): 작업지시 cancelled 상태 변경, options에 취소정보 기록, 자재 투입분 재고 역분개, 데이터 보존
  - reason 필수 (운영 모드)

- WorkOrder 모델에 STATUS_CANCELLED 상수 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 04:19:47 +09:00
d7ca8cfa00 refactor:견적 converted 상태를 데이터 기반(order_id)으로 변경
- Quote 모델에 getStatusAttribute() accessor 추가: order_id 존재 시 자동으로 'converted' 반환
- scopeConverted() → whereNotNull('order_id') 변경
- QuoteService/OrderService에서 status='converted' 직접 세팅 제거, order_id만 세팅
- 상태 필터 쿼리: converted는 order_id IS NOT NULL 기반
- 통계 쿼리: status='converted' → order_id IS NOT NULL
- 수주 직접 등록 시에도 자동으로 수주전환 상태 반영

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 04:19:47 +09:00
a2dbdae14b fix(WEB): 입고 등록 검증 실패 수정 - Store 규칙 보완 및 상태값 정합성
- StoreReceivingRequest에 receiving_qty, receiving_date, lot_no 규칙 추가
- UpdateReceivingRequest status에 inspection_completed 허용 추가
- ReceivingService store()에 receiving_qty/date/lot_no 저장 처리
- order_qty null 안전 처리, 기본 status를 receiving_pending으로 변경

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 04:19:47 +09:00
5cc43828d3 fix(WEB): 철재 면적 공식 레거시 일치 (W1×H1)
- FormulaEvaluatorService: steel 면적 W1×(H1+550) → W1×H1

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 04:19:47 +09:00
김보곤
3ae3a1dcda feat: [tax-invoice] 공급자 설정 Tenant Fallback 로직 추가
- BarobillSetting 미설정 시 Tenant 정보를 기본값으로 반환
- corp_num/corp_name이 비어있으면 Fallback 동작
- Tenant 필드 매핑: business_num, company_name, ceo_name, address, phone
- Tenant options 매핑: business_type, business_category, tax_invoice_contact, tax_invoice_email
2026-02-21 17:19:18 +09:00
김보곤
ef33540940 fix: [tax-invoice] ApiResponse::handle() 호출 방식 수정
- named parameter(data:, message:) → callable 콜백 패턴으로 전환
- ApiResponse::handle(fn () => ..., '제목') 형식 적용
- 기존 scaffold 코드의 잘못된 호출 방식 전체 수정
2026-02-21 17:19:18 +09:00
김보곤
3b116c980b feat: [tax-invoice] 바로빌 SOAP 연동 및 공급자 설정 API 추가
- BarobillService HTTP→SOAP 전환 (MNG EtaxController 포팅)
- TI SOAP 클라이언트, callSoap(), buildTaxInvoiceData MNG 형식 적용
- issueTaxInvoice/cancelTaxInvoice/checkNtsSendStatus SOAP 방식
- 공급자 설정 조회/저장 API (GET/PUT /supplier-settings)
- 생성+즉시발행 통합 API (POST /issue-direct)
- SaveSupplierSettingsRequest FormRequest 추가
2026-02-21 17:19:18 +09:00
김보곤
cdbb825fbe fix: [permission] Role::users() 반환 타입 선언 추가
- Spatie Role::users(): BelongsToMany 시그니처와 호환되도록 수정
- FatalError: Declaration must be compatible 에러 해결
2026-02-21 17:19:18 +09:00
김보곤
295f7b7ee9 fix: [card] CSV(CVC) 복호화 값을 API 응답에 포함
- $appends에 csv 추가, getCsvAttribute accessor 생성
- cvc_encrypted는 $hidden 유지 (암호문 노출 방지)
2026-02-21 17:19:18 +09:00
김보곤
03e3e84066 fix: [card] store/update 응답에 관계(assignedUser) 포함
- fresh() → show() 재사용으로 관계 로딩 보장
2026-02-21 17:19:18 +09:00
김보곤
83ddfabd7c fix: [card] 카드 상세 수정/저장 누락 필드 9개 보강
- 마이그레이션: card_type, alias, cvc_encrypted, payment_day, total_limit, used_amount, remaining_limit, is_manual, memo 컬럼 추가
- Card 모델: $fillable, $casts, $hidden 확장 + CVC 암호화/복호화 메서드 추가
- CardService: store(), update() 메서드에 9개 필드 처리 로직 추가
- StoreCardRequest, UpdateCardRequest: 9개 필드 검증 규칙 추가
2026-02-21 17:19:18 +09:00
김보곤
961ab47bac feat: [corporate-card] 법인카드 관리 API 7개 엔드포인트 구현
- CorporateCard 모델 (corporate_cards 테이블)
- CorporateCardService (CRUD + 토글 + 활성 목록)
- CorporateCardController (ApiResponse 패턴)
- Store/Update FormRequest 검증
- 라우트: /api/v1/corporate-cards (index, store, show, update, destroy, toggle, active)
2026-02-21 17:19:18 +09:00
김보곤
fdea1d0244 fix: [bank-account] 계좌 관리 API 누락 필드 8개 보강
- account_type, balance, currency, opened_at, branch_name, memo, sort_order, last_transaction_at 추가
- Model: fillable, casts, hidden, scopes, accessors를 MNG 모델 기준으로 통일
- Service: store/update에 누락 필드 반영
- FormRequest: Store/Update에 검증 규칙 추가
2026-02-21 17:19:18 +09:00
김보곤
0692eda282 chore: [config] MNG 연구 설정을 API 백엔드로 통합
- Claude AI, Google Cloud (STT/GCS), Vertex AI 서비스 설정 추가
- config/gcs.php 생성 (Google Cloud Storage 설정)
- .env에 바로빌, AI, Google Cloud 환경변수 추가
2026-02-21 17:19:18 +09:00
김보곤
b576fe97e8 feat: [barobill] 바로빌 연동 관리 API 7개 엔드포인트 구현
- SOAP 기반 BarobillSoapService 생성 (MNG 코드 포팅)
- BarobillMember, BarobillConfig 모델 생성
- BarobillController 7개 메서드 (login, signup, status, URL 조회)
- FormRequest 검증 클래스 3개 생성
- 라우트 등록 (POST /barobill/login, /signup, GET /status 등)
- i18n 메시지 키 추가 (ko/en)
- config/services.php에 barobill 설정 추가
2026-02-21 17:19:18 +09:00
김보곤
1dd9057540 refactor: [authz] 역할/권한 API 품질 개선
- Validator::make를 FormRequest로 분리 (6개 생성)
- 하드코딩 한글 문자열을 i18n 키로 교체
- RoleMenuPermission 데드코드 제거
- Role 모델 SpatieRole 상속으로 일원화
- 권한 변경 후 캐시 무효화 추가 (AccessService::bumpVersion)
- 미문서화 8개 Swagger 엔드포인트 추가
- 역할/권한 라우트에 perm.map+permission 미들웨어 추가
2026-02-21 17:19:17 +09:00
555fd196f5 chore(WEB): .gitignore에 Serena MCP 메모리 디렉토리 추가
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 01:06:33 +09:00
400adb7c58 fix(WEB): 방화유리 수량 폴백 제거 및 수주→작업지시 파이프라인 개선
- OrderService: glass_qty에서 quantity 폴백 제거 (투시창 선택 시에만 유효)
- OrderService: createProductionOrder()에서 절곡 공정 bending_info 자동 생성
- OrderService: formula_source 없는 레거시 데이터의 sort_order 기반 개소 분배
- OrderService: note 파싱에서 '-' 단독값 무시 처리
- FormulaEvaluatorService: 철재 W1 계산 (W0+160 → W0+110) 레거시 일치
- WorkOrderService: store()에서 order_node_id null 품목용 rootNodes fallback 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 01:02:23 +09:00
3ab4f24bb4 fix(WEB): 철재 모터용량/셔터박스 계산 레거시 일치 수정
- FormulaHandler: 철재 면적 공식 W1×(H1+550) → W1×H1 (레거시 Slat_updateCol12and13 동일)
- FormulaHandler: 샤프트 인치 자동계산 추가 (레거시 Slat_updateCol22 동일)
- BendingInfoBuilder: 셔터박스 크기를 모터용량→브라켓→박스 매핑으로 결정
  (BOM 원자재 코드 BD-케이스-500*380 대신 조립 크기 650*550 등 사용)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 00:34:01 +09:00
602702b891 feat(WEB): 절곡 공정 BendingInfoBuilder 추가
- 수주→작업지시 시 bending_info JSON 자동 생성 서비스
- 가이드레일: qty×2 적용, baseDimension 벽면형 135*80 / 측면형 135*130
- 하단마감재: 범위별 3000/4000mm 배분 로직
- 셔터박스: coverQty/finCoverQty 계산, 조합배분 로직
- 연기차단재: W50(open_height+250 범위별), W80(floor 공식)
- 레거시(viewBendingWork_slat.php) 수식 기반 구현

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 23:52:08 +09:00
71dc5fae68 refactor: KyungdongFormulaHandler 삭제 (Tenant287/FormulaHandler로 이동 완료)
- b0547c4에서 Tenant287/FormulaHandler.php 신규 생성 완료
- 원본 KyungdongFormulaHandler.php 삭제 (중복 제거)
- Strategy + Factory 패턴 전환의 마지막 정리 작업

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 18:02:29 +09:00
b0547c425f fix:FG 수식 산출 시 제품모델/설치타입/마감타입 올바르게 적용
- parseFgCode() 추가: FG 코드에서 모델/설치타입/마감타입 파싱
- calculateTenantBom() 폴백 순서: 입력값 > FG코드 파싱 > 기본값(KSS01/벽면형/SUS)
- KQTS01 제품이 KSS01 가이드레일 규격(120*70)으로 잘못 산출되던 문제 해결

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 15:41:21 +09:00
김보곤
ee72af10b4 feat:홈택스 세금계산서 거래처 상세정보 컬럼 추가 (종사업장번호, 주소, 업태, 종목, 이메일)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 10:51:12 +09:00
edb81a1041 feat: work_orders 테이블에 options JSON 컬럼 추가
- 마이그레이션: work_orders.options JSON nullable 컬럼 추가
- WorkOrder 모델: $fillable, $casts에 options 추가
- bending_info 등 작업지시 레벨 추가 옵션 저장용

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 10:50:23 +09:00
6ae82b7057 fix: 작업지시 단건조회(show)에 materialInputs eager loading 추가
- show() 메서드에 items.materialInputs, items.materialInputs.stockLot 누락
- 목록조회에만 있고 단건조회에 빠져서 프론트 입고 LOT NO 표시 안됨

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 10:50:23 +09:00
2fedc3a712 fix: 조인트바 자동 계산 추가 (레거시 5130 공식 적용)
- KyungdongFormulaHandler: joint_bar_qty 미전달 시 자동 계산
  공식: (2 + floor((제작가로 - 500) / 1000)) × 셔터수량
- OrderService.extractSlatInfoFromBom(): 동일 폴백 추가
- OrderService.createWorkOrders(): slat_info.joint_bar 0일 때 width 기반 계산
- CURRENT_WORKS.md 업데이트

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 10:50:23 +09:00
1429f22f11 fix: 견적 산출 가이드레일 혼합형(mixed) validation 허용
- QuoteBomBulkCalculateRequest: guideRailType, GT에 mixed 추가
- QuoteBomCalculateRequest: GT에 mixed 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 10:50:23 +09:00
89344c0755 fix: 견적 산출 모터 전압/가이드레일 설치유형 매핑 누락 수정
- MP(single/three) → motor_voltage(220V/380V) 매핑 추가
- GT(wall/floor/mixed) → installation_type(벽면형/측면형/혼합형) 매핑 추가
- 기존: 프론트 선택값이 BOM 계산에 반영되지 않던 버그

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 10:50:23 +09:00
74a83e6711 fix: 슬랫 작업일지 데이터 파이프라인 구축
- OrderService.createFromQuote: BOM 결과에서 slat_info(조인트바/방화유리 수량) 추출하여 OrderNode.options에 저장
- OrderService.createWorkOrders: nodeOptions에 slat_info 없을 때 bom_result에서 fallback 추출
- OrderService.syncFromQuote: 동일하게 slat_info 추출 추가
- WorkOrderService: salesOrder eager loading에 client_contact, options 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 10:50:23 +09:00
10b1b26c1b fix: 경동 BOM 계산 수정 및 품목-공정 매핑
- KyungdongFormulaHandler: product_type 자동 추론(item_category 기반), 철재 주자재 EGI코일로 변경, 조인트바 steel 공통 지원
- FormulaEvaluatorService: FG item_category에서 product_type 자동 판별
- MapItemsToProcesses: 경동 품목-공정 매핑 커맨드 정비
- KyungdongItemMasterSeeder: BOM child_item_id code 기반 재매핑
- ItemsBomController: ghost ID 유효성 검증 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 10:50:21 +09:00
55270198d4 fix: 견적→수주 변환 시 담당자 정보 누락 수정
- Order::createFromQuote() 잘못된 필드명 수정 (contact_person→contact, delivery_date→completion_date)
- 견적 담당자(manager)를 orders.options.manager_name에 저장
- StoreOrderRequest/UpdateOrderRequest에 options.manager_name 유효성 검증 추가
- WorkOrderService show()에서 salesOrder.options 컬럼 포함하여 담당자 표시

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 10:50:13 +09:00
김보곤
e3d5303167 feat:법인카드 결제 항목(items) JSON 컬럼 추가 마이그레이션
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 10:32:55 +09:00
김보곤
7472264364 feat:barobill_bank_transactions에 거래처코드(client_code, client_name) 컬럼 추가
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 21:18:22 +09:00
김보곤
8dbe5dffe4 feat:바로빌 계좌 거래 동기화 상태 테이블 마이그레이션 추가
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 21:00:47 +09:00
유병철
6f456f2cfd fix: 급여 수당/공제 상세 벨리데이션 규칙 수정 (array → numeric)
- StoreSalaryRequest, UpdateSalaryRequest의 allowance_details.*, deduction_details.* 벨리데이션을 array에서 numeric으로 변경
- 수당/공제 항목은 {항목명: 금액} 구조이므로 값은 숫자가 올바름

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 17:34:57 +09:00
김보곤
f1dc2d8bbe feat:sales_tenant_managements 테이블에 handover_at 컬럼 추가
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 16:31:20 +09:00
1f848ad291 fix:트리거 감사로그 created_at 단독 인덱스 추가
- ORDER BY created_at DESC 쿼리 성능 개선
- 기존 복합 인덱스만으로는 단독 정렬에 활용 불가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:34:37 +09:00
59cd8cf4fe feat: 수주 목록 rootNodes 수량 합계 조회 추가
- OrderService index()에 withSum('rootNodes', 'quantity') 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 11:22:08 +09:00
6ac3f0c860 feat: 트리거 감사로그 operation_id 컬럼 추가 및 요청별 UUID 설정
- trigger_audit_logs 테이블에 operation_id(VARCHAR 36) 컬럼 추가
- ix_trig_operation 인덱스 생성 (일괄 롤백 조회용)
- SetAuditSessionVariables 미들웨어에서 요청마다 @sam_operation_id UUID 설정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 11:22:08 +09:00
김보곤
cc5fe61fcd fix:거래처 업태/종목 컬럼 크기 확장 (varchar 20/50 → 100)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 10:00:55 +09:00
김보곤
c290c99cc4 feat:tutorial_videos 테이블 마이그레이션 추가
사용자 매뉴얼 영상 자동 생성 기능을 위한 DB 테이블 생성

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 15:56:28 +09:00
김보곤
6874754b92 feat:video_generations 테이블에 gcs_path 컬럼 추가
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 11:11:09 +09:00
김보곤
d97130fc3a feat:video_generations 테이블 마이그레이션 추가 (YouTube Shorts AI)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 08:46:34 +09:00
김보곤
f3ab7525e2 feat:단체(그룹) 수당 체계 지원을 위한 마이그레이션 추가
sales_partners에 referrer_partner_id, sales_commissions에 유치수당 관련 컬럼 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 19:42:38 +09:00
김보곤
9f3a743988 feat:esign_contracts 테이블에 send_method, sms_fallback 컬럼 추가
- send_method: 발송 방식 (alimtalk/email/both), 기본값 alimtalk
- sms_fallback: SMS 대체발송 여부, 기본값 true

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 16:13:24 +09:00
김보곤
80c1e4aeae feat:sales_partners 테이블에 상호/사업자등록번호/주소 컬럼 추가
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 16:13:24 +09:00
김보곤
2dacefd14f feat:E-Sign 필드 text_align 컬럼 추가 (좌/중/우 정렬 지원)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 16:13:24 +09:00
adfccc9af0 fix:생산지시 생성 시 다중 담당자(assignee_ids) 저장 누락 수정
- CreateProductionOrderRequest에 assignee_ids 배열 validation 추가
- OrderService::createProductionOrder에 work_order_assignees 저장 로직 추가
- 담당자 유무에 따른 status 분기 (pending/unassigned) 적용

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 15:57:39 +09:00
김보곤
9c19536423 feat:거래처 테이블에 매출/매입 구분(trade_type) 컬럼 추가
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 11:27:37 +09:00
김보곤
6d4e5b740b feat:거래처 테이블에 대표자(ceo), 주소(address) 컬럼 추가
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 11:22:29 +09:00
김보곤
cd20f8d73f feat:E-Sign 템플릿 변수 시스템 추가 (마이그레이션+모델)
- field_variable, metadata, variables 컬럼 마이그레이션 추가
- EsignContract 모델에 metadata (JSON cast) 추가
- EsignSignField 모델에 field_variable 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 11:22:28 +09:00
김보곤
ef23b9dda6 feat:E-Sign 필드에 font_size 컬럼 추가
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 11:22:28 +09:00
김보곤
c7c9c3838d feat:E-Sign 필드 템플릿 PDF 파일 컬럼 추가
- esign_field_templates 테이블에 file_path, file_name, file_hash, file_size 컬럼 추가
- 템플릿에 PDF 파일을 포함할 수 있도록 스키마 확장

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 11:22:28 +09:00
4ecf16d387 fix:quote_revisions 조회 시 MySQL Out of sort memory 에러 해결
- quote_revisions(quote_id, tenant_id, revision_number) 복합 인덱스 추가
- previous_data JSON 대용량 컬럼 포함 정렬 시 filesort OOM 방지

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 09:29:43 +09:00
f28bbc2a74 fix:quotes 목록 조회 시 MySQL Out of sort memory 에러 해결
- quotes(tenant_id, registration_date, id) 복합 인덱스 추가
- ORDER BY registration_date DESC, id DESC 정렬 시 filesort 제거

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 09:23:50 +09:00
090a978991 fix:생산지시 되돌리기 시 누락된 관련 데이터 삭제 보완
- 자재 투입(material_inputs) 재고 복구(increaseToLot) 후 삭제
- 문서(documents/data/approvals) 영구삭제 (검사 성적서, 작업일지)
- 출하(shipments) work_order_id 참조 해제
- step_progress, assignees, bending_details, issues 명시적 삭제 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 04:03:10 +09:00
5eaa65cc9c Merge remote-tracking branch 'origin/develop' into develop 2026-02-13 03:44:34 +09:00
df69cabdc3 docs:논리적 관계 문서 갱신 (esign, materialInputs 추가)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 03:41:45 +09:00
89c19b869d chore:조인트바 품목 options 데이터 추가 (lot_managed, consumption_method 등)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 03:41:43 +09:00
fc4fad6e75 refactor:공정 단계 completion_type 한글→영문 코드 전환
- completion_type 값을 한글(클릭 시 완료)에서 영문 코드(click_complete)로 변환
- FormRequest에 in 검증 규칙 추가 (click_complete, selection_complete, inspection_complete)
- 기존 데이터 마이그레이션 포함

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 03:41:41 +09:00
e4c53c7b17 feat:개소별 자재 투입 관리 API 추가
- work_order_material_inputs 테이블 신규 생성 (개소별 자재 투입 추적)
- 개소별 자재 조회/투입/이력/삭제/수정 API 5개 추가
- StockService.increaseToLot: LOT 수량 복원 메서드 추가
- WorkOrderService에 개소별 자재 투입 비즈니스 로직 구현
- WorkOrder, WorkOrderItem 모델에 materialInputs 관계 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 03:41:35 +09:00
d730c2d91a feat:FQC 제품검사 문서 일괄생성 API 추가
- POST /v1/documents/bulk-create-fqc: 수주 개소별 제품검사 문서 일괄생성
- GET /v1/documents/fqc-status: 수주별 FQC 진행현황 조회
- BulkCreateFqcRequest FormRequest 추가
- error.php에 no_order_items, already_created 메시지 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 03:41:27 +09:00
441359f5fd fix:FQC 문서 기본필드 키 형식 수정 (bf_라벨 → bf_ID)
- 제품검사 문서 생성 시 bf_납품명 형식 → bf_{field->id} 형식으로 변경
- 템플릿 basicFields를 로드하여 field_key 기반 매핑
- mng show.blade.php와 키 형식 통일

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 00:32:41 +09:00
김보곤
a13e694174 feat:E-Sign 필드 템플릿 category 컬럼 추가 마이그레이션
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 18:54:58 +09:00
김보곤
57879f7673 feat:E-Sign 필드 템플릿 테이블 마이그레이션 추가
- esign_field_templates: 필드 배치 템플릿 저장
- esign_field_template_items: 템플릿 내 개별 필드 정보

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 18:02:25 +09:00
9af48a15af feat: 내화실 품목 마스터 데이터 업데이트
- code: 80019 → 내화실-WY-MA12
- name: 실 → 내화실
- unit: m → 콘
- attributes.spec: WY-MA12
- options: lot_managed, consumption_method(manual), production_source(purchased), material 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 15:24:49 +09:00
32cbb1071d Merge remote-tracking branch 'origin/develop' into develop 2026-02-12 14:16:32 +09:00
45dd18dbab feat(API): 작업일지 생성/조회 API 추가
- WorkOrderService: getWorkLogTemplate, getWorkLog, createWorkLog 메서드 추가
- WorkOrderController: 작업일지 3개 엔드포인트 추가
- 라우트: GET work-log-template, GET/POST work-log
- WorkOrder 모델: documents() MorphMany 관계 추가
- i18n: work_log_saved, no_work_log_template 메시지 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 09:39:48 +09:00
김보곤
fa6d2082c8 feat:E-Sign 전자계약 i18n 메시지 키 추가
- message.esign: 12개 (created, cancelled, sent, reminded, fields_configured, otp_sent, otp_verified, signed, rejected, completed, verified, downloaded)
- error.esign: 16개 (invalid_token, token_expired, contract_not_signable, already_completed, already_cancelled, already_signed, invalid_status_for_send, no_sign_fields, cannot_remind, fields_only_in_draft, not_verified, otp_max_attempts, otp_not_sent, otp_expired, otp_invalid, file_not_found)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 07:15:42 +09:00
김보곤
6958be1fd8 feat:E-Sign 전자계약 서명 솔루션 백엔드 구현
- 마이그레이션 4개 (esign_contracts, esign_signers, esign_sign_fields, esign_audit_logs)
- 모델 4개 (EsignContract, EsignSigner, EsignSignField, EsignAuditLog)
- 서비스 4개 (EsignContractService, EsignSignService, EsignPdfService, EsignAuditService)
- 컨트롤러 2개 (EsignContractController, EsignSignController)
- FormRequest 4개 (ContractStore, FieldConfigure, SignSubmit, SignReject)
- Mail 1개 (EsignRequestMail + 이메일 템플릿)
- API 라우트 (인증 계약 관리 + 토큰 기반 서명 프로세스)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 07:02:39 +09:00
911c8a36ad feat(API):문서 데이터 정규화 커맨드 추가
- NormalizeDocumentData: 기존 document_data를 정규화 형식으로 일괄 변환

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 00:01:30 +09:00
376348a491 feat(API):자재 투입 LOT 조회 API 및 중간검사 데이터 정규화
- materialInputLots: stock_transactions 기반 투입 LOT 조회 엔드포인트 추가
- createInspectionDocument: 정규화 형식(section_id/column_id/field_key) 지원
- 레거시 형식(section_X_item_Y) 자동 변환 로직 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 00:01:25 +09:00
김보곤
818f764aa5 fix:사진대지 감사 로그 트리거 재생성 (삭제된 컬럼 참조 제거)
컬럼 삭제 후 트리거가 before_photo_path 등을 참조하여 UPDATE 시 에러 발생 수정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 19:28:24 +09:00
김보곤
eade587135 feat:공사현장 사진대지 멀티행 테이블 마이그레이션
construction_site_photo_rows 테이블 생성 + 기존 데이터 마이그레이션 + 부모 테이블 사진 컬럼 삭제

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 18:05:06 +09:00
8d1ed6c096 docs(API): LOGICAL_RELATIONSHIPS 문서 업데이트
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 15:58:43 +09:00
de10441275 fix(API): 경동 견적 수식 핸들러 개선
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 15:58:42 +09:00
bcad646ea6 feat(API): 수주 서비스 기능 개선
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 15:58:40 +09:00
51aad4e522 feat(API): 검사 문서/성적서 연동 개선
- DocumentService: formatTemplateForReact 필드명 정합성 수정 (column_type, sub_labels, section title/image_path)
- WorkOrderService: process.options 로딩, 검사데이터 document_data 변환 로직 추가
- StoreItemInspectionRequest: templateValues 유효성 규칙 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 15:58:39 +09:00
a3a4e18e8a feat(API): 문서 템플릿 기본필드 field_key $fillable 추가
- DocumentTemplateBasicField 모델에 field_key 필드 추가
- Mass Assignment 보호로 field_key 저장 누락 방지

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 15:58:37 +09:00
8b78d62068 feat(API): 공정 options JSON 컬럼 마이그레이션
- needs_work_log 개별 컬럼 → options JSON 구조로 전환
- StoreProcessRequest, UpdateProcessRequest 유효성 규칙 갱신
- Process 모델 $fillable, $casts 갱신

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 15:58:35 +09:00
12053386f6 Merge remote-tracking branch 'origin/develop' into develop 2026-02-11 11:03:58 +09:00
김보곤
bf3ee24314 feat:법인카드 선불결제 테이블 마이그레이션 추가
corporate_card_prepayments 테이블 생성 (tenant_id, year_month, amount, memo)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 10:24:28 +09:00
1d7ef66d19 feat: 작업일지/중간검사 설정을 ProcessStep → Process 레벨로 이동
- Process 모델에 document_template_id, needs_work_log, work_log_template_id 추가
- ProcessStep에서 해당 필드 제거
- WorkOrderService의 검사 관련 3개 메서드(getInspectionTemplate, resolveInspectionDocument, createInspectionDocument) 공정 레벨 참조로 변경
- ProcessService eager loading에 documentTemplate, workLogTemplateRelation 추가
- FormRequest 검증 규칙 이동 (ProcessStep → Process)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 09:51:35 +09:00
bb457d4ca8 Merge remote-tracking branch 'origin/develop' into develop 2026-02-11 08:51:11 +09:00
2d68e5e669 fix: 수주 삭제 시 견적 연결 해제 + 생산지시 공정 매핑 보완
- OrderService::destroy()에서 견적 order_id/status 초기화
- StoreOrderRequest/UpdateOrderRequest에 floor_code, symbol_code, item_code 추가
- createProductionOrder()에 item_code fallback 공정 매핑 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 19:03:22 +09:00
김보곤
d0418eeb85 feat:journal_entries에 source_type, source_key 컬럼 추가
- 원본 거래 추적용 source_type(bank_transaction, hometax_invoice, manual) 컬럼 추가
- 원본 거래 고유키 source_key 컬럼 추가
- tenant_id + source_type + source_key 복합 인덱스 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 17:58:20 +09:00
6733a431bb feat:중간검사 API 다중단계/resolve/upsert 지원 (Phase 5.1.3)
- getInspectionTemplate: 전체 검사 단계 templates[] 반환 (기존 첫번째만→다중)
- resolveInspectionDocument 신규: step_id 기반 기존 문서 조회 또는 템플릿 반환
- createInspectionDocument 개선: step_id 파라미터, 기존 DRAFT/REJECTED 문서 update 지원
- GET inspection-resolve 라우트 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 16:55:29 +09:00
d8fd221278 Merge remote-tracking branch 'origin/develop' into develop 2026-02-10 10:27:26 +09:00
김보곤
ac83d0bddc Merge branch 'develop' of http://114.203.209.83:3000/SamProject/sam-api into develop 2026-02-10 10:02:16 +09:00
김보곤
6c186c91ab feat:회의록 테이블 마이그레이션 추가 (meeting_minutes, meeting_minute_segments)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 09:50:14 +09:00
e885b1ca45 feat(API): 중간검사 문서 템플릿 동적 연동 - process_steps ↔ document_templates 연결
- process_steps 테이블에 document_template_id FK 추가 (migration)
- ProcessStep 모델에 documentTemplate BelongsTo 관계 추가
- ProcessStepService에서 documentTemplate eager loading
- StoreProcessStepRequest/UpdateProcessStepRequest에 document_template_id 유효성 검증
- WorkOrderService에 getInspectionTemplate(), createInspectionDocument() 메서드 추가
- WorkOrderController에 inspection-template/inspection-document 엔드포인트 추가
- DocumentService.formatTemplateForReact() 접근자 public으로 변경
- i18n 메시지 키 추가 (inspection_document_created, no_inspection_template)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 08:35:57 +09:00
7adfc6d536 Merge remote-tracking branch 'origin/develop' into develop 2026-02-09 21:32:54 +09:00
072d0c0ae1 docs: LOGICAL_RELATIONSHIPS.md 모델 관계 업데이트
- OrderNode 모델 관계 추가 (parent, order, children, items)
- Order.nodes/rootNodes, OrderItem.node 관계 추가
- WorkOrder.stepProgress, WorkOrderStepProgress 관계 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 21:31:25 +09:00
be572678db fix: 수주 삭제 로직 강화 - 연관 데이터 cascade soft delete
- 삭제 불가 상태 추가 (생산중/생산완료/출하중/출하완료)
- 작업지시/출하 존재 시 삭제 차단 + 에러 메시지
- order_item_components → order_items → order_nodes → order 순차 soft delete
- DB 트랜잭션으로 원자성 보장

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 21:31:19 +09:00
김보곤
4c02ff64f1 feat:공사현장 사진대지 테이블 마이그레이션 추가
construction_site_photos 테이블 생성 (현장명, 작업일자, 작업전/작업중/작업후 사진 GCS 경로)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 21:25:03 +09:00
61c70b6fd1 Merge remote-tracking branch 'origin/develop' into develop 2026-02-09 18:03:59 +09:00
김보곤
0bd470a6f8 feat:홈택스 세금계산서 분개 테이블 마이그레이션 추가
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 17:13:26 +09:00
9bae7fccae feat: 트리거 감사 로그 Swagger 문서 추가 및 api_request_logs 제외
- TriggerAuditLogApi.php Swagger 파일 생성 (6개 엔드포인트 문서화)
  - 목록 조회, 통계, 상세, 레코드 이력, 롤백 미리보기, 롤백 실행
- api_request_logs를 트리거 제외 테이블 목록에 추가
- Pint 포매팅 적용

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 16:55:34 +09:00
5eaa5f036b fix(API): 자재투입 모달 중복 로트 버그 수정
동일 자재가 여러 작업지시 품목에 걸쳐 있을 때 StockLot이 중복 표시되던 문제 수정.
Phase 1(유니크 자재 수집) → Phase 2(로트 조회) 구조로 변경하여 중복 제거 및 필요수량 합산.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 16:47:50 +09:00
f9de25257f Merge remote-tracking branch 'origin/develop' into develop 2026-02-09 10:46:09 +09:00
김보곤
e9faac5c9d feat:AI 토큰 단가 설정 기능 추가 (DB 기반)
- ai_pricing_configs 테이블 마이그레이션 생성 (기본 시드 데이터 포함)
- AiPricingConfig 모델 추가 (캐시 적용 단가/환율 조회)
- AiReportService 하드코딩 단가를 DB 조회로 변경

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 09:33:49 +09:00
d07bad16df feat:DB 트리거 기반 데이터 변경 추적 시스템 구현
Phase 1: DB 기반 구축
- trigger_audit_logs 테이블 (RANGE 파티셔닝 15개, 3개 인덱스)
- 789개 MySQL AFTER 트리거 (263 테이블 × INSERT/UPDATE/DELETE)
- SetAuditSessionVariables 미들웨어 (@sam_actor_id, @sam_session_info)

Phase 2: 복구 메커니즘
- TriggerAuditLog 모델, TriggerAuditLogService, AuditRollbackService
- 6개 API 엔드포인트 (index, show, stats, history, rollback-preview, rollback)
- FormRequest 검증 (TriggerAuditLogIndexRequest, TriggerAuditRollbackRequest)

Phase 3: 관리 도구
- v_unified_audit VIEW (APP + TRIGGER 통합, COLLATE 처리)
- audit:partitions 커맨드 (파티션 추가/삭제, dry-run)
- audit:triggers 커맨드 (트리거 재생성, 테이블별/전체)
- 월 1회 파티션 자동 관리 스케줄러 등록

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 09:17:15 +09:00
ee6794be1a feat: [생산관리] 중간검사 데이터 저장/조회 API 구현
- POST /work-orders/{id}/items/{itemId}/inspection: 품목별 검사 데이터 저장
- GET /work-orders/{id}/inspection-data: 전체 품목 검사 데이터 조회
- GET /work-orders/{id}/inspection-report: 검사 성적서용 데이터 조회
- WorkOrderItem 모델에 getInspectionData/setInspectionData 헬퍼 추가
- StoreItemInspectionRequest FormRequest 생성
- work_order_items.options['inspection_data']에 검사 결과 저장

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 09:00:57 +09:00
김보곤
b9137c93b0 feat:AI 음성녹음 테이블 마이그레이션 및 모델 추가
- ai_voice_recordings 테이블 마이그레이션 생성
- AiVoiceRecording 모델 추가 (Tenants 네임스페이스)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 12:52:28 +09:00
김보곤
f45f91967f feat:AI 토큰 사용량 추적 기능 추가
- ai_token_usages 테이블 마이그레이션 생성
- AiTokenUsage 모델 생성
- AiReportService에 usageMetadata 추출 및 저장 로직 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 10:27:20 +09:00
78851ec04a feat: 테넌트별 채번 규칙 시스템 구현
- numbering_rules 테이블: JSON 패턴 기반 채번 규칙 저장 (tenant별)
- numbering_sequences 테이블: MySQL UPSERT 기반 atomic 시퀀스 관리
- NumberingService: generate/preview/nextSequence 핵심 서비스
- QuoteNumberService: NumberingService 우선, 폴백 QT{YYYYMMDD}{NNNN}
- OrderService: NumberingService 우선 (pair_code 지원), 폴백 ORD{YYYYMMDD}{NNNN}
- StoreOrderRequest: pair_code 필드 추가
- NumberingRuleSeeder: tenant_id=287 견적(KD-PR)/수주(KD-{pairCode}) 규칙

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 09:50:52 +09:00
6318474b6f fix: [자재투입] 입고 로트번호 기반으로 자재 목록 변경
- getMaterials(): 품목당 1행 → StockLot(입고 로트)당 1행으로 변경
- ITEM-{id} 가짜 로트번호 → Receiving에서 생성된 실제 lot_no 반환
- registerMaterialInput(): material_ids → stock_lot_id+qty 로트별 수량 차감
- StockService::decreaseFromLot() 신규 추가 (특정 로트 지정 차감)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 05:06:28 +09:00
d4125dc4ad fix: [견적관리] 견적-수주 역방향 참조 보정 및 자동 동기화
- QuoteService::show() - order_id가 null인 경우 Order.quote_id 역방향 탐색으로 연결된 수주 자동 보정
- QuoteService::update() - 역방향 참조 포함하여 syncFromQuote() 동기화 트리거 확장
- 수주에서 견적 수정 시 기존 수주에 자동 반영 + "수주 보기" 버튼 정상 표시

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 04:55:22 +09:00
487e651845 feat: 견적확정 밸리데이션, 작업지시 통계 공정별 카운트, 입고/재고 개선
- 견적확정 시 업체명/현장명/담당자/연락처 필수 검증 추가 (QuoteService)
- 작업지시 stats API에 by_process 공정별 카운트 반환 추가
- 작업지시 목록/상세 쿼리에 수주 개소(rootNodes) 연관 로딩
- 작업지시 품목에 sourceOrderItem.node 관계 추가
- 입고관리 완료건 수정 허용 및 재고 차이 조정
- work_order_step_progress 테이블 마이그레이션
- receivings 테이블 options 컬럼 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 03:27:07 +09:00
6b3e5c3e87 Merge remote-tracking branch 'origin/develop' into develop 2026-02-06 22:16:36 +09:00
김보곤
3a62a2a6e6 feat:인터뷰 시나리오 마이그레이션/모델 추가
- interview_categories, interview_templates, interview_questions 테이블 생성
- interview_sessions, interview_answers 테이블 생성
- InterviewCategory, InterviewTemplate, InterviewQuestion 모델 추가
- InterviewSession, InterviewAnswer 모델 추가
- 멀티테넌트(tenant_id) 지원, 감사 로깅(Auditable) 적용

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 21:01:24 +09:00
d2b0f028d4 feat: [수주관리] 전환/동기화 로직에 OrderNode 생성 및 아이템 연결
- convertToOrder: calculation_inputs.items[]로 OrderNode(location) 생성, order_items에 order_node_id 연결
- resolveLocationIndex() 헬퍼 추가 (formula_source/note 기반 개소 인덱스 매칭)
- syncFromQuote: 기존 nodes 삭제 후 재생성, 아이템 node 연결 동기화
- show(): rootNodes + withRecursiveChildren eager loading 추가
- createFromQuoteItem: order_node_id 매핑 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 20:15:00 +09:00
874bf97b8f feat: [수주관리] order_nodes 테이블 및 모델 생성 (N-depth 트리 구조)
- order_nodes 마이그레이션: 자기참조 parent_id, 고정코어(통계용) + options JSON(하이브리드)
- order_items에 order_node_id nullable FK 추가
- OrderNode 모델: BelongsToTenant, Auditable, SoftDeletes, 트리 관계(parent/children)
- Order 모델: nodes(), rootNodes() HasMany 관계 추가
- OrderItem 모델: order_node_id fillable + node() BelongsTo 관계 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 20:06:14 +09:00
4ae7b438f1 feat: 품목 치수 정규화 Artisan 커맨드 추가
- items:normalize-dimensions 커맨드 신규 생성
- 101_specification_1/2/3에서 thickness/width/length 자동 추출
- --dry-run(미리보기) / --execute(실행) 모드 지원
- 기존 값이 있는 경우 안전하게 스킵

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 20:05:23 +09:00
6dbcb5337d feat: [수주관리] convertToOrder 개소 파싱 로직 추가
- convertToOrder에서 calculation_inputs.items[] 파싱하여 floor_code/symbol_code 매핑
- resolveLocationMapping() 공통 메소드 추출 (note 파싱 1순위, formula_source 2순위)
- syncFromQuote와 동일한 2단계 파싱 로직으로 일관성 확보
- Exception → Throwable 변경 (동기화 실패 catch 범위 확대)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 19:40:14 +09:00
b6f1c817d8 Merge remote-tracking branch 'origin/develop' into develop 2026-02-06 15:47:56 +09:00
김보곤
bdf6bcc480 feat:일반전표입력 마이그레이션 추가 (journal_entries, journal_entry_lines)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 15:23:37 +09:00
김보곤
edbd95053c feat:계좌 입출금 분개 테이블 마이그레이션 추가
- barobill_bank_transaction_splits 테이블 생성
- 계좌 입출금 거래를 여러 계정과목으로 분개하여 저장

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 14:43:10 +09:00
김보곤
28bf445844 feat:계좌 입출금내역 수동입력용 is_manual 컬럼 추가
barobill_bank_transactions 테이블에 is_manual boolean 컬럼 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 11:03:08 +09:00
김보곤
3406b12260 feat:바로빌 계좌 거래내역 오버라이드 테이블 마이그레이션 추가
- barobill_bank_transaction_overrides 테이블 생성
- tenant_id + unique_key 복합 유니크 인덱스
- modified_summary, modified_cast 필드로 수정값 저장

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 11:03:08 +09:00
6bc766411b feat: 생산지시 생성 시 공정 자동 분류 및 아이템 연결
- OrderService: 생산지시 생성 로직 개선
  - order_items.item_id → process_items 테이블에서 공정 자동 조회
  - 공정별로 아이템 그룹화 (미지정 아이템은 별도 그룹)
  - 각 공정별 작업지시 생성
  - work_order_items에 해당 공정의 아이템들 자동 추가

- WorkOrderService: 목록 조회 시 관계 추가
  - items 관계 추가 (틀수 계산용)
  - process.department 필드 추가 (부서 표시용)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-06 10:28:30 +09:00
f640a837e9 feat:경동기업 견적/수주 전환 로직 개선
- KyungdongFormulaHandler: 수식 계산 로직 리팩토링 및 확장
- OrderService: 수주 전환 시 BOM 품목 매핑 로직 추가
- QuoteService: 견적 상태 처리 개선
- FormulaEvaluatorService: 디버그 로깅 추가
- Quote 모델: 캐스팅 타입 수정

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 21:58:49 +09:00
9f2b1cf44a feat:품목 검색 API에 수입검사 양식 연결 필드 추가
- has_inspection_template 필드 추가 (수입검사 양식 연결 여부)
- getItemsWithInspectionTemplate() 헬퍼 메서드 추가
- name 필드에 코드 합치기 제거 (중복 표시 해결)
- exclude_process_id 파라미터 추가 (공정별 품목 선택 시 중복 방지)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 21:58:00 +09:00
김보곤
70aab06364 feat:holidays 테이블 마이그레이션 생성
달력 휴일 관리를 위한 holidays 테이블 추가 (시작일/종료일, 유형, 반복 여부)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 19:59:31 +09:00
김보곤
44079f0f0e feat:재무관리 컬럼 추가 마이그레이션 (tax_type, tax_invoice_issued, deduction_type)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 18:26:47 +09:00
bc23debb26 Merge remote-tracking branch 'origin/develop' into develop 2026-02-05 17:37:54 +09:00
김보곤
a94c68ea91 feat:바로빌 카드 거래 숨김 테이블 마이그레이션 추가
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 17:27:14 +09:00
김보곤
f941ca17b9 feat:분개 테이블에 공급가액/부가세 컬럼 추가
- split_supply_amount (decimal 18,2, nullable) 컬럼 추가
- split_tax (decimal 18,2, nullable) 컬럼 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 15:44:28 +09:00
229ebc7483 feat:문서 resolve/upsert API 추가- React 연동용 resolve API (GET /documents/resolve)
- Upsert API (POST /documents/upsert)
- ResolveRequest, UpsertRequest FormRequest 생성
- DocumentService에 resolve/upsert 로직 추가
- document_category common_codes 마이그레이션
- 에러/성공 메시지 i18n 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 14:45:53 +09:00
83d12a8ca2 Merge remote-tracking branch 'origin/develop' into develop 2026-02-05 12:46:38 +09:00
김보곤
bc4a41263a feat:카드 사용내역 수동입력 is_manual 컬럼 마이그레이션 추가
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 11:24:32 +09:00
김보곤
49d68e3b3e fix:이력 테이블 마이그레이션 기존 테이블 충돌 방지
- 이전 실행에서 테이블만 생성되고 FK 추가 실패한 상태 대응
- dropIfExists 추가하여 재실행 가능하도록 수정

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 10:50:18 +09:00
김보곤
98ab3dac9a fix:이력 테이블 외래키 이름 길이 초과 수정
- MySQL 64자 제한으로 자동생성 외래키명 실패
- foreignId()->constrained() → 수동 foreign() + 짧은 이름(bb_amount_log_trans_fk)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 10:49:53 +09:00
5118c364ef Merge remote-tracking branch 'origin/develop' into develop 2026-02-05 10:49:28 +09:00
김보곤
ba34fb09df feat:카드 사용내역 금액 수정 마이그레이션 추가
- modified_supply_amount, modified_tax 컬럼 추가 (사용자 수정 공급가액/부가세)
- barobill_card_transaction_amount_logs 이력 테이블 생성

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 10:02:46 +09:00
김보곤
9626bca7eb feat:purchases 테이블에 MNG용 컬럼 추가 마이그레이션
- date, vendor, item, category, amount, vat, invoice_no, memo 컬럼 추가
- MNG 매입관리 페이지 500 에러 수정용

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 09:35:04 +09:00
김보곤
2b5ac4c54d feat:법인카드 거래내역(card_transactions) 마이그레이션 추가
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 07:44:09 +09:00
김보곤
00470b7d30 feat:일일자금일보 마이그레이션 추가 (daily_fund_transactions, daily_fund_memos)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 07:43:55 +09:00
김보곤
422bad7dfc feat:부가세 관리 vat_records 테이블 마이그레이션 추가
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 06:14:18 +09:00
e364239572 feat: 견적 참조 데이터 API, 수주 전환 로직 개선, 검사기준서 필드 통합
- 견적 참조 데이터(현장명, 부호) 조회 API 추가 (GET /quotes/reference-data)
- 수주 전환 시 floor_code/symbol_code를 quoteItem.note에서 파싱하도록 변경
- 수주 전환 시 note에 formula_category 저장
- 검사기준서 프리셋: standard + standard_criteria → text_with_criteria로 통합
- tolerance 컬럼 width 조정 (120px → 85px)
- LOGICAL_RELATIONSHIPS.md 문서 갱신

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 23:07:08 +09:00
김보곤
9c276ed8c3 fix:기존 테이블 충돌 방지를 위한 hasTable 체크 추가 (sales_records, purchases, subscriptions)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 22:53:02 +09:00
김보곤
343d7f6256 feat:고객/매출/매입/정산 등 재무 관련 8개 테이블 마이그레이션 추가
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 22:50:44 +09:00
fa07e5b58a feat: 경동기업 품목 기준 데이터 배포용 시더 구현
- ExportItemMasterDataCommand: tenant_id=287 데이터를 JSON으로 추출
- KyungdongItemMasterSeeder: JSON 기반 DELETE+재삽입 시더
  - Phase 1: item_pages/sections/fields + entity_relationships
  - Phase 2: categories(depth순) + items(배치500건)
  - Phase 3: item_details + prices
  - ID 매핑으로 환경별 충돌 없음, 트랜잭션 안전
- 8개 JSON 데이터 파일 포함 (총 약 1.5MB)
- .gitignore에 시더 데이터 예외 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 22:47:03 +09:00
a6fc537a02 Merge remote-tracking branch 'origin/develop' into develop 2026-02-04 22:40:58 +09:00
김보곤
0d1b088463 feat:환불/해지 관리 테이블 마이그레이션 추가
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 22:37:35 +09:00
김보곤
cee37b3e20 feat:미지급금(payables) 테이블 마이그레이션 추가
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 22:27:13 +09:00
김보곤
0e9f8be423 feat:미수금(receivables) 테이블 마이그레이션 추가
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 22:22:20 +09:00
김보곤
c7029f9eef feat:거래처(trading_partners) 테이블 마이그레이션 추가
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 22:13:26 +09:00
a7975f7270 fix: ItemService update 시 attributes 머지 로직 추가
- 기존: attributes 전체 덮어쓰기 → 수정: 기존 값 보존 후 새 값만 머지
- 품목 수정 시 레거시 속성(sales_price, legacy 정보 등) 유실 방지

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 20:30:07 +09:00
d27061bbdc Merge remote-tracking branch 'origin/develop' into develop 2026-02-04 20:26:01 +09:00
af42c115ae feat:검사 기준서 동적화 + 외부 키 매핑 동적화
- 템플릿별 동적 필드 정의 (document_template_section_fields)
- 외부 키 매핑 동적화 (document_template_links + link_values)
- 문서 레벨 연결 (document_links)
- 시스템 프리셋 (document_template_field_presets)
- section_items에 field_values JSON 컬럼 추가
- 기존 고정 필드 → 동적 field_values 데이터 마이그레이션
- search_api → source_table 전환 마이그레이션

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 08:37:55 +09:00
김보곤
d012be69eb feat:차량정비이력 테이블 마이그레이션 추가 2026-02-03 19:56:38 +09:00
3d20c6979d feat: 공정 단계(ProcessStep) CRUD API 구현
- process_steps 테이블 마이그레이션 생성 (step_code, sort_order, boolean 플래그 등)
- ProcessStep 모델 생성 (child entity 패턴, HasFactory만 사용)
- ProcessStepService: CRUD + reorder + STP-001 자동채번
- ProcessStepController: DI + ApiResponse::handle 패턴
- FormRequest 3개: Store, Update, Reorder
- Process 모델에 steps() HasMany 관계 추가
- ProcessService eager-load에 steps 추가 (5곳)
- Nested routes: /processes/{processId}/steps

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 18:59:12 +09:00
김보곤
83f0f69643 feat:홈택스 세금계산서 로컬 저장 테이블 추가
- hometax_invoices 테이블 생성 마이그레이션
- 국세청승인번호 기준 중복 방지 인덱스
- 매출/매입 구분, 금액정보, 거래처정보 저장
- 메모/분류/확인여부 등 자체 관리 필드

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 17:13:04 +09:00
김보곤
529c587023 feat:차량일지 구분 유형 확장 및 라벨 수정
- 구분 유형 추가: 출퇴근용(왕복), 업무용(왕복), 비업무용(왕복)
- 비업무 라벨을 '비업무용(개인)'으로 변경
- 출발지/도착지 장소명 라벨 수정 (장소명 → 출발지명/도착지명)
- 새 유형별 색상 추가

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-03 13:27:34 +09:00
김보곤
6c2e74d6ce feat:차량일지(운행기록부) 테이블 마이그레이션 추가
- vehicle_logs 테이블 생성
- 운행 정보 (날짜, 부서, 운전자, 구분)
- 출발지/도착지 정보 (분류, 장소명, 주소)
- 주행거리, 비고

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 11:32:27 +09:00
2779caed6e feat:문서양식 결재라인 user_id 및 연결 필드 마이그레이션 추가
- document_template_approval_lines에 user_id 컬럼 추가
- document_templates에 linked_item_ids(JSON), linked_process_id 컬럼 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 10:33:37 +09:00
e35b167b63 fix: mysqldump 비밀번호 경고 제거 (defaults-extra-file 방식)
- 커맨드라인 --user/--password → 임시 .my.cnf 파일 방식으로 변경
- mysqldump 실행 전 임시 파일 생성, 완료 후 삭제
- "Using a password on the command line interface" 경고 해소

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 10:32:56 +09:00
60e2286cec Merge remote-tracking branch 'origin/develop' into develop 2026-02-03 10:10:50 +09:00
김보곤
fe4303f807 feat:바로빌 회원사 마지막 수집 시간 컬럼 추가
- last_sales_fetch_at: 마지막 매출 조회 시간
- last_purchases_fetch_at: 마지막 매입 조회 시간

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 09:12:30 +09:00
김보곤
c2c19249d7 feat:barobill_members 테이블에 server_mode 컬럼 추가
회원사별로 바로빌 테스트/운영 서버를 개별 설정할 수 있도록 컬럼 추가
- server_mode: enum('test', 'production'), 기본값 'test'

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 07:47:56 +09:00
김보곤
c9970d65fd feat:법인차량관리 테이블 생성 (corporate_vehicles)
- 기본 정보: 차량번호, 모델, 종류, 구분, 연식, 운전자, 상태, 주행거리, 메모
- 법인차량 전용: 취득일, 취득가
- 렌트/리스 전용: 계약일자, 회사명, 연락처, 기간, 약정운행거리, 차량가격, 잔존가액, 보증금, 월 렌트료(공급가/세액), 보험사 정보

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 21:47:54 +09:00
da100ed5ad Merge remote-tracking branch 'origin/develop' into develop 2026-02-02 20:48:01 +09:00
6001df27f2 feat:검사항목 frequency_n/c, standard_criteria 컬럼 추가
- frequency_n(측정횟수), frequency_c(합격판정기준) TINYINT 추가
- standard_criteria JSON 컬럼 추가 (구조화된 검사기준 비교용)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 20:36:57 +09:00
김보곤
7b06644f23 fix:gcs_uri 마이그레이션 컬럼 존재 체크 추가 2026-02-02 20:16:12 +09:00
김보곤
502e34d88e feat:수당 지급 추적 컬럼 추가 마이그레이션
- 1차/2차 납입완료일, 수당지급일 컬럼 추가
- 매니저 수당 관련 컬럼 추가 (첫 구독료, 지급일)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 19:56:09 +09:00
f701e0636e feat:검사기준서 탭 개선 - tolerance, measurement_type 컬럼 및 inspection_method 공통코드 추가
- document_template_section_items에 tolerance(공차), measurement_type(측정유형) 컬럼 추가
- common_codes에 inspection_method 그룹 6개 코드 삽입
- DocumentTemplateSectionItem 모델 $fillable 업데이트

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 16:29:39 +09:00
김보곤
c90077bd51 feat:sales_consultations 테이블에 gcs_uri 컬럼 추가
음성 녹음 파일의 GCS URI 저장용

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 10:30:22 +09:00
김보곤
28ed5d9da7 Merge branch 'develop' of http://114.203.209.83:3000/SamProject/sam-api into develop 2026-02-02 09:54:31 +09:00
김보곤
dc2dee0c0b chore:api docs update 2026-02-02 09:54:31 +09:00
김보곤
9cc73f4688 fix:sales_contract_products.tenant_id nullable 허용
가망고객 단계에서는 아직 테넌트가 생성되지 않으므로
tenant_id가 null일 수 있음

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 09:53:35 +09:00
ce59cdfe81 Merge remote-tracking branch 'origin/develop' into develop 2026-02-01 20:37:46 +09:00
a3c7f83dfb chore:Serena 프로젝트 설정 및 문서 업데이트
- .serena/project.yml 설정 업데이트
- DB 백업 상태 메모리 추가
- LOGICAL_RELATIONSHIPS.md 수정

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 20:37:06 +09:00
김보곤
32392ef4de fix:sales_contract_products.tenant_id nullable 변경
- 가망고객(prospect) 모드에서 계약상품 저장 지원
- tenant_id가 없어도 management_id로 연결 가능

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 20:44:18 +09:00
김보곤
fd3dbb75af feat:sales_consultations 테이블에 tenant_prospect_id 컬럼 추가
- 가망고객(prospect) 상담 기록 지원을 위해 tenant_prospect_id 컬럼 추가
- tenant_id를 nullable로 변경 (가망고객일 경우 null)
- tenant_prospect_id 인덱스 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 19:50:37 +09:00
김보곤
3a2eeb299c Merge branch 'develop' of http://114.203.209.83:3000/SamProject/sam-api into develop 2026-01-31 19:35:27 +09:00
김보곤
6db428ccc5 feat:영업 관리 테이블에 tenant_prospect_id 컬럼 추가
- sales_tenant_managements에 tenant_prospect_id 추가
- sales_scenario_checklists에 tenant_prospect_id 추가
- 가망고객 단계에서도 영업/매니저 체크리스트 사용 가능

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 19:35:00 +09:00
fb06975d97 feat:문서관리 Phase 4.1 - DocumentTemplate API + 결재 워크플로우 활성화
- DocumentTemplate 모델 6개 생성 (Template, ApprovalLine, BasicField, Section, SectionItem, Column)
- DocumentTemplateService (list/show) + DocumentTemplateController (index/show)
- GET /v1/document-templates, GET /v1/document-templates/{id} 라우트
- DocumentTemplateApi.php Swagger (7개 스키마, 2개 엔드포인트)
- Document 결재 워크플로우 4개 엔드포인트 활성화 (submit/approve/reject/cancel)
- ApproveRequest, RejectRequest FormRequest 생성
- DocumentApi.php Swagger에 결재 엔드포인트 4개 추가
- Document.template() 참조 경로 수정 (DocumentTemplate → Documents 네임스페이스)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 09:39:55 +09:00
fb0155624f feat: DB 백업 시스템 구축 (Phase 1,2,4)
- Phase 1: backup.conf.example + sam-db-backup.sh 백업 스크립트
- Phase 2: BackupCheckCommand + StatMonitorService.recordBackupFailure()
- Phase 2: routes/console.php에 db:backup-check 05:00 스케줄 등록
- Phase 4: SlackNotificationService 생성 (웹훅 알림)
- Phase 4: BackupCheckCommand/StatMonitorService에 Slack 알림 연동

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 08:33:19 +09:00
57d9ac2d7f fix: 가이드레일 세트가격 계산을 5130과 동일하게 수정
- 벽면형/측면형: 단가×2 세트가격 후 round(세트가격×길이)×QTY
- 혼합형: (벽면단가+측면단가) 합산 후 단일 항목으로 계산
- 기존: round(단가×길이)×2×QTY → 수정: round(단가×2×길이)×QTY
- 검증: EGI 84/84 + SUS 44/44 + 가이드타입 36/36 = 164/164 ALL PASS

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 00:29:55 +09:00
e5a293ab12 fix(API): 공통코드 조회 DB::table → CommonCode 모델 전환
- DB::table() 직접 쿼리 → CommonCode 모델 사용으로 변경
- SoftDeletes 자동 적용되어 삭제된 레코드 제외
- getComeCode()도 모델 전환 (TenantScope 자동 적용)
- index()는 TenantScope 해제 후 테넌트/글로벌 폴백 직접 처리

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 00:10:36 +09:00
9bd585bdf3 fix: 견적 반올림 순서를 5130과 동일하게 수정 (단건→수량 곱셈)
- 케이스, 케이스용 연기차단재, 가이드레일, 레일용 연기차단재:
  round(단가 × 길이 × QTY) → round(단가 × 길이) × QTY
- 5130 레거시와 동일한 반올림 순서 적용
- 검증: 스크린 44건 + 슬랫 32건 + 가이드타입 21건 = 97건 ALL PASS
- 사이즈 범위: 3000×1500 ~ 12000×4000, QTY 1~5

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 23:52:02 +09:00
f4a902fceb fix: FormulaEvaluatorService 슬랫 통합 및 면적/중량 공식 수정
- FormulaEvaluatorService: 슬랫 면적 공식 분리 (W0×(H0+50) vs W1×(H1+550))
- FormulaEvaluatorService: MOTOR_CAPACITY/BRACKET_SIZE 입력값 우선 처리
- KyungdongFormulaHandler: calculateDynamicItems 면적/중량 제품타입별 분기
- KyungdongFormulaHandler: normalizeGuideType() 추가 (벽면↔벽면형 호환)
- KyungdongFormulaHandler: guide_rail_spec 파라미터 별칭 지원
- 검증: 스크린/슬랫 5치수×3수량 전체 5130 정합성 확인 (±1원 이내)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 22:39:53 +09:00
e9639d1011 feat: 슬랫(철재) 견적 계산 지원 추가 및 5130 정합성 검증
- calculateSlatPrice() 메서드 추가 (면적 = W0×(H0+50), 원자재 단가 '방화')
- calculateDynamicItems() 주자재 분기 (screen→실리카, slat→방화)
- 레일용 연기차단재: 슬랫 ×1 / 스크린 ×2 분기 처리
- 절곡품: 슬랫일 때 L바/보강평철/환봉 제외 (5130 동일)
- 부자재 앵글: 슬랫은 앵글4T, 스크린은 앵글3T
- 모터 받침용 앵글: 슬랫 기본 비활성 (bracket_angle_enabled 파라미터)
- 조인트바: 슬랫 전용 항목 추가 (joint_bar_qty 파라미터)

검증: 치수 5종 × 수량 3종 모두 5130과 완벽 일치

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 22:04:04 +09:00
86ec5c4185 fix: 5130 견적 금액 정합성 수정 (5항목)
- guide_type 매핑: installation_type → guide_type 파라미터 전달 추가 (측면형/혼합형 가이드레일 가격 반영)
- 제어기/뒷박스 수량: QTY 곱셈 제거 (5130 동일: col15/col16/col17은 고정 수량)
- 샤프트 규격 매핑: W0 기반 임의 길이 → 5130 고정 제품(5인치: 6/7/8.2m)으로 매핑
- 환봉/앵글 이중 곱셈 수정: 자동계산에 이미 QTY 포함, 추가 곱셈 제거
- 모터/브라켓 입력값 우선: MOTOR_CAPACITY/BRACKET_SIZE 입력 시 자동계산 대신 사용

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 21:31:22 +09:00
06552ad64e fix: 스크린 면적 계산을 5130 공식과 동일하게 수정
- 기존: (W0+160) × (H0+350+550) / 1,000,000 (W1×H1 기반)
- 수정: W0 × (H0+550) / 1,000,000 (5130 공식과 동일)
- 전 모델 10개 조합 검증 완료 (SAM = 5130 정확 일치)
  KSS01/02, KSE01, KTE01, KWE01, KQTS01, KDSS01
  SUS마감/EGI마감 모두 확인

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 21:06:55 +09:00
868b765658 docs: 앱 버전 API Swagger 문서 추가
- App Version 태그 (latestVersion, download)
- AppVersionCheckResponse, AppVersionLatest 스키마 정의

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 21:06:14 +09:00
24c97cf75a fix(API): 공통코드 조회 테넌트 분리 - 글로벌 폴백 적용
- 테넌트 데이터 존재 시 테넌트만 조회, 없으면 글로벌 폴백
- 기존: tenant OR NULL → 글로벌+테넌트 중복 반환 문제 해결

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 20:13:39 +09:00
49d163ae0c feat: 인앱 업데이트 체크 API 구현
- app_versions 테이블 마이그레이션 (시스템 레벨, tenant_id 없음)
- AppVersion 모델 (SoftDeletes)
- AppVersionService: getLatestVersion, downloadApk
- AppVersionController: GET /api/v1/app/version, GET /api/v1/app/download/{id}
- ApiKeyMiddleware 화이트리스트에 api/v1/app/* 추가
- app_releases 스토리지 디스크 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 19:53:09 +09:00
a41bf48dd8 fix: 가이드레일 모델별 규격 매핑 및 finishing_type 정규화
- getGuideRailSpecs() 메서드 추가: 모델별 가이드레일 규격 매핑
  - KTE01/KQTS01 → 130*75/130*125
  - KDSS01 → 150*150/150*212
  - 기본(KSS01/02, KSE01, KWE01) → 120*70/120*120
- 벽면형/측면형/혼합형 하드코딩 규격을 동적 변수로 교체
- finishing_type 정규화: 'SUS마감' → 'SUS' 변환 (DB 값 매칭)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 19:52:06 +09:00
pro
1ae9a29c62 feat:법인카드 테이블 마이그레이션 추가
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 19:12:32 +09:00
1c3cb48c7c feat(API): FCM 채널명 동기화 및 config 일원화 (7채널)
- push_urgent → push_vendor_register (거래처등록)
- push_payment → push_approval_request (결재요청)
- push_income 신규 추가 (입금)
- config/fcm.php에 전체 7개 채널 등록 (기존 2개→7개)
- 서비스 파일 하드코딩을 config() 참조로 전환

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 18:07:44 +09:00
c111b2b55d fix(API): CommonCode 라벨 조회 시 테넌트 스코프 적용
- getLabel(), getCodeMap()에서 withoutGlobalScopes() → query()로 변경
- BelongsToTenant 스코프가 적용되어 테넌트별 공통코드 정상 조회

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 15:35:36 +09:00
591197f29c fix(API): fix_unique_key 마이그레이션에 테이블 존재 체크 추가
- 테이블 미존재 시 스킵하여 순서 무관하게 안전 실행

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 15:32:29 +09:00
5b553ea13c fix(API): 마이그레이션 순서 수정 - fix_unique_key를 테이블 생성 이후로 이동
- 090000 → 100500으로 변경 (100200 create, 100400 add_columns 이후 실행되도록)
- 로컬 fresh 환경에서 테이블 미존재 상태로 unique key 추가 시 에러 방지

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 15:31:01 +09:00
pro
d412ae45b7 fix:Laravel 12 호환 - Doctrine DBAL 대신 DB::select 사용
- getDoctrineSchemaManager() 제거 (Laravel 12에서 미지원)
- 인덱스 존재 여부 확인을 SHOW INDEX 쿼리로 변경

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 15:17:16 +09:00
pro
73dd6595d0 fix:sales_scenario_checklists 생성 마이그레이션 테이블 존재 체크 추가
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 15:16:20 +09:00
pro
f30fbadc90 fix:sales_consultations 마이그레이션 테이블 존재 체크 추가
- 테이블이 없으면 건너뛰도록 수정
- 컬럼이 이미 존재하면 건너뛰도록 수정

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 15:15:53 +09:00
pro
e407c40228 fix:마이그레이션 인덱스 존재 여부 체크 추가
- sales_scenario_unique 인덱스 삭제 전 존재 여부 확인
- sales_scenario_checkpoint_unique 생성 전 존재 여부 확인
- 서버 환경에서 이미 수동으로 인덱스가 변경된 경우 대응

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 15:15:09 +09:00
pro
60a1d753fd feat:sales_scenario_checklists 테이블 누락 컬럼 마이그레이션 추가
- scenario_type (ENUM: sales/manager)
- checkpoint_id (VARCHAR 50)
- checked_at (TIMESTAMP)
- checked_by (BIGINT UNSIGNED)
- memo (TEXT)
- UNIQUE KEY, INDEX 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 15:12:25 +09:00
a5576a0e00 Merge remote-tracking branch 'origin/develop' into develop 2026-01-30 13:51:49 +09:00
c9e37f4338 fix(API): Schedule 모델 Builder import 누락으로 캘린더/현황판 500 에러 수정
- scopeForTenant 등 스코프 메서드에서 Builder 타입힌트 사용하나 import 누락
- CalendarService, StatusBoardService에서 forTenant() 호출 시 500 발생

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 12:00:13 +09:00
pro
69fa80d36e merge: origin/develop 병합
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 11:55:50 +09:00
63afa4fc9b feat(API): 경동 견적 계산 개선 및 stock_transactions 관계 문서 갱신
- FormulaEvaluatorService: 완제품 미등록 상태에서도 경동 전용 계산 진행, product_model/finishing_type/installation_type 변수 추가
- LOGICAL_RELATIONSHIPS.md: stock_transactions 모델 관계 반영

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 11:23:35 +09:00
66887c9c69 fix(API): 입고 수정 시 completed 상태 변경 지원
- UpdateReceivingRequest: status 허용값에 completed 추가, receiving_qty/receiving_date/lot_no 필드 추가
- ReceivingService::update(): status가 completed로 변경 시 LOT번호 자동생성, 입고수량/입고일 설정, 재고 연동(StockService) 처리

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 11:22:28 +09:00
0fbd080875 docs: sam_stat Swagger API 문서 추가 (Phase 6)
- StatApi.php: Stats 태그, 4개 엔드포인트 Swagger 정의
  - GET /stats/summary - 대시보드 통계 요약
  - GET /stats/daily - 도메인별 일간 통계
  - GET /stats/monthly - 도메인별 월간 통계
  - GET /stats/alerts - 통계 알림 목록
- 스키마: StatSalesDaily, StatFinanceDaily, StatDashboardSummary, StatAlert

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 09:23:51 +09:00
ca2dd44567 fix: 환봉·각파이프 5130 자동계산 공식 적용
- 환봉: W0 기준 자동계산 (≤3000→1, ≤6000→2, ≤9000→3, ≤12000→4 × 수량)
- 각파이프: col67(케이스길이+3000×연결수) 기준 3000mm/6000mm 수량 자동계산
- 기존 하드코딩(각파이프 1개, 환봉 0개) 제거

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:55:40 +09:00
e300062f32 fix: 견적 계산 마구리 수량·각파이프 구조 5130 일치 보정
- 케이스 마구리: 수량 2 고정 → quantity 기반 (5130: maguriPrices × $su)
- 각파이프: 하드코딩 1개 → pipe_3000_qty/pipe_6000_qty 2종 분리 (5130: col68+col69)
- 기본값 fallback: 파이프 수량 미입력 시 W0 기준 자동 결정 유지

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:28:27 +09:00
ca51867cc2 feat: sam_stat 최적화 및 안정화 (Phase 5)
- StatBackfillCommand: 과거 데이터 일괄 백필 (일간+월간, 프로그레스바, 에러 리포트)
- StatVerifyCommand: 원본 DB vs sam_stat 정합성 교차 검증 (--fix 자동 재집계)
- 파티셔닝 준비: 7개 일간 테이블 RANGE COLUMNS(stat_date) 마이그레이션
- Redis 캐싱: StatQueryService Cache::remember TTL 5분 + invalidateCache()
- StatMonitorService: 집계 실패/누락/불일치 시 stat_alerts 알림 기록
- StatAggregatorService: 모니터링 알림 + 캐시 무효화 연동

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:17:11 +09:00
3793e95662 fix: 견적 단가 chandj 원본 소스 전환 및 5130 계산 일치 보정
- MigrateBDModelsPrices: chandj 원본 테이블(price_motor, price_angle 등)에서 직접 마이그레이션
- EstimatePriceService: 모터 LIKE 매칭, 제어기 카테고리 분리, 앵글 bracket/main 분리, 샤프트 포맷 정규화
- KyungdongFormulaHandler:
  - 검사비 항목 추가 (기본 50,000원)
  - 뒷박스 항목 추가 (제어기 섹션)
  - 부자재 앵글3T 항목 추가 (calculatePartItems)
  - 면적 소수점 2자리 반올림 후 곱셈 (5130 동일)
  - model_name에 product_model fallback 추가 (KSS02 단가 정확 조회)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:00:15 +09:00
4d8dac1091 feat: sam_stat P2 도메인 + 통계 API + 대시보드 전환 (Phase 4)
- 4.1: stat_project_monthly + ProjectStatService (건설/프로젝트 월간)
- 4.2: stat_system_daily + SystemStatService (API/감사/FCM/파일/결재)
- 4.3: stat_events, stat_snapshots + StatEventService + StatEventObserver
- 4.4: StatController (summary/daily/monthly/alerts) + StatQueryService + FormRequest 3개 + routes/stats.php
- 4.5: DashboardService sam_stat 우선 조회 + 원본 DB 폴백 패턴

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 21:56:53 +09:00
595e3d59b4 feat: sam_stat P1 도메인 확장 (Phase 3)
- 차원 테이블: dim_client, dim_product 마이그레이션 + SCD Type 2 동기화 (DimensionSyncService)
- 재고 통계: stat_inventory_daily + InventoryStatService (stocks, stock_transactions, inspections)
- 견적/영업 통계: stat_quote_pipeline_daily + QuoteStatService (quotes, biddings, sales_prospects)
- 인사/근태 통계: stat_hr_attendance_daily + HrStatService (attendances, leaves, user_tenants)
- KPI/알림: stat_kpi_targets, stat_alerts + KpiAlertService + StatCheckKpiAlertsCommand
- StatAggregatorService에 inventory, quote, hr 도메인 추가 (총 6개 도메인)
- 스케줄러: stat:check-kpi-alerts 매일 09:00 등록

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 20:19:50 +09:00
6c9735581d feat: 견적 단가를 items+item_details+prices 통합 구조로 전환
- EstimatePriceService 생성: items+item_details+prices JOIN 기반 단가 조회
  - item_details.product_category/part_type/specification 컬럼 매핑
  - items.attributes JSON으로 model_name/finishing_type 추가 차원 처리
  - 세션 내 캐시로 중복 조회 방지
- MigrateBDModelsPrices 커맨드: 레거시 BDmodels + kd_price_tables → 85건 마이그레이션
- KyungdongFormulaHandler: KdPriceTable 의존 제거 → EstimatePriceService 사용
- FormulaEvaluatorService: W1 마진 140→160, 면적 공식 W1×(H1+550) 수정
  - 가이드레일 H0+250, 케이스/L바/평철 W0+220 (레거시 일치)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 19:30:46 +09:00
e882d33de1 feat: sam_stat P0 도메인 집계 구현 (Phase 2)
- 영업(Sales), 재무(Finance), 생산(Production) 3개 도메인 구현
- 일간/월간 통계 테이블 6개 마이그레이션 생성
- 도메인별 StatService (SalesStatService, FinanceStatService, ProductionStatService)
- Daily/Monthly 6개 Eloquent 모델 생성
- StatAggregatorService에 도메인 서비스 매핑 활성화
- StatJobLog duration_ms abs() 처리
- 스케줄러 등록 (일간 02:00, 월간 1일 03:00)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 19:30:30 +09:00
pro
68ffbdfa08 feat:영업수수료 정산 마이그레이션 추가
- sales_commissions 테이블 생성 (영업수수료 정산)
- sales_commission_details 테이블 생성 (상품별 수당 내역)
- sales_tenant_managements 테이블에 입금 정보 컬럼 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 18:13:59 +09:00
c88048db67 feat: sam_stat 통계 DB 인프라 구축 (Phase 1)
- sam_stat DB 연결 설정 (config/database.php, .env)
- 메타 테이블 마이그레이션 (stat_definitions, stat_job_logs)
- dim_date 차원 테이블 + DimDateSeeder (2020~2030, 4018건)
- 기반 모델: BaseStatModel, StatDefinition, StatJobLog, DimDate
- 집계 커맨드: stat:aggregate-daily, stat:aggregate-monthly
- StatAggregatorService + StatDomainServiceInterface 골격

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 17:13:36 +09:00
4f2a329e4e fix: 출금 오늘의 이슈 path 분기 (1건: 상세, 2건+: 목록)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 16:42:41 +09:00
pro
f8d37f0b5e refactor:sales_contract_products 테이블 development_fee → registration_fee 변경 2026-01-29 16:38:34 +09:00
pro
ff829ad184 feat:가입비(registration_fee) 컬럼 추가 및 시더 업데이트 2026-01-29 16:31:50 +09:00
ef4a894cbc fix: TenantUserProfile 모델 Eloquent Model import 누락 수정
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 16:28:57 +09:00
pro
8327568a77 fix:상품 가격 데이터 현실화 (개발비 8천만원, 구독료 50만원 등) 2026-01-29 16:25:30 +09:00
pro
e7054b6633 feat:영업파트너/매니저 수당율 분리 (commission_rate → partner/manager) 2026-01-29 16:18:22 +09:00
e1aa1d1577 fix: TenantSettingController namespace 대소문자 수정 (v1 → V1)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 15:38:09 +09:00
45a15fe64f fix: BiddingController namespace 대소문자 수정 (v1 → V1)
- Linux 대소문자 구분으로 Swagger 문서 생성 실패 해결

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 15:36:11 +09:00
189b38c936 feat: Auditable 트레이트 구현 및 97개 모델 적용
- Auditable 트레이트 신규 생성 (bootAuditable 패턴)
  - creating: created_by/updated_by 자동 채우기
  - updating: updated_by 자동 채우기
  - deleting: deleted_by 채우기 + saveQuietly()
  - created/updated/deleted: audit_logs 자동 기록
- 기존 AuditLogger 패턴과 동일한 try/catch 조용한 실패
- 변경된 필드만 before/after 기록 (updated 이벤트)
- auditExclude 프로퍼티로 모델별 제외 필드 설정 가능
- 제외 대상: Attendance, StockTransaction, TodayIssue 등 고빈도/시스템 모델

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 15:33:54 +09:00
00a1257c63 fix: ApprovalStep 모델 Eloquent Model import 누락 수정
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 15:29:01 +09:00
99a6c89d41 feat: 견적 할인금액(discount_amount) 직접 입력 지원
- QuoteStoreRequest/UpdateRequest에 discount_amount 필드 추가
- QuoteService: 프론트엔드에서 계산한 할인금액 우선 사용, 없으면 비율로 계산

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 15:05:14 +09:00
f7ad9ae36e feat(재고): stock_transactions 입출고 거래 이력 테이블 추가
- stock_transactions 마이그레이션 생성 (type, qty, balance_qty, reference)
- StockTransaction 모델 (IN/OUT/RESERVE/RELEASE 타입, 사유 상수)
- StockService 5개 메서드에 거래 이력 기록 추가
  - increaseFromReceiving → IN
  - decreaseFIFO → OUT (LOT별)
  - reserve → RESERVE (LOT별)
  - releaseReservation → RELEASE (LOT별)
  - decreaseForShipment → OUT (LOT별)
- Stock 모델에 transactions() 관계 추가
- 기존 audit_logs 기록은 유지 (감사 로그와 거래 이력 목적 분리)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 15:05:03 +09:00
847717e631 feat(거래처): client_type 필터 추가
- ClientService.index()에 client_type 파라미터 필터 추가
- 쉼표 구분 복수 값 지원 (예: PURCHASE,BOTH)
- 매입 가능 거래처만 조회하는 용도

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 15:04:55 +09:00
pro
e439bfffda feat:영업 상품관리 DB 스키마 및 시더 추가
- sales_product_categories: 상품 카테고리 테이블
- sales_products: 영업 상품 테이블
- sales_contract_products: 계약별 선택 상품 테이블
- SalesProductSeeder: 제조업체 8개, 공사업체 3개 상품 초기 데이터

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 15:02:07 +09:00
e8fc42c14d fix: 입고 목록 날짜 필터 기준을 created_at으로 변경
- receiving_date 기준 → created_at(작성일) 기준으로 변경
- 입고처리 전(receiving_date=NULL) 데이터가 필터에서 누락되는 문제 해결
- creator 관계 eager loading 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 14:40:32 +09:00
a25d267550 fix: 입고 등록 order_qty 필수 검증 제거
- StoreReceivingRequest에서 order_qty를 required → nullable로 변경
- 입고 등록 시 발주수량 없이도 등록 가능하도록 수정

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 13:48:03 +09:00
pro
c7339ac7db feat:본사 진행 상태 및 수당 지급 상태 컬럼 추가
sales_tenant_managements 테이블:
- hq_status: 본사 진행 상태 (pending, review, planning, coding, dev_test, dev_done, int_test, handover)
- incentive_status: 수당 지급 상태 (pending, eligible, paid)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 11:39:20 +09:00
7acaac8340 feat: 출금 오늘의 이슈를 일일 누계 방식으로 변경
- 출금 건별 개별 이슈 → 해당일 누계 1건으로 upsert
- 첫 건: "거래처명 출금 금액원"
- 2건+: "첫거래처명 외 N건 출금 합계 금액원"
- 삭제 시 남은 건수/금액으로 자동 갱신
- FCM 푸시 미발송 (알림 설정 미구현)
- 기존 개별 source_id 레거시 이슈 자동 정리

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 11:03:19 +09:00
aa7678c358 feat: 수동 품목 단가 조회 API 추가 및 디버그 로그 정리
- QuoteController에 getItemPrices 엔드포인트 추가
- QuoteCalculationService에 품목 코드 배열로 단가 조회 기능 추가
- 불필요한 디버그 로그 제거

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 09:29:19 +09:00
pro
27a558dafb feat:sales_consultations에 gcs_uri 컬럼 추가
- 음성 녹음 파일의 Google Cloud Storage URI 저장용

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 09:15:22 +09:00
946e008b02 fix: API 라우트 분리 시 누락된 라우트 복구
분리 전 원본 라우트와 비교하여 누락된 17개 라우트 복구:

[design.php]
- BomCalculationController: calculateBom, getCompanyFormulas, getEstimateParameters, saveCompanyFormula, testFormula
- DesignBomTemplateController: cloneTemplate, diff, listByVersion, replaceItems, upsertTemplate
- DesignModelVersionController: createDraft (store → createDraft 메서드명 수정)
- ModelSetController: calculateBom, getBomTemplates, getCategoryFields, getEstimateParameters

[inventory.php]
- ItemsFileController: upload, delete (store/destroy → upload/delete 메서드명 수정)
- ItemsBomController: listAll, listCategories, tree, replace
- ItemsController: showByCode, batchDestroy
- LaborController: stats, bulkDestroy

[sales.php]
- ClientController: bulkDestroy
- ClientGroupController: toggle
- BiddingController: updateStatus
- PricingController: stats, cost, byItems, bulkDestroy, finalize, revisions
- QuoteController: convertToBidding, convertToOrder, sendHistory

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 09:10:13 +09:00
pro
4c7cf85ef9 fix:시나리오 체크리스트 유니크 키 수정
- 기존 유니크 키 (tenant_id, user_id, step_id, checkpoint_index) 삭제
- 새 유니크 키 (tenant_id, scenario_type, step_id, checkpoint_id) 생성
- checkpoint_index를 nullable로 변경

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 09:00:07 +09:00
87e20e965a feat: BOM 계산 debug_steps에 수식 정보 추가
- calculateKyungdongBom 메서드에 formulas 배열 추가
- Step 1: 입력값 (변수, 설명, 값, 단위)
- Step 3: 변수계산 (수식, 대입, 결과)
- Step 6-7: 품목별 수량/금액 계산 과정
- Step 9: 카테고리별 소계 계산
- Step 10: 최종합계 수식
- 프론트엔드에서 실제 계산 수식 확인 가능

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 08:17:34 +09:00
a0ffeb954b fix: 견적 확정/취소 API 라우트 추가
- POST /quotes/{id}/finalize 라우트 추가
- POST /quotes/{id}/cancel-finalize 라우트 추가
- 라우트 누락으로 인한 404 오류 수정

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 07:44:04 +09:00
pro
6208d90244 feat:영업관리 테이블 마이그레이션 추가
- sales_partners: 영업 파트너 정보
- sales_tenant_managements: 테넌트별 영업 관리 (tenant_id FK)
- sales_scenario_checklists: 시나리오 체크리스트
- sales_consultations: 상담 기록 (텍스트/음성/파일)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 06:42:25 +09:00
90bcfaf268 chore: 논리적 관계 문서 및 글로벌 카테고리 마이그레이션 추가
- LOGICAL_RELATIONSHIPS.md 업데이트
- create_global_categories_table 마이그레이션 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 01:12:40 +09:00
3fce54b7d4 feat: 경동기업 전용 견적 계산 로직 구현 (Phase 4 완료)
- KdPriceTable 모델: 경동기업 단가 테이블 (motor, shaft, pipe, angle, raw_material, bdmodels)
- KyungdongFormulaHandler: 모터 용량, 브라켓 크기, 절곡품(10종), 부자재(3종) 계산
- FormulaEvaluatorService: tenant_id=287 라우팅 추가
- kd_price_tables 마이그레이션 및 시더 (47건 단가 데이터)

테스트 결과: W0=3000, H0=2500 입력 시 16개 항목, 합계 751,200원 정상 계산

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 01:10:42 +09:00
e9894fef61 feat: 수주 목록/상세 필드 개선
- OrderService: client relation에 manager_name 추가
- Order 모델: shipping_cost_label accessor 추가 (common_codes 조회)
- $appends에 shipping_cost_label 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 22:24:21 +09:00
42deb60861 fix: Quote API 라우트 누락 복구
- 라우트 분리(a96499a) 시 누락된 견적 라우트 복구
- /calculate, /calculate/bom, /calculate/bom/bulk
- /number/preview, /calculation/schema
- /{id}/pdf, /{id}/send/email, /{id}/send/kakao
- 잘못 추가된 bulk-issue-document 라우트 제거

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 22:07:37 +09:00
d3825e4bfb fix: 매입 수정 시 알림/푸시 발송 제외
- handleExpectedExpenseChange()에서 source_type이 'purchases'인 경우 알림 제외
- 매입 알림은 결재 상신 시에만 발송되도록 변경

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 21:40:37 +09:00
8cf588bf05 feat: Phase 3 단가 테이블 마이그레이션 추가
- Phase 3.1: price_motor → items (SM) 누락 품목 13건 추가
  - PM-020~PM-032: 제어기 (6P~100회선)
  - PM-033~PM-035: 방화/방범 콘트롤박스, 스위치
- Phase 3.2: price_raw_materials → items (RM) 누락 품목 4건 추가
  - RM-007~RM-011: 신설비상문, 제연커튼, 화이바/와이어원단
- 중복 확인 로직: 기존 품목명과 mb_strtolower 비교
- 최종 결과: items 651건, prices 651건, BOM 18건

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 21:29:13 +09:00
e06b0637fa feat: 문서 관리 시스템 Route 및 Swagger 구현 (Phase 1.8)
- Document API Route 등록 (CRUD 5개 엔드포인트)
- Swagger 문서 작성 (Document, DocumentApproval, DocumentData, DocumentAttachment 스키마)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-28 21:29:10 +09:00
9bceaab8a3 feat: 문서 관리 시스템 FormRequest 구현 (Phase 1.7)
- IndexRequest: 목록 조회 필터/페이징 검증
- StoreRequest: 문서 생성 검증 (템플릿, 데이터, 첨부파일)
- UpdateRequest: 문서 수정 검증

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-28 21:14:30 +09:00
b7f8157548 feat: 문서 관리 시스템 Controller 구현 (Phase 1.6)
- DocumentController CRUD 엔드포인트 구현
- 결재 워크플로우는 기존 시스템 연동을 위해 보류

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-28 21:11:40 +09:00
94612e3b50 refactor: Attendance store() 메서드를 Upsert 패턴으로 변경
- 같은 날 같은 사용자의 기록이 있으면 업데이트, 없으면 생성
- 기존 Create Only 패턴에서 Upsert 패턴으로 변경
- Swagger 문서 업데이트 (409 응답 제거, 설명 변경)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-28 21:09:53 +09:00
2e219edf8a feat: Phase 2 BOM 마이그레이션 추가
- Phase 2.1: BDmodels.seconditem → PT items 6건 추가
  - 누락 부품: L-BAR, 보강평철, 케이스, 하단마감재 등
- Phase 2.2: items.bom JSON 연결 18건
  - FG items (models) ↔ PT items (seconditem) BOM 관계 설정
- 최종: items 634건, prices 634건, BOM 18건

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 21:06:59 +09:00
bacc42da73 feat: 문서 관리 시스템 Service 구현 (Phase 1.5)
- DocumentService 생성 (CRUD + 결재 워크플로우)
- 순차 결재 로직 구현 (submit/approve/reject/cancel)
- Multi-tenancy 및 DB 트랜잭션 지원

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-28 20:41:31 +09:00
ec47c26ea8 feat: Phase 1.1-1.2 추가 (models, item_list 마이그레이션)
- migrateModels(): chandj.models → items (FG) 18건
- migrateItemList(): chandj.item_list → items (PT) 9건
- migratePrices(): 다양한 소스 단가 처리 로직 개선
- 코드 포맷: FG-{model}-{type}-{finish}, PT-{name}

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 20:38:29 +09:00
2bce30056d feat: 문서 관리 시스템 모델 생성 (Phase 1.2)
- Document 모델 (상태 관리, 다형성 연결, 결재 처리)
- DocumentApproval 모델 (결재 단계, 상태 처리)
- DocumentData 모델 (EAV 패턴 데이터 저장)
- DocumentAttachment 모델 (파일 첨부 연결)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 19:57:22 +09:00
c7b2e97189 feat: 경동기업 품목/단가 마이그레이션 Seeder 구현 (Phase 1.0)
- KyungdongItemSeeder.php 생성
- chandj.KDunitprice → samdb.items, prices 마이그레이션
- is_deleted=NULL 조건 반영 (레거시 데이터 특성)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-28 19:22:18 +09:00
f76fd2f865 feat: 문서 관리 시스템 DB 스키마 구현 (Phase 1.1)
- documents 테이블 생성 (문서 기본 정보, 상태, 다형성 연결)
- document_approvals 테이블 생성 (결재 처리)
- document_data 테이블 생성 (EAV 패턴 데이터 저장)
- document_attachments 테이블 생성 (파일 첨부)
- SAM 규칙 준수 (tenant_id, 감사 컬럼, softDeletes, comment)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 19:22:05 +09:00
a96499a66d feat: API 라우터 분리 및 버전 폴백 시스템 구현
- api.php를 13개 도메인별 파일로 분리 (1,479줄 → 61줄)
- ApiVersionMiddleware 생성 (헤더/쿼리 기반 버전 선택)
- v2 요청 시 v2 없으면 v1으로 자동 폴백
- 지원 헤더: Accept-Version, X-API-Version, api_version 쿼리

분리된 도메인:
auth, admin, users, tenants, hr, finance, sales,
inventory, production, design, files, boards, common

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 17:30:19 +09:00
pro
f163373fb0 feat:credit_inquiries 테이블에 tenant_id 컬럼 추가
- 신용평가 조회회수 집계 기능을 위한 테넌트 구분 컬럼
- tenant_id, (tenant_id, inquired_at) 인덱스 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 16:49:31 +09:00
f74767563f feat: FCM 사용자별 타겟 알림 발송 기능 추가
- today_issues 테이블에 target_user_id 컬럼 추가 (마이그레이션)
- TodayIssue 모델: target_user_id 필드, targetUser 관계, forUser/targetedTo 스코프 추가
- TodayIssue 모델: 기안 상태 뱃지 상수 추가 (BADGE_DRAFT_APPROVED/REJECTED/COMPLETED)
- TodayIssueObserverService: createIssueWithFcm, sendFcmNotification, getEnabledUserTokens에 targetUserId 파라미터 추가
- TodayIssueObserverService: handleApprovalStepChange - 결재자에게만 발송
- TodayIssueObserverService: handleApprovalStatusChange 추가 - 기안자에게만 발송
- ApprovalIssueObserver 신규 생성 및 AppServiceProvider에 등록
- i18n: 기안 승인/반려/완료 알림 메시지 추가

결재요청은 결재자(ApprovalStep.user_id)에게만,
기안 승인/반려는 기안자(Approval.drafter_id)에게만 FCM 발송

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 13:57:46 +09:00
pro
6e553ce3c9 feat:tenant_prospects 테이블에 첨부파일 컬럼 추가
- id_card_path: 신분증 사본 경로
- bankbook_path: 통장 사본 경로

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 08:49:40 +09:00
pro
a88f8a2021 feat:sales_prospects 테이블에 신분증/통장사본 컬럼 추가
- id_card_image: 신분증 사본 이미지 경로
- bankbook_image: 통장 사본 이미지 경로

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 23:42:20 +09:00
pro
3e8570ac3f feat:sales_prospects 테이블에 business_card_image 컬럼 추가
명함 이미지 저장을 위한 컬럼 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 23:36:32 +09:00
pro
4839cfcad2 feat:ai_configs 테이블 마이그레이션 추가
- AI API 설정 테이블 (Gemini, Claude, OpenAI 지원)
- provider별 활성화 상태 관리
- 명함 OCR 시스템을 위한 기반 구조
2026-01-27 23:00:43 +09:00
518ae4657e fix: 오늘의 이슈 뱃지 타입 source_type 기반 매핑
- TodayIssue 모델에 SOURCE_TO_BADGE 매핑 상수 추가
- TodayIssueService에서 source_type 기반 badge 매핑 적용
- 입금/출금 소스 타입 및 뱃지 상수 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 22:40:54 +09:00
pro
9182cbc1b3 feat:영업권(명함등록) 테이블 마이그레이션 추가
- tenant_prospects 테이블 생성
- 영업권 2개월 유효, 1개월 쿨다운 정책 지원
- 테넌트 전환 추적 기능 포함

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 22:39:30 +09:00
3917ea3831 fix: 거래처 연체/악성채권 저장 버그 수정
- ClientUpdateRequest, ClientStoreRequest에 is_overdue 필드 추가
  - FormRequest rules에 누락되어 프론트엔드 값이 필터링됨
- ClientService.update()에 bad_debt 토글 연동 로직 추가
  - bad_debt=true → BadDebt 레코드 생성 (status: collecting)
  - bad_debt=false → BadDebt 레코드 종료 (status: recovered)
- ClientService의 has_bad_debt 판단 로직 수정
  - 기존: sum(debt_amount) > 0
  - 변경: exists() - 금액과 무관하게 레코드 존재 여부로 판단

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 22:39:04 +09:00
51b23adcfe fix: 오늘의 이슈 오늘 날짜만 표시하도록 수정
- TodayIssue 모델에 scopeToday() 스코프 추가
- TodayIssueService::summary()에 오늘 날짜 필터 적용
- 전체 개수 계산에도 오늘 날짜 필터 적용

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-27 21:10:50 +09:00
pro
5de77e3b35 feat:영업담당자 User 통합을 위한 마이그레이션 추가
- users 테이블에 parent_id, approval_status, approved_by, approved_at, rejection_reason 컬럼 추가
- sales_manager_documents 테이블 생성 (멀티파일 업로드)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 20:06:36 +09:00
7bd296b2fa fix: 카테고리 라우트 순서 수정 (/tree → /{id} 앞으로 이동)
- /tree, /reorder 라우트를 /{id} 와일드카드 라우트보다 먼저 정의
- 500 에러 해결: "tree"가 id 파라미터로 잘못 매칭되던 문제

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-27 15:51:16 +09:00
a9cdf004e3 fix: 게시판 코드 기반 조회 라우트 추가
- BoardController에 showByCode(string $code) 메서드 추가
- GET /api/v1/boards/{code} 라우트 등록
- 기존 ID 기반 조회와 코드 기반 조회 분리

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-27 15:24:35 +09:00
dd8a744d12 fix: QuoteItemCategorySeeder 중복 실행 지원
- insertGetId → updateOrInsert로 변경
- 이미 존재하는 카테고리는 업데이트, 없으면 생성

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-27 15:23:02 +09:00
pro
4106f59cd1 feat:바로빌 과금 정책 테이블 마이그레이션
- barobill_pricing_policies 테이블 생성
- 서비스 유형별 과금 정책 저장 (무료 제공량, 추가 과금 단위/금액)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 15:17:34 +09:00
8a1e78ec72 feat: 견적 V2 동적 카테고리 시스템 구현
- CategoryService: tree 메서드에 code_group 필터 지원 추가
- FormulaEvaluatorService: 하드코딩된 process_type을 동적 카테고리로 변경
  - groupItemsByProcess(): item_category 필드 기반 그룹화
  - getItemCategoryTree(): DB에서 카테고리 트리 조회
  - buildCategoryMapping(): BENDING 하위 카테고리 처리
  - addProcessGroupToItems(): category_code 필드 추가 (레거시 호환 유지)
- QuoteItemCategorySeeder: 품목 카테고리 초기 데이터 시더 추가
  - BODY, BENDING(하위 3개), MOTOR_CTRL, ACCESSORY

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-27 15:17:30 +09:00
pro
597d24eb19 feat:바로빌 과금관리 테이블 마이그레이션
- barobill_subscriptions: 월정액 구독 관리
- barobill_billing_records: 과금 내역 기록
- barobill_monthly_summaries: 월별 집계 테이블

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 15:03:57 +09:00
유병철
4c22b74b27 feat: 출퇴근 설정에 자동 출퇴근 사용 여부 필드 추가
- attendance_settings 테이블에 use_auto 컬럼 추가
- AttendanceSetting 모델에 use_auto 필드 추가 (fillable, casts, attributes)
- UpdateAttendanceSettingRequest에 use_auto 유효성 검사 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 14:47:39 +09:00
22f7e9d94a fix: FULLTEXT 검색을 LIKE 검색으로 롤백
- 개발 서버에 FULLTEXT 인덱스 미설치로 500 에러 발생
- 기존 LIKE 검색 방식으로 복원

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 13:55:12 +09:00
3ff3c65ade feat: 품목 조회 시 BOM 유무 필터 및 코드+이름 형식 지원
- has_bom 파라미터 추가 (1: BOM 있는 품목만, 0: BOM 없는 품목만)
- JSON_LENGTH 활용한 BOM 유무 필터링 구현
- name 필드를 "코드 이름" 형식으로 반환 (일시적 변경)
- FULLTEXT 인덱스 활용 검색 개선 (BOOLEAN MODE)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 12:47:38 +09:00
eeb55d1c28 feat: 문서 템플릿 마이그레이션 추가 및 관계 문서 업데이트
- document_templates 테이블 마이그레이션 추가
- LOGICAL_RELATIONSHIPS.md 업데이트

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 22:12:17 +09:00
6d05ab815f feat:테넌트설정 API 및 다수 서비스 개선
- TenantSetting CRUD API 추가
- Calendar, Entertainment, VAT 서비스 개선
- 5130 BOM 계산 로직 수정
- quote_items에 item_type 컬럼 추가
- tenant_settings 테이블 마이그레이션
- Swagger 문서 업데이트
2026-01-26 20:29:22 +09:00
f2da990771 fix: 견적 V2 BOM 계산 오류 수정
- ItemService.php: has_bom 계산 필드 추가 (BOM 필터링용)
- FormulaEvaluatorService.php: process_group 필드 추가 (공정별 그룹핑)

관련: 견적 V2 자동 견적 산출 4가지 오류 수정

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 16:12:45 +09:00
731 changed files with 197078 additions and 5376 deletions

137
.env.example Normal file
View File

@@ -0,0 +1,137 @@
# ─────────────────────────────────────────────────
# SAM API (REST API 서버) 환경 변수
# ─────────────────────────────────────────────────
# 이 파일을 .env로 복사한 후 실제 값을 입력하세요.
# cp .env.example .env && php artisan key:generate
# ─────────────────────────────────────────────────
APP_NAME="SAM API"
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=https://api.sam.kr/
APP_LOCALE=ko
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=ko_KR
APP_TIMEZONE=Asia/Seoul
APP_MAINTENANCE_DRIVER=file
PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
# ─── Slack 로그 알림 ───
LOG_SLACK_WEBHOOK_URL=
LOG_SLACK_USERNAME=API_SERVER
LOG_SLACK_EMOJI=:boom:
# ─── Database ───
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=samdb
DB_USERNAME=samuser
DB_PASSWORD=sampass
# 도커 환경: docker-compose.yml의 환경변수로 오버라이드 (DB_HOST=sam-mysql-1)
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
CACHE_STORE=database
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
# ─── Mail ───
MAIL_MAILER=smtp
MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
MAIL_USERNAME=
MAIL_PASSWORD=
MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS=
MAIL_FROM_NAME="(주)코드브릿지엑스"
# ─── AWS (미사용) ───
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
# ─── Swagger ───
L5_SWAGGER_GENERATE_ALWAYS=true
L5_SWAGGER_CONST_HOST=https://api.sam.kr/
L5_SWAGGER_CONST_NAME="SAM API 서버"
# ─── Sanctum 토큰 만료 (분) ───
SANCTUM_ACCESS_TOKEN_EXPIRATION=120
SANCTUM_REFRESH_TOKEN_EXPIRATION=10080
# ─── 내부 통신 키 (MNG ↔ API HMAC 검증) ───
# MNG 프로젝트의 INTERNAL_EXCHANGE_SECRET과 동일한 값 사용
INTERNAL_EXCHANGE_SECRET=
# ─── Firebase (FCM) ───
FCM_PROJECT_ID=
FCM_SA_PATH=secrets/codebridge-x-firebase-sa.json
# ─── 5130 Legacy DB ───
CHANDJ_DB_HOST=sam-mysql-1
CHANDJ_DB_DATABASE=chandj
CHANDJ_DB_USERNAME=root
CHANDJ_DB_PASSWORD=root
# ─── 바로빌 SOAP API ───
BAROBILL_CERT_KEY_TEST=
BAROBILL_CERT_KEY_PROD=
BAROBILL_CORP_NUM=
BAROBILL_TEST_MODE=true
# ─────────────────────────────────────────────────
# 공유 API 키 (MNG 프로젝트와 동일한 값 사용)
# ─────────────────────────────────────────────────
# ─── Google Gemini AI ───
GEMINI_API_KEY=
GEMINI_MODEL=gemini-2.0-flash
GEMINI_BASE_URL=https://generativelanguage.googleapis.com/v1beta
GEMINI_PROJECT_ID=codebridge-chatbot
# ─── Claude AI ───
CLAUDE_API_KEY=
# ─── Vertex AI (Veo 영상 생성) ───
VERTEX_AI_PROJECT_ID=codebridge-chatbot
VERTEX_AI_LOCATION=us-central1
# ─── Google Cloud (STT + GCS Storage) ───
GOOGLE_APPLICATION_CREDENTIALS=/var/www/mng/apikey/google_service_account.json
GOOGLE_STORAGE_BUCKET=codebridge-speech-audio-files
GOOGLE_STT_LOCATION=asia-southeast1
# ─── FCM (Firebase Cloud Messaging) ───
FCM_BATCH_CHUNK_SIZE=200
FCM_BATCH_DELAY_MS=100
FCM_LOGGING_ENABLED=true
FCM_LOG_CHANNEL=stack

7
.gitignore vendored
View File

@@ -11,6 +11,7 @@
!storage/.gitignore
.env
.env.*
!.env.example
.phpunit.result.cache
Homestead.yaml
Homestead.json
@@ -153,4 +154,10 @@ _ide_helper_models.php
!**/data/
# 그리고 .gitkeep은 예외로 추적
!**/data/.gitkeep
# 시더 데이터 JSON은 추적
!database/seeders/data/kyungdong/
!database/seeders/data/kyungdong/**
storage/secrets/
# Serena MCP memories
.serena/

View File

@@ -0,0 +1,21 @@
# DB Backup System - State
## 상태
- **phase**: Phase 1 대기
- **progress**: 0/14 (0%)
- **next_step**: Phase 1.1 - backup.conf.example 설정 파일 생성
- **last_decision**: 계획 확정 (5 Phase, 14 항목)
- **last_update**: 2026-01-30
## Phase 구성
- Phase 1: 백업 스크립트 (3항목) — api/scripts/backup/
- Phase 2: Laravel 모니터링 (3항목) — api/ BackupCheckCommand
- Phase 3: 서버 배포 & 테스트 (2항목) — 개발서버 crontab
- Phase 4: Slack 알림 (4항목) — api/ SlackNotificationService
- Phase 5: MNG 관리자 패널 (4항목) — mng/ /system/alerts
## 핵심 파일
- 계획 문서: docs/plans/db-backup-system-plan.md
- 개발서버: 114.203.209.83 (SSH: hskwon)
- DB: sam (메인) + sam_stat (통계)
- Slack 웹훅: api/.env → LOG_SLACK_WEBHOOK_URL (이미 설정됨)

View File

@@ -79,6 +79,31 @@ excluded_tools: []
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""
# the name by which the project can be referenced within Serena
project_name: "api"
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default)
included_optional_tools: []
# list of mode names to that are always to be included in the set of active modes
# The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this setting overrides the global configuration.
# Set this to [] to disable base modes for this project.
# Set this to a list of mode names to always include the respective modes for this project.
base_modes:
# list of mode names that are to be activated by default.
# The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
# This setting can, in turn, be overridden by CLI parameters (--mode).
default_modes:
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
fixed_tools: []
# 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.
symbol_info_budget:

View File

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

File diff suppressed because it is too large Load Diff

133
Jenkinsfile vendored Normal file
View File

@@ -0,0 +1,133 @@
pipeline {
agent any
options {
disableConcurrentBuilds()
}
environment {
DEPLOY_USER = 'hskwon'
RELEASE_ID = new Date().format('yyyyMMdd_HHmmss')
}
stages {
stage('Checkout') {
steps {
checkout scm
script {
env.GIT_COMMIT_MSG = sh(script: "git log -1 --pretty=format:'%s'", returnStdout: true).trim()
}
slackSend channel: '#deploy_api', color: '#439FE0', tokenCredentialId: 'slack-token',
message: "🚀 *api* 빌드 시작 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
}
}
// ── main → 운영서버 Stage 배포 ──
stage('Deploy Stage') {
when { branch 'main' }
steps {
sshagent(credentials: ['deploy-ssh-key']) {
sh """
ssh ${DEPLOY_USER}@211.117.60.189 'mkdir -p /home/webservice/api-stage/releases/${RELEASE_ID}'
rsync -az --delete \
--exclude='.git' --exclude='.env' \
--exclude='storage/app' --exclude='storage/logs' \
--exclude='storage/framework/sessions' --exclude='storage/framework/cache' \
. ${DEPLOY_USER}@211.117.60.189:/home/webservice/api-stage/releases/${RELEASE_ID}/
ssh ${DEPLOY_USER}@211.117.60.189 '
cd /home/webservice/api-stage/releases/${RELEASE_ID} &&
mkdir -p bootstrap/cache storage/framework/{views,cache/data,sessions} storage/logs &&
sudo chown -R www-data:webservice storage bootstrap/cache &&
sudo chmod -R 775 storage bootstrap/cache &&
ln -sfn /home/webservice/api-stage/shared/.env .env &&
sudo chmod 640 /home/webservice/api-stage/shared/.env &&
ln -sfn /home/webservice/api-stage/shared/storage/app storage/app &&
composer install --no-dev --optimize-autoloader --no-interaction &&
php artisan config:cache &&
php artisan route:cache &&
php artisan view:cache &&
php artisan migrate --force &&
ln -sfn /home/webservice/api-stage/releases/${RELEASE_ID} /home/webservice/api-stage/current &&
sudo systemctl reload php8.4-fpm &&
cd /home/webservice/api-stage/releases && ls -1dt */ | tail -n +4 | xargs rm -rf 2>/dev/null || true
'
"""
}
}
}
// ── 운영 배포 승인 (런칭 후 활성화) ──
// stage('Production Approval') {
// when { branch 'main' }
// steps {
// slackSend channel: '#product_deploy', color: '#FF9800', tokenCredentialId: 'slack-token',
// message: "🔔 *api* 운영 배포 승인 대기 중\n${env.GIT_COMMIT_MSG}\nStage API: https://stage-api.sam.it.kr\n<${env.BUILD_URL}input|승인하러 가기>"
// timeout(time: 24, unit: 'HOURS') {
// input message: 'Stage 확인 후 운영 배포를 진행하시겠습니까?\nStage API: https://stage-api.sam.it.kr',
// ok: '운영 배포 진행'
// }
// }
// }
// ── main → 운영서버 Production 배포 ──
stage('Deploy Production') {
when { branch 'main' }
steps {
sshagent(credentials: ['deploy-ssh-key']) {
sh """
ssh ${DEPLOY_USER}@211.117.60.189 'mkdir -p /home/webservice/api/releases/${RELEASE_ID}'
rsync -az --delete \
--exclude='.git' --exclude='.env' \
--exclude='storage/app' --exclude='storage/logs' \
--exclude='storage/framework/sessions' --exclude='storage/framework/cache' \
. ${DEPLOY_USER}@211.117.60.189:/home/webservice/api/releases/${RELEASE_ID}/
ssh ${DEPLOY_USER}@211.117.60.189 '
cd /home/webservice/api/releases/${RELEASE_ID} &&
mkdir -p bootstrap/cache storage/framework/{views,cache/data,sessions} storage/logs &&
sudo chown -R www-data:webservice storage bootstrap/cache &&
sudo chmod -R 775 storage bootstrap/cache &&
ln -sfn /home/webservice/api/shared/.env .env &&
sudo chmod 640 /home/webservice/api/shared/.env &&
ln -sfn /home/webservice/api/shared/storage/app storage/app &&
composer install --no-dev --optimize-autoloader --no-interaction &&
php artisan config:cache &&
php artisan route:cache &&
php artisan view:cache &&
php artisan migrate --force &&
ln -sfn /home/webservice/api/releases/${RELEASE_ID} /home/webservice/api/current &&
sudo systemctl reload php8.4-fpm &&
sudo supervisorctl restart sam-queue-worker:* &&
cd /home/webservice/api/releases && ls -1dt */ | tail -n +6 | xargs rm -rf 2>/dev/null || true
'
"""
}
}
}
// develop → Jenkins 관여 안함 (기존 post-update hook 유지)
}
post {
success {
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 {
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 {
if (env.BRANCH_NAME == 'main') {
sshagent(credentials: ['deploy-ssh-key']) {
sh """
ssh ${DEPLOY_USER}@211.117.60.189 '
PREV=\$(ls -1dt /home/webservice/api/releases/*/ | sed -n "2p" | xargs basename 2>/dev/null) &&
[ -n "\$PREV" ] && ln -sfn /home/webservice/api/releases/\$PREV /home/webservice/api/current &&
sudo systemctl reload php8.4-fpm
' || true
"""
}
}
}
}
}
}

View File

@@ -1,6 +1,6 @@
# 논리적 데이터베이스 관계 문서
> **자동 생성**: 2026-01-23 15:57:29
> **자동 생성**: 2026-03-07 02:57:21
> **소스**: Eloquent 모델 관계 분석
## 📊 모델별 관계 현황
@@ -194,6 +194,121 @@ ### model_versions
- **model()**: belongsTo → `models`
- **bomTemplates()**: hasMany → `bom_templates`
### documents
**모델**: `App\Models\Documents\Document`
- **template()**: belongsTo → `document_templates`
- **creator()**: belongsTo → `users`
- **updater()**: belongsTo → `users`
- **approvals()**: hasMany → `document_approvals`
- **data()**: hasMany → `document_data`
- **attachments()**: hasMany → `document_attachments`
- **linkable()**: morphTo → `(Polymorphic)`
### document_approvals
**모델**: `App\Models\Documents\DocumentApproval`
- **document()**: belongsTo → `documents`
- **user()**: belongsTo → `users`
- **creator()**: belongsTo → `users`
### document_attachments
**모델**: `App\Models\Documents\DocumentAttachment`
- **document()**: belongsTo → `documents`
- **file()**: belongsTo → `files`
- **creator()**: belongsTo → `users`
### document_datas
**모델**: `App\Models\Documents\DocumentData`
- **document()**: belongsTo → `documents`
### document_links
**모델**: `App\Models\Documents\DocumentLink`
- **document()**: belongsTo → `documents`
- **linkDefinition()**: belongsTo → `document_template_links`
### document_templates
**모델**: `App\Models\Documents\DocumentTemplate`
- **approvalLines()**: hasMany → `document_template_approval_lines`
- **basicFields()**: hasMany → `document_template_basic_fields`
- **sections()**: hasMany → `document_template_sections`
- **columns()**: hasMany → `document_template_columns`
- **sectionFields()**: hasMany → `document_template_section_fields`
- **links()**: hasMany → `document_template_links`
### document_template_approval_lines
**모델**: `App\Models\Documents\DocumentTemplateApprovalLine`
- **template()**: belongsTo → `document_templates`
### document_template_basic_fields
**모델**: `App\Models\Documents\DocumentTemplateBasicField`
- **template()**: belongsTo → `document_templates`
### document_template_columns
**모델**: `App\Models\Documents\DocumentTemplateColumn`
- **template()**: belongsTo → `document_templates`
### document_template_links
**모델**: `App\Models\Documents\DocumentTemplateLink`
- **template()**: belongsTo → `document_templates`
- **linkValues()**: hasMany → `document_template_link_values`
### document_template_link_values
**모델**: `App\Models\Documents\DocumentTemplateLinkValue`
- **template()**: belongsTo → `document_templates`
- **link()**: belongsTo → `document_template_links`
### document_template_sections
**모델**: `App\Models\Documents\DocumentTemplateSection`
- **template()**: belongsTo → `document_templates`
- **items()**: hasMany → `document_template_section_items`
### document_template_section_fields
**모델**: `App\Models\Documents\DocumentTemplateSectionField`
- **template()**: belongsTo → `document_templates`
### document_template_section_items
**모델**: `App\Models\Documents\DocumentTemplateSectionItem`
- **section()**: belongsTo → `document_template_sections`
### esign_audit_logs
**모델**: `App\Models\ESign\EsignAuditLog`
- **contract()**: belongsTo → `esign_contracts`
- **signer()**: belongsTo → `esign_signers`
### esign_contracts
**모델**: `App\Models\ESign\EsignContract`
- **creator()**: belongsTo → `users`
- **signers()**: hasMany → `esign_signers`
- **signFields()**: hasMany → `esign_sign_fields`
- **auditLogs()**: hasMany → `esign_audit_logs`
### esign_sign_fields
**모델**: `App\Models\ESign\EsignSignField`
- **contract()**: belongsTo → `esign_contracts`
- **signer()**: belongsTo → `esign_signers`
### esign_signers
**모델**: `App\Models\ESign\EsignSigner`
- **contract()**: belongsTo → `esign_contracts`
- **signFields()**: hasMany → `esign_sign_fields`
### estimates
**모델**: `App\Models\Estimate\Estimate`
@@ -219,6 +334,36 @@ ### folders
**모델**: `App\Models\Folder`
### interview_answers
**모델**: `App\Models\Interview\InterviewAnswer`
- **session()**: belongsTo → `interview_sessions`
- **question()**: belongsTo → `interview_questions`
- **template()**: belongsTo → `interview_templates`
### interview_categorys
**모델**: `App\Models\Interview\InterviewCategory`
- **templates()**: hasMany → `interview_templates`
- **sessions()**: hasMany → `interview_sessions`
### interview_questions
**모델**: `App\Models\Interview\InterviewQuestion`
- **template()**: belongsTo → `interview_templates`
### interview_sessions
**모델**: `App\Models\Interview\InterviewSession`
- **category()**: belongsTo → `interview_categories`
- **answers()**: hasMany → `interview_answers`
### interview_templates
**모델**: `App\Models\Interview\InterviewTemplate`
- **category()**: belongsTo → `interview_categories`
- **questions()**: hasMany → `interview_questions`
### custom_tabs
**모델**: `App\Models\ItemMaster\CustomTab`
@@ -255,6 +400,7 @@ ### items
- **category()**: belongsTo → `categories`
- **files()**: hasMany → `files`
- **details()**: hasOne → `item_details`
- **stock()**: hasOne → `stocks`
### item_details
**모델**: `App\Models\Items\ItemDetail`
@@ -379,6 +525,8 @@ ### orders
- **item()**: belongsTo → `items`
- **sale()**: belongsTo → `sales`
- **items()**: hasMany → `order_items`
- **nodes()**: hasMany → `order_nodes`
- **rootNodes()**: hasMany → `order_nodes`
- **histories()**: hasMany → `order_histories`
- **versions()**: hasMany → `order_versions`
- **workOrders()**: hasMany → `work_orders`
@@ -394,6 +542,7 @@ ### order_items
**모델**: `App\Models\Orders\OrderItem`
- **order()**: belongsTo → `orders`
- **node()**: belongsTo → `order_nodes`
- **item()**: belongsTo → `items`
- **quote()**: belongsTo → `quotes`
- **quoteItem()**: belongsTo → `quote_items`
@@ -404,6 +553,14 @@ ### order_item_components
- **orderItem()**: belongsTo → `order_items`
### order_nodes
**모델**: `App\Models\Orders\OrderNode`
- **parent()**: belongsTo → `order_nodes`
- **order()**: belongsTo → `orders`
- **children()**: hasMany → `order_nodes`
- **items()**: hasMany → `order_items`
### order_versions
**모델**: `App\Models\Orders\OrderVersion`
@@ -423,17 +580,10 @@ ### roles
**모델**: `App\Models\Permissions\Role`
- **tenant()**: belongsTo → `tenants`
- **menuPermissions()**: hasMany → `role_menu_permissions`
- **userRoles()**: hasMany → `user_roles`
- **users()**: belongsToMany → `users`
- **permissions()**: belongsToMany → `permissions`
### role_menu_permissions
**모델**: `App\Models\Permissions\RoleMenuPermission`
- **role()**: belongsTo → `roles`
- **menu()**: belongsTo → `menus`
### popups
**모델**: `App\Models\Popups\Popup`
@@ -446,6 +596,7 @@ ### process
- **classificationRules()**: hasMany → `process_classification_rules`
- **processItems()**: hasMany → `process_items`
- **steps()**: hasMany → `process_steps`
### process_classification_rules
**모델**: `App\Models\ProcessClassificationRule`
@@ -458,6 +609,11 @@ ### process_items
- **process()**: belongsTo → `processes`
- **item()**: belongsTo → `items`
### process_steps
**모델**: `App\Models\ProcessStep`
- **process()**: belongsTo → `processes`
### work_orders
**모델**: `App\Models\Production\WorkOrder`
@@ -471,8 +627,12 @@ ### work_orders
- **primaryAssignee()**: hasMany → `work_order_assignees`
- **items()**: hasMany → `work_order_items`
- **issues()**: hasMany → `work_order_issues`
- **stepProgress()**: hasMany → `work_order_step_progress`
- **materialInputs()**: hasMany → `work_order_material_inputs`
- **shipments()**: hasMany → `shipments`
- **inspections()**: hasMany → `inspections`
- **bendingDetail()**: hasOne → `work_order_bending_details`
- **documents()**: morphMany → `documents`
### work_order_assignees
**모델**: `App\Models\Production\WorkOrderAssignee`
@@ -497,6 +657,25 @@ ### work_order_items
- **workOrder()**: belongsTo → `work_orders`
- **item()**: belongsTo → `items`
- **sourceOrderItem()**: belongsTo → `order_items`
- **materialInputs()**: hasMany → `work_order_material_inputs`
### work_order_material_inputs
**모델**: `App\Models\Production\WorkOrderMaterialInput`
- **workOrder()**: belongsTo → `work_orders`
- **workOrderItem()**: belongsTo → `work_order_items`
- **stockLot()**: belongsTo → `stock_lots`
- **item()**: belongsTo → `items`
- **inputBy()**: belongsTo → `users`
### work_order_step_progress
**모델**: `App\Models\Production\WorkOrderStepProgress`
- **workOrder()**: belongsTo → `work_orders`
- **processStep()**: belongsTo → `process_steps`
- **workOrderItem()**: belongsTo → `work_order_items`
- **completedByUser()**: belongsTo → `users`
### work_results
**모델**: `App\Models\Production\WorkResult`
@@ -558,6 +737,7 @@ ### push_notification_settings
### inspections
**모델**: `App\Models\Qualitys\Inspection`
- **workOrder()**: belongsTo → `work_orders`
- **item()**: belongsTo → `items`
- **inspector()**: belongsTo → `users`
- **creator()**: belongsTo → `users`
@@ -573,6 +753,38 @@ ### lot_sales
- **lot()**: belongsTo → `lots`
### performance_reports
**모델**: `App\Models\Qualitys\PerformanceReport`
- **qualityDocument()**: belongsTo → `quality_documents`
- **confirmer()**: belongsTo → `users`
- **creator()**: belongsTo → `users`
### quality_documents
**모델**: `App\Models\Qualitys\QualityDocument`
- **client()**: belongsTo → `clients`
- **inspector()**: belongsTo → `users`
- **creator()**: belongsTo → `users`
- **documentOrders()**: hasMany → `quality_document_orders`
- **locations()**: hasMany → `quality_document_locations`
- **performanceReport()**: hasOne → `performance_reports`
### quality_document_locations
**모델**: `App\Models\Qualitys\QualityDocumentLocation`
- **qualityDocument()**: belongsTo → `quality_documents`
- **qualityDocumentOrder()**: belongsTo → `quality_document_orders`
- **orderItem()**: belongsTo → `order_items`
- **document()**: belongsTo → `documents`
### quality_document_orders
**모델**: `App\Models\Qualitys\QualityDocumentOrder`
- **qualityDocument()**: belongsTo → `quality_documents`
- **order()**: belongsTo → `orders`
- **locations()**: hasMany → `quality_document_locations`
### quotes
**모델**: `App\Models\Quote\Quote`
@@ -631,6 +843,16 @@ ### ai_reports
- **creator()**: belongsTo → `users`
### ai_token_usages
**모델**: `App\Models\Tenants\AiTokenUsage`
- **creator()**: belongsTo → `users`
### ai_voice_recordings
**모델**: `App\Models\Tenants\AiVoiceRecording`
- **user()**: belongsTo → `users`
### approvals
**모델**: `App\Models\Tenants\Approval`
@@ -641,6 +863,7 @@ ### approvals
- **steps()**: hasMany → `approval_steps`
- **approverSteps()**: hasMany → `approval_steps`
- **referenceSteps()**: hasMany → `approval_steps`
- **linkable()**: morphTo → `(Polymorphic)`
### approval_forms
**모델**: `App\Models\Tenants\ApprovalForm`
@@ -738,6 +961,16 @@ ### expense_accounts
- **vendor()**: belongsTo → `clients`
### journal_entrys
**모델**: `App\Models\Tenants\JournalEntry`
- **lines()**: hasMany → `journal_entry_lines`
### journal_entry_lines
**모델**: `App\Models\Tenants\JournalEntryLine`
- **journalEntry()**: belongsTo → `journal_entries`
### leaves
**모델**: `App\Models\Tenants\Leave`
@@ -766,7 +999,10 @@ ### leave_policys
### loans
**모델**: `App\Models\Tenants\Loan`
- **user()**: belongsTo → `users`
- **withdrawal()**: belongsTo → `withdrawals`
- **creator()**: belongsTo → `users`
- **updater()**: belongsTo → `users`
### payments
**모델**: `App\Models\Tenants\Payment`
@@ -848,6 +1084,7 @@ ### shipments
- **creator()**: belongsTo → `users`
- **updater()**: belongsTo → `users`
- **items()**: hasMany → `shipment_items`
- **vehicleDispatches()**: hasMany → `shipment_vehicle_dispatches`
### shipment_items
**모델**: `App\Models\Tenants\ShipmentItem`
@@ -855,6 +1092,11 @@ ### shipment_items
- **shipment()**: belongsTo → `shipments`
- **stockLot()**: belongsTo → `stock_lots`
### shipment_vehicle_dispatchs
**모델**: `App\Models\Tenants\ShipmentVehicleDispatch`
- **shipment()**: belongsTo → `shipments`
### sites
**모델**: `App\Models\Tenants\Site`
@@ -877,12 +1119,21 @@ ### stocks
- **item()**: belongsTo → `items`
- **creator()**: belongsTo → `users`
- **lots()**: hasMany → `stock_lots`
- **transactions()**: hasMany → `stock_transactions`
### stock_lots
**모델**: `App\Models\Tenants\StockLot`
- **stock()**: belongsTo → `stocks`
- **receiving()**: belongsTo → `receivings`
- **workOrder()**: belongsTo → `work_orders`
- **creator()**: belongsTo → `users`
### stock_transactions
**모델**: `App\Models\Tenants\StockTransaction`
- **stock()**: belongsTo → `stocks`
- **stockLot()**: belongsTo → `stock_lots`
- **creator()**: belongsTo → `users`
### subscriptions
@@ -943,6 +1194,8 @@ ### tenant_user_profiles
### today_issues
**모델**: `App\Models\Tenants\TodayIssue`
- **reader()**: belongsTo → `users`
- **targetUser()**: belongsTo → `users`
### withdrawals
**모델**: `App\Models\Tenants\Withdrawal`

View File

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

View File

@@ -0,0 +1,115 @@
<?php
namespace App\Console\Commands;
use App\Services\SlackNotificationService;
use App\Services\Stats\StatMonitorService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class BackupCheckCommand extends Command
{
protected $signature = 'db:backup-check
{--path= : 백업 경로 오버라이드}';
protected $description = 'DB 백업 상태를 확인하고 이상 시 알림을 생성합니다';
public function handle(StatMonitorService $monitorService): int
{
$this->info('DB 백업 상태 확인 시작...');
$statusFile = $this->option('path')
? rtrim($this->option('path'), '/') . '/.backup_status'
: env('BACKUP_STATUS_FILE', '/data/backup/mysql/.backup_status');
$errors = [];
// 1. 상태 파일 존재 여부
if (! file_exists($statusFile)) {
$errors[] = '백업 상태 파일 없음: ' . $statusFile;
$this->reportErrors($monitorService, $errors);
return self::FAILURE;
}
$status = json_decode(file_get_contents($statusFile), true);
if (json_last_error() !== JSON_ERROR_NONE) {
$errors[] = '상태 파일 JSON 파싱 실패: ' . json_last_error_msg();
$this->reportErrors($monitorService, $errors);
return self::FAILURE;
}
// 2. last_run이 25시간 이내인지
$lastRun = strtotime($status['last_run'] ?? '');
if (! $lastRun || (time() - $lastRun) > 25 * 3600) {
$lastRunStr = $status['last_run'] ?? 'unknown';
$errors[] = "마지막 백업이 25시간 초과: {$lastRunStr}";
}
// 3. status가 success인지
if (($status['status'] ?? '') !== 'success') {
$errors[] = '백업 상태 실패: ' . ($status['status'] ?? 'unknown');
}
// 4. 각 DB 백업 파일 크기 검증
$minSizes = [
'sam' => (int) env('BACKUP_MIN_SIZE_SAM', 1048576),
'sam_stat' => (int) env('BACKUP_MIN_SIZE_STAT', 102400),
];
$databases = $status['databases'] ?? [];
foreach ($minSizes as $dbName => $minSize) {
if (! isset($databases[$dbName])) {
$errors[] = "{$dbName} DB 백업 정보 없음";
continue;
}
$sizeBytes = $databases[$dbName]['size_bytes'] ?? 0;
if ($sizeBytes < $minSize) {
$errors[] = "{$dbName} 백업 파일 크기 부족: {$sizeBytes} bytes (최소 {$minSize})";
}
}
// 결과 처리
if (! empty($errors)) {
$this->reportErrors($monitorService, $errors);
return self::FAILURE;
}
$this->info('✅ DB 백업 상태 정상');
Log::info('db:backup-check 정상', [
'last_run' => $status['last_run'],
'databases' => array_keys($databases),
]);
return self::SUCCESS;
}
private function reportErrors(StatMonitorService $monitorService, array $errors): void
{
$errorMessage = implode("\n", $errors);
$this->error('❌ DB 백업 이상 감지:');
foreach ($errors as $error) {
$this->error(" - {$error}");
}
// stat_alerts에 기록
$monitorService->recordBackupFailure(
'[backup] DB 백업 이상 감지',
$errorMessage
);
// Slack 알림 전송
app(SlackNotificationService::class)->sendBackupAlert(
'DB 백업 이상 감지',
$errorMessage
);
Log::error('db:backup-check 실패', ['errors' => $errors]);
}
}

View File

@@ -0,0 +1,202 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* 경동기업(tenant_id=287) 품목 기준 데이터를 JSON으로 추출
*
* 추출 대상: item_pages, item_sections, item_fields,
* entity_relationships, categories, items, item_details, prices
*
* 사용법: php artisan kyungdong:export-item-master
*/
class ExportItemMasterDataCommand extends Command
{
protected $signature = 'kyungdong:export-item-master';
protected $description = '경동기업(tenant_id=287) 품목 기준 데이터를 JSON 파일로 추출';
private const TENANT_ID = 287;
private string $outputPath;
public function handle(): int
{
$this->outputPath = database_path('seeders/data/kyungdong');
if (! is_dir($this->outputPath)) {
mkdir($this->outputPath, 0755, true);
}
$this->info('경동기업 품목 기준 데이터 추출 시작 (tenant_id=' . self::TENANT_ID . ')');
$this->newLine();
$this->exportItemPages();
$this->exportItemSections();
$this->exportItemFields();
$this->exportEntityRelationships();
$this->exportCategories();
$this->exportItems();
$this->exportItemDetails();
$this->exportPrices();
$this->newLine();
$this->info('추출 완료! 경로: ' . $this->outputPath);
return self::SUCCESS;
}
private function exportItemPages(): void
{
$rows = DB::table('item_pages')
->where('tenant_id', self::TENANT_ID)
->whereNull('deleted_at')
->get()
->map(fn ($row) => $this->addOriginalId($row))
->toArray();
$this->writeJson('item_pages.json', $rows);
$this->info(" item_pages: " . count($rows) . "");
}
private function exportItemSections(): void
{
$rows = DB::table('item_sections')
->where('tenant_id', self::TENANT_ID)
->whereNull('deleted_at')
->get()
->map(fn ($row) => $this->addOriginalId($row))
->toArray();
$this->writeJson('item_sections.json', $rows);
$this->info(" item_sections: " . count($rows) . "");
}
private function exportItemFields(): void
{
$rows = DB::table('item_fields')
->where('tenant_id', self::TENANT_ID)
->whereNull('deleted_at')
->get()
->map(fn ($row) => $this->addOriginalId($row))
->toArray();
$this->writeJson('item_fields.json', $rows);
$this->info(" item_fields: " . count($rows) . "");
}
private function exportEntityRelationships(): void
{
// 참조 대상이 실제 존재하는 것만 추출
$validPageIds = DB::table('item_pages')->where('tenant_id', self::TENANT_ID)->whereNull('deleted_at')->pluck('id');
$validSectionIds = DB::table('item_sections')->where('tenant_id', self::TENANT_ID)->whereNull('deleted_at')->pluck('id');
$validFieldIds = DB::table('item_fields')->where('tenant_id', self::TENANT_ID)->whereNull('deleted_at')->pluck('id');
$validBomIds = DB::table('item_bom_items')->where('tenant_id', self::TENANT_ID)->whereNull('deleted_at')->pluck('id');
$validIds = [
'page' => $validPageIds->flip(),
'section' => $validSectionIds->flip(),
'field' => $validFieldIds->flip(),
'bom' => $validBomIds->flip(),
];
$rows = DB::table('entity_relationships')
->where('tenant_id', self::TENANT_ID)
->get()
->filter(function ($row) use ($validIds) {
$parentValid = isset($validIds[$row->parent_type][$row->parent_id]);
$childValid = isset($validIds[$row->child_type][$row->child_id]);
return $parentValid && $childValid;
})
->map(fn ($row) => $this->addOriginalId($row))
->values()
->toArray();
$this->writeJson('entity_relationships.json', $rows);
$this->info(" entity_relationships: " . count($rows) . "");
}
private function exportCategories(): void
{
$rows = DB::table('categories')
->where('tenant_id', self::TENANT_ID)
->whereNull('deleted_at')
->orderByRaw('COALESCE(parent_id, 0), sort_order, id')
->get()
->map(fn ($row) => $this->addOriginalId($row))
->toArray();
$this->writeJson('categories.json', $rows);
$this->info(" categories: " . count($rows) . "");
}
private function exportItems(): void
{
$rows = DB::table('items')
->where('tenant_id', self::TENANT_ID)
->whereNull('deleted_at')
->orderBy('id')
->get()
->map(fn ($row) => $this->addOriginalId($row))
->toArray();
$this->writeJson('items.json', $rows);
$this->info(" items: " . count($rows) . "");
}
private function exportItemDetails(): void
{
$itemIds = DB::table('items')
->where('tenant_id', self::TENANT_ID)
->whereNull('deleted_at')
->pluck('id');
$rows = DB::table('item_details')
->whereIn('item_id', $itemIds)
->get()
->map(fn ($row) => $this->addOriginalId($row))
->toArray();
$this->writeJson('item_details.json', $rows);
$this->info(" item_details: " . count($rows) . "");
}
private function exportPrices(): void
{
$rows = DB::table('prices')
->where('tenant_id', self::TENANT_ID)
->whereNull('deleted_at')
->orderBy('id')
->get()
->map(fn ($row) => $this->addOriginalId($row))
->toArray();
$this->writeJson('prices.json', $rows);
$this->info(" prices: " . count($rows) . "");
}
/**
* _original_id 추가 + id 제거
*/
private function addOriginalId(object $row): array
{
$data = (array) $row;
$data['_original_id'] = $data['id'];
unset($data['id']);
return $data;
}
private function writeJson(string $filename, array $data): void
{
$path = $this->outputPath . '/' . $filename;
file_put_contents(
$path,
json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
);
}
}

View File

@@ -12,7 +12,7 @@ class FcmSendCommand extends Command
{--tenant= : 테넌트 ID (미지정 시 전체)}
{--user= : 사용자 ID (미지정 시 전체)}
{--platform= : 플랫폼 (android, ios, web)}
{--channel=push_default : 알림 채널 (push_default, push_urgent)}
{--channel=push_default : 알림 채널 (push_default, push_vendor_register, push_approval_request, push_income, push_sales_order, push_purchase_order, push_contract)}
{--title= : 알림 제목 (필수)}
{--body= : 알림 내용 (필수)}
{--type= : 알림 타입 (invoice_failed 등)}

View File

@@ -14,7 +14,7 @@ class FcmTestCommand extends Command
*/
protected $signature = 'fcm:test
{--token= : FCM 디바이스 토큰 (필수)}
{--channel=push_default : 알림 채널 ID (push_default, push_urgent)}
{--channel=push_default : 알림 채널 ID (push_default, push_vendor_register, push_approval_request, push_income, push_sales_order, push_purchase_order, push_contract)}
{--title=테스트 알림 : 알림 제목}
{--body=FCM 테스트 메시지입니다. : 알림 내용}
{--data= : 추가 데이터 (JSON 형식)}';
@@ -38,7 +38,7 @@ public function handle(): int
$this->line('');
$this->line('사용법:');
$this->line(' php artisan fcm:test --token=YOUR_FCM_TOKEN');
$this->line(' php artisan fcm:test --token=YOUR_FCM_TOKEN --channel=push_urgent --title="긴급 알림"');
$this->line(' php artisan fcm:test --token=YOUR_FCM_TOKEN --channel=push_vendor_register --title="신규업체 알림"');
return self::FAILURE;
}

View File

@@ -0,0 +1,141 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
class ManageAuditPartitions extends Command
{
protected $signature = 'audit:partitions
{--add-months=3 : 미래 파티션 추가 개월 수}
{--retention-months=13 : 보관 기간 (개월)}
{--drop : 보관 기간 초과 파티션 삭제 실행}
{--dry-run : 변경 없이 계획만 출력}';
protected $description = '트리거 감사 로그 파티션 자동 관리 (추가/삭제)';
public function handle(): int
{
$addMonths = (int) $this->option('add-months');
$retentionMonths = (int) $this->option('retention-months');
$doDrop = $this->option('drop');
$dryRun = $this->option('dry-run');
$this->info('=== 트리거 감사 로그 파티션 관리 ===');
$this->newLine();
// 현재 파티션 목록 조회
$partitions = $this->getPartitions();
$this->info('현재 파티션: '.count($partitions).'개');
$this->table(
['파티션명', '상한값 (UNIX_TIMESTAMP)', '행 수'],
collect($partitions)->map(fn ($p) => [
$p->PARTITION_NAME,
$p->PARTITION_DESCRIPTION === 'MAXVALUE' ? 'MAXVALUE' : Carbon::createFromTimestamp($p->PARTITION_DESCRIPTION)->format('Y-m-d'),
number_format($p->TABLE_ROWS),
])->toArray()
);
$this->newLine();
// 1. 미래 파티션 추가
$added = $this->addFuturePartitions($partitions, $addMonths, $dryRun);
// 2. 오래된 파티션 삭제
$dropped = 0;
if ($doDrop) {
$dropped = $this->dropOldPartitions($partitions, $retentionMonths, $dryRun);
} else {
$this->warn('파티션 삭제는 --drop 옵션 필요');
}
$this->newLine();
$this->info("결과: 추가 {$added}개, 삭제 {$dropped}".($dryRun ? ' (dry-run)' : ''));
return self::SUCCESS;
}
private function getPartitions(): array
{
return DB::select("
SELECT PARTITION_NAME, PARTITION_DESCRIPTION, TABLE_ROWS
FROM INFORMATION_SCHEMA.PARTITIONS
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = 'trigger_audit_logs'
AND PARTITION_NAME IS NOT NULL
ORDER BY PARTITION_ORDINAL_POSITION
", [config('database.connections.mysql.database')]);
}
private function addFuturePartitions(array $partitions, int $addMonths, bool $dryRun): int
{
$existingBounds = [];
foreach ($partitions as $p) {
if ($p->PARTITION_DESCRIPTION !== 'MAXVALUE') {
$existingBounds[] = (int) $p->PARTITION_DESCRIPTION;
}
}
$added = 0;
$now = Carbon::now();
for ($i = 0; $i <= $addMonths; $i++) {
$target = $now->copy()->addMonths($i)->startOfMonth()->addMonth();
$ts = $target->timestamp;
$name = 'p'.$target->copy()->subMonth()->format('Ym');
if (in_array($ts, $existingBounds)) {
continue;
}
$sql = "ALTER TABLE trigger_audit_logs REORGANIZE PARTITION p_future INTO (
PARTITION {$name} VALUES LESS THAN ({$ts}),
PARTITION p_future VALUES LESS THAN MAXVALUE
)";
if ($dryRun) {
$this->line(" [DRY-RUN] 추가: {$name} (< {$target->format('Y-m-d')})");
} else {
DB::statement($sql);
$this->info(" 추가: {$name} (< {$target->format('Y-m-d')})");
}
$added++;
}
if ($added === 0) {
$this->info(' 추가할 파티션 없음');
}
return $added;
}
private function dropOldPartitions(array $partitions, int $retentionMonths, bool $dryRun): int
{
$cutoff = Carbon::now()->subMonths($retentionMonths)->startOfMonth()->timestamp;
$dropped = 0;
foreach ($partitions as $p) {
if ($p->PARTITION_DESCRIPTION === 'MAXVALUE') {
continue;
}
$bound = (int) $p->PARTITION_DESCRIPTION;
if ($bound <= $cutoff) {
if ($dryRun) {
$this->line(" [DRY-RUN] 삭제: {$p->PARTITION_NAME} ({$p->TABLE_ROWS}행)");
} else {
DB::statement("ALTER TABLE trigger_audit_logs DROP PARTITION {$p->PARTITION_NAME}");
$this->warn(" 삭제: {$p->PARTITION_NAME} ({$p->TABLE_ROWS}행)");
}
$dropped++;
}
}
if ($dropped === 0) {
$this->info(' 삭제할 파티션 없음');
}
return $dropped;
}
}

View File

@@ -0,0 +1,345 @@
<?php
namespace App\Console\Commands;
use App\Models\Items\Item;
use App\Models\Process;
use App\Models\ProcessItem;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* 5130 기준 품목-공정 매핑 (A+B+C 전략)
*
* A. 품목명 키워드 기반:
* - "스크린용", "스크린" → P-002 스크린
* - "철재용", "철재", "슬랫" → P-001 슬랫
*
* B. BD 코드 기반:
* - BD-* → P-003 절곡
*
* C. 재고생산(LOT) 기반 (5130 lot 테이블 분석):
* - PT-* 코드 → P-004 재고생산
* - 가이드레일, 케이스, 연기차단재, L-Bar → P-004 재고생산
*/
class MapItemsToProcesses extends Command
{
protected $signature = 'items:map-to-processes
{--tenant= : 특정 테넌트 ID (기본: 모든 테넌트)}
{--dry-run : 실제 실행 없이 미리보기만}
{--clear : 기존 매핑 삭제 후 새로 매핑}';
protected $description = '5130 기준 품목-공정 자동 매핑 (A: 키워드 + B: BD코드 + C: 재고생산)';
/**
* 공정별 매핑 규칙 정의
*
* 5130 LOT 생산 품목 분류:
* - R: 가이드레일-벽면형, S: 가이드레일-측면형
* - C: 케이스 (린텔부, 전면부, 점검구, 후면코너부)
* - B: 하단마감재-스크린, T: 하단마감재-철재
* - G: 연기차단재
* - L: L-Bar
*/
/**
* FG(완제품), RM(원자재) 제외 - 공정별 생산 품목만 매핑
* EST-INSPECTION(검사비), EST-MOTOR/EST-CTRL(구매품)도 제외
*/
private array $globalExcludes = ['FG-%', 'RM-%', 'EST-INSPECTION'];
private array $mappingRules = [
'P-001' => [
'name' => '슬랫',
'code_patterns' => ['EST-RAW-슬랫-%'], // 슬랫 원자재 (방화/방범/조인트바)
'name_keywords' => ['슬랫'],
'name_excludes' => ['스크린', '가이드레일', '하단마감', '연기차단', '케이스'],
],
'P-002' => [
'name' => '스크린',
'code_patterns' => ['EST-RAW-스크린-%'], // 스크린 원자재 (실리카/와이어 등)
'name_keywords' => ['스크린용', '스크린', '원단', '실리카', '방충', '와이어'],
'name_excludes' => ['가이드레일', '하단마감', '연기차단', '케이스'],
],
'P-003' => [
'name' => '절곡',
'code_patterns' => ['BD-%'], // BD 코드는 절곡
'name_keywords' => ['절곡', '연기차단재'], // 연기차단재는 절곡 공정에서 조립
'name_excludes' => [],
],
'P-004' => [
'name' => '재고생산',
'code_patterns' => ['PT-%'], // PT 코드는 재고생산 부품
'name_keywords' => ['가이드레일', '케이스', 'L-Bar', 'L-BAR', 'LBar', '하단마감', '린텔', '하장바', '환봉', '감기샤프트', '각파이프', '앵글'],
'name_excludes' => [],
'code_excludes' => ['BD-%', 'EST-SMOKE-%'], // BD는 P-003, EST-SMOKE는 P-003
],
];
public function handle(): int
{
$tenantId = $this->option('tenant');
$dryRun = $this->option('dry-run');
$clear = $this->option('clear');
$this->info('=== 5130 기준 품목-공정 매핑 (A+B+C 전략) ===');
$this->info('A. 품목명 키워드: 스크린용→P-002, 철재용→P-001');
$this->info('B. BD 코드: BD-* → P-003 절곡');
$this->info('C. 재고생산: PT-* 또는 가이드레일/케이스/연기차단재/L-Bar → P-004');
$this->newLine();
// 공정 조회
$processQuery = Process::query();
if ($tenantId) {
$processQuery->where('tenant_id', $tenantId);
}
$processes = $processQuery->whereIn('process_code', array_keys($this->mappingRules))->get()->keyBy('process_code');
if ($processes->isEmpty()) {
$this->error('매핑 대상 공정이 없습니다. (P-001, P-002, P-003, P-004)');
return self::FAILURE;
}
$this->info('대상 공정:');
foreach ($processes as $code => $process) {
$this->line(" - {$code}: {$process->process_name} (ID: {$process->id})");
}
$this->newLine();
// 기존 매핑 삭제 (--clear 옵션)
if ($clear) {
$processIds = $processes->pluck('id')->toArray();
$existingCount = ProcessItem::whereIn('process_id', $processIds)->count();
if ($dryRun) {
$this->warn("[DRY-RUN] 기존 매핑 {$existingCount}개 삭제 예정");
} else {
ProcessItem::whereIn('process_id', $processIds)->delete();
$this->warn("기존 매핑 {$existingCount}개 삭제 완료");
}
$this->newLine();
}
// 매핑 결과 저장
$results = [
'P-001' => ['items' => collect(), 'process' => $processes->get('P-001')],
'P-002' => ['items' => collect(), 'process' => $processes->get('P-002')],
'P-003' => ['items' => collect(), 'process' => $processes->get('P-003')],
'P-004' => ['items' => collect(), 'process' => $processes->get('P-004')],
];
// 품목 조회 및 분류
$itemQuery = Item::query();
if ($tenantId) {
$itemQuery->where('tenant_id', $tenantId);
}
$items = $itemQuery->get();
$this->info("전체 품목 수: {$items->count()}");
$this->newLine();
$mappedCount = 0;
$unmappedItems = collect();
foreach ($items as $item) {
$processCode = $this->classifyItem($item);
if ($processCode && isset($results[$processCode])) {
$results[$processCode]['items']->push($item);
$mappedCount++;
} else {
$unmappedItems->push($item);
}
}
// 결과 출력
$this->info('=== 분류 결과 ===');
$this->newLine();
$tableData = [];
foreach ($results as $code => $data) {
$count = $data['items']->count();
$processName = $data['process']?->process_name ?? '(없음)';
$tableData[] = [$code, $processName, $count];
}
$tableData[] = ['-', '미분류', $unmappedItems->count()];
$tableData[] = ['=', '합계', $items->count()];
$this->table(['공정코드', '공정명', '품목 수'], $tableData);
$this->newLine();
// 샘플 출력
foreach ($results as $code => $data) {
if ($data['items']->isNotEmpty()) {
$this->info("[{$code} {$data['process']?->process_name}] 샘플 (최대 10개):");
foreach ($data['items']->take(10) as $item) {
$this->line(" - {$item->code}: {$item->name}");
}
$this->newLine();
}
}
// 미분류 샘플
if ($unmappedItems->isNotEmpty()) {
$this->info('[미분류] 샘플 (최대 10개):');
foreach ($unmappedItems->take(10) as $item) {
$this->line(" - {$item->code}: {$item->name}");
}
$this->newLine();
}
// 실제 매핑 실행
if (! $dryRun) {
$this->info('=== 매핑 실행 ===');
DB::transaction(function () use ($results) {
foreach ($results as $code => $data) {
$process = $data['process'];
if (! $process) {
continue;
}
$priority = 0;
foreach ($data['items'] as $item) {
// 중복 체크
$exists = ProcessItem::where('process_id', $process->id)
->where('item_id', $item->id)
->exists();
if (! $exists) {
ProcessItem::create([
'process_id' => $process->id,
'item_id' => $item->id,
'priority' => $priority++,
'is_active' => true,
]);
}
}
$this->info(" {$code}: {$data['items']->count()}개 매핑 완료");
}
});
$this->newLine();
$this->info("{$mappedCount}개 품목 매핑 완료!");
} else {
$this->newLine();
$this->warn('[DRY-RUN] 실제 매핑은 수행되지 않았습니다.');
$this->line('실제 실행: php artisan items:map-to-processes --clear');
}
return self::SUCCESS;
}
/**
* 품목을 공정에 분류 (A+B+C 전략)
*/
private function classifyItem(Item $item): ?string
{
$code = $item->code ?? '';
$name = $item->name ?? '';
// 0. 글로벌 제외 (FG 완제품, RM 원자재, EST-INSPECTION 서비스)
foreach ($this->globalExcludes as $excludePattern) {
$prefix = rtrim($excludePattern, '-%');
if (str_starts_with($code, $prefix)) {
return null;
}
}
// 1. 코드 패턴 우선 매핑 (정확한 매칭)
// EST-RAW-슬랫-* → P-001
if (str_starts_with($code, 'EST-RAW-슬랫-')) {
return 'P-001';
}
// EST-RAW-스크린-* → P-002
if (str_starts_with($code, 'EST-RAW-스크린-')) {
return 'P-002';
}
// BD-* → P-003 절곡
if (str_starts_with($code, 'BD-')) {
return 'P-003';
}
// EST-SMOKE-* → P-003 절곡 (연기차단재는 절곡 공정에서 조립)
if (str_starts_with($code, 'EST-SMOKE-')) {
return 'P-003';
}
// PT-* → P-004 재고생산
if (str_starts_with($code, 'PT-')) {
return 'P-004';
}
// EST-MOTOR/EST-CTRL → 구매품, 공정 없음
if (str_starts_with($code, 'EST-MOTOR-') || str_starts_with($code, 'EST-CTRL-')) {
return null;
}
// EST-SHAFT/EST-PIPE/EST-ANGLE → P-004 재고생산 (조달 품목)
if (str_starts_with($code, 'EST-SHAFT-') || str_starts_with($code, 'EST-PIPE-') || str_starts_with($code, 'EST-ANGLE-')) {
return 'P-004';
}
// 2. P-004 재고생산 키워드 체크
foreach ($this->mappingRules['P-004']['name_keywords'] as $keyword) {
if (mb_stripos($name, $keyword) !== false) {
// code_excludes 체크
$excluded = false;
foreach ($this->mappingRules['P-004']['code_excludes'] ?? [] as $excludePattern) {
$prefix = rtrim($excludePattern, '-%');
if (str_starts_with($code, $prefix)) {
$excluded = true;
break;
}
}
if (! $excluded) {
return 'P-004';
}
}
}
// 3. P-003 절곡 키워드 체크
foreach ($this->mappingRules['P-003']['name_keywords'] as $keyword) {
if (mb_stripos($name, $keyword) !== false) {
return 'P-003';
}
}
// 4. P-002 스크린 키워드 체크
foreach ($this->mappingRules['P-002']['name_keywords'] as $keyword) {
if (mb_stripos($name, $keyword) !== false) {
$excluded = false;
foreach ($this->mappingRules['P-002']['name_excludes'] as $exclude) {
if (mb_stripos($name, $exclude) !== false) {
$excluded = true;
break;
}
}
if (! $excluded) {
return 'P-002';
}
}
}
// 5. P-001 슬랫 키워드 체크
foreach ($this->mappingRules['P-001']['name_keywords'] as $keyword) {
if (mb_stripos($name, $keyword) !== false) {
$excluded = false;
foreach ($this->mappingRules['P-001']['name_excludes'] as $exclude) {
if (mb_stripos($name, $exclude) !== false) {
$excluded = true;
break;
}
}
if (! $excluded) {
return 'P-001';
}
}
}
return null;
}
}

View File

@@ -0,0 +1,631 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Attributes\AsCommand;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
#[AsCommand(name: 'migrate:5130-bending-stock', description: '5130 레거시 절곡품 코드 생성 + BD-* 전체 품목 초기 재고 셋팅')]
class Migrate5130BendingStock extends Command
{
protected $signature = 'migrate:5130-bending-stock
{--tenant_id=287 : Target tenant ID (default: 287 경동기업)}
{--dry-run : 실제 저장 없이 시뮬레이션만 수행}
{--min-stock=100 : 품목별 초기 재고 수량 (기본: 100)}
{--rollback : 초기 재고 셋팅 롤백 (init_stock 소스 데이터 삭제)}';
private string $sourceDb = 'chandj';
private string $targetDb = 'mysql';
// 5130 prod 코드 → 한글명
private array $prodNames = [
'R' => '가이드레일(벽면)', 'S' => '가이드레일(측면)',
'G' => '연기차단재', 'B' => '하단마감재(스크린)',
'T' => '하단마감재(철재)', 'L' => 'L-Bar', 'C' => '케이스',
];
// 5130 spec 코드 → 한글명
private array $specNames = [
'I' => '화이바원단', 'S' => 'SUS', 'U' => 'SUS2', 'E' => 'EGI',
'A' => '스크린용', 'D' => 'D형', 'C' => 'C형', 'M' => '본체',
'T' => '본체(철재)', 'B' => '후면코너부', 'L' => '린텔부',
'P' => '점검구', 'F' => '전면부',
];
// 5130 slength 코드 → 한글명
private array $slengthNames = [
'53' => 'W50×3000', '54' => 'W50×4000', '83' => 'W80×3000',
'84' => 'W80×4000', '12' => '1219mm', '24' => '2438mm',
'30' => '3000mm', '35' => '3500mm', '40' => '4000mm',
'41' => '4150mm', '42' => '4200mm', '43' => '4300mm',
];
private array $stats = [
'items_found' => 0,
'items_created_5130' => 0,
'items_category_updated' => 0,
'stocks_created' => 0,
'stocks_skipped' => 0,
'lots_created' => 0,
'transactions_created' => 0,
];
public function handle(): int
{
$tenantId = (int) $this->option('tenant_id');
$dryRun = $this->option('dry-run');
$rollback = $this->option('rollback');
$minStock = (int) $this->option('min-stock');
$this->info('=== BD-* 절곡품 초기 재고 셋팅 ===');
$this->info("Tenant ID: {$tenantId}");
$this->info('Mode: '.($dryRun ? 'DRY-RUN (시뮬레이션)' : 'LIVE'));
$this->info("초기 재고: {$minStock}개/품목");
$this->newLine();
if ($rollback) {
return $this->rollbackInitStock($tenantId, $dryRun);
}
// 0. 5130 레거시 데이터에서 BD-{PROD}{SPEC}-{SLENGTH} 아이템 생성
$this->info('📥 Step 0: 5130 레거시 코드 → BD 아이템 생성...');
$this->createLegacyItems($tenantId, $dryRun);
$this->newLine();
// 1. 전체 BD-* 아이템 조회 (기존 58개 + 5130 생성분)
$this->info('📥 Step 1: BD-* 절곡품 품목 조회...');
$items = DB::connection($this->targetDb)
->table('items')
->where('tenant_id', $tenantId)
->where('code', 'like', 'BD-%')
->whereNull('deleted_at')
->select('id', 'code', 'name', 'item_type', 'item_category', 'unit', 'options')
->orderBy('code')
->get();
$this->stats['items_found'] = $items->count();
$this->info(" - BD-* 품목: {$items->count()}");
if ($items->isEmpty()) {
$this->warn('BD-* 품목이 없습니다. 종료합니다.');
return self::SUCCESS;
}
// 2. item_category 미설정 품목 업데이트
$this->newLine();
$this->info('🏷️ Step 2: item_category 업데이트...');
$needsCategoryUpdate = $items->filter(fn ($item) => $item->item_category !== 'BENDING');
if ($needsCategoryUpdate->isNotEmpty()) {
$this->info(" - item_category 미설정/불일치: {$needsCategoryUpdate->count()}");
if (! $dryRun) {
DB::connection($this->targetDb)
->table('items')
->where('tenant_id', $tenantId)
->where('code', 'like', 'BD-%')
->whereNull('deleted_at')
->where(function ($q) {
$q->whereNull('item_category')
->orWhere('item_category', '!=', 'BENDING');
})
->update(['item_category' => 'BENDING', 'updated_at' => now()]);
}
$this->stats['items_category_updated'] = $needsCategoryUpdate->count();
} else {
$this->info(' - 모든 품목 BENDING 카테고리 설정 완료');
}
// 3. 현재 재고 현황 표시
$this->newLine();
$this->info('📊 Step 3: 현재 재고 현황...');
$this->showCurrentStockStatus($tenantId, $items);
// 4. 재고 셋팅 대상 확인
$this->newLine();
$this->info('📦 Step 4: 재고 셋팅 대상 확인...');
$itemsNeedingStock = $this->getItemsNeedingStock($tenantId, $items, $minStock);
if ($itemsNeedingStock->isEmpty()) {
$this->info(" - 모든 품목이 이미 {$minStock}개 이상 재고 보유. 추가 작업 불필요.");
$this->showStats();
return self::SUCCESS;
}
$this->info(" - 재고 셋팅 필요: {$itemsNeedingStock->count()}");
$this->table(
['코드', '품목명', '현재고', '목표', '추가수량'],
$itemsNeedingStock->map(fn ($item) => [
$item->code,
mb_strlen($item->name) > 30 ? mb_substr($item->name, 0, 30).'...' : $item->name,
number_format($item->current_qty),
number_format($minStock),
number_format($item->supplement_qty),
])->toArray()
);
if ($dryRun) {
$this->stats['stocks_created'] = $itemsNeedingStock->filter(fn ($i) => ! $i->has_stock)->count();
$this->stats['lots_created'] = $itemsNeedingStock->count();
$this->stats['transactions_created'] = $itemsNeedingStock->count();
$this->showStats();
$this->info('🔍 DRY RUN 완료. 실제 실행은 --dry-run 플래그를 제거하세요.');
return self::SUCCESS;
}
if (! $this->confirm('초기 재고를 셋팅하시겠습니까?')) {
$this->info('취소되었습니다.');
return self::SUCCESS;
}
// 5. 실행
$this->newLine();
$this->info('🚀 Step 5: 초기 재고 셋팅 실행...');
DB::connection($this->targetDb)->transaction(function () use ($tenantId, $itemsNeedingStock, $minStock) {
$this->executeStockSetup($tenantId, $itemsNeedingStock, $minStock);
});
$this->newLine();
$this->showStats();
$this->info('✅ 초기 재고 셋팅 완료!');
return self::SUCCESS;
}
/**
* 현재 재고 현황 표시
*/
private function showCurrentStockStatus(int $tenantId, \Illuminate\Support\Collection $items): void
{
$itemIds = $items->pluck('id');
$stocks = DB::connection($this->targetDb)
->table('stocks')
->where('tenant_id', $tenantId)
->whereIn('item_id', $itemIds)
->whereNull('deleted_at')
->get()
->keyBy('item_id');
$hasStock = 0;
$noStock = 0;
foreach ($items as $item) {
$stock = $stocks->get($item->id);
if ($stock && (float) $stock->stock_qty > 0) {
$hasStock++;
} else {
$noStock++;
}
}
$this->info(" - 재고 있음: {$hasStock}");
$this->info(" - 재고 없음: {$noStock}");
}
/**
* 재고 셋팅이 필요한 품목 목록 조회
*/
private function getItemsNeedingStock(int $tenantId, \Illuminate\Support\Collection $items, int $minStock): \Illuminate\Support\Collection
{
$itemIds = $items->pluck('id');
$stocks = DB::connection($this->targetDb)
->table('stocks')
->where('tenant_id', $tenantId)
->whereIn('item_id', $itemIds)
->whereNull('deleted_at')
->get()
->keyBy('item_id');
$result = collect();
foreach ($items as $item) {
$stock = $stocks->get($item->id);
$currentQty = $stock ? (float) $stock->stock_qty : 0;
if ($currentQty >= $minStock) {
$this->stats['stocks_skipped']++;
continue;
}
$supplementQty = $minStock - $currentQty;
$item->has_stock = (bool) $stock;
$item->stock_id = $stock?->id;
$item->current_qty = $currentQty;
$item->supplement_qty = $supplementQty;
$result->push($item);
}
return $result;
}
/**
* 초기 재고 셋팅 실행
*/
private function executeStockSetup(int $tenantId, \Illuminate\Support\Collection $items, int $minStock): void
{
foreach ($items as $item) {
$stockId = $item->stock_id;
// Stock 레코드가 없으면 생성
if (! $item->has_stock) {
$stockId = DB::connection($this->targetDb)->table('stocks')->insertGetId([
'tenant_id' => $tenantId,
'item_id' => $item->id,
'item_code' => $item->code,
'item_name' => $item->name,
'item_type' => 'bent_part',
'unit' => $item->unit ?? 'EA',
'stock_qty' => 0,
'safety_stock' => 0,
'reserved_qty' => 0,
'available_qty' => 0,
'lot_count' => 0,
'status' => 'out',
'created_at' => now(),
'updated_at' => now(),
]);
$this->stats['stocks_created']++;
$this->line(" + Stock 생성: {$item->code}");
}
// FIFO 순서 계산
$maxFifo = DB::connection($this->targetDb)
->table('stock_lots')
->where('stock_id', $stockId)
->max('fifo_order');
$nextFifo = ($maxFifo ?? 0) + 1;
// LOT 번호 생성
$lotNo = 'INIT-'.now()->format('ymd').'-'.str_replace(['-', ' ', '*'], ['', '', 'x'], $item->code);
// 중복 체크
$existingLot = DB::connection($this->targetDb)
->table('stock_lots')
->where('tenant_id', $tenantId)
->where('stock_id', $stockId)
->where('lot_no', $lotNo)
->whereNull('deleted_at')
->first();
if ($existingLot) {
$this->warn(" ⚠️ 이미 LOT 존재 (skip): {$lotNo}");
continue;
}
$supplementQty = $item->supplement_qty;
// StockLot 생성
$stockLotId = DB::connection($this->targetDb)->table('stock_lots')->insertGetId([
'tenant_id' => $tenantId,
'stock_id' => $stockId,
'lot_no' => $lotNo,
'fifo_order' => $nextFifo,
'receipt_date' => now()->toDateString(),
'qty' => $supplementQty,
'reserved_qty' => 0,
'available_qty' => $supplementQty,
'unit' => $item->unit ?? 'EA',
'status' => 'available',
'created_at' => now(),
'updated_at' => now(),
]);
$this->stats['lots_created']++;
// StockTransaction 생성
DB::connection($this->targetDb)->table('stock_transactions')->insert([
'tenant_id' => $tenantId,
'stock_id' => $stockId,
'stock_lot_id' => $stockLotId,
'type' => 'IN',
'qty' => $supplementQty,
'balance_qty' => 0,
'reference_type' => 'init_stock',
'reference_id' => 0,
'lot_no' => $lotNo,
'reason' => 'receiving',
'remark' => "절곡품 초기 재고 셋팅 (min-stock={$minStock})",
'item_code' => $item->code,
'item_name' => $item->name,
'created_at' => now(),
]);
$this->stats['transactions_created']++;
// Stock 집계 갱신
$this->refreshStockFromLots($stockId, $tenantId);
$this->line("{$item->code}: 0 → {$supplementQty} (+{$supplementQty})");
}
}
/**
* Stock 집계 갱신 (LOT 기반)
*/
private function refreshStockFromLots(int $stockId, int $tenantId): void
{
$lotStats = DB::connection($this->targetDb)
->table('stock_lots')
->where('stock_id', $stockId)
->where('tenant_id', $tenantId)
->whereNull('deleted_at')
->selectRaw('
COALESCE(SUM(qty), 0) as total_qty,
COALESCE(SUM(reserved_qty), 0) as total_reserved,
COALESCE(SUM(available_qty), 0) as total_available,
COUNT(*) as lot_count,
MIN(receipt_date) as oldest_lot_date,
MAX(receipt_date) as latest_receipt_date
')
->first();
$stockQty = (float) $lotStats->total_qty;
DB::connection($this->targetDb)
->table('stocks')
->where('id', $stockId)
->update([
'stock_qty' => $stockQty,
'reserved_qty' => (float) $lotStats->total_reserved,
'available_qty' => (float) $lotStats->total_available,
'lot_count' => (int) $lotStats->lot_count,
'oldest_lot_date' => $lotStats->oldest_lot_date,
'last_receipt_date' => $lotStats->latest_receipt_date,
'status' => $stockQty > 0 ? 'normal' : 'out',
'updated_at' => now(),
]);
}
/**
* 롤백: init_stock 참조 데이터 삭제
*/
private function rollbackInitStock(int $tenantId, bool $dryRun): int
{
$this->warn('⚠️ 롤백: 초기 재고 셋팅 데이터를 삭제합니다.');
// init_stock으로 생성된 트랜잭션
$txCount = DB::connection($this->targetDb)
->table('stock_transactions')
->where('tenant_id', $tenantId)
->where('reference_type', 'init_stock')
->count();
// init_stock 트랜잭션에 연결된 LOT
$lotIds = DB::connection($this->targetDb)
->table('stock_transactions')
->where('tenant_id', $tenantId)
->where('reference_type', 'init_stock')
->whereNotNull('stock_lot_id')
->pluck('stock_lot_id')
->unique();
// 5130으로 생성된 아이템
$legacyItemCount = DB::connection($this->targetDb)
->table('items')
->where('tenant_id', $tenantId)
->where('options->source', '5130_migration')
->whereNull('deleted_at')
->count();
$this->info(' 삭제 대상:');
$this->info(" - stock_transactions (reference_type=init_stock): {$txCount}");
$this->info(" - stock_lots (연결 LOT): {$lotIds->count()}");
$this->info(" - items (source=5130_migration): {$legacyItemCount}");
if ($dryRun) {
$this->info('DRY RUN - 실제 삭제 없음');
return self::SUCCESS;
}
if (! $this->confirm('정말 롤백하시겠습니까? 되돌릴 수 없습니다.')) {
return self::SUCCESS;
}
DB::connection($this->targetDb)->transaction(function () use ($tenantId, $lotIds) {
// 1. 트랜잭션 삭제
DB::connection($this->targetDb)
->table('stock_transactions')
->where('tenant_id', $tenantId)
->where('reference_type', 'init_stock')
->delete();
// 2. LOT에서 stock_id 목록 수집 (집계 갱신용)
$affectedStockIds = collect();
if ($lotIds->isNotEmpty()) {
$affectedStockIds = DB::connection($this->targetDb)
->table('stock_lots')
->whereIn('id', $lotIds)
->pluck('stock_id')
->unique();
// LOT 삭제
DB::connection($this->targetDb)
->table('stock_lots')
->whereIn('id', $lotIds)
->delete();
}
// 3. 영향받은 Stock 집계 갱신
foreach ($affectedStockIds as $stockId) {
$this->refreshStockFromLots($stockId, $tenantId);
}
// 4. 5130 migration으로 생성된 아이템 + 연결 stocks 삭제
$migrationItemIds = DB::connection($this->targetDb)
->table('items')
->where('tenant_id', $tenantId)
->where('options->source', '5130_migration')
->whereNull('deleted_at')
->pluck('id');
if ($migrationItemIds->isNotEmpty()) {
$migrationStockIds = DB::connection($this->targetDb)
->table('stocks')
->where('tenant_id', $tenantId)
->whereIn('item_id', $migrationItemIds)
->pluck('id');
if ($migrationStockIds->isNotEmpty()) {
DB::connection($this->targetDb)
->table('stock_lots')
->whereIn('stock_id', $migrationStockIds)
->delete();
DB::connection($this->targetDb)
->table('stocks')
->whereIn('id', $migrationStockIds)
->delete();
}
DB::connection($this->targetDb)
->table('items')
->whereIn('id', $migrationItemIds)
->delete();
}
});
$this->info('✅ 롤백 완료');
return self::SUCCESS;
}
/**
* 5130 레거시 데이터에서 BD-{PROD}{SPEC}-{SLENGTH} 아이템 생성
*/
private function createLegacyItems(int $tenantId, bool $dryRun): void
{
// 5130 lot 테이블에서 고유 prod+spec+slength 조합 추출
$lots = DB::connection($this->sourceDb)
->table('lot')
->where(function ($q) {
$q->whereNull('is_deleted')
->orWhere('is_deleted', 0);
})
->whereNotNull('prod')
->where('prod', '!=', '')
->whereNotNull('surang')
->where('surang', '>', 0)
->select('prod', 'spec', 'slength')
->distinct()
->get();
// bending_work_log 테이블에서도 추출 (lot에 없는 조합 포함)
$workLogs = DB::connection($this->sourceDb)
->table('bending_work_log')
->where(function ($q) {
$q->whereNull('is_deleted')
->orWhere('is_deleted', 0);
})
->whereNotNull('prod_code')
->where('prod_code', '!=', '')
->select('prod_code as prod', 'spec_code as spec', 'slength_code as slength')
->distinct()
->get();
$allRecords = $lots->merge($workLogs);
if ($allRecords->isEmpty()) {
$this->info(' - 5130 데이터 없음');
return;
}
// 고유 제품 조합 추출
$uniqueProducts = [];
foreach ($allRecords as $row) {
$key = trim($row->prod).'-'.trim($row->spec ?? '').'-'.trim($row->slength ?? '');
if (! isset($uniqueProducts[$key])) {
$uniqueProducts[$key] = [
'prod' => trim($row->prod),
'spec' => trim($row->spec ?? ''),
'slength' => trim($row->slength ?? ''),
];
}
}
$this->info(" - 5130 고유 제품 조합: ".count($uniqueProducts).'개');
$created = 0;
$skipped = 0;
foreach ($uniqueProducts as $data) {
$itemCode = "BD-{$data['prod']}{$data['spec']}-{$data['slength']}";
$prodName = $this->prodNames[$data['prod']] ?? $data['prod'];
$specName = $this->specNames[$data['spec']] ?? $data['spec'];
$slengthName = $this->slengthNames[$data['slength']] ?? $data['slength'];
$itemName = implode(' ', array_filter([$prodName, $specName, $slengthName]));
// 이미 존재하는지 확인
$existing = DB::connection($this->targetDb)
->table('items')
->where('tenant_id', $tenantId)
->where('code', $itemCode)
->whereNull('deleted_at')
->first();
if ($existing) {
$skipped++;
continue;
}
if (! $dryRun) {
DB::connection($this->targetDb)->table('items')->insert([
'tenant_id' => $tenantId,
'code' => $itemCode,
'name' => $itemName,
'item_type' => 'PT',
'item_category' => 'BENDING',
'unit' => 'EA',
'options' => json_encode([
'source' => '5130_migration',
'lot_managed' => true,
'consumption_method' => 'auto',
'production_source' => 'self_produced',
'input_tracking' => true,
'legacy_prod' => $data['prod'],
'legacy_spec' => $data['spec'],
'legacy_slength' => $data['slength'],
]),
'is_active' => true,
'created_at' => now(),
'updated_at' => now(),
]);
}
$created++;
}
$this->stats['items_created_5130'] = $created;
$this->info(" - 신규 생성: {$created}건, 기존 존재 (skip): {$skipped}");
}
/**
* 통계 출력
*/
private function showStats(): void
{
$this->newLine();
$this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
$this->info('📊 실행 통계');
$this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
$this->info(" 5130 아이템 생성: {$this->stats['items_created_5130']}");
$this->info(" BD-* 품목 수 (전체): {$this->stats['items_found']}");
$this->info(" 카테고리 업데이트: {$this->stats['items_category_updated']}");
$this->info(" Stock 레코드 생성: {$this->stats['stocks_created']}");
$this->info(" 기존 재고 충분 (skip): {$this->stats['stocks_skipped']}");
$this->info(" StockLot 생성: {$this->stats['lots_created']}");
$this->info(" 입고 트랜잭션: {$this->stats['transactions_created']}");
$this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
}
}

View File

@@ -145,7 +145,7 @@ private function loadBomTemplates(int $tenantId): bool
$bom = json_decode($sourceItem->bom, true);
if (is_array($bom) && count($bom) > 0) {
$this->bomTemplates[$category] = $sourceItem->bom;
$this->info("{$category}: {$sourceCode} 템플릿 로드됨 (" . count($bom) . "개 항목)");
$this->info("{$category}: {$sourceCode} 템플릿 로드됨 (".count($bom).'개 항목)');
} else {
$this->warn(" ⚠️ {$category}: {$sourceCode} BOM이 비어있음");
}
@@ -227,9 +227,9 @@ private function applyBomToItem(object $item, bool $dryRun): string
return 'success';
} catch (\Exception $e) {
$this->error(" ❌ [{$item->code}] 오류: " . $e->getMessage());
$this->error(" ❌ [{$item->code}] 오류: ".$e->getMessage());
return 'failed';
}
}
}
}

View File

@@ -0,0 +1,640 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* chandj 원본 가격 테이블 → items + item_details + prices 마이그레이션
*
* 레거시 chandj DB의 BDmodels, price_motor, price_raw_materials,
* price_shaft, price_pipe, price_angle, price_smokeban 데이터를
* items + item_details + prices 통합 구조로 마이그레이션
*/
class MigrateBDModelsPrices extends Command
{
protected $signature = 'kd:migrate-prices
{--dry-run : 실제 DB 변경 없이 미리보기}
{--fresh : 기존 EST-* 항목 삭제 후 재생성}';
protected $description = '경동 견적 단가를 chandj 원본에서 items+item_details+prices로 마이그레이션';
private const TENANT_ID = 287;
private int $created = 0;
private int $updated = 0;
private int $skipped = 0;
private int $deleted = 0;
public function handle(): int
{
$dryRun = $this->option('dry-run');
$fresh = $this->option('fresh');
$this->info('=== 경동 견적 단가 마이그레이션 (chandj 원본) ===');
$this->info($dryRun ? '[DRY RUN] 실제 변경 없음' : '[LIVE] DB에 반영합니다');
$this->newLine();
DB::beginTransaction();
try {
// --fresh: 기존 EST-* 항목 삭제
if ($fresh) {
$this->cleanExistingEstItems($dryRun);
}
// 1. BDmodels (절곡품: 케이스, 가이드레일, 하단마감재, 마구리, 연기차단재, L바, 보강평철)
$this->migrateBDModels($dryRun);
// 2. price_motor (모터 + 제어기)
$this->migrateMotors($dryRun);
// 3. price_raw_materials (원자재: 실리카, 화이바, 와이어 등)
$this->migrateRawMaterials($dryRun);
// 4. price_shaft (감기샤프트)
$this->migrateShafts($dryRun);
// 5. price_pipe (각파이프)
$this->migratePipes($dryRun);
// 6. price_angle (앵글)
$this->migrateAngles($dryRun);
// 7. price_smokeban (연기차단재 - BDmodels에 없는 경우 보완)
$this->migrateSmokeBan($dryRun);
if ($dryRun) {
DB::rollBack();
$this->warn('[DRY RUN] 롤백 완료');
} else {
DB::commit();
$this->info('커밋 완료');
}
$this->newLine();
$this->info("생성: {$this->created}건, 업데이트: {$this->updated}건, 스킵: {$this->skipped}건, 삭제: {$this->deleted}");
return Command::SUCCESS;
} catch (\Exception $e) {
DB::rollBack();
$this->error("오류: {$e->getMessage()}");
$this->error($e->getTraceAsString());
return Command::FAILURE;
}
}
/**
* 기존 EST-* 항목 삭제 (--fresh 옵션)
*/
private function cleanExistingEstItems(bool $dryRun): void
{
$this->info('--- 기존 EST-* 항목 삭제 ---');
$items = DB::table('items')
->where('tenant_id', self::TENANT_ID)
->where('code', 'LIKE', 'EST-%')
->whereNull('deleted_at')
->get(['id', 'code']);
foreach ($items as $item) {
$this->line(" [삭제] {$item->code}");
if (! $dryRun) {
DB::table('prices')->where('item_id', $item->id)->delete();
DB::table('item_details')->where('item_id', $item->id)->delete();
DB::table('items')->where('id', $item->id)->delete();
}
$this->deleted++;
}
}
/**
* chandj.BDmodels → items + item_details + prices
*/
private function migrateBDModels(bool $dryRun): void
{
$this->info('--- BDmodels (절곡품) ---');
$rows = DB::connection('chandj')->select("
SELECT model_name, seconditem, finishing_type, spec, unitprice, description
FROM BDmodels
WHERE is_deleted = 0
ORDER BY model_name, seconditem, finishing_type, spec
");
foreach ($rows as $row) {
$modelName = trim($row->model_name ?? '');
$secondItem = trim($row->seconditem ?? '');
$finishingType = trim($row->finishing_type ?? '');
$spec = trim($row->spec ?? '');
$unitPrice = (float) str_replace(',', '', $row->unitprice ?? '0');
// finishing_type 정규화: 'SUS마감' → 'SUS', 'EGI마감' → 'EGI'
$finishingType = str_replace('마감', '', $finishingType);
if (empty($secondItem) || $unitPrice <= 0) {
$this->skipped++;
continue;
}
$codeParts = ['BD', $secondItem];
if ($modelName) {
$codeParts[] = $modelName;
}
if ($finishingType) {
$codeParts[] = $finishingType;
}
if ($spec) {
$codeParts[] = $spec;
}
$code = implode('-', $codeParts);
$nameParts = [$secondItem];
if ($modelName) {
$nameParts[] = $modelName;
}
if ($finishingType) {
$nameParts[] = $finishingType;
}
if ($spec) {
$nameParts[] = $spec;
}
$name = implode(' ', $nameParts);
$this->upsertEstimateItem(
code: $code,
name: $name,
productCategory: 'bdmodels',
partType: $secondItem,
specification: $spec ?: null,
attributes: array_filter([
'model_name' => $modelName ?: null,
'finishing_type' => $finishingType ?: null,
'bdmodel_source' => 'BDmodels',
'description' => $row->description ?: null,
]),
salesPrice: $unitPrice,
note: 'chandj.BDmodels',
dryRun: $dryRun
);
}
}
/**
* chandj.price_motor → 모터 + 제어기
*
* col1: 전압 (220, 380, 제어기, 방화, 방범)
* col2: 용량/종류 (150K(S), 300K, 매립형, 노출형 등)
* col13: 판매가
*/
private function migrateMotors(bool $dryRun): void
{
$this->info('--- price_motor (모터/제어기) ---');
$row = DB::connection('chandj')->selectOne(
"SELECT itemList FROM price_motor WHERE is_deleted IS NULL OR is_deleted = 0 ORDER BY NUM LIMIT 1"
);
if (! $row) {
return;
}
$items = json_decode($row->itemList, true);
foreach ($items as $item) {
$category = trim($item['col1'] ?? ''); // 220, 380, 제어기, 방화, 방범
$name = trim($item['col2'] ?? ''); // 150K(S), 매립형 등
$price = (int) str_replace(',', '', $item['col13'] ?? '0');
if (empty($name) || $price <= 0) {
$this->skipped++;
continue;
}
// 카테고리 분류
if (in_array($category, ['220', '380'])) {
$productCategory = 'motor';
$code = "EST-MOTOR-{$category}V-{$name}";
$displayName = "모터 {$name} ({$category}V)";
$partType = $name;
} elseif ($category === '제어기') {
$productCategory = 'controller';
$code = "EST-CTRL-{$name}";
$displayName = "제어기 {$name}";
$partType = $name;
} else {
// 방화, 방범 등
$productCategory = 'controller';
$code = "EST-CTRL-{$category}-{$name}";
$displayName = "{$category} {$name}";
$partType = "{$category} {$name}";
}
$this->upsertEstimateItem(
code: $code,
name: $displayName,
productCategory: $productCategory,
partType: $partType,
specification: null,
attributes: ['voltage' => $category, 'source' => 'price_motor'],
salesPrice: (float) $price,
note: 'chandj.price_motor',
dryRun: $dryRun
);
}
}
/**
* chandj.price_raw_materials → 원자재
*
* col1: 카테고리 (슬랫, 스크린)
* col2: 품명 (방화, 실리카, 화이바, 와이어 등)
* col13: 판매단가
*/
private function migrateRawMaterials(bool $dryRun): void
{
$this->info('--- price_raw_materials (원자재) ---');
$row = DB::connection('chandj')->selectOne(
"SELECT itemList FROM price_raw_materials WHERE is_deleted IS NULL OR is_deleted = 0 ORDER BY registedate DESC LIMIT 1"
);
if (! $row) {
return;
}
$items = json_decode($row->itemList, true);
foreach ($items as $item) {
$category = trim($item['col1'] ?? '');
$name = trim($item['col2'] ?? '');
$price = (int) str_replace(',', '', $item['col13'] ?? '0');
if (empty($name) || $price <= 0) {
$this->skipped++;
continue;
}
$code = "EST-RAW-{$category}-{$name}";
$displayName = "{$category} {$name}";
$this->upsertEstimateItem(
code: $code,
name: $displayName,
productCategory: 'raw_material',
partType: $name,
specification: $category,
attributes: ['category' => $category, 'source' => 'price_raw_materials'],
salesPrice: (float) $price,
note: 'chandj.price_raw_materials',
dryRun: $dryRun
);
}
}
/**
* chandj.price_shaft → 감기샤프트
*
* col4: 인치 (3, 4, 5, 6, 8, 10, 12)
* col10: 길이 (m)
* col19: 판매가
*/
private function migrateShafts(bool $dryRun): void
{
$this->info('--- price_shaft (감기샤프트) ---');
$row = DB::connection('chandj')->selectOne(
"SELECT itemList FROM price_shaft WHERE is_deleted IS NULL OR is_deleted = 0 ORDER BY NUM LIMIT 1"
);
if (! $row) {
return;
}
$items = json_decode($row->itemList, true);
foreach ($items as $item) {
$inch = trim($item['col4'] ?? '');
$lengthM = trim($item['col10'] ?? '');
$price = (int) str_replace(',', '', $item['col19'] ?? '0');
if (empty($inch) || empty($lengthM) || $price <= 0) {
$this->skipped++;
continue;
}
$code = "EST-SHAFT-{$inch}-{$lengthM}";
$name = "감기샤프트 {$inch}인치 {$lengthM}m";
$this->upsertEstimateItem(
code: $code,
name: $name,
productCategory: 'shaft',
partType: $inch,
specification: $lengthM,
attributes: ['source' => 'price_shaft'],
salesPrice: (float) $price,
note: 'chandj.price_shaft',
dryRun: $dryRun
);
}
}
/**
* chandj.price_pipe → 각파이프
*
* col4: 두께 (1.4, 2)
* col2: 길이 (3,000 / 6,000)
* col8: 판매가
*/
private function migratePipes(bool $dryRun): void
{
$this->info('--- price_pipe (각파이프) ---');
$row = DB::connection('chandj')->selectOne(
"SELECT itemList FROM price_pipe WHERE (is_deleted IS NULL OR is_deleted = 0 OR is_deleted = '') ORDER BY NUM LIMIT 1"
);
if (! $row) {
return;
}
$items = json_decode($row->itemList, true);
foreach ($items as $item) {
$thickness = trim($item['col4'] ?? '');
$length = (int) str_replace(',', '', $item['col2'] ?? '0');
$price = (int) str_replace(',', '', $item['col8'] ?? '0');
if (empty($thickness) || $length <= 0 || $price <= 0) {
$this->skipped++;
continue;
}
$code = "EST-PIPE-{$thickness}-{$length}";
$name = "각파이프 {$thickness}T {$length}mm";
$this->upsertEstimateItem(
code: $code,
name: $name,
productCategory: 'pipe',
partType: $thickness,
specification: (string) $length,
attributes: ['spec' => $item['col3'] ?? '', 'source' => 'price_pipe'],
salesPrice: (float) $price,
note: 'chandj.price_pipe',
dryRun: $dryRun
);
}
}
/**
* chandj.price_angle → 앵글 (bracket + main 분리)
*
* bracket angle (모터 받침용): col2가 텍스트 (스크린용, 철제300K 등)
* - col2: 검색옵션, col3: 브라켓크기, col4: 앵글타입, col19: 판매가
*
* main angle (부자재용): col2가 숫자 (4 등)
* - col4: 종류 (앵글3T, 앵글4T), col10: 길이 (2.5, 10), col19: 판매가
*/
private function migrateAngles(bool $dryRun): void
{
$this->info('--- price_angle (앵글) ---');
$row = DB::connection('chandj')->selectOne(
"SELECT itemList FROM price_angle WHERE is_deleted IS NULL OR is_deleted = 0 ORDER BY NUM LIMIT 1"
);
if (! $row) {
return;
}
$items = json_decode($row->itemList, true);
foreach ($items as $item) {
$col2 = trim($item['col2'] ?? '');
$col3 = trim($item['col3'] ?? '');
$col4 = trim($item['col4'] ?? '');
$col10 = trim($item['col10'] ?? '');
$price = (int) str_replace(',', '', $item['col19'] ?? '0');
if ($price <= 0) {
$this->skipped++;
continue;
}
// col2가 숫자이면 main angle, 텍스트이면 bracket angle
if (is_numeric($col2)) {
// Main angle (부자재용): col4=앵글3T, col10=2.5
if (empty($col4) || empty($col10)) {
$this->skipped++;
continue;
}
$code = "EST-ANGLE-MAIN-{$col4}-{$col10}";
$name = "앵글 {$col4} {$col10}m";
$this->upsertEstimateItem(
code: $code,
name: $name,
productCategory: 'angle_main',
partType: $col4,
specification: $col10,
attributes: ['source' => 'price_angle'],
salesPrice: (float) $price,
note: 'chandj.price_angle (main)',
dryRun: $dryRun
);
} else {
// Bracket angle (모터 받침용): col2=스크린용, col3=380*180
if (empty($col2)) {
$this->skipped++;
continue;
}
$code = "EST-ANGLE-BRACKET-{$col2}";
$name = "모터받침 앵글 {$col2}";
$this->upsertEstimateItem(
code: $code,
name: $name,
productCategory: 'angle_bracket',
partType: $col2,
specification: $col3 ?: null,
attributes: [
'angle_type' => $col4,
'source' => 'price_angle',
],
salesPrice: (float) $price,
note: 'chandj.price_angle (bracket)',
dryRun: $dryRun
);
}
}
}
/**
* chandj.price_smokeban → 연기차단재
*
* col2: 용도 (레일용, 케이스용)
* col11: 판매가
*/
private function migrateSmokeBan(bool $dryRun): void
{
$this->info('--- price_smokeban (연기차단재) ---');
$row = DB::connection('chandj')->selectOne(
"SELECT itemList FROM price_smokeban WHERE is_deleted IS NULL OR is_deleted = 0 ORDER BY NUM LIMIT 1"
);
if (! $row) {
return;
}
$items = json_decode($row->itemList, true);
foreach ($items as $item) {
$usage = trim($item['col2'] ?? '');
$price = (int) str_replace(',', '', $item['col11'] ?? '0');
if (empty($usage) || $price <= 0) {
$this->skipped++;
continue;
}
$code = "EST-SMOKE-{$usage}";
$name = "연기차단재 {$usage}";
$this->upsertEstimateItem(
code: $code,
name: $name,
productCategory: 'smokeban',
partType: $usage,
specification: null,
attributes: ['source' => 'price_smokeban'],
salesPrice: (float) $price,
note: 'chandj.price_smokeban',
dryRun: $dryRun
);
}
}
/**
* 견적 품목 생성 또는 가격 업데이트
*/
private function upsertEstimateItem(
string $code,
string $name,
string $productCategory,
string $partType,
?string $specification,
array $attributes,
float $salesPrice,
string $note,
bool $dryRun
): void {
$existing = DB::table('items')
->where('tenant_id', self::TENANT_ID)
->where('code', $code)
->whereNull('deleted_at')
->first();
if ($existing) {
// 가격 업데이트
$currentPrice = DB::table('prices')
->where('item_id', $existing->id)
->where('status', 'active')
->orderByDesc('id')
->value('sales_price');
if ((float) $currentPrice === $salesPrice) {
$this->skipped++;
return;
}
$this->line(" [업데이트] {$code} 가격: " . number_format($currentPrice ?? 0) . "" . number_format($salesPrice));
if (! $dryRun) {
// 기존 가격 비활성화
DB::table('prices')
->where('item_id', $existing->id)
->where('status', 'active')
->update(['status' => 'inactive', 'updated_at' => now()]);
// 새 가격 추가
DB::table('prices')->insert([
'tenant_id' => self::TENANT_ID,
'item_type_code' => 'PT',
'item_id' => $existing->id,
'sales_price' => $salesPrice,
'effective_from' => now()->toDateString(),
'status' => 'active',
'note' => $note,
'created_at' => now(),
'updated_at' => now(),
]);
}
$this->updated++;
return;
}
// 신규 생성
$this->line(" [생성] {$code} ({$name}) = " . number_format($salesPrice));
if ($dryRun) {
$this->created++;
return;
}
$now = now();
$itemId = DB::table('items')->insertGetId([
'tenant_id' => self::TENANT_ID,
'item_type' => 'PT',
'code' => $code,
'name' => $name,
'unit' => 'EA',
'attributes' => json_encode($attributes, JSON_UNESCAPED_UNICODE),
'is_active' => true,
'created_at' => $now,
'updated_at' => $now,
]);
DB::table('item_details')->insert([
'item_id' => $itemId,
'product_category' => $productCategory,
'part_type' => $partType,
'specification' => $specification,
'item_name' => $name,
'is_purchasable' => true,
'created_at' => $now,
'updated_at' => $now,
]);
DB::table('prices')->insert([
'tenant_id' => self::TENANT_ID,
'item_type_code' => 'PT',
'item_id' => $itemId,
'sales_price' => $salesPrice,
'effective_from' => $now->toDateString(),
'status' => 'active',
'note' => $note,
'created_at' => $now,
'updated_at' => $now,
]);
$this->created++;
}
}

View File

@@ -0,0 +1,253 @@
<?php
namespace App\Console\Commands;
use App\Models\Documents\Document;
use App\Models\Documents\DocumentData;
use App\Models\Documents\DocumentTemplateSection;
use Illuminate\Console\Attributes\AsCommand;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
#[AsCommand(name: 'documents:normalize-data', description: '기존 document_data의 field_key를 정규화 형식으로 변환')]
class NormalizeDocumentData extends Command
{
protected $signature = 'documents:normalize-data
{--document= : 특정 문서 ID만 처리 (쉼표 구분)}
{--dry-run : 실제 변경 없이 시뮬레이션만 수행}
{--force : 확인 없이 실행}';
public function handle(): int
{
$dryRun = $this->option('dry-run');
$documentIds = $this->option('document')
? array_map('intval', explode(',', $this->option('document')))
: null;
$this->info('=== document_data 정규화 마이그레이션 ===');
$this->info('Mode: ' . ($dryRun ? 'DRY-RUN (시뮬레이션)' : 'LIVE'));
if ($documentIds) {
$this->info('대상 문서: ' . implode(', ', $documentIds));
}
$this->newLine();
// 정규화 대상 레코드 조회: section_id가 NULL이고 field_key가 s{N}_ 패턴인 레코드
$query = DocumentData::query()
->whereNull('section_id')
->where(function ($q) {
// MNG 형식: s{sectionId}_r{rowIndex}_c{colId}...
$q->where('field_key', 'regexp', '^s[0-9]+_r[0-9]+_c[0-9]+')
// React 형식: {itemId}_n{N}, {itemId}_okng_n{N}, {itemId}_result
->orWhere('field_key', 'regexp', '^[0-9]+_(n[0-9]+|okng_n[0-9]+|result)$')
// 푸터 형식: footer_remark, footer_judgement
->orWhereIn('field_key', ['footer_remark', 'footer_judgement']);
});
if ($documentIds) {
$query->whereIn('document_id', $documentIds);
}
$records = $query->get();
if ($records->isEmpty()) {
$this->info('정규화 대상 레코드가 없습니다.');
return self::SUCCESS;
}
$this->info("대상 레코드: {$records->count()}");
// 관련 문서 ID 수집 및 템플릿 정보 사전 로드
$docIds = $records->pluck('document_id')->unique();
$documents = Document::whereIn('id', $docIds)
->with(['template.sections.items', 'template.columns'])
->get()
->keyBy('id');
// 변환 결과 집계
$stats = ['mng' => 0, 'react' => 0, 'footer' => 0, 'skipped' => 0];
$updates = [];
foreach ($records as $record) {
$doc = $documents->get($record->document_id);
if (! $doc || ! $doc->template) {
$stats['skipped']++;
continue;
}
$result = $this->normalizeRecord($record, $doc);
if ($result) {
$updates[] = $result;
$stats[$result['type']]++;
} else {
$stats['skipped']++;
}
}
// 결과 테이블 출력
$this->newLine();
$this->table(
['유형', '건수'],
[
['MNG 형식 (s{}_r{}_c{})', $stats['mng']],
['React 형식 ({itemId}_*)', $stats['react']],
['Footer 형식', $stats['footer']],
['건너뜀', $stats['skipped']],
['총 변환', count($updates)],
]
);
if (empty($updates)) {
$this->info('변환할 레코드가 없습니다.');
return self::SUCCESS;
}
// 변환 상세 샘플
$this->newLine();
$this->info('변환 샘플 (최대 10건):');
$this->table(
['ID', 'doc_id', '기존 field_key', '→ section_id', '→ column_id', '→ row_index', '→ field_key'],
collect($updates)->take(10)->map(fn ($u) => [
$u['id'],
$u['document_id'],
$u['old_field_key'],
$u['section_id'] ?? 'NULL',
$u['column_id'] ?? 'NULL',
$u['row_index'],
$u['field_key'],
])->toArray()
);
if ($dryRun) {
$this->warn('DRY-RUN 모드: 실제 변경 없음. --dry-run 제거하여 실행.');
return self::SUCCESS;
}
// 실행 확인
if (! $this->option('force') && ! $this->confirm(count($updates) . '건의 레코드를 정규화하시겠습니까?')) {
$this->info('취소되었습니다.');
return self::SUCCESS;
}
// 트랜잭션으로 일괄 업데이트
DB::transaction(function () use ($updates) {
foreach ($updates as $update) {
DocumentData::where('id', $update['id'])->update([
'section_id' => $update['section_id'],
'column_id' => $update['column_id'],
'row_index' => $update['row_index'],
'field_key' => $update['field_key'],
]);
}
});
$this->info(count($updates) . '건 정규화 완료.');
return self::SUCCESS;
}
/**
* 단일 레코드 정규화
*/
private function normalizeRecord(DocumentData $record, Document $doc): ?array
{
$key = $record->field_key;
// 1. Footer 형식
if ($key === 'footer_remark') {
return $this->buildUpdate($record, null, null, 0, 'remark', 'footer');
}
if ($key === 'footer_judgement') {
return $this->buildUpdate($record, null, null, 0, 'overall_result', 'footer');
}
// 2. MNG 형식: s{sectionId}_r{rowIndex}_c{colId}[_suffix]
if (preg_match('/^s(\d+)_r(\d+)_c(\d+)(?:_(.+))?$/', $key, $m)) {
$sectionId = (int) $m[1];
$rowIndex = (int) $m[2];
$columnId = (int) $m[3];
$suffix = $m[4] ?? null;
// suffix 정규화: n1, n1_ok, n1_ng, sub0 등은 그대로, 없으면 value
$fieldKey = $suffix ?: 'value';
return $this->buildUpdate($record, $sectionId, $columnId, $rowIndex, $fieldKey, 'mng');
}
// 3. React 형식: {itemId}_n{N} 또는 {itemId}_okng_n{N} 또는 {itemId}_result
if (preg_match('/^(\d+)_(n\d+|okng_n\d+|result)$/', $key, $m)) {
$itemId = (int) $m[1];
$suffix = $m[2];
$template = $doc->template;
$sectionId = null;
$rowIndex = 0;
// 아이템 ID로 섹션과 행 인덱스 찾기
foreach ($template->sections as $section) {
foreach ($section->items as $idx => $item) {
if ($item->id === $itemId) {
$sectionId = $section->id;
$rowIndex = $idx;
break 2;
}
}
}
if ($sectionId === null) {
return null; // 아이템을 찾을 수 없음
}
// suffix → 정규화된 field_key
if ($suffix === 'result') {
$fieldKey = 'value';
// 결과 컬럼 ID 찾기
$resultCol = $template->columns
->first(fn ($c) => in_array($c->column_type, ['select', 'check'])
|| str_contains($c->label, '판정'));
$columnId = $resultCol?->id;
} elseif (str_starts_with($suffix, 'okng_')) {
// okng_n1 → n1_ok (checked value로 저장된 경우)
$nPart = str_replace('okng_', '', $suffix);
$fieldKey = $nPart . '_ok';
$complexCol = $template->columns->first(fn ($c) => $c->column_type === 'complex');
$columnId = $complexCol?->id;
} else {
// n1, n2, ... 그대로
$fieldKey = $suffix;
$complexCol = $template->columns->first(fn ($c) => $c->column_type === 'complex');
$columnId = $complexCol?->id;
}
return $this->buildUpdate($record, $sectionId, $columnId, $rowIndex, $fieldKey, 'react');
}
return null;
}
private function buildUpdate(
DocumentData $record,
?int $sectionId,
?int $columnId,
int $rowIndex,
string $fieldKey,
string $type
): array {
return [
'id' => $record->id,
'document_id' => $record->document_id,
'old_field_key' => $record->field_key,
'section_id' => $sectionId,
'column_id' => $columnId,
'row_index' => $rowIndex,
'field_key' => $fieldKey,
'type' => $type,
];
}
}

View File

@@ -0,0 +1,218 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Attributes\AsCommand;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
#[AsCommand(name: 'items:normalize-dimensions', description: '품목 attributes에서 thickness/width/length 정규화')]
class NormalizeItemDimensions extends Command
{
protected $signature = 'items:normalize-dimensions
{--tenant_id=287 : Target tenant ID (default: 287 경동기업)}
{--dry-run : 기본 모드 - 변경 예정 목록만 출력}
{--execute : 실제 DB 업데이트 수행}';
protected $description = '101_specification_1/2/3에서 thickness/width/length를 추출하여 attributes에 정규화';
private int $updatedCount = 0;
private int $skippedCount = 0;
private array $changes = [];
public function handle(): int
{
$tenantId = (int) $this->option('tenant_id');
$execute = $this->option('execute');
$dryRun = ! $execute;
$this->info('=== 품목 치수 정규화 ===');
$this->info("Tenant ID: {$tenantId}");
$this->info('Mode: '.($dryRun ? 'DRY-RUN (미리보기)' : 'EXECUTE (실행)'));
$this->newLine();
$items = DB::table('items')
->where('tenant_id', $tenantId)
->whereNull('deleted_at')
->whereRaw("JSON_EXTRACT(attributes, '$.\"101_specification_1\"') IS NOT NULL")
->get();
$this->info("대상 품목: {$items->count()}건 (101_specification_1 존재)");
$this->newLine();
$bar = $this->output->createProgressBar($items->count());
$bar->start();
foreach ($items as $item) {
$this->processItem($item, $dryRun);
$bar->advance();
}
$bar->finish();
$this->newLine(2);
$this->showResults($dryRun);
return self::SUCCESS;
}
private function processItem(object $item, bool $dryRun): void
{
$attributes = json_decode($item->attributes, true) ?? [];
$spec1 = $attributes['101_specification_1'] ?? null;
$spec2 = $attributes['102_specification_2'] ?? null;
$spec3 = $attributes['103_specification_3'] ?? null;
$existingThickness = $attributes['thickness'] ?? null;
$existingWidth = $attributes['width'] ?? null;
$existingLength = $attributes['length'] ?? null;
$changed = false;
$changeDetails = [];
// thickness 추출: spec1에서 숫자 추출 (t/T 제거)
if ($spec1 !== null && $spec1 !== '' && $existingThickness === null) {
$thickness = $this->extractThickness($spec1);
if ($thickness !== null) {
$attributes['thickness'] = $thickness;
$changeDetails[] = "thickness: {$spec1}{$thickness}";
$changed = true;
}
}
// width 추출: spec2에서 순수 숫자만
if ($spec2 !== null && $spec2 !== '' && $existingWidth === null) {
$width = $this->extractNumeric($spec2);
if ($width !== null) {
$attributes['width'] = $width;
$changeDetails[] = "width: {$spec2}{$width}";
$changed = true;
}
}
// length 추출: spec3에서 순수 숫자만 (c, P/L, 문자 포함 시 스킵)
if ($spec3 !== null && $spec3 !== '' && $existingLength === null) {
$length = $this->extractLength($spec3);
if ($length !== null) {
$attributes['length'] = $length;
$changeDetails[] = "length: {$spec3}{$length}";
$changed = true;
}
}
if ($changed) {
$this->changes[] = [
'id' => $item->id,
'name' => $item->name,
'changes' => implode(', ', $changeDetails),
];
if (! $dryRun) {
DB::table('items')
->where('id', $item->id)
->update(['attributes' => json_encode($attributes, JSON_UNESCAPED_UNICODE)]);
}
$this->updatedCount++;
} else {
$this->skippedCount++;
}
}
/**
* thickness 추출: "t1.2", "T1.2", "1.2", "egi1.17" → 숫자
* 패턴: 선행 문자(t/T/영문) 제거 후 숫자 추출
*/
private function extractThickness(?string $value): ?string
{
if ($value === null || trim($value) === '') {
return null;
}
$cleaned = trim($value);
// "t1.2", "T1.2" → "1.2"
$cleaned = preg_replace('/^[tT]/', '', $cleaned);
// "egi1.17", "sus1.2" → 영문자 제거 후 숫자 추출
if (preg_match('/(\d+(?:\.\d+)?)/', $cleaned, $matches)) {
return $matches[1];
}
return null;
}
/**
* 순수 숫자만 추출 (정수/소수)
* "1219" → "1219", "1219.5" → "1219.5"
* "c" → null, "" → null, "P/L" → null
*/
private function extractNumeric(?string $value): ?string
{
if ($value === null || trim($value) === '') {
return null;
}
$cleaned = trim($value);
// 순수 숫자 (정수/소수)만 허용
if (preg_match('/^\d+(?:\.\d+)?$/', $cleaned)) {
return $cleaned;
}
return null;
}
/**
* length 추출: "3000" → "3000", "3000 P/L" → "3000", "c" → null
* 선행 숫자가 있고 뒤에 공백+문자(P/L 등)가 붙는 경우 숫자만 추출
*/
private function extractLength(?string $value): ?string
{
if ($value === null || trim($value) === '') {
return null;
}
$cleaned = trim($value);
// 순수 숫자
if (preg_match('/^\d+(?:\.\d+)?$/', $cleaned)) {
return $cleaned;
}
// "3000 P/L" → "3000" (숫자로 시작하고 뒤에 공백+문자)
if (preg_match('/^(\d+(?:\.\d+)?)\s+/', $cleaned, $matches)) {
return $matches[1];
}
// "c", "P/L" 등 숫자 없는 경우
return null;
}
private function showResults(bool $dryRun): void
{
$this->info('=== 결과 ===');
$this->info("변경 대상: {$this->updatedCount}");
$this->info("스킵 (변경 불필요): {$this->skippedCount}");
$this->newLine();
if (! empty($this->changes)) {
$this->table(
['ID', '품목명', '변경 내용'],
array_map(fn ($c) => [$c['id'], mb_substr($c['name'], 0, 30), $c['changes']], $this->changes)
);
}
if ($dryRun) {
$this->newLine();
$this->warn('DRY-RUN 모드입니다. 실제 적용하려면 --execute 옵션을 사용하세요:');
$this->line(' php artisan items:normalize-dimensions --execute');
} else {
$this->newLine();
$this->info("{$this->updatedCount}건 업데이트 완료");
}
}
}

View File

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

View File

@@ -0,0 +1,184 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class RegenerateAuditTriggers extends Command
{
protected $signature = 'audit:triggers
{--table= : 특정 테이블만 재생성}
{--drop-only : 트리거 삭제만 (재생성 안 함)}
{--dry-run : 변경 없이 대상 목록만 출력}';
protected $description = '트리거 감사 로그용 MySQL 트리거 재생성 (스키마 변경 후 사용)';
/** @var string[] 트리거 제외 테이블 */
private array $excludeTables = [
'audit_logs',
'trigger_audit_logs',
'sessions',
'cache',
'cache_locks',
'jobs',
'job_batches',
'failed_jobs',
'migrations',
'personal_access_tokens',
'api_request_logs',
];
public function handle(): int
{
$specificTable = $this->option('table');
$dropOnly = $this->option('drop-only');
$dryRun = $this->option('dry-run');
$dbName = config('database.connections.mysql.database');
$this->info('=== 트리거 감사 로그 트리거 '.($dropOnly ? '삭제' : '재생성').' ===');
$this->newLine();
// 대상 테이블 목록
$tables = $this->getTargetTables($dbName, $specificTable);
$this->info('대상 테이블: '.count($tables).'개');
if ($dryRun) {
foreach ($tables as $t) {
$this->line(" - {$t}");
}
$this->newLine();
$this->info('[DRY-RUN] 실제 변경 없음');
return self::SUCCESS;
}
$dropped = 0;
$created = 0;
foreach ($tables as $table) {
// 기존 트리거 삭제
foreach (['ai', 'au', 'ad'] as $suffix) {
$triggerName = "trg_{$table}_{$suffix}";
DB::unprepared("DROP TRIGGER IF EXISTS `{$triggerName}`");
$dropped++;
}
if (! $dropOnly) {
// 트리거 재생성
$this->createTriggersForTable($dbName, $table);
$created += 3;
}
$this->line(" {$table}: ".($dropOnly ? '삭제 완료' : '재생성 완료'));
}
$this->newLine();
$this->info("결과: 삭제 {$dropped}개, 생성 {$created}");
return self::SUCCESS;
}
private function getTargetTables(string $dbName, ?string $specificTable): array
{
if ($specificTable) {
if (in_array($specificTable, $this->excludeTables)) {
$this->error("{$specificTable}은(는) 트리거 제외 테이블입니다.");
return [];
}
return [$specificTable];
}
$rows = DB::select("
SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_SCHEMA = ? AND TABLE_TYPE = 'BASE TABLE'
ORDER BY TABLE_NAME
", [$dbName]);
return collect($rows)
->pluck('TABLE_NAME')
->reject(fn ($t) => in_array($t, $this->excludeTables))
->values()
->toArray();
}
private function createTriggersForTable(string $dbName, string $table): void
{
// PK 컬럼
$pkRow = DB::selectOne("
SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_KEY = 'PRI'
LIMIT 1
", [$dbName, $table]);
$pkCol = $pkRow?->COLUMN_NAME ?? 'id';
// 컬럼 목록 (제외: created_at, updated_at, deleted_at, remember_token)
$excludeCols = ['created_at', 'updated_at', 'deleted_at', 'remember_token'];
$columns = DB::select('
SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
ORDER BY ORDINAL_POSITION
', [$dbName, $table]);
$cols = collect($columns)
->pluck('COLUMN_NAME')
->reject(fn ($c) => in_array($c, $excludeCols))
->values()
->toArray();
if (empty($cols)) {
return;
}
// JSON_OBJECT 표현식
$newJson = 'JSON_OBJECT('.collect($cols)->map(fn ($c) => "'{$c}', NEW.`{$c}`")->implode(', ').')';
$oldJson = 'JSON_OBJECT('.collect($cols)->map(fn ($c) => "'{$c}', OLD.`{$c}`")->implode(', ').')';
// changed_columns (UPDATE용)
$changedCols = collect($cols)->map(fn ($c) => "IF(NOT (NEW.`{$c}` <=> OLD.`{$c}`), '{$c}', NULL)")->implode(', ');
$changeCheck = collect($cols)->map(fn ($c) => "NOT (NEW.`{$c}` <=> OLD.`{$c}`)")->implode(' OR ');
$tenantExpr = in_array('tenant_id', $cols) ? 'NEW.`tenant_id`' : 'NULL';
$tenantExprOld = in_array('tenant_id', $cols) ? 'OLD.`tenant_id`' : 'NULL';
$guard = 'IF @disable_audit_trigger IS NULL OR @disable_audit_trigger != 1 THEN';
// INSERT trigger
DB::unprepared("
CREATE TRIGGER `trg_{$table}_ai` AFTER INSERT ON `{$table}`
FOR EACH ROW BEGIN
{$guard}
INSERT INTO trigger_audit_logs (table_name, row_id, dml_type, old_values, new_values, changed_columns, tenant_id, actor_id, session_info, db_user, created_at)
VALUES ('{$table}', NEW.`{$pkCol}`, 'INSERT', NULL, {$newJson}, NULL, {$tenantExpr}, @sam_actor_id, @sam_session_info, CURRENT_USER(), NOW());
END IF;
END
");
// UPDATE trigger
DB::unprepared("
CREATE TRIGGER `trg_{$table}_au` AFTER UPDATE ON `{$table}`
FOR EACH ROW BEGIN
{$guard}
IF {$changeCheck} THEN
INSERT INTO trigger_audit_logs (table_name, row_id, dml_type, old_values, new_values, changed_columns, tenant_id, actor_id, session_info, db_user, created_at)
VALUES ('{$table}', NEW.`{$pkCol}`, 'UPDATE', {$oldJson}, {$newJson}, JSON_ARRAY({$changedCols}), {$tenantExpr}, @sam_actor_id, @sam_session_info, CURRENT_USER(), NOW());
END IF;
END IF;
END
");
// DELETE trigger
DB::unprepared("
CREATE TRIGGER `trg_{$table}_ad` AFTER DELETE ON `{$table}`
FOR EACH ROW BEGIN
{$guard}
INSERT INTO trigger_audit_logs (table_name, row_id, dml_type, old_values, new_values, changed_columns, tenant_id, actor_id, session_info, db_user, created_at)
VALUES ('{$table}', OLD.`{$pkCol}`, 'DELETE', {$oldJson}, NULL, NULL, {$tenantExprOld}, @sam_actor_id, @sam_session_info, CURRENT_USER(), NOW());
END IF;
END
");
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Console\Commands;
use App\Services\Stats\StatAggregatorService;
use Carbon\Carbon;
use Illuminate\Console\Command;
class StatAggregateDailyCommand extends Command
{
protected $signature = 'stat:aggregate-daily
{--date= : 집계 대상 날짜 (YYYY-MM-DD, 기본: 전일)}
{--domain= : 특정 도메인만 집계 (sales, finance, production)}
{--tenant= : 특정 테넌트만 집계}';
protected $description = '일간 통계 집계 (sam_stat DB)';
public function handle(StatAggregatorService $aggregator): int
{
$date = $this->option('date')
? Carbon::parse($this->option('date'))
: Carbon::yesterday();
$domain = $this->option('domain');
$tenantId = $this->option('tenant') ? (int) $this->option('tenant') : null;
$this->info("📊 일간 통계 집계 시작: {$date->format('Y-m-d')}");
if ($domain) {
$this->info(" 도메인 필터: {$domain}");
}
if ($tenantId) {
$this->info(" 테넌트 필터: {$tenantId}");
}
try {
$result = $aggregator->aggregateDaily($date, $domain, $tenantId);
$this->info('✅ 일간 집계 완료:');
$this->info(" - 처리 테넌트: {$result['tenants_processed']}");
$this->info(" - 처리 도메인: {$result['domains_processed']}");
$this->info(" - 소요 시간: {$result['duration_ms']}ms");
if (! empty($result['errors'])) {
$this->warn(' ⚠️ 에러 발생: '.count($result['errors']).'건');
foreach ($result['errors'] as $error) {
$this->error(" - {$error}");
}
return self::FAILURE;
}
return self::SUCCESS;
} catch (\Throwable $e) {
$this->error("❌ 집계 실패: {$e->getMessage()}");
return self::FAILURE;
}
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Console\Commands;
use App\Services\Stats\StatAggregatorService;
use Carbon\Carbon;
use Illuminate\Console\Command;
class StatAggregateMonthlyCommand extends Command
{
protected $signature = 'stat:aggregate-monthly
{--year= : 집계 대상 연도 (기본: 전월 기준)}
{--month= : 집계 대상 월 (기본: 전월)}
{--domain= : 특정 도메인만 집계}
{--tenant= : 특정 테넌트만 집계}';
protected $description = '월간 통계 집계 (sam_stat DB)';
public function handle(StatAggregatorService $aggregator): int
{
$lastMonth = Carbon::now()->subMonth();
$year = $this->option('year') ? (int) $this->option('year') : $lastMonth->year;
$month = $this->option('month') ? (int) $this->option('month') : $lastMonth->month;
$domain = $this->option('domain');
$tenantId = $this->option('tenant') ? (int) $this->option('tenant') : null;
$this->info("📊 월간 통계 집계 시작: {$year}-".str_pad($month, 2, '0', STR_PAD_LEFT));
try {
$result = $aggregator->aggregateMonthly($year, $month, $domain, $tenantId);
$this->info('✅ 월간 집계 완료:');
$this->info(" - 처리 테넌트: {$result['tenants_processed']}");
$this->info(" - 처리 도메인: {$result['domains_processed']}");
$this->info(" - 소요 시간: {$result['duration_ms']}ms");
if (! empty($result['errors'])) {
$this->warn(' ⚠️ 에러 발생: '.count($result['errors']).'건');
foreach ($result['errors'] as $error) {
$this->error(" - {$error}");
}
return self::FAILURE;
}
return self::SUCCESS;
} catch (\Throwable $e) {
$this->error("❌ 집계 실패: {$e->getMessage()}");
return self::FAILURE;
}
}
}

View File

@@ -0,0 +1,174 @@
<?php
namespace App\Console\Commands;
use App\Services\Stats\DimensionSyncService;
use App\Services\Stats\StatAggregatorService;
use Carbon\Carbon;
use Carbon\CarbonPeriod;
use Illuminate\Console\Command;
class StatBackfillCommand extends Command
{
protected $signature = 'stat:backfill
{--from= : 시작 날짜 (YYYY-MM-DD, 필수)}
{--to= : 종료 날짜 (YYYY-MM-DD, 기본: 어제)}
{--domain= : 특정 도메인만 집계}
{--tenant= : 특정 테넌트만 집계}
{--skip-monthly : 월간 집계 건너뛰기}
{--skip-dimensions : 차원 동기화 건너뛰기}';
protected $description = '과거 데이터 일괄 통계 집계 (백필)';
public function handle(StatAggregatorService $aggregator, DimensionSyncService $dimensionSync): int
{
$from = $this->option('from');
if (! $from) {
$this->error('--from 옵션은 필수입니다. 예: --from=2024-01-01');
return self::FAILURE;
}
$startDate = Carbon::parse($from);
$endDate = $this->option('to')
? Carbon::parse($this->option('to'))
: Carbon::yesterday();
$domain = $this->option('domain');
$tenantId = $this->option('tenant') ? (int) $this->option('tenant') : null;
$totalDays = $startDate->diffInDays($endDate) + 1;
$this->info("📊 백필 시작: {$startDate->format('Y-m-d')} ~ {$endDate->format('Y-m-d')} ({$totalDays}일)");
if ($domain) {
$this->info(" 도메인 필터: {$domain}");
}
if ($tenantId) {
$this->info(" 테넌트 필터: {$tenantId}");
}
$totalErrors = [];
$totalDomainsProcessed = 0;
$startTime = microtime(true);
// 1. 차원 테이블 동기화 (최초 1회)
if (! $this->option('skip-dimensions')) {
$this->info('');
$this->info('🔄 차원 테이블 동기화...');
try {
$tenants = $this->getTargetTenants($tenantId);
foreach ($tenants as $tenant) {
$clients = $dimensionSync->syncClients($tenant->id);
$products = $dimensionSync->syncProducts($tenant->id);
$this->line(" tenant={$tenant->id}: 고객 {$clients}건, 제품 {$products}");
}
} catch (\Throwable $e) {
$this->warn(" 차원 동기화 실패: {$e->getMessage()}");
}
}
// 2. 일간 집계
$this->info('');
$this->info('📅 일간 집계 시작...');
$bar = $this->output->createProgressBar($totalDays);
$bar->setFormat(' %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s% %message%');
$bar->setMessage('');
$period = CarbonPeriod::create($startDate, $endDate);
foreach ($period as $date) {
$bar->setMessage($date->format('Y-m-d'));
try {
$result = $aggregator->aggregateDaily($date, $domain, $tenantId);
$totalDomainsProcessed += $result['domains_processed'];
if (! empty($result['errors'])) {
$totalErrors = array_merge($totalErrors, $result['errors']);
}
} catch (\Throwable $e) {
$totalErrors[] = "daily {$date->format('Y-m-d')}: {$e->getMessage()}";
}
$bar->advance();
}
$bar->finish();
$this->newLine();
// 3. 월간 집계
if (! $this->option('skip-monthly')) {
$this->info('');
$this->info('📆 월간 집계 시작...');
$months = $this->getMonthRange($startDate, $endDate);
$monthBar = $this->output->createProgressBar(count($months));
foreach ($months as [$year, $month]) {
$monthBar->setMessage("{$year}-".str_pad($month, 2, '0', STR_PAD_LEFT));
try {
$result = $aggregator->aggregateMonthly($year, $month, $domain, $tenantId);
$totalDomainsProcessed += $result['domains_processed'];
if (! empty($result['errors'])) {
$totalErrors = array_merge($totalErrors, $result['errors']);
}
} catch (\Throwable $e) {
$totalErrors[] = "monthly {$year}-{$month}: {$e->getMessage()}";
}
$monthBar->advance();
}
$monthBar->finish();
$this->newLine();
}
$durationSec = round(microtime(true) - $startTime, 1);
$this->info('');
$this->info('✅ 백필 완료:');
$this->info(" - 기간: {$startDate->format('Y-m-d')} ~ {$endDate->format('Y-m-d')} ({$totalDays}일)");
$this->info(" - 처리 도메인-테넌트: {$totalDomainsProcessed}");
$this->info(" - 소요 시간: {$durationSec}");
if (! empty($totalErrors)) {
$this->warn(' - 에러: '.count($totalErrors).'건');
foreach (array_slice($totalErrors, 0, 20) as $error) {
$this->error(" - {$error}");
}
if (count($totalErrors) > 20) {
$this->warn(' ... 외 '.(count($totalErrors) - 20).'건');
}
return self::FAILURE;
}
return self::SUCCESS;
}
private function getTargetTenants(?int $tenantId): \Illuminate\Database\Eloquent\Collection
{
$query = \App\Models\Tenants\Tenant::where('tenant_st_code', '!=', 'none');
if ($tenantId) {
$query->where('id', $tenantId);
}
return $query->get();
}
private function getMonthRange(Carbon $start, Carbon $end): array
{
$months = [];
$current = $start->copy()->startOfMonth();
$endMonth = $end->copy()->startOfMonth();
while ($current->lte($endMonth)) {
$months[] = [$current->year, $current->month];
$current->addMonth();
}
return $months;
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Console\Commands;
use App\Services\Stats\KpiAlertService;
use Illuminate\Console\Command;
class StatCheckKpiAlertsCommand extends Command
{
protected $signature = 'stat:check-kpi-alerts';
protected $description = 'KPI 목표 대비 실적을 체크하고 미달 시 알림을 생성합니다';
public function handle(KpiAlertService $service): int
{
$this->info('KPI 알림 체크 시작...');
$result = $service->checkKpiAlerts();
$this->info("알림 생성: {$result['alerts_created']}");
if (! empty($result['errors'])) {
$this->warn('오류 발생:');
foreach ($result['errors'] as $error) {
$this->error(" - {$error}");
}
}
$this->info('KPI 알림 체크 완료.');
return empty($result['errors']) ? self::SUCCESS : self::FAILURE;
}
}

View File

@@ -0,0 +1,211 @@
<?php
namespace App\Console\Commands;
use App\Models\Stats\Daily\StatFinanceDaily;
use App\Models\Stats\Daily\StatSalesDaily;
use App\Models\Stats\Daily\StatSystemDaily;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class StatVerifyCommand extends Command
{
protected $signature = 'stat:verify
{--date= : 검증 날짜 (YYYY-MM-DD, 기본: 어제)}
{--tenant= : 특정 테넌트만 검증}
{--domain= : 특정 도메인만 검증 (sales,finance,system)}
{--fix : 불일치 시 자동 재집계}';
protected $description = '원본 DB와 sam_stat 통계 정합성 교차 검증';
private int $totalChecks = 0;
private int $passedChecks = 0;
private int $failedChecks = 0;
private array $mismatches = [];
public function handle(): int
{
$date = $this->option('date')
? Carbon::parse($this->option('date'))
: Carbon::yesterday();
$tenantId = $this->option('tenant') ? (int) $this->option('tenant') : null;
$domain = $this->option('domain');
$dateStr = $date->format('Y-m-d');
$this->info("🔍 정합성 검증: {$dateStr}");
$tenants = $this->getTargetTenants($tenantId);
$domains = $domain
? [$domain]
: ['sales', 'finance', 'system'];
foreach ($tenants as $tenant) {
$this->info('');
$this->info("── tenant={$tenant->id} ──");
foreach ($domains as $d) {
match ($d) {
'sales' => $this->verifySales($tenant->id, $dateStr),
'finance' => $this->verifyFinance($tenant->id, $dateStr),
'system' => $this->verifySystem($tenant->id, $dateStr),
default => $this->warn(" 미지원 도메인: {$d}"),
};
}
}
$this->printSummary();
if ($this->failedChecks > 0 && $this->option('fix')) {
$this->info('');
$this->info('🔧 불일치 항목 재집계...');
$this->reAggregate($date, $tenantId, $domains);
}
return $this->failedChecks > 0 ? self::FAILURE : self::SUCCESS;
}
private function verifySales(int $tenantId, string $dateStr): void
{
$this->line(' [sales]');
$originOrderCount = DB::connection('mysql')
->table('orders')
->where('tenant_id', $tenantId)
->whereDate('created_at', $dateStr)
->whereNull('deleted_at')
->count();
$originSalesAmount = (float) DB::connection('mysql')
->table('sales')
->where('tenant_id', $tenantId)
->where('sale_date', $dateStr)
->whereNull('deleted_at')
->sum('supply_amount');
$stat = StatSalesDaily::where('tenant_id', $tenantId)
->where('stat_date', $dateStr)
->first();
$this->check('수주건수', $originOrderCount, $stat?->order_count ?? 0, $tenantId, 'sales');
$this->check('매출금액', $originSalesAmount, (float) ($stat?->sales_amount ?? 0), $tenantId, 'sales');
}
private function verifyFinance(int $tenantId, string $dateStr): void
{
$this->line(' [finance]');
$originDepositAmount = (float) DB::connection('mysql')
->table('deposits')
->where('tenant_id', $tenantId)
->where('deposit_date', $dateStr)
->whereNull('deleted_at')
->sum('amount');
$originWithdrawalAmount = (float) DB::connection('mysql')
->table('withdrawals')
->where('tenant_id', $tenantId)
->where('withdrawal_date', $dateStr)
->whereNull('deleted_at')
->sum('amount');
$stat = StatFinanceDaily::where('tenant_id', $tenantId)
->where('stat_date', $dateStr)
->first();
$this->check('입금액', $originDepositAmount, (float) ($stat?->deposit_amount ?? 0), $tenantId, 'finance');
$this->check('출금액', $originWithdrawalAmount, (float) ($stat?->withdrawal_amount ?? 0), $tenantId, 'finance');
}
private function verifySystem(int $tenantId, string $dateStr): void
{
$this->line(' [system]');
$originApiCount = DB::connection('mysql')
->table('api_request_logs')
->where('tenant_id', $tenantId)
->whereDate('created_at', $dateStr)
->count();
$originAuditCount = DB::connection('mysql')
->table('audit_logs')
->where('tenant_id', $tenantId)
->whereDate('created_at', $dateStr)
->count();
$stat = StatSystemDaily::where('tenant_id', $tenantId)
->where('stat_date', $dateStr)
->first();
$this->check('API요청수', $originApiCount, $stat?->api_request_count ?? 0, $tenantId, 'system');
$statAuditTotal = ($stat?->audit_create_count ?? 0)
+ ($stat?->audit_update_count ?? 0)
+ ($stat?->audit_delete_count ?? 0);
$this->check('감사로그수', $originAuditCount, $statAuditTotal, $tenantId, 'system');
}
private function check(string $label, float|int $expected, float|int $actual, int $tenantId, string $domain): void
{
$this->totalChecks++;
$tolerance = is_float($expected) ? 0.01 : 0;
$match = abs($expected - $actual) <= $tolerance;
if ($match) {
$this->passedChecks++;
$this->line("{$label}: {$actual}");
} else {
$this->failedChecks++;
$this->error("{$label}: 원본={$expected} / 통계={$actual} (차이=".($actual - $expected).')');
$this->mismatches[] = compact('tenantId', 'domain', 'label', 'expected', 'actual');
}
}
private function printSummary(): void
{
$this->info('');
$this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
$this->info("📊 검증 결과: {$this->totalChecks}건 검사, ✅ {$this->passedChecks}건 일치, ❌ {$this->failedChecks}건 불일치");
if ($this->failedChecks > 0) {
$this->warn('');
$this->warn('불일치 목록:');
foreach ($this->mismatches as $m) {
$this->warn(" - tenant={$m['tenantId']} [{$m['domain']}] {$m['label']}: 원본={$m['expected']} / 통계={$m['actual']}");
}
}
}
private function reAggregate(Carbon $date, ?int $tenantId, array $domains): void
{
$aggregator = app(\App\Services\Stats\StatAggregatorService::class);
foreach ($domains as $d) {
$result = $aggregator->aggregateDaily($date, $d, $tenantId);
$this->line(" {$d}: 재집계 완료 ({$result['domains_processed']}건)");
if (! empty($result['errors'])) {
foreach ($result['errors'] as $error) {
$this->error(" {$error}");
}
}
}
$this->info('✅ 재집계 완료. stat:verify를 다시 실행하여 확인하세요.');
}
private function getTargetTenants(?int $tenantId): \Illuminate\Database\Eloquent\Collection
{
$query = \App\Models\Tenants\Tenant::where('tenant_st_code', '!=', 'none');
if ($tenantId) {
$query->where('id', $tenantId);
}
return $query->get();
}
}

View File

@@ -0,0 +1,137 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Attributes\AsCommand;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
#[AsCommand(name: 'bending:validate-items', description: 'BD-* 절곡 세부품목 마스터 데이터 검증 (prefix × lengthCode 전 조합)')]
class ValidateBendingItems extends Command
{
protected $signature = 'bending:validate-items
{--tenant_id=287 : Target tenant ID (default: 287 경동기업)}';
/**
* prefix별 유효 길이코드 정의
*
* 가이드레일: 30, 35, 40, 43 (벽면/측면 공통)
* 하단마감재: 30, 40
* 셔터박스: 12, 24, 30, 35, 40, 41
* 연기차단재: 53, 54, 83, 84 (W50/W80 전용 코드)
* XX: 12, 24, 30, 35, 40, 41, 43 (하부BASE + 셔터 상부/마구리)
* YY: 30, 35, 40, 43 (별도 SUS 마감)
* HH: 30, 40 (보강평철)
*/
private function getPrefixLengthCodes(): array
{
$guideRailCodes = ['30', '35', '40', '43'];
$guideRailCodesWithExtra = ['24', '30', '35', '40', '43']; // RT/ST는 적은 종류
$bottomBarCodes = ['30', '40'];
$shutterBoxCodes = ['12', '24', '30', '35', '40', '41'];
return [
// 가이드레일 벽면형
'RS' => $guideRailCodes, // 벽면 SUS 마감재
'RM' => ['24', '30', '35', '40', '42', '43'], // 벽면 본체 (EGI)
'RC' => ['24', '30', '35', '40', '42', '43'], // 벽면 C형
'RD' => ['24', '30', '35', '40', '42', '43'], // 벽면 D형
'RT' => ['30', '43'], // 벽면 본체 (철재)
// 가이드레일 측면형
'SS' => ['30', '35', '40'], // 측면 SUS 마감재
'SM' => ['24', '30', '35', '40', '43'], // 측면 본체 (EGI)
'SC' => ['24', '30', '35', '40', '43'], // 측면 C형
'SD' => ['24', '30', '35', '40', '43'], // 측면 D형
'ST' => ['43'], // 측면 본체 (철재)
'SU' => ['30', '35', '40', '43'], // 측면 SUS (SUS2)
// 하단마감재
'BE' => $bottomBarCodes, // EGI 마감
'BS' => ['24', '30', '35', '40', '43'], // SUS 마감
'TS' => ['43'], // 철재 SUS
'LA' => $bottomBarCodes, // L-Bar
// 셔터박스
'CF' => $shutterBoxCodes, // 전면부
'CL' => $shutterBoxCodes, // 린텔부
'CP' => $shutterBoxCodes, // 점검구
'CB' => $shutterBoxCodes, // 후면코너부
// 연기차단재
'GI' => ['53', '54', '83', '84', '30', '35', '40'], // W50/W80 + 일반
// 공용/기타
'XX' => ['12', '24', '30', '35', '40', '41', '43'], // 하부BASE/셔터 상부/마구리
'YY' => ['30', '35', '40', '43'], // 별도 SUS 마감
'HH' => ['30', '40'], // 보강평철
];
}
public function handle(): int
{
$tenantId = (int) $this->option('tenant_id');
$this->info("=== BD-* 절곡 세부품목 마스터 검증 (tenant: {$tenantId}) ===");
$this->newLine();
// DB에서 전체 BD-* 품목 조회
$existingItems = DB::table('items')
->where('tenant_id', $tenantId)
->where('code', 'like', 'BD-%')
->whereNull('deleted_at')
->pluck('code')
->toArray();
$existingSet = array_flip($existingItems);
$this->info('현재 등록된 BD-* 품목: '.count($existingItems).'개');
$this->newLine();
$prefixMap = $this->getPrefixLengthCodes();
$totalExpected = 0;
$missing = [];
$found = 0;
foreach ($prefixMap as $prefix => $codes) {
$prefixMissing = [];
foreach ($codes as $code) {
$itemCode = "BD-{$prefix}-{$code}";
$totalExpected++;
if (isset($existingSet[$itemCode])) {
$found++;
} else {
$prefixMissing[] = $itemCode;
$missing[] = $itemCode;
}
}
$status = empty($prefixMissing) ? '✅' : '❌';
$countStr = count($codes) - count($prefixMissing).'/'.count($codes);
$this->line(" {$status} BD-{$prefix}: {$countStr}");
if (! empty($prefixMissing)) {
foreach ($prefixMissing as $m) {
$this->line(" ⚠️ 누락: {$m}");
}
}
}
$this->newLine();
$this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
$this->info("검증 결과: {$found}/{$totalExpected} 등록 완료");
if (empty($missing)) {
$this->info('✅ All items registered — 누락 0건');
return self::SUCCESS;
}
$this->warn('❌ 누락 항목: '.count($missing).'건');
$this->newLine();
$this->table(['누락 품목코드'], array_map(fn ($m) => [$m], $missing));
return self::FAILURE;
}
}

View File

@@ -44,7 +44,7 @@ public function handle(FormulaEvaluatorService $formulaEvaluator): int
$finishedGoodsCode = $this->option('finished-goods');
$verboseMode = $this->option('verbose-mode');
$this->info("📥 입력 파라미터:");
$this->info('📥 입력 파라미터:');
$this->table(
['항목', '값'],
[
@@ -87,7 +87,7 @@ public function handle(FormulaEvaluatorService $formulaEvaluator): int
);
if (! $samResult['success']) {
$this->error("SAM 계산 실패: " . ($samResult['error'] ?? '알 수 없는 오류'));
$this->error('SAM 계산 실패: '.($samResult['error'] ?? '알 수 없는 오류'));
return Command::FAILURE;
}
@@ -214,4 +214,4 @@ private function calculateSamVariables(float $W0, float $H0, string $productType
'K' => $K,
];
}
}
}

View File

@@ -0,0 +1,101 @@
<?php
namespace App\DTOs\Production;
use InvalidArgumentException;
/**
* dynamic_bom JSON 항목 DTO
*
* work_order_items.options.dynamic_bom 배열의 각 엔트리를 표현
*/
class DynamicBomEntry
{
public function __construct(
public readonly int $child_item_id,
public readonly string $child_item_code,
public readonly string $lot_prefix,
public readonly string $part_type,
public readonly string $category,
public readonly string $material_type,
public readonly int $length_mm,
public readonly int|float $qty,
) {}
/**
* 배열에서 DTO 생성
*/
public static function fromArray(array $data): self
{
self::validate($data);
return new self(
child_item_id: (int) $data['child_item_id'],
child_item_code: (string) $data['child_item_code'],
lot_prefix: (string) $data['lot_prefix'],
part_type: (string) $data['part_type'],
category: (string) $data['category'],
material_type: (string) $data['material_type'],
length_mm: (int) $data['length_mm'],
qty: $data['qty'],
);
}
/**
* DTO → 배열 변환 (JSON 저장용)
*/
public function toArray(): array
{
return [
'child_item_id' => $this->child_item_id,
'child_item_code' => $this->child_item_code,
'lot_prefix' => $this->lot_prefix,
'part_type' => $this->part_type,
'category' => $this->category,
'material_type' => $this->material_type,
'length_mm' => $this->length_mm,
'qty' => $this->qty,
];
}
/**
* 필수 필드 검증
*
* @throws InvalidArgumentException
*/
public static function validate(array $data): bool
{
$required = ['child_item_id', 'child_item_code', 'lot_prefix', 'part_type', 'category', 'material_type', 'length_mm', 'qty'];
foreach ($required as $field) {
if (! array_key_exists($field, $data) || $data[$field] === null) {
throw new InvalidArgumentException("DynamicBomEntry: '{$field}' is required");
}
}
if ((int) $data['child_item_id'] <= 0) {
throw new InvalidArgumentException('DynamicBomEntry: child_item_id must be positive');
}
$validCategories = ['guideRail', 'bottomBar', 'shutterBox', 'smokeBarrier'];
if (! in_array($data['category'], $validCategories, true)) {
throw new InvalidArgumentException('DynamicBomEntry: category must be one of: '.implode(', ', $validCategories));
}
if ($data['qty'] <= 0) {
throw new InvalidArgumentException('DynamicBomEntry: qty must be positive');
}
return true;
}
/**
* DynamicBomEntry 배열 → JSON 저장용 배열 변환
*
* @param DynamicBomEntry[] $entries
*/
public static function toArrayList(array $entries): array
{
return array_map(fn (self $e) => $e->toArray(), $entries);
}
}

View File

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

View File

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

View File

@@ -5,6 +5,7 @@
use App\Exceptions\DuplicateCodeException;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpKernel\Exception\HttpException;
class ApiResponse
@@ -245,6 +246,15 @@ public static function handle(
return self::success($data, $responseTitle, $debug, $statusCode);
} catch (\Throwable $e) {
// 모든 예외를 로깅 (디버깅용)
Log::error('API Exception', [
'message' => $e->getMessage(),
'exception' => get_class($e),
'file' => $e->getFile(),
'line' => $e->getLine(),
'url' => request()->fullUrl(),
'method' => request()->method(),
]);
// ValidationException - 422 Unprocessable Entity
if ($e instanceof \Illuminate\Validation\ValidationException) {
@@ -279,9 +289,12 @@ public static function handle(
);
}
// 일반 예외는 500으로 처리, debug 모드에서만 스택 트레이스 포함
return self::error('서버 에러', 500, [
// 일반 예외는 500으로 처리, debug 모드에서만 상세 정보 포함
$errorMessage = config('app.debug') ? $e->getMessage() : '서버 에러';
return self::error($errorMessage, 500, [
'details' => config('app.debug') ? $e->getTraceAsString() : null,
'exception' => config('app.debug') ? get_class($e) : null,
]);
}
}

View File

@@ -14,7 +14,7 @@
* - 두께 매핑 (normalizeThickness)
* - 면적 계산 (calculateArea)
*
* @see docs/plans/5130-sam-data-migration-plan.md 섹션 4.5
* @see docs/dev_plans/5130-sam-data-migration-plan.md 섹션 4.5
*/
class Legacy5130Calculator
{
@@ -506,4 +506,4 @@ public static function validateAgainstLegacy(
'differences' => $differences,
];
}
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\V1\AccountSubject\StoreAccountSubjectRequest;
use App\Services\AccountCodeService;
use Illuminate\Http\Request;
class AccountSubjectController extends Controller
{
public function __construct(
private readonly AccountCodeService $service
) {}
/**
* 계정과목 목록 조회
*/
public function index(Request $request)
{
$params = $request->only(['search', 'category']);
$subjects = $this->service->index($params);
return ApiResponse::success($subjects, __('message.fetched'));
}
/**
* 계정과목 등록
*/
public function store(StoreAccountSubjectRequest $request)
{
$subject = $this->service->store($request->validated());
return ApiResponse::success($subject, __('message.created'), [], 201);
}
/**
* 계정과목 활성/비활성 토글
*/
public function toggleStatus(int $id, Request $request)
{
$isActive = (bool) $request->input('is_active', true);
$subject = $this->service->toggleStatus($id, $isActive);
return ApiResponse::success($subject, __('message.toggled'));
}
/**
* 계정과목 삭제
*/
public function destroy(int $id)
{
$this->service->destroy($id);
return ApiResponse::success(null, __('message.deleted'));
}
}

View File

@@ -2,10 +2,10 @@
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\V1\AiReport\AiReportGenerateRequest;
use App\Http\Requests\V1\AiReport\AiReportListRequest;
use App\Helpers\ApiResponse;
use App\Services\AiReportService;
use Illuminate\Http\JsonResponse;

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Services\AppVersionService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\StreamedResponse;
class AppVersionController extends Controller
{
/**
* 최신 버전 확인
* GET /api/v1/app/version?platform=android&current_version_code=1
*/
public function latestVersion(Request $request): JsonResponse
{
$platform = $request->input('platform', 'android');
$currentVersionCode = (int) $request->input('current_version_code', 0);
$result = AppVersionService::getLatestVersion($platform, $currentVersionCode);
return response()->json([
'success' => true,
'data' => $result,
]);
}
/**
* APK 다운로드
* GET /api/v1/app/download/{id}
*/
public function download(int $id): StreamedResponse
{
return AppVersionService::downloadApk($id);
}
}

View File

@@ -0,0 +1,83 @@
<?php
namespace App\Http\Controllers\Api\V1\Audit;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\Audit\TriggerAuditLogIndexRequest;
use App\Http\Requests\Audit\TriggerAuditRollbackRequest;
use App\Services\Audit\AuditRollbackService;
use App\Services\Audit\TriggerAuditLogService;
class TriggerAuditLogController extends Controller
{
public function __construct(
protected TriggerAuditLogService $service,
protected AuditRollbackService $rollbackService,
) {}
/**
* 트리거 감사 로그 목록 조회
*/
public function index(TriggerAuditLogIndexRequest $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->paginate($request->validated());
}, __('message.fetched'));
}
/**
* 트리거 감사 로그 상세 조회
*/
public function show(int $id)
{
return ApiResponse::handle(function () use ($id) {
return \App\Models\Audit\TriggerAuditLog::findOrFail($id);
}, __('message.fetched'));
}
/**
* 특정 레코드의 변경 이력
*/
public function recordHistory(string $tableName, string $rowId)
{
return ApiResponse::handle(function () use ($tableName, $rowId) {
return $this->service->recordHistory($tableName, $rowId);
}, __('message.fetched'));
}
/**
* 통계 조회
*/
public function stats()
{
return ApiResponse::handle(function () {
$tenantId = request()->query('tenant_id');
return $this->service->stats($tenantId ? (int) $tenantId : null);
}, __('message.fetched'));
}
/**
* 롤백 SQL 미리보기
*/
public function rollbackPreview(int $id)
{
return ApiResponse::handle(function () use ($id) {
return [
'audit_id' => $id,
'rollback_sql' => $this->rollbackService->generateRollbackSQL($id),
];
}, __('message.fetched'));
}
/**
* 롤백 실행
*/
public function rollbackExecute(TriggerAuditRollbackRequest $request, int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->rollbackService->executeRollback($id);
}, __('message.updated'));
}
}

View File

@@ -0,0 +1,144 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Services\BarobillService;
use Illuminate\Http\Request;
class BarobillController extends Controller
{
public function __construct(
private BarobillService $barobillService
) {}
/**
* 연동 현황 조회
*/
public function status()
{
return ApiResponse::handle(function () {
$setting = $this->barobillService->getSetting();
return [
'bank_service_count' => 0,
'account_link_count' => 0,
'member' => $setting ? [
'barobill_id' => $setting->barobill_id,
'biz_no' => $setting->corp_num,
'status' => $setting->isVerified() ? 'active' : 'inactive',
'server_mode' => config('services.barobill.test_mode', true) ? 'test' : 'production',
] : null,
];
}, __('message.fetched'));
}
/**
* 바로빌 로그인 정보 등록
*/
public function login(Request $request)
{
$data = $request->validate([
'barobill_id' => 'required|string',
'password' => 'required|string',
]);
return ApiResponse::handle(function () use ($data) {
return $this->barobillService->saveSetting([
'barobill_id' => $data['barobill_id'],
]);
}, __('message.saved'));
}
/**
* 바로빌 회원가입 정보 등록
*/
public function signup(Request $request)
{
$data = $request->validate([
'business_number' => 'required|string|size:10',
'company_name' => 'required|string',
'ceo_name' => 'required|string',
'business_type' => 'nullable|string',
'business_category' => 'nullable|string',
'address' => 'nullable|string',
'barobill_id' => 'required|string',
'password' => 'required|string',
'manager_name' => 'nullable|string',
'manager_phone' => 'nullable|string',
'manager_email' => 'nullable|email',
]);
return ApiResponse::handle(function () use ($data) {
return $this->barobillService->saveSetting([
'corp_num' => $data['business_number'],
'corp_name' => $data['company_name'],
'ceo_name' => $data['ceo_name'],
'biz_type' => $data['business_type'] ?? null,
'biz_class' => $data['business_category'] ?? null,
'addr' => $data['address'] ?? null,
'barobill_id' => $data['barobill_id'],
'contact_name' => $data['manager_name'] ?? null,
'contact_tel' => $data['manager_phone'] ?? null,
'contact_id' => $data['manager_email'] ?? null,
]);
}, __('message.saved'));
}
/**
* 은행 빠른조회 서비스 URL 조회
*/
public function bankServiceUrl(Request $request)
{
return ApiResponse::handle(function () {
$baseUrl = config('services.barobill.test_mode', true)
? 'https://testws.barobill.co.kr'
: 'https://ws.barobill.co.kr';
return ['url' => $baseUrl.'/Bank/BankAccountService'];
}, __('message.fetched'));
}
/**
* 계좌 연동 등록 URL 조회
*/
public function accountLinkUrl()
{
return ApiResponse::handle(function () {
$baseUrl = config('services.barobill.test_mode', true)
? 'https://testws.barobill.co.kr'
: 'https://ws.barobill.co.kr';
return ['url' => $baseUrl.'/Bank/AccountLink'];
}, __('message.fetched'));
}
/**
* 카드 연동 등록 URL 조회
*/
public function cardLinkUrl()
{
return ApiResponse::handle(function () {
$baseUrl = config('services.barobill.test_mode', true)
? 'https://testws.barobill.co.kr'
: 'https://ws.barobill.co.kr';
return ['url' => $baseUrl.'/Card/CardLink'];
}, __('message.fetched'));
}
/**
* 공인인증서 등록 URL 조회
*/
public function certificateUrl()
{
return ApiResponse::handle(function () {
$baseUrl = config('services.barobill.test_mode', true)
? 'https://testws.barobill.co.kr'
: 'https://ws.barobill.co.kr';
return ['url' => $baseUrl.'/Certificate/Register'];
}, __('message.fetched'));
}
}

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\Api\v1;
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;

View File

@@ -106,6 +106,22 @@ public function tenantBoards()
}, __('message.fetched'));
}
/**
* 게시판 상세 조회 (코드 기반)
*/
public function showByCode(string $code)
{
return ApiResponse::handle(function () use ($code) {
$board = $this->boardService->getBoardByCode($code);
if (! $board) {
abort(404, __('error.board.not_found'));
}
return $board;
}, __('message.fetched'));
}
/**
* 게시판 필드 목록 조회
*/

View File

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

View File

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

View File

@@ -3,17 +3,17 @@
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Models\Products\CommonCode;
use App\Models\Scopes\TenantScope;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class CommonController
{
public static function getComeCode()
{
return ApiResponse::handle(function () {
return DB::table('common_codes')
return CommonCode::query()
->select(['code_group', 'code', 'name', 'description', 'is_active'])
->where('tenant_id', app('tenant_id'))
->get();
}, '공통코드');
}
@@ -36,13 +36,22 @@ public function index(Request $request, string $group)
return ApiResponse::handle(function () use ($group) {
$tenantId = app('tenant_id');
return DB::table('common_codes')
->select(['id', 'code', 'name', 'description', 'sort_order', 'attributes'])
// BelongsToTenant 스코프 해제 (글로벌 폴백 로직 직접 처리)
$base = CommonCode::withoutGlobalScope(TenantScope::class)
->where('code_group', $group)
->where('is_active', true)
->where(function ($query) use ($tenantId) {
$query->where('tenant_id', $tenantId)
->orWhereNull('tenant_id');
->where('is_active', true);
// 테넌트 전용 데이터가 있으면 테넌트만, 없으면 글로벌 폴백
$hasTenantData = (clone $base)->where('tenant_id', $tenantId)->exists();
return (clone $base)
->select(['id', 'code', 'name', 'description', 'sort_order', 'attributes'])
->where(function ($query) use ($tenantId, $hasTenantData) {
if ($hasTenantData) {
$query->where('tenant_id', $tenantId);
} else {
$query->whereNull('tenant_id');
}
})
->orderBy('sort_order')
->get();

View File

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

View File

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

View File

@@ -2,10 +2,10 @@
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\V1\Dashboard\DashboardApprovalsRequest;
use App\Http\Requests\V1\Dashboard\DashboardChartsRequest;
use App\Helpers\ApiResponse;
use App\Services\DashboardService;
use Illuminate\Http\JsonResponse;

View File

@@ -0,0 +1,201 @@
<?php
namespace App\Http\Controllers\Api\V1\Documents;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\Document\ApproveRequest;
use App\Http\Requests\Document\BulkCreateFqcRequest;
use App\Http\Requests\Document\IndexRequest;
use App\Http\Requests\Document\RejectRequest;
use App\Http\Requests\Document\ResolveRequest;
use App\Http\Requests\Document\StoreRequest;
use App\Http\Requests\Document\UpdateRequest;
use App\Http\Requests\Document\UpsertRequest;
use App\Services\DocumentService;
use Illuminate\Http\JsonResponse;
class DocumentController extends Controller
{
public function __construct(private DocumentService $service) {}
/**
* 문서 목록 조회
* GET /v1/documents
*/
public function index(IndexRequest $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
return $this->service->list($request->validated());
}, __('message.fetched'));
}
/**
* 문서 상세 조회
* GET /v1/documents/{id}
*/
public function show(int $id): JsonResponse
{
return ApiResponse::handle(function () use ($id) {
return $this->service->show($id);
}, __('message.fetched'));
}
/**
* 문서 생성
* POST /v1/documents
*/
public function store(StoreRequest $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
return $this->service->create($request->validated());
}, __('message.created'));
}
/**
* 문서 수정
* PATCH /v1/documents/{id}
*/
public function update(int $id, UpdateRequest $request): JsonResponse
{
return ApiResponse::handle(function () use ($id, $request) {
return $this->service->update($id, $request->validated());
}, __('message.updated'));
}
/**
* 문서 삭제
* DELETE /v1/documents/{id}
*/
public function destroy(int $id): JsonResponse
{
return ApiResponse::handle(function () use ($id) {
return $this->service->destroy($id);
}, __('message.deleted'));
}
/**
* rendered_html 스냅샷 저장 (Lazy Snapshot)
* PATCH /v1/documents/{id}/snapshot
*/
public function patchSnapshot(int $id, UpdateRequest $request): JsonResponse
{
return ApiResponse::handle(function () use ($id, $request) {
$renderedHtml = $request->validated()['rendered_html'] ?? null;
if (! $renderedHtml) {
throw new \Symfony\Component\HttpKernel\Exception\BadRequestHttpException('rendered_html is required');
}
return $this->service->patchSnapshot($id, $renderedHtml);
}, __('message.updated'));
}
// =========================================================================
// FQC 일괄생성 (제품검사)
// =========================================================================
/**
* 수주 개소별 제품검사 문서 일괄생성
* POST /v1/documents/bulk-create-fqc
*/
public function bulkCreateFqc(BulkCreateFqcRequest $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
return $this->service->bulkCreateFqc($request->validated());
}, __('message.created'));
}
/**
* 수주 FQC 진행현황 조회
* GET /v1/documents/fqc-status?order_id=1&template_id=65
*/
public function fqcStatus(): JsonResponse
{
return ApiResponse::handle(function () {
$orderId = (int) request('order_id');
$templateId = (int) request('template_id');
if (! $orderId || ! $templateId) {
throw new \Symfony\Component\HttpKernel\Exception\BadRequestHttpException(
__('validation.required', ['attribute' => 'order_id, template_id'])
);
}
return $this->service->fqcStatus($orderId, $templateId);
}, __('message.fetched'));
}
// =========================================================================
// Resolve/Upsert (React 연동용)
// =========================================================================
/**
* 문서 Resolve
* GET /v1/documents/resolve?category=incoming_inspection&item_id=12596
*/
public function resolve(ResolveRequest $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
return $this->service->resolve($request->validated());
}, __('message.fetched'));
}
/**
* 문서 Upsert
* POST /v1/documents/upsert
*/
public function upsert(UpsertRequest $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
return $this->service->upsert($request->validated());
}, __('message.saved'));
}
// =========================================================================
// 결재 워크플로우
// =========================================================================
/**
* 결재 제출 (DRAFT → PENDING)
* POST /v1/documents/{id}/submit
*/
public function submit(int $id): JsonResponse
{
return ApiResponse::handle(function () use ($id) {
return $this->service->submit($id);
}, __('message.updated'));
}
/**
* 결재 승인
* POST /v1/documents/{id}/approve
*/
public function approve(int $id, ApproveRequest $request): JsonResponse
{
return ApiResponse::handle(function () use ($id, $request) {
return $this->service->approve($id, $request->validated()['comment'] ?? null);
}, __('message.updated'));
}
/**
* 결재 반려
* POST /v1/documents/{id}/reject
*/
public function reject(int $id, RejectRequest $request): JsonResponse
{
return ApiResponse::handle(function () use ($id, $request) {
return $this->service->reject($id, $request->validated()['comment']);
}, __('message.updated'));
}
/**
* 결재 취소/회수
* POST /v1/documents/{id}/cancel
*/
public function cancel(int $id): JsonResponse
{
return ApiResponse::handle(function () use ($id) {
return $this->service->cancel($id);
}, __('message.updated'));
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Http\Controllers\Api\V1\Documents;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\DocumentTemplate\IndexRequest;
use App\Services\DocumentTemplateService;
use Illuminate\Http\JsonResponse;
class DocumentTemplateController extends Controller
{
public function __construct(private DocumentTemplateService $service) {}
/**
* 양식 목록 조회
* GET /v1/document-templates
*/
public function index(IndexRequest $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
return $this->service->list($request->validated());
}, __('message.fetched'));
}
/**
* 양식 상세 조회
* GET /v1/document-templates/{id}
*/
public function show(int $id): JsonResponse
{
return ApiResponse::handle(function () use ($id) {
return $this->service->show($id);
}, __('message.fetched'));
}
}

View File

@@ -0,0 +1,116 @@
<?php
namespace App\Http\Controllers\Api\V1\ESign;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\ESign\ContractStoreRequest;
use App\Http\Requests\ESign\FieldConfigureRequest;
use App\Services\ESign\EsignContractService;
use App\Services\ESign\EsignPdfService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class EsignContractController extends Controller
{
public function __construct(
private EsignContractService $service,
private EsignPdfService $pdfService,
) {}
public function index(Request $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
return $this->service->list($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(ContractStoreRequest $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
return $this->service->create($request->validated() + ['file' => $request->file('file')]);
}, __('message.created'));
}
public function cancel(int $id): JsonResponse
{
return ApiResponse::handle(function () use ($id) {
return $this->service->cancel($id);
}, __('message.esign.cancelled'));
}
public function configureFields(int $id, FieldConfigureRequest $request): JsonResponse
{
return ApiResponse::handle(function () use ($id, $request) {
return $this->service->configureFields($id, $request->validated()['fields']);
}, __('message.esign.fields_configured'));
}
public function send(int $id): JsonResponse
{
return ApiResponse::handle(function () use ($id) {
return $this->service->send($id);
}, __('message.esign.sent'));
}
public function remind(int $id): JsonResponse
{
return ApiResponse::handle(function () use ($id) {
return $this->service->remind($id);
}, __('message.esign.reminded'));
}
public function stats(): JsonResponse
{
return ApiResponse::handle(function () {
return $this->service->stats();
}, __('message.fetched'));
}
public function download(int $id): \Symfony\Component\HttpFoundation\StreamedResponse|JsonResponse
{
try {
$contract = $this->service->show($id);
$filePath = $contract->signed_file_path ?? $contract->original_file_path;
if (! $filePath || ! Storage::disk('local')->exists($filePath)) {
return ApiResponse::error(__('error.esign.file_not_found'), 404);
}
$fileName = $contract->original_file_name ?? 'contract.pdf';
return Storage::disk('local')->download($filePath, $fileName);
} catch (\Throwable $e) {
return ApiResponse::error($e->getMessage(), 500);
}
}
public function verify(int $id): JsonResponse
{
return ApiResponse::handle(function () use ($id) {
$contract = $this->service->show($id);
if (! $contract->original_file_path || ! $contract->original_file_hash) {
return ['verified' => false, 'message' => '파일 정보가 없습니다.'];
}
$isValid = $this->pdfService->verifyIntegrity(
$contract->original_file_path,
$contract->original_file_hash
);
return [
'verified' => $isValid,
'original_hash' => $contract->original_file_hash,
];
}, __('message.esign.verified'));
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace App\Http\Controllers\Api\V1\ESign;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\ESign\SignRejectRequest;
use App\Http\Requests\ESign\SignSubmitRequest;
use App\Services\ESign\EsignSignService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class EsignSignController extends Controller
{
public function __construct(
private EsignSignService $service,
) {}
public function getContract(string $token): JsonResponse
{
return ApiResponse::handle(function () use ($token) {
return $this->service->getByToken($token);
}, __('message.fetched'));
}
public function sendOtp(string $token): JsonResponse
{
return ApiResponse::handle(function () use ($token) {
return $this->service->sendOtp($token);
}, __('message.esign.otp_sent'));
}
public function verifyOtp(string $token, Request $request): JsonResponse
{
return ApiResponse::handle(function () use ($token, $request) {
$request->validate(['otp_code' => 'required|string|size:6']);
return $this->service->verifyOtp($token, $request->input('otp_code'));
}, __('message.esign.otp_verified'));
}
public function getDocument(string $token): \Symfony\Component\HttpFoundation\StreamedResponse|JsonResponse
{
try {
$data = $this->service->getByToken($token);
$contract = $data['contract'];
$filePath = $contract->original_file_path;
if (! $filePath || ! Storage::disk('local')->exists($filePath)) {
return ApiResponse::error(__('error.esign.file_not_found'), 404);
}
return Storage::disk('local')->response($filePath, null, [
'Content-Type' => 'application/pdf',
]);
} catch (\Throwable $e) {
return ApiResponse::error($e->getMessage(), $e->getCode() ?: 500);
}
}
public function submit(string $token, SignSubmitRequest $request): JsonResponse
{
return ApiResponse::handle(function () use ($token, $request) {
return $this->service->submitSignature($token, $request->validated());
}, __('message.esign.signed'));
}
public function reject(string $token, SignRejectRequest $request): JsonResponse
{
return ApiResponse::handle(function () use ($token, $request) {
return $this->service->reject($token, $request->validated()['reason']);
}, __('message.esign.rejected'));
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -98,6 +98,22 @@ public function store(int $id, Request $request)
return ApiResponse::handle(function () use ($id, $request) {
$item = $this->getItem($id);
$inputItems = $request->input('items', []);
$tenantId = app('tenant_id');
// child_item_id 존재 검증
$childIds = collect($inputItems)->pluck('child_item_id')->filter()->unique()->values()->toArray();
if (! empty($childIds)) {
$validIds = Item::where('tenant_id', $tenantId)
->whereIn('id', $childIds)
->pluck('id')
->toArray();
$invalidIds = array_diff($childIds, $validIds);
if (! empty($invalidIds)) {
throw new \InvalidArgumentException(
__('error.bom.invalid_child_items', ['ids' => implode(', ', $invalidIds)])
);
}
}
$existingBom = $item->bom ?? [];
$existingMap = collect($existingBom)->keyBy('child_item_id')->toArray();
@@ -273,6 +289,22 @@ public function replace(int $id, Request $request)
return ApiResponse::handle(function () use ($id, $request) {
$item = $this->getItem($id);
$inputItems = $request->input('items', []);
$tenantId = app('tenant_id');
// child_item_id 존재 검증
$childIds = collect($inputItems)->pluck('child_item_id')->filter()->unique()->values()->toArray();
if (! empty($childIds)) {
$validIds = Item::where('tenant_id', $tenantId)
->whereIn('id', $childIds)
->pluck('id')
->toArray();
$invalidIds = array_diff($childIds, $validIds);
if (! empty($invalidIds)) {
throw new \InvalidArgumentException(
__('error.bom.invalid_child_items', ['ids' => implode(', ', $invalidIds)])
);
}
}
$newBom = [];
foreach ($inputItems as $inputItem) {

View File

@@ -30,6 +30,8 @@ public function index(Request $request)
'item_category' => $request->input('item_category'),
'group_id' => $request->input('group_id'),
'active' => $request->input('is_active') ?? $request->input('active'),
'has_bom' => $request->input('has_bom'),
'exclude_process_id' => $request->input('exclude_process_id'),
];
return $this->service->index($params);

View File

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

View File

@@ -6,6 +6,7 @@
use App\Http\Controllers\Controller;
use App\Http\Requests\Order\CreateFromQuoteRequest;
use App\Http\Requests\Order\CreateProductionOrderRequest;
use App\Http\Requests\Order\OrderBulkDeleteRequest;
use App\Http\Requests\Order\StoreOrderRequest;
use App\Http\Requests\Order\UpdateOrderRequest;
use App\Http\Requests\Order\UpdateOrderStatusRequest;
@@ -66,6 +67,21 @@ public function update(UpdateOrderRequest $request, int $id)
}, __('message.order.updated'));
}
/**
* 일괄 삭제
*/
public function bulkDestroy(OrderBulkDeleteRequest $request)
{
return ApiResponse::handle(function () use ($request) {
$validated = $request->validated();
return $this->service->bulkDestroy(
$validated['ids'],
$validated['force'] ?? false
);
}, __('message.order.bulk_deleted'));
}
/**
* 삭제
*/
@@ -119,12 +135,25 @@ public function revertOrderConfirmation(int $id)
}
/**
* 생산지시 되돌리기 (작업지시 및 관련 데이터 삭제)
* 절곡 BOM 품목 재고 확인
*/
public function revertProductionOrder(int $id)
public function checkBendingStock(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->revertProductionOrder($id);
return $this->service->checkBendingStockForOrder($id);
}, __('message.fetched'));
}
/**
* 생산지시 되돌리기 (작업지시 및 관련 데이터 삭제)
*/
public function revertProductionOrder(Request $request, int $id)
{
$force = $request->boolean('force', false);
$reason = $request->input('reason');
return ApiResponse::handle(function () use ($id, $force, $reason) {
return $this->service->revertProductionOrder($id, $force, $reason);
}, __('message.order.production_order_reverted'));
}
}

View File

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

View File

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

View File

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

View File

@@ -81,19 +81,8 @@ public function store(QuoteStoreRequest $request)
*/
public function update(QuoteUpdateRequest $request, int $id)
{
$validated = $request->validated();
// 🔍 디버깅: 요청 데이터 확인
\Log::info('🔍 [QuoteController::update] 요청 수신', [
'id' => $id,
'raw_options_keys' => $request->input('options') ? array_keys($request->input('options')) : null,
'raw_options_detail_items_count' => $request->input('options.detail_items') ? count($request->input('options.detail_items')) : 0,
'validated_options_keys' => isset($validated['options']) ? array_keys($validated['options']) : null,
'validated_options_detail_items_count' => isset($validated['options']['detail_items']) ? count($validated['options']['detail_items']) : 0,
]);
return ApiResponse::handle(function () use ($validated, $id) {
return $this->quoteService->update($id, $validated);
return ApiResponse::handle(function () use ($request, $id) {
return $this->quoteService->update($id, $request->validated());
}, __('message.quote.updated'));
}
@@ -162,6 +151,16 @@ public function convertToBidding(int $id)
}, __('message.bidding.converted'));
}
/**
* 참조 데이터 조회 (현장명, 부호 목록)
*/
public function referenceData()
{
return ApiResponse::handle(function () {
return $this->quoteService->referenceData();
}, __('message.fetched'));
}
/**
* 견적번호 미리보기
*/
@@ -270,4 +269,22 @@ public function sendHistory(int $id)
return $this->documentService->getSendHistory($id);
}, __('message.fetched'));
}
/**
* 품목 단가 조회
*
* 품목 코드 배열을 받아 단가를 조회합니다.
* 수동 품목 추가 시 단가를 조회하여 견적금액에 반영합니다.
*/
public function getItemPrices(\Illuminate\Http\Request $request)
{
$request->validate([
'item_codes' => 'required|array|min:1',
'item_codes.*' => 'required|string',
]);
return ApiResponse::handle(function () use ($request) {
return $this->calculationService->getItemPrices($request->input('item_codes'));
}, __('message.fetched'));
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\V1\Stat\StatAlertRequest;
use App\Http\Requests\V1\Stat\StatDailyRequest;
use App\Http\Requests\V1\Stat\StatMonthlyRequest;
use App\Services\Stats\StatQueryService;
use Illuminate\Http\JsonResponse;
class StatController extends Controller
{
public function __construct(
private readonly StatQueryService $statQueryService
) {}
/**
* 대시보드 요약 통계 (sam_stat 기반)
*/
public function summary(): JsonResponse
{
$data = $this->statQueryService->getDashboardSummary();
return ApiResponse::handle(['data' => $data], __('message.fetched'));
}
/**
* 일간 통계 조회
*/
public function daily(StatDailyRequest $request): JsonResponse
{
$data = $this->statQueryService->getDailyStat(
$request->validated('domain'),
$request->validated()
);
return ApiResponse::handle(['data' => $data], __('message.fetched'));
}
/**
* 월간 통계 조회
*/
public function monthly(StatMonthlyRequest $request): JsonResponse
{
$data = $this->statQueryService->getMonthlyStat(
$request->validated('domain'),
$request->validated()
);
return ApiResponse::handle(['data' => $data], __('message.fetched'));
}
/**
* 알림 목록 조회
*/
public function alerts(StatAlertRequest $request): JsonResponse
{
$data = $this->statQueryService->getAlerts($request->validated());
return ApiResponse::handle(['data' => $data], __('message.fetched'));
}
}

View File

@@ -24,4 +24,4 @@ public function summary()
return $this->statusBoardService->summary();
}, __('message.fetched'));
}
}
}

View File

@@ -22,12 +22,15 @@ public function index(Request $request): JsonResponse
$params = $request->only([
'search',
'item_type',
'item_category',
'status',
'location',
'sort_by',
'sort_dir',
'per_page',
'page',
'start_date',
'end_date',
]);
$stocks = $this->service->index($params);

View File

@@ -0,0 +1,111 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\TenantSetting\BulkUpdateSettingsRequest;
use App\Http\Requests\TenantSetting\GetSettingsRequest;
use App\Http\Requests\TenantSetting\UpdateSettingRequest;
use App\Services\TenantSettingService;
class TenantSettingController extends Controller
{
public function __construct(
private TenantSettingService $service
) {}
/**
* 모든 설정 조회 (그룹별)
*/
public function index(GetSettingsRequest $request)
{
$validated = $request->validated();
if (! empty($validated['group'])) {
$data = $this->service->getByGroup($validated['group']);
} else {
$data = $this->service->getAll();
}
return ApiResponse::handle(__('message.fetched'), $data);
}
/**
* 특정 설정 조회
*/
public function show(string $group, string $key)
{
$value = $this->service->get($group, $key);
return ApiResponse::handle(__('message.fetched'), [
'group' => $group,
'key' => $key,
'value' => $value,
]);
}
/**
* 설정 저장/업데이트
*/
public function store(UpdateSettingRequest $request)
{
$validated = $request->validated();
$setting = $this->service->set(
$validated['group'],
$validated['key'],
$validated['value'],
$validated['description'] ?? null
);
return ApiResponse::handle(__('message.updated'), $setting);
}
/**
* 여러 설정 일괄 저장
*/
public function bulkUpdate(BulkUpdateSettingsRequest $request)
{
$validated = $request->validated();
$settings = collect($validated['settings'])->mapWithKeys(function ($item) {
return [$item['key'] => [
'value' => $item['value'],
'description' => $item['description'] ?? null,
]];
})->toArray();
$results = $this->service->setMany($validated['group'], $settings);
return ApiResponse::handle(__('message.bulk_upsert'), [
'updated' => count($results),
]);
}
/**
* 설정 삭제
*/
public function destroy(string $group, string $key)
{
$deleted = $this->service->delete($group, $key);
if (! $deleted) {
return ApiResponse::handle(__('error.not_found'), null, 404);
}
return ApiResponse::handle(__('message.deleted'));
}
/**
* 기본 설정 초기화
*/
public function initialize()
{
$results = $this->service->initializeDefaults();
return ApiResponse::handle(__('message.created'), [
'initialized' => count($results),
]);
}
}

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,8 @@
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\WorkOrder\MaterialInputForItemRequest;
use App\Http\Requests\WorkOrder\StoreItemInspectionRequest;
use App\Http\Requests\WorkOrder\WorkOrderAssignRequest;
use App\Http\Requests\WorkOrder\WorkOrderIssueRequest;
use App\Http\Requests\WorkOrder\WorkOrderStatusRequest;
@@ -149,12 +151,211 @@ public function materials(int $id)
}
/**
* 자재 투입 등록
* 자재 투입 등록 (로트별 수량 차감)
*/
public function registerMaterialInput(Request $request, int $id)
{
return ApiResponse::handle(function () use ($request, $id) {
return $this->service->registerMaterialInput($id, $request->input('material_ids', []));
return $this->service->registerMaterialInput($id, $request->input('inputs', []));
}, __('message.work_order.material_input_registered'));
}
/**
* 공정 단계 진행 현황 조회
*/
public function stepProgress(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->getStepProgress($id);
}, __('message.work_order.fetched'));
}
/**
* 공정 단계 완료 토글
*/
public function toggleStepProgress(Request $request, int $id, int $progressId)
{
return ApiResponse::handle(function () use ($id, $progressId) {
return $this->service->toggleStepProgress($id, $progressId);
}, __('message.work_order.updated'));
}
/**
* 자재 투입 이력 조회
*/
public function materialInputHistory(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->getMaterialInputHistory($id);
}, __('message.work_order.fetched'));
}
/**
* 자재 투입 LOT 번호 조회 (stock_transactions 기반)
*/
public function materialInputLots(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->getMaterialInputLots($id);
}, __('message.work_order.fetched'));
}
/**
* 품목별 중간검사 데이터 저장
*/
public function storeItemInspection(StoreItemInspectionRequest $request, int $workOrderId, int $itemId)
{
return ApiResponse::handle(function () use ($request, $workOrderId, $itemId) {
return $this->service->storeItemInspection($workOrderId, $itemId, $request->validated());
}, __('message.work_order.inspection_saved'));
}
/**
* 작업지시 전체 품목 검사 데이터 조회
*/
public function inspectionData(Request $request, int $id)
{
return ApiResponse::handle(function () use ($request, $id) {
return $this->service->getInspectionData($id, $request->all());
}, __('message.work_order.fetched'));
}
/**
* 작업지시 검사 성적서용 데이터 조회
*/
public function inspectionReport(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->getInspectionReport($id);
}, __('message.work_order.fetched'));
}
/**
* 작업지시 검사 설정 조회 (공정 자동 판별 + 구성품 목록)
*/
public function inspectionConfig(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->getInspectionConfig($id);
}, __('message.work_order.fetched'));
}
/**
* 작업지시의 검사용 문서 템플릿 조회
*/
public function inspectionTemplate(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->getInspectionTemplate($id);
}, __('message.work_order.fetched'));
}
/**
* 검사 문서 resolve (기존 문서 조회 또는 생성 정보 반환)
*/
public function resolveInspectionDocument(Request $request, int $id)
{
return ApiResponse::handle(function () use ($request, $id) {
return $this->service->resolveInspectionDocument($id, $request->all());
}, __('message.fetched'));
}
/**
* 검사 완료 시 검사 문서(Document) 생성/수정
*/
public function createInspectionDocument(Request $request, int $id)
{
return ApiResponse::handle(function () use ($request, $id) {
return $this->service->createInspectionDocument($id, $request->all());
}, __('message.work_order.inspection_document_created'));
}
/**
* 작업일지 양식 템플릿 조회
*/
public function workLogTemplate(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->getWorkLogTemplate($id);
}, __('message.work_order.fetched'));
}
/**
* 작업일지 조회 (기존 문서 + 템플릿 + 통계)
*/
public function workLog(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->getWorkLog($id);
}, __('message.work_order.fetched'));
}
/**
* 작업일지 생성/수정
*/
public function createWorkLog(Request $request, int $id)
{
return ApiResponse::handle(function () use ($request, $id) {
return $this->service->createWorkLog($id, $request->all());
}, __('message.work_order.work_log_saved'));
}
// ──────────────────────────────────────────────────────────────
// 개소별 자재 투입
// ──────────────────────────────────────────────────────────────
/**
* 개소별 자재 목록 조회
*/
public function materialsForItem(int $id, int $itemId)
{
return ApiResponse::handle(function () use ($id, $itemId) {
return $this->service->getMaterialsForItem($id, $itemId);
}, __('message.work_order.materials_fetched'));
}
/**
* 개소별 자재 투입 등록
*/
public function registerMaterialInputForItem(MaterialInputForItemRequest $request, int $id, int $itemId)
{
return ApiResponse::handle(function () use ($request, $id, $itemId) {
$validated = $request->validated();
return $this->service->registerMaterialInputForItem(
$id,
$itemId,
$validated['inputs'],
(bool) ($validated['replace'] ?? false)
);
}, __('message.work_order.material_input_registered'));
}
/**
* 개소별 자재 투입 이력 조회
*/
public function materialInputsForItem(int $id, int $itemId)
{
return ApiResponse::handle(function () use ($id, $itemId) {
return $this->service->getMaterialInputsForItem($id, $itemId);
}, __('message.work_order.fetched'));
}
public function deleteMaterialInput(int $id, int $inputId)
{
return ApiResponse::handle(function () use ($id, $inputId) {
$this->service->deleteMaterialInput($id, $inputId);
}, __('message.work_order.deleted'));
}
public function updateMaterialInput(Request $request, int $id, int $inputId)
{
$data = $request->validate([
'qty' => ['required', 'numeric', 'gt:0'],
]);
return ApiResponse::handle(function () use ($id, $inputId, $data) {
return $this->service->updateMaterialInput($id, $inputId, (float) $data['qty']);
}, __('message.work_order.updated'));
}
}

View File

@@ -0,0 +1,84 @@
<?php
namespace App\Http\Controllers\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\V1\ProcessStep\ReorderProcessStepRequest;
use App\Http\Requests\V1\ProcessStep\StoreProcessStepRequest;
use App\Http\Requests\V1\ProcessStep\UpdateProcessStepRequest;
use App\Services\ProcessStepService;
use Illuminate\Http\JsonResponse;
class ProcessStepController extends Controller
{
public function __construct(
private readonly ProcessStepService $processStepService
) {}
/**
* 공정 단계 목록 조회
*/
public function index(int $processId): JsonResponse
{
return ApiResponse::handle(
fn () => $this->processStepService->index($processId),
'message.fetched'
);
}
/**
* 공정 단계 상세 조회
*/
public function show(int $processId, int $stepId): JsonResponse
{
return ApiResponse::handle(
fn () => $this->processStepService->show($processId, $stepId),
'message.fetched'
);
}
/**
* 공정 단계 생성
*/
public function store(StoreProcessStepRequest $request, int $processId): JsonResponse
{
return ApiResponse::handle(
fn () => $this->processStepService->store($processId, $request->validated()),
'message.created'
);
}
/**
* 공정 단계 수정
*/
public function update(UpdateProcessStepRequest $request, int $processId, int $stepId): JsonResponse
{
return ApiResponse::handle(
fn () => $this->processStepService->update($processId, $stepId, $request->validated()),
'message.updated'
);
}
/**
* 공정 단계 삭제
*/
public function destroy(int $processId, int $stepId): JsonResponse
{
return ApiResponse::handle(
fn () => $this->processStepService->destroy($processId, $stepId),
'message.deleted'
);
}
/**
* 공정 단계 순서 변경
*/
public function reorder(ReorderProcessStepRequest $request, int $processId): JsonResponse
{
return ApiResponse::handle(
fn () => $this->processStepService->reorder($processId, $request->validated('items')),
'message.reordered'
);
}
}

View File

@@ -123,6 +123,7 @@ public function handle(Request $request, Closure $next)
'api/v1/debug-apikey',
'api/v1/internal/exchange-token', // 내부 서버간 토큰 교환 (HMAC 인증 사용)
'api/v1/admin/fcm/*', // Admin FCM API (MNG에서 API Key만으로 접근)
'api/v1/app/*', // 앱 버전 확인/다운로드 (API Key만 필요)
];
// 현재 라우트 확인 (경로 또는 이름)

View File

@@ -0,0 +1,171 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Symfony\Component\HttpFoundation\Response;
/**
* API 버전 관리 미들웨어
*
* - 헤더 Accept-Version으로 버전 선택 (기본: v1)
* - 요청된 버전의 라우트가 없으면 하위 버전으로 fallback
* - 응답 헤더에 실제 사용된 버전 표시
*/
class ApiVersionMiddleware
{
/**
* 지원하는 API 버전 목록 (우선순위 순)
*/
protected array $supportedVersions = ['v2', 'v1'];
/**
* 기본 버전
*/
protected string $defaultVersion = 'v1';
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next): Response
{
// 1. 요청된 버전 확인 (헤더 > 쿼리 파라미터 > 기본값)
$requestedVersion = $this->getRequestedVersion($request);
// 2. 실제 사용할 버전 결정 (fallback 적용)
$actualVersion = $this->resolveVersion($request, $requestedVersion);
// 3. 요청에 버전 정보 저장 (컨트롤러에서 사용 가능)
$request->attributes->set('api_version', $actualVersion);
$request->attributes->set('api_version_requested', $requestedVersion);
$request->attributes->set('api_version_fallback', $actualVersion !== $requestedVersion);
// 4. 요청 처리
$response = $next($request);
// 5. 응답 헤더에 버전 정보 추가
$response->headers->set('X-API-Version', $actualVersion);
if ($actualVersion !== $requestedVersion) {
$response->headers->set('X-API-Version-Fallback', 'true');
$response->headers->set('X-API-Version-Requested', $requestedVersion);
}
return $response;
}
/**
* 요청에서 버전 정보 추출
*/
protected function getRequestedVersion(Request $request): string
{
// 1. Accept-Version 헤더 (권장)
$version = $request->header('Accept-Version');
if ($version && $this->isValidVersion($version)) {
return $version;
}
// 2. X-API-Version 헤더 (대안)
$version = $request->header('X-API-Version');
if ($version && $this->isValidVersion($version)) {
return $version;
}
// 3. 쿼리 파라미터 (테스트용)
$version = $request->query('api_version');
if ($version && $this->isValidVersion($version)) {
return $version;
}
return $this->defaultVersion;
}
/**
* 유효한 버전인지 확인
*/
protected function isValidVersion(string $version): bool
{
return in_array($version, $this->supportedVersions, true);
}
/**
* 실제 사용할 버전 결정 (fallback 로직)
*/
protected function resolveVersion(Request $request, string $requestedVersion): string
{
// 요청된 버전부터 하위 버전까지 순차 확인
$startIndex = array_search($requestedVersion, $this->supportedVersions, true);
if ($startIndex === false) {
return $this->defaultVersion;
}
// 요청된 버전부터 하위 버전까지 체크
for ($i = $startIndex; $i < count($this->supportedVersions); $i++) {
$version = $this->supportedVersions[$i];
if ($this->versionRouteExists($request, $version)) {
return $version;
}
}
// 모든 버전에서 라우트를 찾지 못하면 기본값 반환
return $this->defaultVersion;
}
/**
* 해당 버전의 라우트가 존재하는지 확인
*/
protected function versionRouteExists(Request $request, string $version): bool
{
$path = $request->path();
// URL에서 버전 부분 교체
// /api/v1/users → /api/v2/users
$versionedPath = preg_replace('/^api\/v\d+/', "api/{$version}", $path);
// 해당 경로의 라우트가 존재하는지 확인
$routes = Route::getRoutes();
foreach ($routes as $route) {
$routeUri = $route->uri();
// 정확히 일치하거나 파라미터 패턴 매칭
if ($this->matchesRoute($versionedPath, $routeUri, $request->method())) {
return true;
}
}
return false;
}
/**
* 경로가 라우트와 일치하는지 확인
*/
protected function matchesRoute(string $path, string $routeUri, string $method): bool
{
// 라우트 URI의 파라미터를 정규식으로 변환
// {id} → [^/]+
$pattern = preg_replace('/\{[^}]+\}/', '[^/]+', $routeUri);
$pattern = '#^'.$pattern.'$#';
return (bool) preg_match($pattern, $path);
}
/**
* 지원 버전 목록 반환 (외부에서 사용)
*/
public function getSupportedVersions(): array
{
return $this->supportedVersions;
}
/**
* 기본 버전 반환
*/
public function getDefaultVersion(): string
{
return $this->defaultVersion;
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
class SetAuditSessionVariables
{
public function handle(Request $request, Closure $next): Response
{
// 요청 단위 operation_id (인증 여부와 무관하게 항상 설정)
DB::statement('SET @sam_operation_id = ?', [Str::uuid()->toString()]);
if (auth()->check()) {
DB::statement('SET @sam_actor_id = ?', [auth()->id()]);
DB::statement('SET @sam_session_info = ?', [
json_encode([
'ip' => $request->ip(),
'ua' => mb_substr((string) $request->userAgent(), 0, 255),
'route' => $request->route()?->getName(),
], JSON_UNESCAPED_UNICODE),
]);
}
return $next($request);
}
}

View File

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

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Http\Requests\Audit;
use App\Http\Requests\Traits\HasPagination;
use Illuminate\Foundation\Http\FormRequest;
class TriggerAuditLogIndexRequest extends FormRequest
{
use HasPagination;
protected int $maxSize = 200;
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'page' => 'nullable|integer|min:1',
'size' => 'nullable|integer|min:1',
'table_name' => 'nullable|string|max:64',
'row_id' => 'nullable|string|max:64',
'dml_type' => 'nullable|string|in:INSERT,UPDATE,DELETE',
'tenant_id' => 'nullable|integer|min:1',
'actor_id' => 'nullable|integer|min:1',
'db_user' => 'nullable|string|max:100',
'from' => 'nullable|date',
'to' => 'nullable|date|after_or_equal:from',
'sort' => 'nullable|string|in:created_at,id',
'order' => 'nullable|string|in:asc,desc',
];
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests\Audit;
use Illuminate\Foundation\Http\FormRequest;
class TriggerAuditRollbackRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'confirm' => 'required|boolean|accepted',
];
}
}

View File

@@ -65,7 +65,7 @@ public function rules(): array
'mobile' => 'nullable|string|max:20',
'fax' => 'nullable|string|max:20',
'email' => 'nullable|email|max:100',
'address' => 'nullable|string|max:255',
'address' => 'nullable|string|max:500',
// 담당자 정보
'manager_name' => 'nullable|string|max:50',
'manager_tel' => 'nullable|string|max:20',
@@ -96,6 +96,7 @@ public function rules(): array
// 기타
'memo' => 'nullable|string',
'is_active' => 'nullable|boolean',
'is_overdue' => 'nullable|boolean',
];
}
}

View File

@@ -65,7 +65,7 @@ public function rules(): array
'mobile' => 'nullable|string|max:20',
'fax' => 'nullable|string|max:20',
'email' => 'nullable|email|max:100',
'address' => 'nullable|string|max:255',
'address' => 'nullable|string|max:500',
// 담당자 정보
'manager_name' => 'nullable|string|max:50',
'manager_tel' => 'nullable|string|max:20',
@@ -96,6 +96,7 @@ public function rules(): array
// 기타
'memo' => 'nullable|string',
'is_active' => 'nullable|boolean',
'is_overdue' => 'nullable|boolean',
];
}
}

View File

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

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests\Document;
use Illuminate\Foundation\Http\FormRequest;
class BulkCreateFqcRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'template_id' => 'required|integer|exists:document_templates,id',
'order_id' => 'required|integer|exists:orders,id',
];
}
public function messages(): array
{
return [
'template_id.required' => __('validation.required', ['attribute' => '템플릿']),
'template_id.exists' => __('validation.exists', ['attribute' => '템플릿']),
'order_id.required' => __('validation.required', ['attribute' => '수주']),
'order_id.exists' => __('validation.exists', ['attribute' => '수주']),
];
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Http\Requests\Document;
use App\Models\Documents\Document;
use Illuminate\Foundation\Http\FormRequest;
class IndexRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$statuses = implode(',', [
Document::STATUS_DRAFT,
Document::STATUS_PENDING,
Document::STATUS_APPROVED,
Document::STATUS_REJECTED,
Document::STATUS_CANCELLED,
]);
return [
'status' => "nullable|string|in:{$statuses}",
'template_id' => 'nullable|integer',
'search' => 'nullable|string|max:100',
'from_date' => 'nullable|date',
'to_date' => 'nullable|date|after_or_equal:from_date',
'sort_by' => 'nullable|string|in:created_at,document_no,title,status,submitted_at,completed_at',
'sort_dir' => 'nullable|string|in:asc,desc',
'per_page' => 'nullable|integer|min:1|max:100',
'page' => 'nullable|integer|min:1',
];
}
}

View File

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

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests\Document;
use Illuminate\Foundation\Http\FormRequest;
class ResolveRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'category' => 'required|string|max:50',
'item_id' => 'required|integer',
];
}
public function messages(): array
{
return [
'category.required' => __('validation.required', ['attribute' => '문서 분류']),
'item_id.required' => __('validation.required', ['attribute' => '품목 ID']),
'item_id.integer' => __('validation.integer', ['attribute' => '품목 ID']),
];
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Http\Requests\Document;
use App\Models\Documents\DocumentAttachment;
use Illuminate\Foundation\Http\FormRequest;
class StoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$attachmentTypes = implode(',', DocumentAttachment::TYPES);
return [
// 기본 정보
'template_id' => 'required|integer|exists:document_templates,id',
'title' => 'required|string|max:255',
'linkable_type' => 'nullable|string|max:100',
'linkable_id' => 'nullable|integer',
// 결재선 (보류 상태이지만 구조는 유지)
'approvers' => 'nullable|array',
'approvers.*.user_id' => 'required_with:approvers|integer|exists:users,id',
'approvers.*.role' => 'nullable|string|max:50',
// HTML 스냅샷
'rendered_html' => 'nullable|string',
// 문서 데이터 (EAV)
'data' => 'nullable|array',
'data.*.section_id' => 'nullable|integer',
'data.*.column_id' => 'nullable|integer',
'data.*.row_index' => 'nullable|integer|min:0',
'data.*.field_key' => 'required_with:data|string|max:100',
'data.*.field_value' => 'nullable|string',
// 첨부파일
'attachments' => 'nullable|array',
'attachments.*.file_id' => 'required_with:attachments|integer|exists:files,id',
'attachments.*.attachment_type' => "nullable|string|in:{$attachmentTypes}",
'attachments.*.description' => 'nullable|string|max:255',
];
}
public function messages(): array
{
return [
'template_id.required' => __('validation.required', ['attribute' => '템플릿']),
'template_id.exists' => __('validation.exists', ['attribute' => '템플릿']),
'title.required' => __('validation.required', ['attribute' => '제목']),
'title.max' => __('validation.max.string', ['attribute' => '제목', 'max' => 255]),
'approvers.*.user_id.exists' => __('validation.exists', ['attribute' => '결재자']),
'data.*.field_key.required_with' => __('validation.required', ['attribute' => '필드 키']),
'attachments.*.file_id.exists' => __('validation.exists', ['attribute' => '파일']),
];
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Http\Requests\Document;
use App\Models\Documents\DocumentAttachment;
use Illuminate\Foundation\Http\FormRequest;
class UpdateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$attachmentTypes = implode(',', DocumentAttachment::TYPES);
return [
// 기본 정보
'title' => 'nullable|string|max:255',
'linkable_type' => 'nullable|string|max:100',
'linkable_id' => 'nullable|integer',
// 결재선 (보류 상태이지만 구조는 유지)
'approvers' => 'nullable|array',
'approvers.*.user_id' => 'required_with:approvers|integer|exists:users,id',
'approvers.*.role' => 'nullable|string|max:50',
// HTML 스냅샷
'rendered_html' => 'nullable|string',
// 문서 데이터 (EAV)
'data' => 'nullable|array',
'data.*.section_id' => 'nullable|integer',
'data.*.column_id' => 'nullable|integer',
'data.*.row_index' => 'nullable|integer|min:0',
'data.*.field_key' => 'required_with:data|string|max:100',
'data.*.field_value' => 'nullable|string',
// 첨부파일
'attachments' => 'nullable|array',
'attachments.*.file_id' => 'required_with:attachments|integer|exists:files,id',
'attachments.*.attachment_type' => "nullable|string|in:{$attachmentTypes}",
'attachments.*.description' => 'nullable|string|max:255',
];
}
public function messages(): array
{
return [
'title.max' => __('validation.max.string', ['attribute' => '제목', 'max' => 255]),
'approvers.*.user_id.exists' => __('validation.exists', ['attribute' => '결재자']),
'data.*.field_key.required_with' => __('validation.required', ['attribute' => '필드 키']),
'attachments.*.file_id.exists' => __('validation.exists', ['attribute' => '파일']),
];
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Http\Requests\Document;
use App\Models\Documents\DocumentAttachment;
use Illuminate\Foundation\Http\FormRequest;
class UpsertRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$attachmentTypes = implode(',', DocumentAttachment::TYPES);
return [
// 필수: 템플릿 + 품목
'template_id' => 'required|integer|exists:document_templates,id',
'item_id' => 'required|integer',
'title' => 'nullable|string|max:255',
// 문서 데이터 (EAV)
'data' => 'nullable|array',
'data.*.section_id' => 'nullable|integer',
'data.*.column_id' => 'nullable|integer',
'data.*.row_index' => 'nullable|integer|min:0',
'data.*.field_key' => 'required_with:data|string|max:100',
'data.*.field_value' => 'nullable|string',
// HTML 스냅샷
'rendered_html' => 'nullable|string',
// 첨부파일
'attachments' => 'nullable|array',
'attachments.*.file_id' => 'required_with:attachments|integer|exists:files,id',
'attachments.*.attachment_type' => "nullable|string|in:{$attachmentTypes}",
'attachments.*.description' => 'nullable|string|max:255',
];
}
public function messages(): array
{
return [
'template_id.required' => __('validation.required', ['attribute' => '템플릿']),
'template_id.exists' => __('validation.exists', ['attribute' => '템플릿']),
'item_id.required' => __('validation.required', ['attribute' => '품목 ID']),
'data.*.field_key.required_with' => __('validation.required', ['attribute' => '필드 키']),
'attachments.*.file_id.exists' => __('validation.exists', ['attribute' => '파일']),
];
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Http\Requests\DocumentTemplate;
use Illuminate\Foundation\Http\FormRequest;
class IndexRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'is_active' => 'nullable|boolean',
'category' => 'nullable|string|max:50',
'search' => 'nullable|string|max:100',
'sort_by' => 'nullable|string|in:created_at,name,category',
'sort_dir' => 'nullable|string|in:asc,desc',
'per_page' => 'nullable|integer|min:1|max:100',
'page' => 'nullable|integer|min:1',
];
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Http\Requests\ESign;
use App\Models\ESign\EsignContract;
use Illuminate\Foundation\Http\FormRequest;
class ContractStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'title' => 'required|string|max:200',
'description' => 'nullable|string|max:2000',
'sign_order_type' => 'nullable|string|in:' . implode(',', EsignContract::SIGN_ORDERS),
'file' => 'required|file|mimes:pdf|max:20480',
'expires_at' => 'nullable|date|after:now',
'creator_name' => 'required|string|max:100',
'creator_email' => 'required|email|max:255',
'creator_phone' => 'nullable|string|max:20',
'counterpart_name' => 'required|string|max:100',
'counterpart_email' => 'required|email|max:255',
'counterpart_phone' => 'nullable|string|max:20',
];
}
public function messages(): array
{
return [
'title.required' => __('validation.required', ['attribute' => '계약 제목']),
'file.required' => __('validation.required', ['attribute' => 'PDF 파일']),
'file.mimes' => __('validation.mimes', ['attribute' => '파일', 'values' => 'PDF']),
'file.max' => __('validation.max.file', ['attribute' => '파일', 'max' => '20MB']),
'creator_name.required' => __('validation.required', ['attribute' => '작성자 이름']),
'creator_email.required' => __('validation.required', ['attribute' => '작성자 이메일']),
'counterpart_name.required' => __('validation.required', ['attribute' => '상대방 이름']),
'counterpart_email.required' => __('validation.required', ['attribute' => '상대방 이메일']),
];
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Http\Requests\ESign;
use App\Models\ESign\EsignSignField;
use Illuminate\Foundation\Http\FormRequest;
class FieldConfigureRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'fields' => 'required|array|min:1',
'fields.*.signer_id' => 'required|integer|exists:esign_signers,id',
'fields.*.page_number' => 'required|integer|min:1',
'fields.*.position_x' => 'required|numeric|min:0|max:100',
'fields.*.position_y' => 'required|numeric|min:0|max:100',
'fields.*.width' => 'required|numeric|min:1|max:100',
'fields.*.height' => 'required|numeric|min:1|max:100',
'fields.*.field_type' => 'nullable|string|in:' . implode(',', EsignSignField::FIELD_TYPES),
'fields.*.field_label' => 'nullable|string|max:100',
'fields.*.is_required' => 'nullable|boolean',
'fields.*.sort_order' => 'nullable|integer|min:0',
];
}
public function messages(): array
{
return [
'fields.required' => __('validation.required', ['attribute' => '서명 필드']),
'fields.min' => '최소 1개 이상의 서명 필드가 필요합니다.',
];
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Requests\ESign;
use Illuminate\Foundation\Http\FormRequest;
class SignRejectRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'reason' => 'required|string|max:1000',
];
}
public function messages(): array
{
return [
'reason.required' => __('validation.required', ['attribute' => '거절 사유']),
];
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Requests\ESign;
use Illuminate\Foundation\Http\FormRequest;
class SignSubmitRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'signature_image' => 'required|string',
];
}
public function messages(): array
{
return [
'signature_image.required' => __('validation.required', ['attribute' => '서명 이미지']),
];
}
}

View File

@@ -22,6 +22,7 @@ public function rules(): array
Inspection::TYPE_FQC,
])],
'lot_no' => ['required', 'string', 'max:50'],
'work_order_id' => ['nullable', 'integer', 'exists:work_orders,id'],
'item_name' => ['nullable', 'string', 'max:200'],
'process_name' => ['nullable', 'string', 'max:100'],
'quantity' => ['nullable', 'numeric', 'min:0'],

View File

@@ -29,6 +29,7 @@ public function rules(): array
return [
'user_id' => ['nullable', 'integer', 'exists:users,id'],
'status' => ['nullable', 'string', Rule::in(Loan::STATUSES)],
'category' => ['nullable', 'string', Rule::in(Loan::CATEGORIES)],
'start_date' => ['nullable', 'date', 'date_format:Y-m-d'],
'end_date' => ['nullable', 'date', 'date_format:Y-m-d', 'after_or_equal:start_date'],
'search' => ['nullable', 'string', 'max:100'],

View File

@@ -2,7 +2,9 @@
namespace App\Http\Requests\Loan;
use App\Models\Tenants\Loan;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class LoanStoreRequest extends FormRequest
{
@@ -21,12 +23,27 @@ public function authorize(): bool
*/
public function rules(): array
{
$isGiftCertificate = $this->input('category') === Loan::CATEGORY_GIFT_CERTIFICATE;
return [
'user_id' => ['required', 'integer', 'exists:users,id'],
'user_id' => [$isGiftCertificate ? 'nullable' : 'required', 'integer', 'exists:users,id'],
'loan_date' => ['required', 'date', 'date_format:Y-m-d'],
'amount' => ['required', 'numeric', 'min:0', 'max:999999999999.99'],
'purpose' => ['nullable', 'string', 'max:1000'],
'withdrawal_id' => ['nullable', 'integer', 'exists:withdrawals,id'],
'category' => ['nullable', 'string', Rule::in(Loan::CATEGORIES)],
'status' => ['nullable', 'string', Rule::in(Loan::STATUSES)],
'metadata' => ['nullable', 'array'],
'metadata.serial_number' => ['nullable', 'string', 'max:100'],
'metadata.cert_name' => ['nullable', 'string', 'max:200'],
'metadata.vendor_id' => ['nullable', 'string', 'max:50'],
'metadata.vendor_name' => ['nullable', 'string', 'max:200'],
'metadata.purchase_purpose' => ['nullable', 'string', 'max:50'],
'metadata.entertainment_expense' => ['nullable', 'string', 'max:50'],
'metadata.recipient_name' => ['nullable', 'string', 'max:100'],
'metadata.recipient_organization' => ['nullable', 'string', 'max:200'],
'metadata.usage_description' => ['nullable', 'string', 'max:1000'],
'metadata.memo' => ['nullable', 'string', 'max:2000'],
];
}

View File

@@ -2,7 +2,9 @@
namespace App\Http\Requests\Loan;
use App\Models\Tenants\Loan;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class LoanUpdateRequest extends FormRequest
{
@@ -27,6 +29,20 @@ public function rules(): array
'amount' => ['sometimes', 'numeric', 'min:0', 'max:999999999999.99'],
'purpose' => ['nullable', 'string', 'max:1000'],
'withdrawal_id' => ['nullable', 'integer', 'exists:withdrawals,id'],
'category' => ['sometimes', 'string', Rule::in(Loan::CATEGORIES)],
'status' => ['sometimes', 'string', Rule::in(Loan::STATUSES)],
'settlement_date' => ['nullable', 'date', 'date_format:Y-m-d'],
'metadata' => ['nullable', 'array'],
'metadata.serial_number' => ['nullable', 'string', 'max:100'],
'metadata.cert_name' => ['nullable', 'string', 'max:200'],
'metadata.vendor_id' => ['nullable', 'string', 'max:50'],
'metadata.vendor_name' => ['nullable', 'string', 'max:200'],
'metadata.purchase_purpose' => ['nullable', 'string', 'max:50'],
'metadata.entertainment_expense' => ['nullable', 'string', 'max:50'],
'metadata.recipient_name' => ['nullable', 'string', 'max:100'],
'metadata.recipient_organization' => ['nullable', 'string', 'max:200'],
'metadata.usage_description' => ['nullable', 'string', 'max:1000'],
'metadata.memo' => ['nullable', 'string', 'max:2000'],
];
}

View File

@@ -22,6 +22,8 @@ public function rules(): array
'priority' => 'nullable|string',
'process_type' => ['nullable', Rule::in(WorkOrder::PROCESS_TYPES)],
'assignee_id' => 'nullable|integer|exists:users,id',
'assignee_ids' => 'nullable|array',
'assignee_ids.*' => 'integer|exists:users,id',
'team_id' => 'nullable|integer|exists:departments,id',
'scheduled_date' => 'nullable|date',
'memo' => 'nullable|string',

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Http\Requests\Order;
use Illuminate\Foundation\Http\FormRequest;
class OrderBulkDeleteRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'ids' => 'required|array|min:1',
'ids.*' => 'required|integer',
'force' => 'sometimes|boolean',
];
}
}

View File

@@ -24,6 +24,7 @@ public function rules(): array
Order::STATUS_CONFIRMED,
])],
'category_code' => 'nullable|string|max:50',
'pair_code' => 'nullable|string|max:20',
// 거래처 정보
'client_id' => 'nullable|integer|exists:clients,id',
@@ -53,10 +54,12 @@ public function rules(): array
'options.receiver_contact' => 'nullable|string|max:100',
'options.shipping_address' => 'nullable|string|max:500',
'options.shipping_address_detail' => 'nullable|string|max:500',
'options.manager_name' => 'nullable|string|max:100',
// 품목 배열
'items' => 'nullable|array',
'items.*.item_id' => 'nullable|integer|exists:items,id',
'items.*.item_code' => 'nullable|string|max:50',
'items.*.item_name' => 'required|string|max:200',
'items.*.specification' => 'nullable|string|max:500',
'items.*.quantity' => 'required|numeric|min:0',
@@ -65,6 +68,8 @@ public function rules(): array
'items.*.supply_amount' => 'nullable|numeric|min:0',
'items.*.tax_amount' => 'nullable|numeric|min:0',
'items.*.total_amount' => 'nullable|numeric|min:0',
'items.*.floor_code' => 'nullable|string|max:50',
'items.*.symbol_code' => 'nullable|string|max:50',
];
}

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