chore(payment): NHN KCP와 NICE 결제 플러그인 개발 브랜치 분리
정식 develop에서는 기본 번들 정책에 따라 제거된 결제 플러그인을 별도 개발 브랜치에서 계속 유지보수할 수 있도록 복원한다.
This commit is contained in:
1 parent
49b21e788c
commit
657f546efc
651 files changed
+49118
-20476
No files matched your search
@@ -4,17 +4,6 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.1] - 2026-07-08
|
||||
|
||||
### Added
|
||||
|
||||
- 관리자 게시물 조회 화면(목록·상세)에 "유저 화면 보기" 버튼을 추가했습니다. 클릭하면 해당 게시판/게시글의 실제 사용자 화면이 새 탭으로 열립니다.
|
||||
- 사용자 게시판 화면(목록·상세)에 관리자 진입 버튼을 추가했습니다. 게시판 관리 권한이 있는 사용자에게만 "관리자 게시물 조회"·"게시판 관리" 버튼이 보이며, 클릭하면 관리자 화면이 새 탭으로 열립니다.
|
||||
|
||||
### Changed
|
||||
|
||||
- 관리자 게시판 설정 화면의 주소가 게시판 번호 대신 게시판 아이디(슬러그) 기준으로 바뀌었습니다 (예: /admin/boards/free/edit). 새 게시판을 만든 직후 이동하는 편집 화면, 목록의 활성화 토글·삭제, 목록에서 여는 수정 화면, 상품 문의 설정의 "관리자 메뉴에 추가"까지 모두 게시판 아이디 기준으로 통일되었습니다. 기존 번호 기반 주소도 계속 동작합니다.
|
||||
|
||||
## [1.0.0] - 2026-07-01
|
||||
|
||||
### Added
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "modules/sirsoft-board",
|
||||
"description": "Board module for Gnuboard7",
|
||||
"type": "library",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
||||
@@ -2170,10 +2170,10 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "admin 게시판 폼 — route.slug 유무로 수정/신규(본문 line 9), _local.activeTab 으로 탭 분기(본문 line 161). 검증 오류는 _local.errors.",
|
||||
"comment": "admin 게시판 폼 — route.id 유무로 수정/신규(본문 line 9), _local.activeTab 으로 탭 분기(본문 line 161). 검증 오류는 _local.errors.",
|
||||
"scope": {
|
||||
"kind": "route",
|
||||
"match": "*/admin/boards/:slug/edit"
|
||||
"match": "*/admin/boards/:id/edit"
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"ko": "게시판",
|
||||
"en": "Board"
|
||||
},
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"description": {
|
||||
"ko": "게시판 관리를 위한 모듈",
|
||||
|
||||
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@g7/sirsoft-board",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@g7/sirsoft-board",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"jsdom": "^27.4.0",
|
||||
"typescript": "^5.3.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@g7/sirsoft-board",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.0",
|
||||
"description": "그누보드7 게시판 모듈 프론트엔드 에셋",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
+2
-2
@@ -174,7 +174,7 @@
|
||||
"type": "click",
|
||||
"handler": "navigate",
|
||||
"params": {
|
||||
"path": "/admin/boards/{{settings?.data?.inquiry?.board_slug ?? ''}}/edit"
|
||||
"path": "/admin/boards/{{(board_list_for_inquiry?.data?.data ?? []).find(b => b.slug === (settings?.data?.inquiry?.board_slug ?? ''))?.id}}/edit"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -278,7 +278,7 @@
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "apiCall",
|
||||
"target": "/api/modules/sirsoft-board/admin/boards/{{settings?.data?.inquiry?.board_slug ?? ''}}/add-to-menu",
|
||||
"target": "/api/modules/sirsoft-board/admin/boards/{{(board_list_for_inquiry?.data?.data ?? []).find(b => b.slug === (settings?.data?.inquiry?.board_slug ?? ''))?.id}}/add-to-menu",
|
||||
"params": {
|
||||
"method": "POST"
|
||||
},
|
||||
|
||||
-80
@@ -1,80 +0,0 @@
|
||||
/**
|
||||
* @file admin-board-cross-navigation.test.tsx
|
||||
* @description 관리자 게시판 ↔ 유저 게시판 상호 이동 크로스링크 검증 (이슈 #450)
|
||||
*
|
||||
* 검증 방식: 레이아웃 JSON 트리 직접 분석 (DOM 비의존).
|
||||
* 검증 목적:
|
||||
* 1. 관리자 게시물 리스트/상세에 "유저 화면 보기" 링크 존재 (새 탭, 조건 없음)
|
||||
* 2. 링크가 올바른 유저 라우트 href 와 target=_blank 를 사용
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import postsIndex from '../../../../resources/layouts/admin/admin_board_posts_index.json';
|
||||
import postDetail from '../../../../resources/layouts/admin/admin_board_post_detail.json';
|
||||
|
||||
interface LayoutNode {
|
||||
id?: string;
|
||||
name?: string;
|
||||
props?: Record<string, unknown>;
|
||||
children?: LayoutNode[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** 레이아웃 트리에서 id 로 노드를 재귀 탐색 */
|
||||
function findById(node: LayoutNode | LayoutNode[] | undefined, id: string): LayoutNode | null {
|
||||
if (!node) return null;
|
||||
const nodes = Array.isArray(node) ? node : [node];
|
||||
for (const n of nodes) {
|
||||
if (n && typeof n === 'object') {
|
||||
if (n.id === id) return n;
|
||||
for (const key of Object.keys(n)) {
|
||||
const child = (n as Record<string, unknown>)[key];
|
||||
if (Array.isArray(child) || (child && typeof child === 'object')) {
|
||||
const found = findById(child as LayoutNode | LayoutNode[], id);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
describe('이슈 #450 — 관리자 게시판 → 유저 게시판 크로스링크', () => {
|
||||
describe('admin_board_posts_index.json (게시물 리스트)', () => {
|
||||
const link = findById(postsIndex as LayoutNode, 'view_user_page_link');
|
||||
|
||||
it('유저 화면 보기 링크 노드가 존재한다', () => {
|
||||
expect(link).not.toBeNull();
|
||||
});
|
||||
|
||||
it('A 태그로 유저 게시판 리스트 경로를 새 탭으로 연다', () => {
|
||||
expect(link?.name).toBe('A');
|
||||
expect(link?.props?.href).toBe('/board/{{route.slug}}');
|
||||
expect(link?.props?.target).toBe('_blank');
|
||||
expect(link?.props?.rel).toBe('noopener noreferrer');
|
||||
});
|
||||
|
||||
it('노출 조건(if)이 없어 관리자 화면에서 항상 표시된다', () => {
|
||||
expect(link?.if).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('admin_board_post_detail.json (게시물 상세)', () => {
|
||||
const link = findById(postDetail as LayoutNode, 'view_user_page_link');
|
||||
|
||||
it('유저 화면 보기 링크 노드가 존재한다', () => {
|
||||
expect(link).not.toBeNull();
|
||||
});
|
||||
|
||||
it('A 태그로 유저 게시판 상세 경로를 새 탭으로 연다', () => {
|
||||
expect(link?.name).toBe('A');
|
||||
expect(link?.props?.href).toBe('/board/{{route.slug}}/{{route?.id}}');
|
||||
expect(link?.props?.target).toBe('_blank');
|
||||
});
|
||||
|
||||
it('노출 조건(if)이 없어 관리자 화면에서 항상 표시된다', () => {
|
||||
expect(link?.if).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
+2
-2
@@ -968,7 +968,7 @@ describe('_tab_report_policy.json - 신고 정책 탭', () => {
|
||||
it('admin-card 안 4 섹션 description 이 .card-description 시맨틱이고 card-title 의 직계 sibling 이다 (#408)', () => {
|
||||
// _tab_report_policy.json 의 4 섹션 (자동 숨김, abuse_prevention, permissions, notification)
|
||||
// description 이 stack 안에 묻혀있으면 .card-title:has(+ .card-description) 셀렉터 불발 →
|
||||
// card-title 의 하단 여백이 의도(mb-1)와 다르게 mb-6 으로 적용됨 (보고 #408)
|
||||
// card-title 의 하단 여백이 의도(mb-1)와 다르게 mb-6 으로 적용됨 (PO 보고 #408)
|
||||
//
|
||||
// 기대: admin-card 직계 자식 순서가 [card-title, card-description, stack, ...] 이고,
|
||||
// card-description 의 className 에 'card-description' 시맨틱 자산이 포함되어야 함.
|
||||
@@ -1045,7 +1045,7 @@ describe('_tab_report_policy.json - 신고 정책 탭', () => {
|
||||
});
|
||||
|
||||
it('board layout 의 모든 .card-description 은 Div 태그여야 한다 — Span/P 금지 (#408)', () => {
|
||||
// 표준: span.card-description / p.card-description 는 잘못된 시맨틱 HTML
|
||||
// PO 표준: span.card-description / p.card-description 는 잘못된 시맨틱 HTML
|
||||
// → div.card-description 만 허용.
|
||||
const layouts: Array<[string, any]> = [
|
||||
['mainLayout', mainLayout],
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
* 게시글 삭제로 함께 숨겨진(cascade) 댓글이 사용자 직접 삭제 댓글과 동일하게
|
||||
* "삭제된 댓글입니다" 로 마스킹되던 회귀의 방지.
|
||||
*
|
||||
* 정책(확정): 관리 권한자에게 cascade 댓글은 마스킹 없이 원문을 노출한다.
|
||||
* 정책(PO 확정): 관리 권한자에게 cascade 댓글은 마스킹 없이 원문을 노출한다.
|
||||
* 백엔드(CommentResource)가 is_cascade_deleted 플래그를 내려주고,
|
||||
* 레이아웃은 그 플래그로 분기를 결정한다.
|
||||
*
|
||||
|
||||
+1
-1
@@ -265,7 +265,7 @@ describe('admin_dashboard_community.json - community 슬롯 주입', () => {
|
||||
// 카드는 Pending/Review 신고만 표시한다 (ReportRepository::getPendingAcrossBoards).
|
||||
// 배지는 신고 대상(게시글/댓글)의 상태가 아니라 신고 건 자체의 처리상태(status)를 보여준다.
|
||||
// 색상은 신고현황 페이지(admin_board_reports_index 의 status 컬럼) 와 동일한 매핑을 사용한다.
|
||||
// 시각 검증 피드백: "대상 상태가 아닌 처리상태가 보여져야 한다" + "처리상태는 게시판 신고현황과 동일한 색상으로".
|
||||
// PO 시각 검증 피드백: "대상 상태가 아닌 처리상태가 보여져야 한다" + "처리상태는 게시판 신고현황과 동일한 색상으로".
|
||||
const statusBadge = findById(communityExt as any, 'report_status_badge');
|
||||
expect(statusBadge).not.toBeNull();
|
||||
expect(statusBadge.if).toBe('{{!!report?.status_label}}');
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
* @file board-copy-modal-info.test.tsx
|
||||
* @description 게시판 복사 확인 모달 - 안내 박스(배지 박스 + 불릿 3항목) 레이아웃 JSON 구조 검증
|
||||
*
|
||||
* 이슈 #413-16 후속(요청): 복사 모달 안내를 "설정·권한·관리자 복사 / 게시글·댓글 제외 /
|
||||
* 이슈 #413-16 후속(PO 요청): 복사 모달 안내를 "설정·권한·관리자 복사 / 게시글·댓글 제외 /
|
||||
* 새 슬러그 확인" 3항목으로 구조화. 안내 박스가 테두리+배경 컨테이너 안에 불릿 3개를
|
||||
* 포함하는지 JSON 트리로 검증한다 (DOM 비의존 구조 검증).
|
||||
*/
|
||||
|
||||
@@ -233,8 +233,7 @@
|
||||
"blind": "Blind",
|
||||
"restore": "Restore",
|
||||
"delete": "Delete",
|
||||
"create": "Create Post",
|
||||
"view_user_page": "View on site"
|
||||
"create": "Create Post"
|
||||
},
|
||||
"author_actions": {
|
||||
"view_user": "View User Info"
|
||||
|
||||
@@ -236,8 +236,7 @@
|
||||
"blind": "블라인드",
|
||||
"restore": "복원",
|
||||
"delete": "삭제",
|
||||
"create": "게시글 추가",
|
||||
"view_user_page": "유저 화면 보기"
|
||||
"create": "게시글 추가"
|
||||
},
|
||||
"author_actions": {
|
||||
"view_user": "사용자 정보 보기"
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
],
|
||||
"extends": "_admin_base",
|
||||
"meta": {
|
||||
"title": "{{route?.slug ? '$t:sirsoft-board.admin.form.title_edit' : '$t:sirsoft-board.admin.form.title_create'}}",
|
||||
"title": "{{route?.id ? '$t:sirsoft-board.admin.form.title_edit' : '$t:sirsoft-board.admin.form.title_create'}}",
|
||||
"description": "$t:sirsoft-board.admin.form.description"
|
||||
},
|
||||
"computed": {
|
||||
"isReadOnly": "{{!!route?.slug && !!boards?.data && boards?.data?.abilities?.can_update !== true}}"
|
||||
"isReadOnly": "{{!!route?.id && !!boards?.data && boards?.data?.abilities?.can_update !== true}}"
|
||||
},
|
||||
"state": {
|
||||
"activeTab": "section_basic_card",
|
||||
@@ -40,7 +40,7 @@
|
||||
"cache": false,
|
||||
"loading_strategy": "blocking",
|
||||
"params": {
|
||||
"board_slug": "{{route?.slug}}",
|
||||
"board_id": "{{route?.id}}",
|
||||
"copy_id": "{{query?.copy_id}}"
|
||||
},
|
||||
"initLocal": "form",
|
||||
@@ -141,7 +141,7 @@
|
||||
"props": {
|
||||
"className": "page-title"
|
||||
},
|
||||
"text": "{{route?.slug ? '$t:sirsoft-board.admin.form.title_edit' : '$t:sirsoft-board.admin.form.title_create'}}"
|
||||
"text": "{{route?.id ? '$t:sirsoft-board.admin.form.title_edit' : '$t:sirsoft-board.admin.form.title_create'}}"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
@@ -368,7 +368,7 @@
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"disabled": "{{_computed.isReadOnly || _local.isSaving || (!!route?.slug && !_local.hasChanges)}}",
|
||||
"disabled": "{{_computed.isReadOnly || _local.isSaving || (!!route?.id && !_local.hasChanges)}}",
|
||||
"className": "btn btn-primary flex-center gap-1.5"
|
||||
},
|
||||
"children": [
|
||||
@@ -403,9 +403,9 @@
|
||||
{
|
||||
"handler": "apiCall",
|
||||
"auth_required": true,
|
||||
"target": "{{route?.slug ? '/api/modules/sirsoft-board/admin/boards/' + route.slug : '/api/modules/sirsoft-board/admin/boards'}}",
|
||||
"target": "{{route?.id ? '/api/modules/sirsoft-board/admin/boards/' + route.id : '/api/modules/sirsoft-board/admin/boards'}}",
|
||||
"params": {
|
||||
"method": "{{route?.slug ? 'PUT' : 'POST'}}",
|
||||
"method": "{{route?.id ? 'PUT' : 'POST'}}",
|
||||
"body": "{{_local.form}}"
|
||||
},
|
||||
"onSuccess": [
|
||||
@@ -422,14 +422,14 @@
|
||||
"handler": "toast",
|
||||
"params": {
|
||||
"type": "success",
|
||||
"message": "{{route?.slug ? '$t:sirsoft-board.admin.board.messages.update_success' : '$t:sirsoft-board.admin.board.messages.create_success'}}"
|
||||
"message": "{{route?.id ? '$t:sirsoft-board.admin.board.messages.update_success' : '$t:sirsoft-board.admin.board.messages.create_success'}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"handler": "openWindow",
|
||||
"condition": "{{!route?.slug}}",
|
||||
"condition": "{{!route?.id}}",
|
||||
"params": {
|
||||
"path": "{{'/admin/boards/' + result.data.slug + '/edit'}}",
|
||||
"path": "{{'/admin/boards/' + result.data.id + '/edit'}}",
|
||||
"target": "_self"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,7 +259,7 @@
|
||||
{
|
||||
"handler": "apiCall",
|
||||
"auth_required": true,
|
||||
"target": "/api/modules/sirsoft-board/admin/boards/{{row.slug}}",
|
||||
"target": "/api/modules/sirsoft-board/admin/boards/{{row.id}}",
|
||||
"params": {
|
||||
"method": "PUT",
|
||||
"body": {
|
||||
@@ -340,7 +340,7 @@
|
||||
"edit": {
|
||||
"handler": "navigate",
|
||||
"params": {
|
||||
"path": "/admin/boards/{{row.slug}}/edit",
|
||||
"path": "/admin/boards/{{row.id}}/edit",
|
||||
"mergeQuery": false,
|
||||
"query": []
|
||||
}
|
||||
@@ -758,7 +758,7 @@
|
||||
{
|
||||
"handler": "apiCall",
|
||||
"auth_required": true,
|
||||
"target": "/api/modules/sirsoft-board/admin/boards/{{_global.modal_data?.slug}}",
|
||||
"target": "/api/modules/sirsoft-board/admin/boards/{{_global.modal_data?.id}}",
|
||||
"params": {
|
||||
"method": "DELETE",
|
||||
"body": []
|
||||
|
||||
@@ -80,33 +80,6 @@
|
||||
"description": "{{post?.data?.board?.name}}$t:sirsoft-board.admin.posts.page_description_with_board"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "view_user_page_link",
|
||||
"type": "basic",
|
||||
"name": "A",
|
||||
"props": {
|
||||
"href": "/board/{{route.slug}}/{{route?.id}}",
|
||||
"target": "_blank",
|
||||
"rel": "noopener noreferrer",
|
||||
"title": "$t:sirsoft-board.admin.posts.actions.view_user_page",
|
||||
"className": "flex-center gap-2 px-3 py-2 text-sm font-medium text-gray-600 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors no-underline"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "composite",
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "external-link",
|
||||
"size": "sm"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"text": "$t:sirsoft-board.admin.posts.actions.view_user_page"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "refresh_button",
|
||||
"type": "basic",
|
||||
|
||||
@@ -112,33 +112,6 @@
|
||||
"className": "flex-center gap-2"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "view_user_page_link",
|
||||
"type": "basic",
|
||||
"name": "A",
|
||||
"props": {
|
||||
"href": "/board/{{route.slug}}",
|
||||
"target": "_blank",
|
||||
"rel": "noopener noreferrer",
|
||||
"title": "$t:sirsoft-board.admin.posts.actions.view_user_page",
|
||||
"className": "flex-center gap-2 px-3 py-2 text-sm font-medium text-gray-600 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors no-underline"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "external-link",
|
||||
"size": "sm"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"text": "$t:sirsoft-board.admin.posts.actions.view_user_page"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "refresh_button",
|
||||
"type": "basic",
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "*/admin/boards/:slug/edit",
|
||||
"path": "*/admin/boards/:id/edit",
|
||||
"layout": "admin_board_form",
|
||||
"auth_required": true,
|
||||
"meta": {
|
||||
|
||||
@@ -15,7 +15,6 @@ use Modules\Sirsoft\Board\Http\Resources\BoardCollection;
|
||||
use Modules\Sirsoft\Board\Http\Resources\BoardResource;
|
||||
use Modules\Sirsoft\Board\Http\Resources\BoardTypeResource;
|
||||
use Modules\Sirsoft\Board\Http\Resources\PostResource;
|
||||
use Modules\Sirsoft\Board\Models\Board;
|
||||
use Modules\Sirsoft\Board\Services\BoardService;
|
||||
use Modules\Sirsoft\Board\Services\BoardTypeService;
|
||||
use Modules\Sirsoft\Board\Services\PostService;
|
||||
@@ -70,13 +69,13 @@ class BoardController extends AdminBaseController
|
||||
/**
|
||||
* 게시판 상세 정보를 조회합니다.
|
||||
*
|
||||
* @param Board $board 라우트 바인딩된 게시판 (slug 또는 ID)
|
||||
* @param int $id 게시판 ID
|
||||
* @return JsonResponse 게시판 상세 정보 응답
|
||||
*/
|
||||
public function show(Board $board): JsonResponse
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$board = $this->boardService->getBoard($board->id);
|
||||
$board = $this->boardService->getBoard($id);
|
||||
|
||||
return $this->successWithResource(
|
||||
'sirsoft-board::messages.boards.fetch_success',
|
||||
@@ -169,13 +168,13 @@ class BoardController extends AdminBaseController
|
||||
* 게시판 정보를 수정합니다.
|
||||
*
|
||||
* @param UpdateBoardRequest $request 게시판 수정 요청
|
||||
* @param Board $board 라우트 바인딩된 게시판 (slug 또는 ID)
|
||||
* @param int $id 게시판 ID
|
||||
* @return JsonResponse 수정된 게시판 정보 응답
|
||||
*/
|
||||
public function update(UpdateBoardRequest $request, Board $board): JsonResponse
|
||||
public function update(UpdateBoardRequest $request, int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$board = $this->boardService->updateBoard($board->id, $request->validated());
|
||||
$board = $this->boardService->updateBoard($id, $request->validated());
|
||||
|
||||
return $this->successWithResource(
|
||||
'sirsoft-board::messages.boards.update_success',
|
||||
@@ -195,16 +194,16 @@ class BoardController extends AdminBaseController
|
||||
/**
|
||||
* 게시판을 영구 삭제합니다.
|
||||
*
|
||||
* @param Board $board 라우트 바인딩된 게시판 (slug 또는 ID)
|
||||
* @param int $id 게시판 ID
|
||||
* @param Request $request HTTP 요청
|
||||
* @return JsonResponse 삭제 성공 응답
|
||||
*/
|
||||
// audit:allow controller-base-request-injection reason: DELETE 단건 삭제. force_delete 불리언 플래그만 input()으로 읽음 (검증 불필요)
|
||||
public function destroy(Board $board, Request $request): JsonResponse
|
||||
public function destroy(int $id, Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$forceDelete = (bool) $request->input('force_delete', false);
|
||||
$this->boardService->deleteBoard($board->id, $forceDelete);
|
||||
$this->boardService->deleteBoard($id, $forceDelete);
|
||||
|
||||
return $this->success('sirsoft-board::messages.boards.delete_success');
|
||||
} catch (ModelNotFoundException $e) {
|
||||
@@ -228,17 +227,11 @@ class BoardController extends AdminBaseController
|
||||
try {
|
||||
$basicDefaults = g7_module_settings('sirsoft-board', 'basic_defaults', []);
|
||||
|
||||
// 수정 모드 — board_slug(신규, slug 라우팅) 또는 board_id(하위호환) 로 식별
|
||||
if ($request->filled('board_slug') || $request->filled('board_id')) {
|
||||
// 수정 모드
|
||||
if ($request->filled('board_id')) {
|
||||
$request->merge(['include_permissions' => true]);
|
||||
|
||||
$board = $request->filled('board_slug')
|
||||
? $this->boardService->getBoardBySlug($request->board_slug)
|
||||
: $this->boardService->getBoard((int) $request->board_id);
|
||||
|
||||
if (! $board) {
|
||||
return $this->notFound('sirsoft-board::messages.boards.error_404');
|
||||
}
|
||||
$board = $this->boardService->getBoard($request->board_id);
|
||||
// 폼 토글 초기값: 관리자 메뉴 등록 여부를 모델 속성으로 전달
|
||||
$board->is_in_admin_menu = $this->boardService->isInAdminMenu($board);
|
||||
$data = (new BoardResource($board))->toArray($request);
|
||||
@@ -324,14 +317,14 @@ class BoardController extends AdminBaseController
|
||||
/**
|
||||
* 게시판을 관리자 메뉴에 추가합니다.
|
||||
*
|
||||
* @param Board $board 게시판 모델 (slug/id 라우트 바인딩)
|
||||
* @param int $id 게시판 ID
|
||||
* @return JsonResponse 메뉴 추가 결과 응답
|
||||
*/
|
||||
public function addToAdminMenu(Board $board): JsonResponse
|
||||
public function addToAdminMenu(int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
// Service 계층에 위임
|
||||
$menu = $this->boardService->addToAdminMenu($board->id);
|
||||
$menu = $this->boardService->addToAdminMenu($id);
|
||||
|
||||
return $this->success('sirsoft-board::messages.boards.menu_added_success', ['menu' => $menu]);
|
||||
} catch (MenuAlreadyExistsException $e) {
|
||||
|
||||
@@ -152,7 +152,6 @@ class BoardResource extends BaseApiResource
|
||||
public function toBoardInfoForUser(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'slug' => $this->slug,
|
||||
'name' => $this->getLocalizedName(),
|
||||
'description' => $this->getLocalizedDescription(),
|
||||
@@ -395,11 +394,9 @@ class BoardResource extends BaseApiResource
|
||||
'can_upload' => "sirsoft-board.{$slug}.attachments.upload",
|
||||
'can_download' => "sirsoft-board.{$slug}.attachments.download",
|
||||
'can_manage' => "sirsoft-board.{$slug}.manager",
|
||||
// 유저 화면에서 관리자 게시판 화면 진입 게이트 (Admin 타입 admin.manage 보유자만 true)
|
||||
'can_access_admin' => "sirsoft-board.{$slug}.admin.manage",
|
||||
];
|
||||
|
||||
// 권한 일괄 조회 (개별 Permission::where() → whereIn 1회)
|
||||
// 8개 권한을 일괄 조회 (개별 Permission::where() 8회 → whereIn 1회)
|
||||
$identifiers = array_values($permissionMap);
|
||||
$permissions = Permission::whereIn('identifier', $identifiers)->get()->keyBy('identifier');
|
||||
|
||||
|
||||
@@ -300,9 +300,6 @@ class PostCollection extends BaseApiCollection
|
||||
'can_download' => $this->checkBoardPermission($slug, 'attachments.download', PermissionType::User),
|
||||
'can_manage' => $canManage,
|
||||
'can_view_deleted' => $canManage,
|
||||
// 유저 화면에서 관리자 게시판 화면으로 진입 가능한지 여부 (Admin 타입 admin.manage 권한 보유자만 true).
|
||||
// 유저용 can_manage(게시판 매니저)와 다른, 관리자 화면 접근 게이트 전용 플래그.
|
||||
'can_access_admin' => $this->checkBoardPermission($slug, 'admin.manage'),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -325,7 +325,6 @@ class PostResource extends BaseApiResource
|
||||
private function getBoardInfo(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->board->id,
|
||||
'slug' => $this->board->slug,
|
||||
'name' => $this->board->getLocalizedName(),
|
||||
'type' => $this->board->type,
|
||||
@@ -602,19 +601,11 @@ class PostResource extends BaseApiResource
|
||||
'can_manage' => "sirsoft-board.{$slug}.manager",
|
||||
];
|
||||
|
||||
$abilities = collect($permissionMap)
|
||||
return collect($permissionMap)
|
||||
->mapWithKeys(fn (string $identifier, string $key) => [
|
||||
$key => $this->checkPermissionByIdentifier($identifier),
|
||||
])
|
||||
->toArray();
|
||||
|
||||
// 유저 상세 화면에서만: 관리자 게시판 화면 진입 게이트(Admin 타입 admin.manage).
|
||||
// Admin 요청에서는 can_manage 가 이미 admin.manage 이므로 중복 노출 불필요.
|
||||
if (! $this->isAdminRequest($request)) {
|
||||
$abilities['can_access_admin'] = $this->checkPermissionByIdentifier("sirsoft-board.{$slug}.admin.manage");
|
||||
}
|
||||
|
||||
return $abilities;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -37,29 +37,6 @@ class Board extends Model
|
||||
'max_file_count' => ['label_key' => 'sirsoft-board::activity_log.fields.max_file_count', 'type' => 'number'],
|
||||
];
|
||||
|
||||
/**
|
||||
* 라우트 모델 바인딩 시 슬러그(slug) 또는 숫자 ID로 게시판을 조회합니다.
|
||||
*
|
||||
* 관리자 게시판 URL 은 slug 기준(/admin/boards/:slug/edit)이며,
|
||||
* 숫자 ID 도 하위호환으로 허용합니다.
|
||||
*
|
||||
* @param mixed $value 라우트 파라미터 값 (slug 또는 ID)
|
||||
* @param string|null $field 검색할 필드명
|
||||
* @return Model|null
|
||||
*/
|
||||
public function resolveRouteBinding($value, $field = null)
|
||||
{
|
||||
if ($field) {
|
||||
return $this->where($field, $value)->firstOrFail();
|
||||
}
|
||||
|
||||
if (is_numeric($value)) {
|
||||
return $this->where('id', $value)->firstOrFail();
|
||||
}
|
||||
|
||||
return $this->where('slug', $value)->firstOrFail();
|
||||
}
|
||||
|
||||
/**
|
||||
* 팩토리 생성
|
||||
*/
|
||||
|
||||
@@ -216,7 +216,7 @@ class CommentRepository implements CommentRepositoryInterface
|
||||
* 재귀적으로 모든 하위 자손 수를 계산합니다.
|
||||
*
|
||||
* tombstone(삭제) 댓글은 트리 구조 유지를 위해 노드로는 남기지만,
|
||||
* "답글 N" 집계 모수에서는 제외한다(확정 정책). 단, 삭제 댓글의
|
||||
* "답글 N" 집계 모수에서는 제외한다(PO 확정). 단, 삭제 댓글의
|
||||
* 살아있는 하위 자손은 계속 카운트해 누락되지 않게 한다.
|
||||
*
|
||||
* @param Collection $grouped parent_id로 그룹화된 댓글 컬렉션
|
||||
|
||||
@@ -373,83 +373,6 @@ class BoardManagementTest extends ModuleTestCase
|
||||
$this->assertTrue($response->json('data.is_active'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시판 상세를 slug 로 조회할 수 있다 (#450 관리자 라우팅 slug 전환)
|
||||
*/
|
||||
public function test_board_detail_can_be_fetched_by_slug(): void
|
||||
{
|
||||
// Given
|
||||
$board = Board::factory()->create(['is_active' => true]);
|
||||
|
||||
// When: 숫자 id 가 아닌 slug 로 상세 조회
|
||||
$response = $this->actingAs($this->adminUser)
|
||||
->getJson("/api/modules/sirsoft-board/admin/boards/{$board->slug}");
|
||||
|
||||
// Then: 동일 게시판 반환
|
||||
$response->assertStatus(200);
|
||||
$this->assertSame($board->id, $response->json('data.id'));
|
||||
$this->assertSame($board->slug, $response->json('data.slug'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시판을 slug 로 수정할 수 있다 (#450)
|
||||
*/
|
||||
public function test_board_can_be_updated_by_slug(): void
|
||||
{
|
||||
// Given
|
||||
$board = Board::factory()->create(['is_active' => true]);
|
||||
|
||||
// When: slug 로 PUT
|
||||
$response = $this->actingAs($this->adminUser)
|
||||
->putJson("/api/modules/sirsoft-board/admin/boards/{$board->slug}", [
|
||||
'is_active' => false,
|
||||
]);
|
||||
|
||||
// Then
|
||||
$response->assertStatus(200);
|
||||
$this->assertFalse($board->fresh()->is_active);
|
||||
}
|
||||
|
||||
/**
|
||||
* 폼 데이터를 board_slug 로 수정 모드 조회할 수 있다 (#450)
|
||||
*/
|
||||
public function test_get_form_data_edit_mode_by_board_slug(): void
|
||||
{
|
||||
// Given
|
||||
$board = Board::factory()->create(['is_active' => true]);
|
||||
|
||||
// When: board_slug 쿼리로 폼 데이터 조회
|
||||
$response = $this->actingAs($this->adminUser)
|
||||
->getJson('/api/modules/sirsoft-board/admin/boards/form-data?board_slug='.$board->slug);
|
||||
|
||||
// Then: 해당 게시판 데이터가 로드됨
|
||||
$response->assertStatus(200);
|
||||
$this->assertSame($board->id, $response->json('data.id'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시판을 slug 로 관리자 메뉴에 추가할 수 있다 (#450 add-to-menu slug 바인딩)
|
||||
*
|
||||
* 라우트 파라미터가 {board} 이므로 컨트롤러가 Board route-model binding 으로
|
||||
* slug 를 해석해야 한다. int $id 로 받으면 slug 문자열이 전달될 때 실패한다.
|
||||
*/
|
||||
public function test_add_to_admin_menu_resolves_board_by_slug(): void
|
||||
{
|
||||
// Given: 활성 게시판
|
||||
$board = Board::factory()->create([
|
||||
'slug' => 'test-'.substr(md5(microtime().'addmenu'), 0, 8),
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
// When: 숫자 id 가 아닌 slug 로 add-to-menu 호출
|
||||
$response = $this->actingAs($this->adminUser)
|
||||
->postJson("/api/modules/sirsoft-board/admin/boards/{$board->slug}/add-to-menu");
|
||||
|
||||
// Then: slug 가 정상 해석되어 메뉴가 추가됨 (route-model binding 성공)
|
||||
$response->assertStatus(200);
|
||||
$this->assertNotNull($response->json('data.menu'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 부분 업데이트 시 is_active만 변경 가능 테스트
|
||||
*/
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ use Modules\Sirsoft\Board\Tests\BoardTestCase;
|
||||
* 증상2 — cascade 로 함께 숨겨진 댓글이 user 선삭제 댓글과 동일하게
|
||||
* "삭제된 댓글입니다" 로 마스킹됨 (cascade/user 구분 신호 부재)
|
||||
*
|
||||
* 정책(확정):
|
||||
* 정책(PO 확정):
|
||||
* - 첨부: 관리 권한자에게는 댓글과 동일하게 cascade 삭제분도 노출
|
||||
* - cascade 댓글: 관리 권한자에게는 마스킹 없이 원문 노출 (is_cascade_deleted 신호 제공)
|
||||
* - user 선삭제분: 기존대로 마스킹 유지
|
||||
|
||||
@@ -132,7 +132,7 @@ class PostDeleteCascadeTest extends BoardTestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시글 복원 시 cascade 항목만 복원되고, 사용자가 먼저 지운 것은 trashed 로 유지된다 (우려 차단).
|
||||
* 게시글 복원 시 cascade 항목만 복원되고, 사용자가 먼저 지운 것은 trashed 로 유지된다 (PO 우려 차단).
|
||||
*
|
||||
* @effects cascaded_comment_restored_on_post_restore,cascaded_attachment_restored_on_post_restore,user_pre_deleted_comment_stays_trashed,user_pre_deleted_attachment_stays_trashed
|
||||
*/
|
||||
|
||||
@@ -262,122 +262,6 @@ class PostResourcePermissionTest extends BoardTestCase
|
||||
$this->assertArrayNotHasKey('admin_manage', $abilities);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// can_access_admin 게이트 테스트 (#450)
|
||||
// 유저 화면에서 관리자 게시판 화면 진입 크로스링크 노출 여부
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* User 상세: admin.manage(Admin 타입) 권한 보유자는 can_access_admin = true
|
||||
*
|
||||
* @scenario link=user_board_to_admin, permitted=true
|
||||
* @effects board_admin_gate_true_shows_link
|
||||
*/
|
||||
#[Test]
|
||||
public function user_detail_returns_can_access_admin_true_for_admin_permission_holder(): void
|
||||
{
|
||||
// Given: 게시글 + admin.manage 권한 보유 사용자
|
||||
$postId = $this->createPost([
|
||||
'user_id' => $this->postAuthor->id,
|
||||
'title' => 'Test Post',
|
||||
'content' => 'Test Content',
|
||||
]);
|
||||
|
||||
// When: 관리 권한 보유자가 유저 라우트로 상세 조회
|
||||
$response = $this->actingAs($this->adminUser, 'sanctum')
|
||||
->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}");
|
||||
|
||||
// Then: 유저 컨텍스트에도 can_access_admin 노출 + true
|
||||
$response->assertStatus(200);
|
||||
$abilities = $response->json('data.abilities');
|
||||
|
||||
$this->assertArrayHasKey('can_access_admin', $abilities);
|
||||
$this->assertTrue($abilities['can_access_admin']);
|
||||
}
|
||||
|
||||
/**
|
||||
* User 상세: 일반 사용자(admin.manage 미보유)는 can_access_admin = false
|
||||
*
|
||||
* @scenario link=user_board_to_admin, permitted=false
|
||||
* @effects board_admin_gate_false_hides_link
|
||||
*/
|
||||
#[Test]
|
||||
public function user_detail_returns_can_access_admin_false_for_regular_user(): void
|
||||
{
|
||||
// Given
|
||||
$postId = $this->createPost([
|
||||
'user_id' => $this->postAuthor->id,
|
||||
'title' => 'Test Post',
|
||||
'content' => 'Test Content',
|
||||
]);
|
||||
|
||||
// When: user 권한만 보유한 사용자가 유저 라우트로 상세 조회
|
||||
$response = $this->actingAs($this->regularUser, 'sanctum')
|
||||
->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}");
|
||||
|
||||
// Then: can_access_admin 은 노출되나 false
|
||||
$response->assertStatus(200);
|
||||
$abilities = $response->json('data.abilities');
|
||||
|
||||
$this->assertArrayHasKey('can_access_admin', $abilities);
|
||||
$this->assertFalse($abilities['can_access_admin']);
|
||||
}
|
||||
|
||||
/**
|
||||
* User 목록: 컬렉션 abilities 에도 can_access_admin 노출
|
||||
*/
|
||||
#[Test]
|
||||
public function user_list_collection_exposes_can_access_admin(): void
|
||||
{
|
||||
// Given
|
||||
$this->createPost([
|
||||
'user_id' => $this->postAuthor->id,
|
||||
'title' => 'List Post',
|
||||
'content' => 'List Content',
|
||||
]);
|
||||
|
||||
// When: 관리 권한 보유자가 유저 라우트로 목록 조회
|
||||
$response = $this->actingAs($this->adminUser, 'sanctum')
|
||||
->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts");
|
||||
|
||||
// Then: 컬렉션 abilities 에 can_access_admin = true
|
||||
$response->assertStatus(200);
|
||||
$abilities = $response->json('data.abilities');
|
||||
|
||||
$this->assertArrayHasKey('can_access_admin', $abilities);
|
||||
$this->assertTrue($abilities['can_access_admin']);
|
||||
}
|
||||
|
||||
/**
|
||||
* User 목록/상세 응답의 board 정보에 id 가 포함된다 (#450)
|
||||
*
|
||||
* "게시판 관리" 크로스링크가 /admin/boards/:id/edit 로 이동하려면 board id 필요.
|
||||
*/
|
||||
#[Test]
|
||||
public function user_response_board_info_includes_id(): void
|
||||
{
|
||||
// Given
|
||||
$postId = $this->createPost([
|
||||
'user_id' => $this->postAuthor->id,
|
||||
'title' => 'Board Id Post',
|
||||
'content' => 'Board Id Content',
|
||||
]);
|
||||
|
||||
// When: 목록
|
||||
$listResponse = $this->actingAs($this->regularUser, 'sanctum')
|
||||
->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts");
|
||||
// And: 상세
|
||||
$detailResponse = $this->actingAs($this->regularUser, 'sanctum')
|
||||
->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}");
|
||||
|
||||
// Then: 목록/상세 board 정보 모두 실제 게시판 id 노출
|
||||
$listResponse->assertStatus(200);
|
||||
$this->assertSame($this->board->id, $listResponse->json('data.board.id'));
|
||||
|
||||
$detailResponse->assertStatus(200);
|
||||
$this->assertSame($this->board->id, $detailResponse->json('data.board.id'));
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 소유권 플래그 테스트
|
||||
// ==========================================
|
||||
|
||||
@@ -16,7 +16,7 @@ use PHPUnit\Framework\Attributes\Test;
|
||||
*
|
||||
* 게시판 내부 검색은 별도 엔드포인트가 아니라 목록 조회(index)에 search 필터를 얹은 것이다.
|
||||
* 비밀글(is_secret)·블라인드(status=blinded) 게시글은 목록/검색 응답에서 본문 요약이
|
||||
* 권한과 무관하게 빈 문자열로 차단되어야 한다 (확정 정책: 목록 미리보기는 권한자도 숨김).
|
||||
* 권한과 무관하게 빈 문자열로 차단되어야 한다 (PO 확정: 목록 미리보기는 권한자도 숨김).
|
||||
*
|
||||
* 제목은 현행 유지(노출), 차단 대상은 content_preview 뿐.
|
||||
* 상세 본문(content) 권한자 노출은 기존 정책(PostBlindedAccessControlTest)이 보장 — 본 테스트 범위 밖.
|
||||
@@ -237,7 +237,7 @@ class PostSearchSecretPreviewTest extends BoardTestCase
|
||||
|
||||
/**
|
||||
* 비밀글 미리보기는 작성자 본인에게도 목록에서는 차단된다(빈 문자열).
|
||||
* (확정 정책: 목록 미리보기는 권한자도 숨김 — 본문은 상세에서만 노출)
|
||||
* (PO 확정: 목록 미리보기는 권한자도 숨김 — 본문은 상세에서만 노출)
|
||||
*
|
||||
* @scenario viewer=author
|
||||
*
|
||||
|
||||
@@ -183,7 +183,7 @@ class BoardDashboardServiceTest extends ModuleTestCase
|
||||
// FormatsBoardDate 표준형 규칙:
|
||||
// - 24시간 이상 차이: 올해는 'MM-DD', 작년 이전은 'YY-MM-DD'
|
||||
// 캡션이 게시판 날짜 표시 규칙과 일관되어야 한다는 회귀 가드.
|
||||
// (게시글 created_at 표시와 동일 컨벤션 — 시각 검증 피드백)
|
||||
// (게시글 created_at 표시와 동일 컨벤션 — PO 시각 검증 피드백)
|
||||
$today = CarbonImmutable::today();
|
||||
// 어제 자정에 마지막 스케줄러가 돈 상황을 시뮬레이션
|
||||
$yesterdayBoundary = $today->subDay()->setTime(0, 0, 0);
|
||||
@@ -223,7 +223,7 @@ class BoardDashboardServiceTest extends ModuleTestCase
|
||||
public function test_get_recent_posts_excludes_replies(): void
|
||||
{
|
||||
// 대시보드 "최신 게시글" 카드는 본문 글만 보여줘야 한다 (답글 Re: 제외).
|
||||
// 시각 검증 피드백: "최신 게시글이 최신순으로 안 나오는 것 같다" — 원인은 답글이 섞여 표시된 것.
|
||||
// PO 시각 검증 피드백: "최신 게시글이 최신순으로 안 나오는 것 같다" — 원인은 답글이 섞여 표시된 것.
|
||||
// PostRepository::getRecentAcrossBoards 가 whereNull('parent_id') 누락했던 회귀 가드.
|
||||
$parent = $this->makePost('원글');
|
||||
$reply = $this->makePost('Re: 원글');
|
||||
|
||||
@@ -17,7 +17,7 @@ description: |
|
||||
(composite/ImageGallery.tsx 의 downloadAuthenticatedFile 과 동일 패턴).
|
||||
· 레이아웃 카드 3곳 전환: 사용자 _post_attachments(권한 분기 보존),
|
||||
관리자 _post_card_content / _reply_card_content(무분기) 의 <A href> → Div + click 핸들러.
|
||||
- 정책 확정:
|
||||
- PO 정책 확정:
|
||||
· 범위 = 게시판만 3곳 (페이지 첨부는 별도). 비회원 = NULL 유지(현행). 성공 토스트 없음.
|
||||
· 서버(라우트/컨트롤러/서비스) 무변경 — optional.sanctum 유지가 비회원 NULL 정책과 정합.
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ description: |
|
||||
canViewBlindedContent(관리자 OR 작성자 본인) 게이트 추가, 권한 없으면 null
|
||||
· CommentResource::getFilteredContent — 동일 결함 → 동일 게이트 추가
|
||||
· PostResource::toListArray — 블라인드 글 content_preview 를 빈 문자열로 차단
|
||||
- 정책 확정 (일반 관례): 원문 = 관리자 + 작성자 본인. 안내/사유/제목은 전원 노출.
|
||||
- PO 정책 확정 (일반 관례): 원문 = 관리자 + 작성자 본인. 안내/사유/제목은 전원 노출.
|
||||
- 비밀글(canViewSecretContent)과 달리 비밀번호 검증·posts.read-secret 은 인정하지 않음
|
||||
(블라인드는 운영 제재이므로 비밀번호로 해제되지 않음).
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ description: |
|
||||
identifier 는 stripBoardScopeRoles 로 제거 (attachRoles 의 전역 whereIn 으로 인한 교차 누수 차단)
|
||||
· 새 게시판 manager/step 역할은 생성 시 injectBoardRolesToPermissions 가 자동 주입
|
||||
· 비-스코프 역할(member 등) 은 그대로 보존
|
||||
- 정책 확정: 복제는 원본 manager/step 명단을 승계 (순수 생성 모드는 본인 단독)
|
||||
- PO 정책 확정: 복제는 원본 manager/step 명단을 승계 (순수 생성 모드는 본인 단독)
|
||||
- role 조회 로직은 BoardResource::getBoardRoleData 를 public static 으로 공유 (SSoT)
|
||||
|
||||
axes:
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ description: |
|
||||
고아 자식의 조상 체인을 따라 누락된 삭제 부모를 일괄 IN 조회(N+1 회피)로
|
||||
tombstone 노드 복구. 자식 없는 삭제 부모는 고아 자식이 없어 자연 제외.
|
||||
· countDescendants() 보정 — tombstone(삭제) 댓글은 "답글 N" 집계 모수에서 제외
|
||||
(트리 노드로는 유지, 살아있는 자손은 계속 카운트). 확정 정책.
|
||||
(트리 노드로는 유지, 살아있는 자손은 계속 카운트). PO 확정.
|
||||
· content 권한·표시(tombstone 안내 + 관리자 원문 토글)는 #413-50 기구현 재사용.
|
||||
· withTrashed=true(manager del_cmt 토글) 경로는 이미 전체 노출 → 고아 복구 비적용.
|
||||
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ description: |
|
||||
(b) 저장 경로는 slug 기준({slug}/...)인데 삭제는 board_id 기준 디렉토리를
|
||||
지우려 해 경로 불일치로 아무 파일도 안 지워짐.
|
||||
|
||||
조치 (결정 B안):
|
||||
조치 (PO 결정 B안):
|
||||
· Repository 3종(Post/Comment/Attachment)에 forceDeleteByBoardId 신설
|
||||
(기존 softDeleteByBoardId 의 delete → forceDelete). 인터페이스 + 구현.
|
||||
· BoardService::deleteBoard() 의 자식 정리를 force 로 교체.
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ description: |
|
||||
미처리 → 비로그인·제3자도 게시판 내부 검색/목록 응답에서 비밀글 본문 요약 열람 가능 (보안 결함).
|
||||
- 수정: PostResource::getMaskedContentPreviewForList 신설 — status=Blinded 또는 is_secret 이면
|
||||
권한 무관 빈 문자열. toListArray 가 이 헬퍼를 사용.
|
||||
- 정책 확정: 목록 미리보기는 권한자(작성자/관리자)에게도 숨김(상세에서만 본문 노출).
|
||||
- PO 정책 확정: 목록 미리보기는 권한자(작성자/관리자)에게도 숨김(상세에서만 본문 노출).
|
||||
DB select 심층방어는 미포함(응답 계층 차단만 — 노출 경계 차단 관례).
|
||||
- 통합검색(/api/search)은 쿼리에서 published+non-secret 만 검색하므로 누수 없음(무관).
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ description: |
|
||||
정리되지 않아, 게시글만 trashed 되고 댓글·첨부는 살아남아 목록/상세에서 고아로
|
||||
노출되던 문제를 수정한다. 동시에 게시글 복원 시 cascade 로 지워진 항목만 선택
|
||||
복원하여, 삭제 이전에 사용자가 직접 지운 항목이 게시글 복원과 함께 부활하는
|
||||
문제(우려)를 차단한다.
|
||||
문제(PO 우려)를 차단한다.
|
||||
|
||||
근본 원인:
|
||||
· PostService::deletePost() 가 게시글 자신만 soft delete 하고 댓글·첨부는
|
||||
@@ -14,7 +14,7 @@ description: |
|
||||
· 첨부 테이블(board_attachments)에는 cascade 와 사용자 삭제를 구분할
|
||||
trigger_type 컬럼 자체가 없었다.
|
||||
|
||||
조치 (결정 방법1: 연쇄 soft delete + cascade 플래그 기반 선별 복원):
|
||||
조치 (PO 결정 방법1: 연쇄 soft delete + cascade 플래그 기반 선별 복원):
|
||||
· board_comments.trigger_type enum 에 'cascade' 추가 (마이그레이션).
|
||||
· board_attachments 에 trigger_type enum 컬럼 신설 (+ board_id/post_id/trigger_type
|
||||
복합 인덱스).
|
||||
|
||||
@@ -4,23 +4,6 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.1] - 2026-07-08
|
||||
|
||||
### Added
|
||||
|
||||
- 관리자 상품 수정 화면에 "유저 화면 보기" 버튼을 추가했습니다 — 클릭하면 해당 상품의 실제 사용자 상세 화면이 새 탭으로 열립니다.
|
||||
|
||||
### Changed
|
||||
|
||||
- 상품 상세 페이지 주소가 상품 번호 대신 상품코드 기준으로 바뀌었습니다 (상품 목록·최근 본 상품·검색·장바구니·주문내역·마이페이지 상품 문의내역 등 모든 상품 링크 포함). 기존 번호 기반 주소도 계속 동작합니다.
|
||||
- 상품 검색에서 상품코드와 상품 번호로도 상품을 찾을 수 있습니다 — 관리자 상품 검색과 사용자 통합검색 모두 지원합니다. 통합검색 결과에서 상품을 클릭하면 상품코드 주소로 이동합니다.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 관리자 주문 상세에서 주문상품을 "구매확정"으로 변경할 때 구매확정 처리가 고객이 직접 구매확정한 경우와 동일하게 이뤄지지 않던 문제를 수정했습니다 — 이제 구매확정 시점이 정확히 기록되어(부분 수량만 구매확정한 경우 포함), 구매확정 기준으로 지급되는 마일리지 적립이 정상적으로 지급되고, 리뷰 작성 기한도 올바르게 계산됩니다. 기존에는 관리자가 구매확정 처리한 주문상품이 마일리지 적립 대상에서 누락되고 리뷰 작성 기한이 계산되지 않을 수 있었습니다. (#61 @miles44229 님께서 제보해주셨습니다.)
|
||||
- 주문상품을 여러 번에 나누어 구매확정할 때(예: 2개 중 1개씩 따로 확정) 적립 포인트가 일부만 지급되던 문제를 수정했습니다 — 이제 나누어 확정해도 예정 적립 포인트가 정확한 총액으로 지급됩니다.
|
||||
- 마일리지 기능을 사용 안 함으로 설정한 뒤에는 지연 적립뿐 아니라 즉시 적립도 지급되지 않도록 통일했습니다 — 기존에는 지급 시점 설정에 따라 어떤 적립은 지급되고 어떤 적립은 지급되지 않을 수 있었습니다.
|
||||
|
||||
## [1.0.0] - 2026-07-01
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "modules/sirsoft-ecommerce",
|
||||
"description": "Ecommerce module for Gnuboard7",
|
||||
"type": "library",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"ko": "이커머스",
|
||||
"en": "Ecommerce"
|
||||
},
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"description": {
|
||||
"ko": "그누보드7 이커머스 모듈 - 상품, 주문, 결제 관리",
|
||||
|
||||
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@g7/sirsoft-ecommerce",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@g7/sirsoft-ecommerce",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"jsdom": "^27.4.0",
|
||||
"typescript": "^5.3.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@g7/sirsoft-ecommerce",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.0",
|
||||
"description": "그누보드7 이커머스 모듈 프론트엔드 에셋",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
// e2e:allow MP13 — i18n/UX 소품 묶음(라벨 표시·링크 분기·모달 조건부 필드·placeholder 치환). 핵심 회귀는 Vitest(모듈 22 + 템플릿 13)로 커버, 라이브 검수는 계획서 §14 Chrome MCP 매트릭스(검수 환경)로 수행.
|
||||
// e2e:allow MP13 — i18n/UX 소품 묶음(라벨 표시·링크 분기·모달 조건부 필드·placeholder 치환). 핵심 회귀는 Vitest(모듈 22 + 템플릿 13)로 커버, 라이브 검수는 계획서 §14 Chrome MCP 매트릭스(PO 환경)로 수행.
|
||||
/**
|
||||
* bulkUpdateHandlers 테스트
|
||||
*
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
* 배경:
|
||||
* - partial_cancelled 주문상태 제거 후, 일부 취소 사실은 파생 플래그 is_partially_cancelled 로 표시한다.
|
||||
* - DataGrid order_status 컬럼 cellChildren 에 StatusBadge + 보조 배지를 평면 형제로 두면
|
||||
* 목록 셀과 모바일 카드(responsiveBreakpoint)에서 한 줄로 나란히 떠 깨진다(검수 발견).
|
||||
* 목록 셀과 모바일 카드(responsiveBreakpoint)에서 한 줄로 나란히 떠 깨진다(PO 발견).
|
||||
* → 둘을 flex-col 묶음 Div 로 감싸 상태 배지 아래(두 줄)에 보조 배지를 배치한다.
|
||||
* DataGrid 는 동일 columns 정의를 목록·카드 양쪽에 재사용하므로 한 곳 수정으로 둘 다 적용된다.
|
||||
*
|
||||
|
||||
+1
-1
@@ -406,7 +406,7 @@ describe('DataGrid (_transactions_table.json)', () => {
|
||||
expect(edit).toBeTruthy();
|
||||
// 비적립계 또는 권한없음이면 비활성 — Resource 가 can_edit = can_manage && isEarning 으로 계산
|
||||
expect(edit.disabledField).toBe('abilities.can_edit');
|
||||
// 원장 불변: 삭제 액션은 존재하지 않는다 (정책 확정)
|
||||
// 원장 불변: 삭제 액션은 존재하지 않는다 (PO 확정)
|
||||
const ids = grid.props.rowActions.map((a: any) => a.id);
|
||||
expect(ids).not.toContain('delete');
|
||||
expect(ids).not.toContain('manual');
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* @description
|
||||
* - 마일리지 설정은 이커머스 환경설정(admin_ecommerce_settings.json)의 서브탭으로 통합됨
|
||||
* (별도 독립 화면 아님 — 합의사항)
|
||||
* (별도 독립 화면 아님 — PO 합의사항)
|
||||
* - 환경설정 메인: mileage 탭 등록 + 탭 콘텐츠 partial(_tab_mileage.json) 참조 + 탭 if 가드
|
||||
* - 탭 콘텐츠 partial 5종(탭/기본/통화테이블/통화카드/유효기간/소멸알림) 구조 검증
|
||||
* - 카드 4종(기본/통화/유효기간/소멸알림), 토글 3종, 적립시점 기본=구매확정
|
||||
@@ -180,7 +180,7 @@ describe('마일리지 탭 콘텐츠 (_tab_mileage.json)', () => {
|
||||
});
|
||||
|
||||
it('마일리지 내역 보기 버튼이 기본 설정 카드 헤더에 있고 내역 화면으로 이동한다', () => {
|
||||
// 공간 절약 위해 별도 헤더 행 대신 기본 설정 카드 우측 상단으로 이동 (요청)
|
||||
// 공간 절약 위해 별도 헤더 행 대신 기본 설정 카드 우측 상단으로 이동 (PO 요청)
|
||||
const btn = findById(basicCard, 'view_transactions_button');
|
||||
expect(btn, '내역 보기 버튼이 기본 설정 카드에 없다').toBeTruthy();
|
||||
expect(btn.actions[0].handler).toBe('navigate');
|
||||
|
||||
+1
-1
@@ -102,7 +102,7 @@ describe('마이페이지 프로필 내 마일리지 카드 주입 (mypage-profi
|
||||
it('사용자 화면(마이페이지) 카드는 ExtensionBadge 를 사용하지 않는다 (회귀 차단)', () => {
|
||||
// ExtensionBadge 는 관리자 템플릿(sirsoft-admin_basic)에만 존재하는 컴포넌트로,
|
||||
// 사용자 템플릿(sirsoft-basic)에는 미등록이라 사용자 화면에 주입 시 "컴포넌트를 찾을 수 없습니다" 렌더 실패.
|
||||
// 정책: 사용자 화면에서 ExtensionBadge 사용 금지.
|
||||
// PO 정책: 사용자 화면에서 ExtensionBadge 사용 금지.
|
||||
const json = jsonOf(mypageCard);
|
||||
expect(json).not.toContain('ExtensionBadge');
|
||||
});
|
||||
|
||||
+2
-2
@@ -258,7 +258,7 @@ describe('productFormLayouts', () => {
|
||||
* "Failed to execute action: navigate" 오류 토스트가 추가로 발생하고
|
||||
* 목록 페이지로 이동하지 않음 (성공 토스트 + 오류 토스트 2건 동시 노출).
|
||||
* 원인: onSuccess 시퀀스의 navigate 핸들러가 params.url 로 경로를 전달했으나,
|
||||
* G7 navigate 핸들러는 params.path 만 인식한다 (코어 규정: navigate path 필수).
|
||||
* G7 navigate 핸들러는 params.path 만 인식한다 (CLAUDE.md "navigate path 필수").
|
||||
* url 키는 무시되어 finalPath=undefined → navigate throw → 오류 토스트.
|
||||
* 동일 모듈의 다른 navigate(주문 상세/브랜드 목록 등)는 모두 path 사용.
|
||||
* 해결: onSuccess navigate 의 params.url → params.path 로 정정.
|
||||
@@ -527,7 +527,7 @@ describe('productFormLayouts', () => {
|
||||
findNodeById(otherInfoPartial, 'field_max_purchase_qty'),
|
||||
]);
|
||||
// $event.target.value 는 허용하되, parseInt($event)/Number($event) 같은
|
||||
// 이벤트 객체 직접 숫자화 패턴은 금지 (코어 데이터 바인딩 규정)
|
||||
// 이벤트 객체 직접 숫자화 패턴은 금지 (CLAUDE.md 데이터 바인딩 규정)
|
||||
expect(serialized).not.toMatch(/parseInt\(\$event\)/);
|
||||
expect(serialized).not.toMatch(/Number\(\$event\)/);
|
||||
});
|
||||
|
||||
+2
-2
@@ -95,7 +95,7 @@ describe('header-currency-selector-user.json — 헤더 공용 셀렉터 2섹션
|
||||
});
|
||||
|
||||
it('루트는 if(마운트 게이트) 없이 항상 마운트 + className 으로 기본 hidden 토글 (깜빡임 방지)', () => {
|
||||
// 회귀(유저 헤더): if 마운트/언마운트 대신 항상 마운트 + className 기본 hidden(display:none) → 조건 충족 시 해제.
|
||||
// PO 회귀(유저 헤더): if 마운트/언마운트 대신 항상 마운트 + className 기본 hidden(display:none) → 조건 충족 시 해제.
|
||||
expect(root.if).toBeUndefined();
|
||||
const cls = root.props?.className ?? '';
|
||||
expect(cls).toContain("'hidden'");
|
||||
@@ -160,7 +160,7 @@ describe('header-currency-selector-admin.json — 관리자 헤더 2섹션', ()
|
||||
});
|
||||
|
||||
it('루트는 if(마운트 게이트) 없이 항상 마운트 + className 으로 기본 hidden 토글 (깜빡임 방지)', () => {
|
||||
// 회귀: if 로 마운트/언마운트하면 첫 페인트에 잠깐 보였다 사라진다.
|
||||
// PO 회귀: if 로 마운트/언마운트하면 첫 페인트에 잠깐 보였다 사라진다.
|
||||
// 항상 마운트하되 className 에 기본 hidden(display:none) 을 깔고 조건 충족 시에만 해제한다.
|
||||
expect(root.if).toBeUndefined();
|
||||
const cls = root.props?.className ?? '';
|
||||
|
||||
+1
-1
@@ -1201,7 +1201,7 @@ export function testShippingApiHandler(
|
||||
G7Core.state.set?.({ apiTestLoading: true, apiTestResult: null });
|
||||
|
||||
// apiCall 구조: URL 은 액션 top-level target, method/body 는 params,
|
||||
// onSuccess/onError 도 top-level (코어 apiCall 규약). 백엔드 test-api-call
|
||||
// onSuccess/onError 도 top-level (CLAUDE.md apiCall 규약). 백엔드 test-api-call
|
||||
// 엔드포인트가 사용자가 입력한 설정으로 외부 API 를 대신 호출하고 결과를 서빙한다.
|
||||
G7Core.dispatch({
|
||||
handler: 'apiCall',
|
||||
|
||||
@@ -87,8 +87,7 @@
|
||||
"edit_search": "Edit Search",
|
||||
"search": "Search",
|
||||
"reset": "Reset",
|
||||
"save_preset": "Save Preset",
|
||||
"view_user_page": "View on site"
|
||||
"save_preset": "Save Preset"
|
||||
},
|
||||
"bulk_apply": "Apply",
|
||||
"bulk_success": "Selected products have been changed.",
|
||||
|
||||
@@ -87,8 +87,7 @@
|
||||
"edit_search": "검색 편집",
|
||||
"search": "검색",
|
||||
"reset": "초기화",
|
||||
"save_preset": "프리셋 저장",
|
||||
"view_user_page": "유저 화면 보기"
|
||||
"save_preset": "프리셋 저장"
|
||||
},
|
||||
"bulk_apply": "일괄 적용",
|
||||
"bulk_success": "선택한 상품이 일괄 변경되었습니다.",
|
||||
|
||||
+1
-29
@@ -393,7 +393,7 @@
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "page-header-padded flex items-start justify-between gap-3"
|
||||
"className": "page-header-padded"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
@@ -417,34 +417,6 @@
|
||||
"text": "{{route.itemCode ? '$t:sirsoft-ecommerce.admin.product.description_edit' : '$t:sirsoft-ecommerce.admin.product.description_create'}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "view_user_page_link",
|
||||
"type": "basic",
|
||||
"name": "A",
|
||||
"if": "{{!!route.itemCode}}",
|
||||
"props": {
|
||||
"href": "{{(_global.modules?.['sirsoft-ecommerce']?.basic_info?.no_route ? '' : ('/' + (_global.modules?.['sirsoft-ecommerce']?.basic_info?.route_path ?? 'shop'))) + '/products/' + route.itemCode}}",
|
||||
"target": "_blank",
|
||||
"rel": "noopener noreferrer",
|
||||
"title": "$t:sirsoft-ecommerce.admin.product.action.view_user_page",
|
||||
"className": "flex-center gap-2 flex-shrink-0 px-3 py-2 text-sm font-medium text-gray-600 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors no-underline"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "external-link",
|
||||
"size": "sm"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"text": "$t:sirsoft-ecommerce.admin.product.action.view_user_page"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+1
-1
@@ -1914,7 +1914,7 @@
|
||||
"type": "basic",
|
||||
"name": "A",
|
||||
"props": {
|
||||
"href": "{{`/admin/ecommerce/products/${row.product?.product_code ?? row.product?.id}/edit`}}",
|
||||
"href": "{{`/admin/ecommerce/products/${row.product?.id}`}}",
|
||||
"className": "text-blue-600 hover:underline dark:text-blue-400 font-medium text-sm leading-snug"
|
||||
},
|
||||
"text": "{{row.product?.name ?? ''}}"
|
||||
|
||||
+1
-1
@@ -421,7 +421,7 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "환불 우선순위 라디오 — PG 환불 가능(requires_pg_cancellation)일 때 노출한다. 포인트 사용분이 없으면(total_points_used_amount=0) PG↔포인트 배분 순서가 무의미하므로 노출은 하되 선택 불가(disabled)로 둔다 (A28, 2026-06-22).",
|
||||
"comment": "환불 우선순위 라디오 — PG 환불 가능(requires_pg_cancellation)일 때 노출한다. 포인트 사용분이 없으면(total_points_used_amount=0) PG↔포인트 배분 순서가 무의미하므로 노출은 하되 선택 불가(disabled)로 둔다 (A28, PO 2026-06-22).",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{order.data?.payment?.requires_pg_cancellation === true}}",
|
||||
|
||||
+1
-1
@@ -1565,7 +1565,7 @@
|
||||
"edit": {
|
||||
"handler": "navigate",
|
||||
"params": {
|
||||
"path": "/admin/ecommerce/products/{{$args[1].product_code ?? $args[1].id}}/edit"
|
||||
"path": "/admin/ecommerce/products/{{$args[1].id}}/edit"
|
||||
}
|
||||
},
|
||||
"copy": {
|
||||
|
||||
@@ -339,7 +339,7 @@ enum OrderStatusEnum: string
|
||||
* - 취소(CANCELLED) 진입은 이 목록에 포함하지 않는다(canTransitionTo 가 별도 통과 처리).
|
||||
* - 현재 상태가 CANCELLED 면 판매 반영 상태(salesEligibleStatuses)로만 복원 허용
|
||||
* (취소된 주문/옵션을 다시 판매 진행 상태로 되살리는 reactivation 운영 기능 — 재고 재차감 동반.
|
||||
* 결제 전 상태(pending_order/pending_payment)로의 복원은 차단. 2026-06-23).
|
||||
* 결제 전 상태(pending_order/pending_payment)로의 복원은 차단. PO 2026-06-23).
|
||||
*
|
||||
* @return array<self>
|
||||
*/
|
||||
|
||||
+4
-8
@@ -13,7 +13,6 @@ use Modules\Sirsoft\Ecommerce\Http\Requests\Public\PublicProductRecentRequest;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Resources\ProductCollection;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Resources\ProductListResource;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Resources\PublicProductResource;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Product;
|
||||
use Modules\Sirsoft\Ecommerce\Services\ProductService;
|
||||
|
||||
/**
|
||||
@@ -168,19 +167,16 @@ class ProductController extends PublicBaseController
|
||||
* 공개 상품 상세 정보를 조회합니다.
|
||||
*
|
||||
* 전시상태가 visible인 상품만 조회할 수 있습니다.
|
||||
* 라우트 파라미터는 상품코드(product_code) 기준이며, 숫자 ID도 하위호환으로 허용됩니다
|
||||
* (Product::resolveRouteBinding 이 code/id 를 자동 해석).
|
||||
*
|
||||
* @param Product $product 라우트 바인딩된 상품 (product_code 또는 id)
|
||||
* @param int $id 상품 ID
|
||||
* @return JsonResponse 상품 상세 정보를 포함한 JSON 응답
|
||||
*/
|
||||
public function show(Product $product): JsonResponse
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$this->logApiUsage('products.show', ['product_id' => $product->id]);
|
||||
$this->logApiUsage('products.show', ['product_id' => $id]);
|
||||
|
||||
// 바인딩된 상품의 id 로 상세(옵션/관계 포함)를 재조회
|
||||
$product = $this->productService->getDetail($product->id);
|
||||
$product = $this->productService->getDetail($id);
|
||||
|
||||
if (! $product || $product->display_status->value !== 'visible') {
|
||||
return ResponseHelper::notFound(
|
||||
|
||||
+6
-7
@@ -7,7 +7,6 @@ use App\Http\Controllers\Api\Base\PublicBaseController;
|
||||
use Exception;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Requests\Public\StoreInquiryRequest;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Product;
|
||||
use Modules\Sirsoft\Ecommerce\Services\ProductInquiryService;
|
||||
|
||||
/**
|
||||
@@ -25,10 +24,10 @@ class ProductInquiryController extends PublicBaseController
|
||||
/**
|
||||
* 상품 문의 목록 조회
|
||||
*
|
||||
* @param Product $product 라우트 바인딩된 상품 (product_code 또는 id)
|
||||
* @param int $productId 상품 ID
|
||||
* @return JsonResponse 문의 목록 및 board_settings 메타 JSON 응답
|
||||
*/
|
||||
public function index(Product $product): JsonResponse
|
||||
public function index(int $productId): JsonResponse
|
||||
{
|
||||
try {
|
||||
$this->logApiUsage('inquiry.index');
|
||||
@@ -36,7 +35,7 @@ class ProductInquiryController extends PublicBaseController
|
||||
$page = (int) (request()->query('page', 1));
|
||||
$excludeSecret = filter_var(request()->query('exclude_secret', false), FILTER_VALIDATE_BOOLEAN);
|
||||
|
||||
$result = $this->inquiryService->getProductInquiries($product->id, $perPage, $page, $excludeSecret);
|
||||
$result = $this->inquiryService->getProductInquiries($productId, $perPage, $page, $excludeSecret);
|
||||
|
||||
return ResponseHelper::moduleSuccess(
|
||||
'sirsoft-ecommerce',
|
||||
@@ -56,14 +55,14 @@ class ProductInquiryController extends PublicBaseController
|
||||
* 상품 문의 작성
|
||||
*
|
||||
* @param StoreInquiryRequest $request 문의 작성 요청
|
||||
* @param Product $product 라우트 바인딩된 상품 (product_code 또는 id)
|
||||
* @param int $productId 상품 ID
|
||||
* @return JsonResponse 작성된 문의 JSON 응답
|
||||
*/
|
||||
public function store(StoreInquiryRequest $request, Product $product): JsonResponse
|
||||
public function store(StoreInquiryRequest $request, int $productId): JsonResponse
|
||||
{
|
||||
try {
|
||||
$this->logApiUsage('inquiry.store');
|
||||
$inquiry = $this->inquiryService->createInquiry($product->id, $request->validated());
|
||||
$inquiry = $this->inquiryService->createInquiry($productId, $request->validated());
|
||||
|
||||
return ResponseHelper::moduleSuccess(
|
||||
'sirsoft-ecommerce',
|
||||
|
||||
+4
-5
@@ -8,7 +8,6 @@ use Exception;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Requests\Public\PublicReviewListRequest;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Resources\ProductReviewResource;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Product;
|
||||
use Modules\Sirsoft\Ecommerce\Services\ProductReviewService;
|
||||
|
||||
/**
|
||||
@@ -25,11 +24,11 @@ class ProductReviewController extends PublicBaseController
|
||||
/**
|
||||
* 상품 공개 리뷰 목록 및 별점 통계 조회
|
||||
*
|
||||
* @param PublicReviewListRequest $request 요청
|
||||
* @param Product $product 라우트 바인딩된 상품 (product_code 또는 id)
|
||||
* @param Request $request 요청
|
||||
* @param int $productId 상품 ID
|
||||
* @return JsonResponse 리뷰 목록 및 별점 통계 JSON 응답
|
||||
*/
|
||||
public function index(PublicReviewListRequest $request, Product $product): JsonResponse
|
||||
public function index(PublicReviewListRequest $request, int $productId): JsonResponse
|
||||
{
|
||||
try {
|
||||
$this->logApiUsage('review.index');
|
||||
@@ -41,7 +40,7 @@ class ProductReviewController extends PublicBaseController
|
||||
: (json_decode($rawOptionFilters, true) ?? []);
|
||||
$perPage = (int) ($filters['per_page'] ?? 10);
|
||||
|
||||
$result = $this->reviewService->getProductReviews($product->id, $filters, $perPage);
|
||||
$result = $this->reviewService->getProductReviews($productId, $filters, $perPage);
|
||||
|
||||
return ResponseHelper::moduleSuccess(
|
||||
'sirsoft-ecommerce',
|
||||
|
||||
+3
-4
@@ -7,7 +7,6 @@ use App\Http\Controllers\Api\Base\PublicBaseController;
|
||||
use Exception;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Product;
|
||||
use Modules\Sirsoft\Ecommerce\Services\UserCouponService;
|
||||
|
||||
/**
|
||||
@@ -28,16 +27,16 @@ class PublicCouponController extends PublicBaseController
|
||||
* 로그인 시 is_downloaded 정보가 추가됩니다.
|
||||
*
|
||||
* @param Request $request 요청 데이터
|
||||
* @param Product $product 라우트 바인딩된 상품 (product_code 또는 id)
|
||||
* @param int $productId 상품 ID
|
||||
* @return JsonResponse 쿠폰 목록
|
||||
*/
|
||||
public function downloadableCoupons(Request $request, Product $product): JsonResponse
|
||||
public function downloadableCoupons(Request $request, int $productId): JsonResponse
|
||||
{
|
||||
try {
|
||||
$this->logApiUsage('products.downloadable_coupons');
|
||||
|
||||
$userId = $request->user('sanctum')?->id;
|
||||
$coupons = $this->userCouponService->getProductDownloadableCoupons($product->id, $userId);
|
||||
$coupons = $this->userCouponService->getProductDownloadableCoupons($productId, $userId);
|
||||
|
||||
return ResponseHelper::moduleSuccess(
|
||||
'sirsoft-ecommerce',
|
||||
|
||||
+1
-1
@@ -116,7 +116,7 @@ class MileageTransactionResource extends BaseApiResource
|
||||
* 행 레벨 능력을 계산합니다.
|
||||
*
|
||||
* 표준 can_manage 에 더해, 행 편집 메뉴('사유·기간 변경') 게이팅용 can_edit 를 노출합니다.
|
||||
* can_edit = manage 권한 보유 && 적립계 거래 (비적립계 행은 메뉴 비활성 — 합의 C-2).
|
||||
* can_edit = manage 권한 보유 && 적립계 거래 (비적립계 행은 메뉴 비활성 — PO 합의 C-2).
|
||||
*
|
||||
* @param Request $request HTTP 요청
|
||||
* @return array<string, bool> 능력 불리언 맵
|
||||
|
||||
@@ -34,7 +34,6 @@ class ProductReviewResource extends BaseApiResource
|
||||
// 상품 정보
|
||||
'product' => $this->whenLoaded('product', fn () => [
|
||||
'id' => $this->product->id,
|
||||
'product_code' => $this->product->product_code,
|
||||
'name' => $this->product->getLocalizedName(),
|
||||
'thumbnail_url' => $this->product->getThumbnailUrl(),
|
||||
]),
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Http\Resources;
|
||||
|
||||
use App\Enums\PermissionType;
|
||||
use App\Http\Resources\BaseApiResource;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@@ -163,14 +162,6 @@ class PublicProductResource extends BaseApiResource
|
||||
? $this->currentUserWishlist->isNotEmpty()
|
||||
: ProductWishlist::where('user_id', Auth::id())->where('product_id', $this->id)->exists()
|
||||
: false,
|
||||
|
||||
// 권한 (유저 상세 화면에서 관리자 상품 수정 화면 진입 게이트 전용)
|
||||
// can_update: 상품 수정 권한(sirsoft-ecommerce.products.update, Admin 타입) 보유자만 true.
|
||||
// 식별자는 DB 시더 권한 기준(products 복수형) — 라우트 정의의 단수 표기가 아닌 실제 등록 권한과 일치시킴.
|
||||
'abilities' => [
|
||||
'can_update' => Auth::check()
|
||||
&& Auth::user()->hasPermission('sirsoft-ecommerce.products.update', PermissionType::Admin),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -175,7 +175,7 @@ class MileageTransactionListener implements HookListenerInterface
|
||||
|
||||
// 취소 전이: 기적립 건 회수.
|
||||
// 취소는 옵션 단위로 처리되므로 회수도 옵션(CANCELLED) 기준이다 — 부분취소든 전체취소든
|
||||
// 취소된 옵션마다 이 분기를 타 개별 회수된다(별도 partial_cancelled 상태 불요). 2026-06-22
|
||||
// 취소된 옵션마다 이 분기를 타 개별 회수된다(별도 partial_cancelled 상태 불요). PO 2026-06-22
|
||||
if ($newStatus === OrderStatusEnum::CANCELLED) {
|
||||
$this->mileageService->cancelEarnForOption($order, $option);
|
||||
}
|
||||
@@ -184,20 +184,15 @@ class MileageTransactionListener implements HookListenerInterface
|
||||
/**
|
||||
* 적립 처리.
|
||||
*
|
||||
* mileage.enabled 가 꺼져 있으면 즉시 적립도 발생시키지 않는다 — 지연 적립(스케줄러
|
||||
* EarnMileageCommand)이 enabled 게이트로 막히는 것과 미지급 기준으로 통일한다.
|
||||
* (기능 비활성 시 계산 완료건도 지급하지 않음 — 즉시/지연 경로 일관성 확보.)
|
||||
* 저장 적립액이 0 이면 Service 가 자연 no-op.
|
||||
* mileage.enabled 토글은 주문 당시 적립액 "계산"(OrderCalculationService) 단계에서만 작용한다.
|
||||
* 리스너는 이미 계산·저장된 subtotal_earned_points_amount 를 실행할 뿐이므로 enabled 를 재검사하지 않는다.
|
||||
* (저장값이 0 이면 Service 가 자연 no-op — PO 확정: 계산 완료된 적립은 토글로 막지 않음)
|
||||
*
|
||||
* @param Order $order 주문
|
||||
* @param OrderOption $option 주문옵션
|
||||
*/
|
||||
private function earn(Order $order, OrderOption $option): void
|
||||
{
|
||||
if (! $this->earnEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->mileageService->earnForOrderOption($order, $option, MileageTransactionTypeEnum::PURCHASE_EARN);
|
||||
} catch (\Throwable $e) {
|
||||
@@ -210,18 +205,6 @@ class MileageTransactionListener implements HookListenerInterface
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 마일리지 기능 활성화 여부를 반환합니다.
|
||||
*
|
||||
* 스케줄러(EarnMileageCommand)의 enabled 게이트와 동일 기준 — 즉시/지연 적립 미지급 통일.
|
||||
*
|
||||
* @return bool 활성화 여부
|
||||
*/
|
||||
private function earnEnabled(): bool
|
||||
{
|
||||
return (bool) $this->settings->getSetting('mileage.enabled', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정된 적립 시점을 반환합니다.
|
||||
*
|
||||
|
||||
@@ -202,7 +202,6 @@ class SearchProductsListener implements HookListenerInterface
|
||||
|
||||
return [
|
||||
'id' => $product->id,
|
||||
'product_code' => $product->product_code,
|
||||
'name' => $name,
|
||||
'name_highlighted' => $this->highlightKeyword($name, $keyword),
|
||||
'short_description' => $shortDescription,
|
||||
@@ -220,7 +219,7 @@ class SearchProductsListener implements HookListenerInterface
|
||||
'multi_currency_selling_price' => $this->buildMultiCurrencyPrices($product->selling_price),
|
||||
'multi_currency_list_price' => $this->buildMultiCurrencyPrices($product->list_price),
|
||||
'labels' => $labels,
|
||||
'url' => '/shop/products/'.$product->product_code,
|
||||
'url' => '/shop/'.$product->id,
|
||||
'review_count' => (int) ($product->review_count ?? 0),
|
||||
'rating_avg' => $product->rating_avg !== null ? round((float) $product->rating_avg, 1) : 0.0,
|
||||
];
|
||||
|
||||
@@ -7,12 +7,12 @@ use App\Models\User;
|
||||
use Modules\Sirsoft\Ecommerce\Services\UserMileageService;
|
||||
|
||||
/**
|
||||
* 회원 탈퇴/삭제 시 마일리지 데이터 정리 리스너 (정책 확정 #8)
|
||||
* 회원 탈퇴/삭제 시 마일리지 데이터 정리 리스너 (PO 확정 #8)
|
||||
*
|
||||
* 코어 UserService 의 탈퇴(before_withdraw)·삭제(before_delete) 훅을 구독해,
|
||||
* 활성 lot 을 expired 거래로 소멸 기록(감사 흐름 보존)한 뒤 원장·잔액 캐시 행을 명시 삭제합니다.
|
||||
* 탈퇴는 soft 상태 변경이라 cascadeOnDelete 가 발화하지 않으므로 명시 정리가 필수입니다
|
||||
* (코어 규정: DB CASCADE 의존 삭제 금지). cascade 는 삭제 경로의 안전망으로만 둡니다.
|
||||
* (CLAUDE.md "DB CASCADE 의존 삭제 금지"). cascade 는 삭제 경로의 안전망으로만 둡니다.
|
||||
*/
|
||||
class UserMileageCleanupListener implements HookListenerInterface
|
||||
{
|
||||
|
||||
@@ -526,7 +526,7 @@ class Order extends Model
|
||||
*
|
||||
* 부분취소는 별도 주문 상태(partial_cancelled)가 아니라 "취소된 옵션이 있으면서
|
||||
* 잔여 활성 옵션도 존재"하는 파생 상태다. 주문 상태(order_status)는 잔여 활성 옵션 기준으로
|
||||
* 결정되고, "일부가 취소되었다"는 사실은 이 플래그로 노출한다 (2026-06-22, partial_cancelled 제거).
|
||||
* 결정되고, "일부가 취소되었다"는 사실은 이 플래그로 노출한다 (PO 2026-06-22, partial_cancelled 제거).
|
||||
*
|
||||
* @return bool 일부 옵션만 취소된 주문이면 true
|
||||
*/
|
||||
|
||||
-34
@@ -76,40 +76,6 @@ interface MileageTransactionRepositoryInterface
|
||||
*/
|
||||
public function existsEarnForOption(int $orderOptionId): bool;
|
||||
|
||||
/**
|
||||
* 해당 주문옵션의 구매 적립(purchase_earn) 총액을 반환합니다 (금액 델타 멱등 기준).
|
||||
*
|
||||
* 관리자 수동 지급(admin_earn)·복원(refund_restore/order_cancel_restore)은 제외하고
|
||||
* 구매확정/배송 적립(purchase_earn)만 합산한다. 나눠 확정·병합으로 옵션의 적립 예정액이
|
||||
* 늘었을 때, 목표액과 이 합계의 차액(델타)만 추가 적립하기 위한 기준값이다.
|
||||
*
|
||||
* @param int $orderOptionId 주문옵션 ID
|
||||
* @return float 기적립 purchase_earn 총액
|
||||
*/
|
||||
public function sumPurchaseEarnedForOption(int $orderOptionId): float;
|
||||
|
||||
/**
|
||||
* 피흡수 옵션의 구매 적립(purchase_earn) 거래를 생존 옵션으로 이전합니다 (병합 정합).
|
||||
*
|
||||
* 옵션 병합 시 shipping·review 처럼 마일리지 적립 거래의 order_option_id 도 생존 레코드로
|
||||
* 옮겨, 병합 후 생존 옵션 기준의 적립 합계·델타 계산이 정합하도록 한다.
|
||||
*
|
||||
* @param int $fromOrderOptionId 피흡수 옵션 ID
|
||||
* @param int $toOrderOptionId 생존 옵션 ID
|
||||
* @return int 이전된 거래 수
|
||||
*/
|
||||
public function transferPurchaseEarnByOrderOptionId(int $fromOrderOptionId, int $toOrderOptionId): int;
|
||||
|
||||
/**
|
||||
* 기존 구매 적립건(lot)에 델타 금액을 증액합니다 (방식 A — 적립 내역 한 줄 유지).
|
||||
*
|
||||
* amount·remaining_amount 를 델타만큼 증가시킨다. expires_at 은 최초 적립 시점을 유지한다.
|
||||
*
|
||||
* @param MileageTransaction $lot 대상 적립건
|
||||
* @param float $delta 증액할 금액 (> 0)
|
||||
*/
|
||||
public function incrementEarnLotAmount(MileageTransaction $lot, float $delta): void;
|
||||
|
||||
/**
|
||||
* 해당 주문취소에 대한 복원 거래가 이미 존재하는지 확인합니다 (멱등 가드).
|
||||
*
|
||||
|
||||
+5
-40
@@ -94,38 +94,6 @@ class MileageTransactionRepository implements MileageTransactionRepositoryInterf
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function sumPurchaseEarnedForOption(int $orderOptionId): float
|
||||
{
|
||||
return (float) MileageTransaction::query()
|
||||
->where('order_option_id', $orderOptionId)
|
||||
->where('type', MileageTransactionTypeEnum::PURCHASE_EARN->value)
|
||||
->sum('amount');
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function transferPurchaseEarnByOrderOptionId(int $fromOrderOptionId, int $toOrderOptionId): int
|
||||
{
|
||||
return MileageTransaction::query()
|
||||
->where('order_option_id', $fromOrderOptionId)
|
||||
->where('type', MileageTransactionTypeEnum::PURCHASE_EARN->value)
|
||||
->update(['order_option_id' => $toOrderOptionId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function incrementEarnLotAmount(MileageTransaction $lot, float $delta): void
|
||||
{
|
||||
$lot->amount = (float) $lot->amount + $delta;
|
||||
$lot->remaining_amount = (float) $lot->remaining_amount + $delta;
|
||||
$lot->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
@@ -367,14 +335,11 @@ class MileageTransactionRepository implements MileageTransactionRepositoryInterf
|
||||
->whereNotNull("opt.{$triggerColumn}")
|
||||
->where("opt.{$triggerColumn}", '<=', $threshold)
|
||||
->where('opt.subtotal_earned_points_amount', '>', 0)
|
||||
// 금액 델타 멱등: 목표 적립액보다 기적립 purchase_earn 합계가 적은 옵션을 대상으로 삼는다.
|
||||
// (나눠 확정·병합으로 목표액이 늘어난 옵션의 잔여분까지 스케줄러가 포착)
|
||||
// 빌더 서브쿼리로 표현 — raw SQL/테이블 별칭은 prefix 자동 적용을 받지 못하므로 별칭 없이 컬럼만 참조.
|
||||
->where('opt.subtotal_earned_points_amount', '>', function ($sub) {
|
||||
$sub->from('ecommerce_mileage_transactions')
|
||||
->selectRaw('COALESCE(SUM(amount), 0)')
|
||||
->whereColumn('order_option_id', 'opt.id')
|
||||
->where('type', MileageTransactionTypeEnum::PURCHASE_EARN->value);
|
||||
->whereNotExists(function ($q) {
|
||||
$q->select(DB::raw(1))
|
||||
->from('ecommerce_mileage_transactions as tx')
|
||||
->whereColumn('tx.order_option_id', 'opt.id')
|
||||
->whereIn('tx.type', self::EARN_TYPES);
|
||||
})
|
||||
->orderBy('opt.id', 'asc')
|
||||
->select([
|
||||
|
||||
@@ -67,7 +67,7 @@ class ProductRepository implements ProductRepositoryInterface
|
||||
// FULLTEXT 매칭 ID (Scout 엔진 순수 검색 — queryCallback 없음)
|
||||
$ftIds = Product::search($keyword)->keys()->all();
|
||||
|
||||
// 보조필드(id/product_code/sku/barcode) LIKE 매칭 ID (all 일 때만)
|
||||
// 보조필드(product_code/sku/barcode) LIKE 매칭 ID (all 일 때만)
|
||||
$auxIds = [];
|
||||
if ($field === 'all') {
|
||||
$auxIds = $this->model->newQuery()
|
||||
@@ -75,11 +75,6 @@ class ProductRepository implements ProductRepositoryInterface
|
||||
$q->where('product_code', 'like', "%{$keyword}%")
|
||||
->orWhere('sku', 'like', "%{$keyword}%")
|
||||
->orWhere('barcode', 'like', "%{$keyword}%");
|
||||
|
||||
// 숫자 키워드는 상품 ID 정확 매칭도 포함 (예: "99" → id=99)
|
||||
if (ctype_digit($keyword)) {
|
||||
$q->orWhere('id', (int) $keyword);
|
||||
}
|
||||
})
|
||||
->pluck('id')
|
||||
->all();
|
||||
@@ -504,49 +499,23 @@ class ProductRepository implements ProductRepositoryInterface
|
||||
{
|
||||
$page = (int) floor($offset / $limit) + 1;
|
||||
|
||||
// FULLTEXT(name/description) 매칭 + 보조필드(product_code/id) 매칭의 합집합.
|
||||
// 통합검색도 관리자 검색과 동일하게 상품코드/ID 로 상품을 찾을 수 있어야 한다.
|
||||
$matchedIds = $this->resolveKeywordMatchedProductIds($keyword);
|
||||
$results = Product::search($keyword)
|
||||
->query(function ($query) use ($categoryId, $orderBy, $direction) {
|
||||
$query->where('display_status', ProductDisplayStatus::VISIBLE->value);
|
||||
|
||||
$paginator = $this->model->newQuery()
|
||||
->whereIn('id', $matchedIds ?: [0])
|
||||
->where('display_status', ProductDisplayStatus::VISIBLE->value)
|
||||
->with(['images', 'primaryCategory', 'brand', 'activeLabelAssignments.label'])
|
||||
->withCount('visibleReviews as review_count')
|
||||
->withAvg('visibleReviews as rating_avg', 'rating')
|
||||
->when($categoryId !== null, fn ($q) => $q->whereHas('categories', fn ($c) => $c->where('ecommerce_categories.id', $categoryId)))
|
||||
->orderBy($orderBy, $direction)
|
||||
->paginate($limit, ['*'], 'page', $page);
|
||||
$query->with(['images', 'primaryCategory', 'brand', 'activeLabelAssignments.label'])
|
||||
->withCount('visibleReviews as review_count')
|
||||
->withAvg('visibleReviews as rating_avg', 'rating');
|
||||
|
||||
return ['total' => $paginator->total(), 'items' => $paginator->getCollection()];
|
||||
}
|
||||
|
||||
/**
|
||||
* 키워드로 매칭되는 상품 ID 집합을 반환합니다.
|
||||
*
|
||||
* FULLTEXT(name/description) Scout 매칭과 보조필드(product_code / 숫자 ID) 매칭의
|
||||
* 합집합. 통합검색의 결과 조회와 total 계산이 동일한 조건을 쓰도록 단일 경로로 산출한다.
|
||||
*
|
||||
* @param string $keyword 검색 키워드
|
||||
* @return array<int> 매칭된 상품 ID 배열
|
||||
*/
|
||||
private function resolveKeywordMatchedProductIds(string $keyword): array
|
||||
{
|
||||
$ftIds = Product::search($keyword)->keys()->all();
|
||||
|
||||
$auxIds = $this->model->newQuery()
|
||||
->where(function ($q) use ($keyword) {
|
||||
$q->where('product_code', 'like', "%{$keyword}%");
|
||||
|
||||
// 숫자 키워드는 상품 ID 정확 매칭도 포함
|
||||
if (ctype_digit($keyword)) {
|
||||
$q->orWhere('id', (int) $keyword);
|
||||
if ($categoryId !== null) {
|
||||
$query->whereHas('categories', fn ($q) => $q->where('ecommerce_categories.id', $categoryId));
|
||||
}
|
||||
})
|
||||
->pluck('id')
|
||||
->all();
|
||||
|
||||
return array_values(array_unique([...$ftIds, ...$auxIds]));
|
||||
$query->orderBy($orderBy, $direction);
|
||||
})
|
||||
->paginate($limit, 'page', $page);
|
||||
|
||||
return ['total' => $results->total(), 'items' => $results->getCollection()];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -554,14 +523,16 @@ class ProductRepository implements ProductRepositoryInterface
|
||||
*/
|
||||
public function countByKeyword(string $keyword, ?int $categoryId = null): int
|
||||
{
|
||||
// searchByKeyword 와 동일한 매칭 조건(FULLTEXT ∪ 보조필드)으로 total 산출.
|
||||
$matchedIds = $this->resolveKeywordMatchedProductIds($keyword);
|
||||
return Product::search($keyword)
|
||||
->query(function ($query) use ($categoryId) {
|
||||
$query->where('display_status', ProductDisplayStatus::VISIBLE->value);
|
||||
|
||||
return $this->model->newQuery()
|
||||
->whereIn('id', $matchedIds ?: [0])
|
||||
->where('display_status', ProductDisplayStatus::VISIBLE->value)
|
||||
->when($categoryId !== null, fn ($q) => $q->whereHas('categories', fn ($c) => $c->where('ecommerce_categories.id', $categoryId)))
|
||||
->count();
|
||||
if ($categoryId !== null) {
|
||||
$query->whereHas('categories', fn ($q) => $q->where('ecommerce_categories.id', $categoryId));
|
||||
}
|
||||
})
|
||||
->paginate(1)
|
||||
->total();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -664,7 +664,7 @@ class OrderAdjustmentService
|
||||
{
|
||||
$updates = [];
|
||||
|
||||
// 부분취소 재계산 시 옵션 레벨 다통화(mc_) 컬럼도 재산출 (정책 확정)
|
||||
// 부분취소 재계산 시 옵션 레벨 다통화(mc_) 컬럼도 재산출 (PO 확정)
|
||||
$currencySnapshot = $order->currency_snapshot;
|
||||
|
||||
// 재계산 결과에서 옵션별 금액 반영
|
||||
|
||||
@@ -118,7 +118,7 @@ class OrderCalculationService
|
||||
// 쿠폰 조합 가능 여부 검증 (is_combinable) — 전체 슬롯 통합 1회 (A15/MP06)
|
||||
// 슬롯별 독립 검증은 "상품별(false) 1개 + 주문(true) 1개" 같은 슬롯 교차 조합을
|
||||
// 각 슬롯 count=1 이라 놓친다. 적용 대상 전체 쿠폰을 한 집합으로 모아 1회 검증한다.
|
||||
// 스냅샷 모드(환불 재계산)에서도 동일 검증한다(2026-06-19 확정).
|
||||
// 스냅샷 모드(환불 재계산)에서도 동일 검증한다(PO 2026-06-19 확정).
|
||||
$allAppliedCoupons = $this->collectAllCouponsForCombination($coupons, $itemCouponIssues);
|
||||
$combinationViolations = $this->findNonCombinableViolations($allAppliedCoupons);
|
||||
foreach ($combinationViolations as $violationIssue) {
|
||||
@@ -874,7 +874,7 @@ class OrderCalculationService
|
||||
|
||||
// 마일리지 기능 비활성 시 주문 시점 적립 "계산" 자체를 수행하지 않는다 (전부 0).
|
||||
// mileage_value/mileage_type 은 상품 옵션(상품 등록 당시) 속성이므로,
|
||||
// 전역 OFF 면 상품옵션 명시 적립률·기본 적립률 모두 계산 대상에서 제외된다 (정책 확정).
|
||||
// 전역 OFF 면 상품옵션 명시 적립률·기본 적립률 모두 계산 대상에서 제외된다 (PO 확정).
|
||||
if (! (bool) $this->settingsService->getSetting('mileage.enabled', false)) {
|
||||
$pointsPerItem[$optionId] = 0;
|
||||
|
||||
|
||||
@@ -683,7 +683,7 @@ class OrderCancellationService
|
||||
}
|
||||
// 부분취소는 별도 주문 상태(partial_cancelled)를 두지 않는다 — 옵션은 이미 CANCELLED 로 전이됐고,
|
||||
// 주문 상태는 아래 syncParentOrderStatus 가 "취소 제외 잔여 활성 옵션" 기준으로 파생 결정한다.
|
||||
// (잔여 옵션이 모두 취소된 경우 syncParentOrderStatus 가 CANCELLED 로 전이.) — 2026-06-22
|
||||
// (잔여 옵션이 모두 취소된 경우 syncParentOrderStatus 가 CANCELLED 로 전이.) — PO 2026-06-22
|
||||
|
||||
$order->update($updateData);
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Exceptions\OrderProcessingException;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderOption;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\MileageTransactionRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderOptionRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderShippingRepositoryInterface;
|
||||
@@ -28,7 +27,6 @@ class OrderOptionService
|
||||
* @param OrderShippingRepositoryInterface $orderShippingRepository 주문 배송 Repository
|
||||
* @param OrderRepositoryInterface $orderRepository 주문 Repository
|
||||
* @param ProductReviewRepositoryInterface $productReviewRepository 상품 리뷰 Repository
|
||||
* @param MileageTransactionRepositoryInterface $mileageTransactionRepository 마일리지 거래 Repository (병합 시 적립 거래 이전)
|
||||
*/
|
||||
public function __construct(
|
||||
protected OrderOptionRepositoryInterface $orderOptionRepository,
|
||||
@@ -37,7 +35,6 @@ class OrderOptionService
|
||||
protected ProductReviewRepositoryInterface $productReviewRepository,
|
||||
protected StockService $stockService,
|
||||
protected EcommerceSettingsService $settingsService,
|
||||
protected MileageTransactionRepositoryInterface $mileageTransactionRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -144,15 +141,10 @@ class OrderOptionService
|
||||
if ($quantity === $option->quantity) {
|
||||
// 전체 수량 변경 → UPDATE만
|
||||
$updateData = ['option_status' => $newStatus];
|
||||
// 배송완료 진입 시점 기록 (지연 적립 기준)
|
||||
// 배송완료 진입 시점 기록 (confirmed_at 과 대칭 — 지연 적립 기준)
|
||||
if ($newStatus === OrderStatusEnum::DELIVERED && $option->delivered_at === null) {
|
||||
$updateData['delivered_at'] = now();
|
||||
}
|
||||
// 구매확정 진입 시점 기록 (delivered_at 과 대칭 — 지연 적립·리뷰 기한 기준).
|
||||
// 멱등: 이미 값이 있으면 유지(상태 재진입 시 최초 확정 시점 보존).
|
||||
if ($newStatus === OrderStatusEnum::CONFIRMED && $option->confirmed_at === null) {
|
||||
$updateData['confirmed_at'] = now();
|
||||
}
|
||||
$this->orderOptionRepository->update($option, $updateData);
|
||||
|
||||
// 배송 정보(택배사·송장번호) 기록 — 병합 전 상태 변경 대상 옵션에 반영.
|
||||
@@ -195,11 +187,6 @@ class OrderOptionService
|
||||
if ($newStatus === OrderStatusEnum::DELIVERED && $splitOption->delivered_at === null) {
|
||||
$splitOption->delivered_at = now();
|
||||
}
|
||||
// 구매확정으로 분할 전이 시 분할 레코드에 구매확정 시점 기록 (delivered_at 과 대칭).
|
||||
// 원본에서 replicate 로 계승한 delivered_at 은 유지한다(배송완료·구매확정은 둘 다 실제 발생한 사건).
|
||||
if ($newStatus === OrderStatusEnum::CONFIRMED && $splitOption->confirmed_at === null) {
|
||||
$splitOption->confirmed_at = now();
|
||||
}
|
||||
|
||||
// 분할 레코드 금액 = 원본 × ratio
|
||||
$this->applySplitAmounts($splitOption, $origAmounts, $ratio, $quantity);
|
||||
@@ -239,14 +226,6 @@ class OrderOptionService
|
||||
// 변경 후 훅
|
||||
HookManager::doAction('sirsoft-ecommerce.order_option.after_status_change', $result['original'], $newStatus, $result['split']);
|
||||
|
||||
// 구매확정 전이는 유저 셀프 확정(OrderService::confirmOption)과 동일하게 order-option.after_confirm 을 발화한다.
|
||||
// (즉시 적립·구매확정 활동로그 등 확정 전용 부수효과가 관리자 경로에서도 대칭 동작하도록)
|
||||
// 즉시 적립·로그 대상은 CONFIRMED 를 보유한 (병합 후) 생존 레코드 → merged_into > split > original 순으로 선택.
|
||||
if ($newStatus === OrderStatusEnum::CONFIRMED) {
|
||||
$confirmTarget = $result['merged_into'] ?? $result['split'] ?? $result['original'];
|
||||
HookManager::doAction('sirsoft-ecommerce.order-option.after_confirm', $confirmTarget->order, $confirmTarget);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
@@ -495,8 +474,6 @@ class OrderOptionService
|
||||
// 1. 의존 레코드 이전 (cascade 삭제 방지)
|
||||
$this->orderShippingRepository->transferByOrderOptionId($consumed->id, $survivor->id);
|
||||
$this->productReviewRepository->transferByOrderOptionId($consumed->id, $survivor->id);
|
||||
// 피흡수 옵션의 구매 적립 거래도 생존 옵션으로 이전 — 병합 후 생존 옵션 기준 적립 델타 계산 정합.
|
||||
$this->mileageTransactionRepository->transferPurchaseEarnByOrderOptionId($consumed->id, $survivor->id);
|
||||
|
||||
// 2. 수량/금액 합산
|
||||
$amountFields = [
|
||||
@@ -626,7 +603,7 @@ class OrderOptionService
|
||||
*
|
||||
* 부분취소는 별도 주문 상태가 아니라 "취소 옵션 + 잔여 활성 옵션 공존" 파생 상태다.
|
||||
* 따라서 주문 상태는 항상 잔여 활성 옵션 기준으로 결정하고, 부분취소 여부는
|
||||
* Order::isPartiallyCancelled() 파생 플래그로 노출한다 (2026-06-22, partial_cancelled 제거).
|
||||
* Order::isPartiallyCancelled() 파생 플래그로 노출한다 (PO 2026-06-22, partial_cancelled 제거).
|
||||
*
|
||||
* @param int $orderId 동기화할 주문 ID
|
||||
*/
|
||||
@@ -693,13 +670,7 @@ class OrderOptionService
|
||||
}
|
||||
|
||||
$previousStatus = $order->order_status?->value;
|
||||
$orderUpdate = ['order_status' => $newStatus->value];
|
||||
// 주문 전체가 구매확정으로 전이되면 헤더에도 확정 시점 기록 (유저 셀프 확정 OrderService::confirmOption 과 대칭).
|
||||
// 멱등: 이미 값이 있으면 유지(최초 확정 시점 보존).
|
||||
if ($newStatus === OrderStatusEnum::CONFIRMED && $order->confirmed_at === null) {
|
||||
$orderUpdate['confirmed_at'] = now();
|
||||
}
|
||||
$order->update($orderUpdate);
|
||||
$order->update(['order_status' => $newStatus->value]);
|
||||
|
||||
// 옵션별 일괄 상태변경(운송장 등)으로 부모 주문 상태가 전이되면 알림 훅 발화 (A35/A36/C3).
|
||||
// OrderStatusNotificationListener 가 결제완료/배송중/배송완료/구매확정 알림으로 매핑한다.
|
||||
|
||||
@@ -316,10 +316,9 @@ class ProductInquiryService
|
||||
'product_id' => $inquiry->product_id,
|
||||
'product' => $inquiry->product ? [
|
||||
'id' => $inquiry->product->id,
|
||||
'product_code' => $inquiry->product->product_code,
|
||||
'name' => $inquiry->product->getLocalizedName(),
|
||||
'thumbnail_url' => $inquiry->product->getThumbnailUrl(),
|
||||
'url' => '/' . ltrim($this->settingsService->getSetting('basic_info.route_path', 'shop'), '/') . '/products/' . $inquiry->product->product_code,
|
||||
'url' => '/' . ltrim($this->settingsService->getSetting('basic_info.route_path', 'shop'), '/') . '/products/' . $inquiry->product->id,
|
||||
] : null,
|
||||
'product_name' => $this->localizeProductName($inquiry->product_name_snapshot),
|
||||
'is_answered' => $inquiry->is_answered,
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace Modules\Sirsoft\Ecommerce\Services;
|
||||
*
|
||||
* 유저 locale 을 환경설정의 통화별 언어(currencies[].locales) 매핑과 대조해 부여 통화를 결정한다.
|
||||
*
|
||||
* 매칭 우선순위(D-SIGNUP, 정책 확정):
|
||||
* 매칭 우선순위(D-SIGNUP, PO 확정):
|
||||
* 1) locale 이 매칭되는 통화가 1개 → 그 통화
|
||||
* 2) 여러 통화에 중복 매칭 → 매칭된 통화 중 is_default 통화 우선
|
||||
* 3) 매칭 없음/모호 → is_default 통화 → 그래도 없으면 default_currency 폴백
|
||||
|
||||
@@ -284,36 +284,16 @@ class UserMileageService
|
||||
return null;
|
||||
}
|
||||
|
||||
// 금액 델타 멱등: 목표 적립액(옵션 subtotal_earned) − 기적립 purchase_earn 합계 = 델타만 적립.
|
||||
// 나눠 확정·병합으로 목표액이 늘면 차액을 추가 지급하고, 단순 재확정·스케줄러 재실행은 델타 0 으로 no-op.
|
||||
$target = (float) $option->subtotal_earned_points_amount;
|
||||
$alreadyEarned = $this->ledger->sumPurchaseEarnedForOption($option->id);
|
||||
$amount = $target - $alreadyEarned;
|
||||
if ($this->ledger->existsEarnForOption($option->id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$amount = (float) $option->subtotal_earned_points_amount;
|
||||
if ($amount <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$currency = $this->baseCurrencyForOrder($order);
|
||||
|
||||
// 방식 A: 기존 purchase_earn lot 이 있으면 그 lot 에 델타를 증액(적립 내역 한 줄 유지).
|
||||
// 취소 회수(findEarnLotForOption 단일 lot 가정)·유효기간 정합을 위해 신규 lot 을 늘리지 않는다.
|
||||
if ($type === MileageTransactionTypeEnum::PURCHASE_EARN
|
||||
&& ($existingLot = $this->ledger->findEarnLotForOption($option->id)) !== null) {
|
||||
$this->ledger->incrementEarnLotAmount($existingLot, $amount);
|
||||
|
||||
$this->cache->recalculateForUser($order->user_id, $currency);
|
||||
$this->cache->recalculatePending($order->user_id, $currency);
|
||||
|
||||
$this->logActivity('mileage.earn', [
|
||||
'loggable' => $existingLot,
|
||||
'description_key' => 'sirsoft-ecommerce::activity_log.description.mileage_earn',
|
||||
'description_params' => ['amount' => (int) $amount],
|
||||
'properties' => ['order_id' => $order->id, 'order_option_id' => $option->id, 'currency' => $currency, 'delta' => (int) $amount],
|
||||
]);
|
||||
|
||||
return $existingLot;
|
||||
}
|
||||
|
||||
$expiresAt = $this->resolveEarnExpiry();
|
||||
|
||||
$tx = $this->ledger->createTransaction([
|
||||
@@ -751,7 +731,7 @@ class UserMileageService
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원 탈퇴/삭제 시 활성 lot 소멸 기록 (감사 흐름 보존 — 정책 확정 #8)
|
||||
* 회원 탈퇴/삭제 시 활성 lot 소멸 기록 (감사 흐름 보존 — PO 확정 #8)
|
||||
*
|
||||
* 활성 lot 을 expired 거래로 소멸 기록(활동로그에 금액·통화·회원ID 잔존)합니다.
|
||||
* 거래/캐시 행은 이후 cascade 또는 명시 삭제로 함께 사라집니다.
|
||||
@@ -784,7 +764,7 @@ class UserMileageService
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원 탈퇴/삭제 시 마일리지 데이터를 명시적으로 정리합니다 (정책 확정 #8).
|
||||
* 회원 탈퇴/삭제 시 마일리지 데이터를 명시적으로 정리합니다 (PO 확정 #8).
|
||||
*
|
||||
* 활성 lot 을 expired 거래로 소멸 기록(활동로그 보존)한 뒤, 원장·잔액 캐시 행을
|
||||
* 동일 트랜잭션에서 삭제합니다. CASCADE 는 안전망으로만 두고 명시 삭제합니다
|
||||
@@ -1006,7 +986,7 @@ class UserMileageService
|
||||
* 마일리지 사용이 가능한지 여부를 반환합니다.
|
||||
*
|
||||
* 기본 통화(base_currency)에 대한 마일리지 통화별 사용 단위 규칙이 설정되어 있지 않으면
|
||||
* 마일리지 사용을 허용하지 않는다(정책). 마일리지는 base_currency 기준으로 사용/정산되므로
|
||||
* 마일리지 사용을 허용하지 않는다(PO 정책). 마일리지는 base_currency 기준으로 사용/정산되므로
|
||||
* base 규칙이 없으면 사용 단위·최소 사용액·한도를 알 수 없어 사용 자체가 불가하다.
|
||||
*
|
||||
* @return bool 기본 통화 마일리지 사용 규칙이 설정되어 있으면 true
|
||||
|
||||
@@ -95,31 +95,29 @@ Route::prefix('products')->middleware(['optional.sanctum', ResolveShippingCountr
|
||||
Route::get('/recent', [PublicProductController::class, 'recent'])
|
||||
->name('products.recent');
|
||||
|
||||
// 상품 상세 — product_code(16자 영숫자) 기준. 숫자 ID 도 하위호환 허용
|
||||
// (Product::resolveRouteBinding 이 code/id 자동 해석).
|
||||
Route::get('/{product}', [PublicProductController::class, 'show'])
|
||||
->where('product', '[0-9A-Za-z]+')
|
||||
Route::get('/{id}', [PublicProductController::class, 'show'])
|
||||
->where('id', '[0-9]+')
|
||||
->name('products.show');
|
||||
|
||||
Route::get('/{product}/downloadable-coupons', [PublicCouponController::class, 'downloadableCoupons'])
|
||||
->where('product', '[0-9A-Za-z]+')
|
||||
Route::get('/{productId}/downloadable-coupons', [PublicCouponController::class, 'downloadableCoupons'])
|
||||
->where('productId', '[0-9]+')
|
||||
->name('products.downloadable-coupons');
|
||||
|
||||
// 상품 공개 리뷰 목록 및 별점 통계 조회
|
||||
Route::get('/{product}/reviews', [PublicProductReviewController::class, 'index'])
|
||||
->where('product', '[0-9A-Za-z]+')
|
||||
Route::get('/{productId}/reviews', [PublicProductReviewController::class, 'index'])
|
||||
->whereNumber('productId')
|
||||
->name('products.reviews.index');
|
||||
|
||||
// 상품 1:1 문의 목록 조회 (비회원 포함)
|
||||
// GET /api/modules/sirsoft-ecommerce/products/{product}/inquiries
|
||||
Route::get('/{product}/inquiries', [PublicProductInquiryController::class, 'index'])
|
||||
->where('product', '[0-9A-Za-z]+')
|
||||
// GET /api/modules/sirsoft-ecommerce/products/{productId}/inquiries
|
||||
Route::get('/{productId}/inquiries', [PublicProductInquiryController::class, 'index'])
|
||||
->whereNumber('productId')
|
||||
->name('products.inquiries.index');
|
||||
|
||||
// 상품 1:1 문의 작성 (회원 전용)
|
||||
// POST /api/modules/sirsoft-ecommerce/products/{product}/inquiries
|
||||
Route::post('/{product}/inquiries', [PublicProductInquiryController::class, 'store'])
|
||||
->where('product', '[0-9A-Za-z]+')
|
||||
// POST /api/modules/sirsoft-ecommerce/products/{productId}/inquiries
|
||||
Route::post('/{productId}/inquiries', [PublicProductInquiryController::class, 'store'])
|
||||
->whereNumber('productId')
|
||||
->middleware('auth:sanctum')
|
||||
->name('products.inquiries.store');
|
||||
});
|
||||
|
||||
-34
@@ -72,40 +72,6 @@ class ProductControllerSearchTest extends ModuleTestCase
|
||||
$this->assertEquals($product->id, $data[0]['id']);
|
||||
}
|
||||
|
||||
/**
|
||||
* all 검색에서 숫자 키워드로 상품 ID 정확 매칭이 잡히는지 확인
|
||||
*/
|
||||
public function test_search_all_matches_by_product_id(): void
|
||||
{
|
||||
$user = $this->createAdminUser(['sirsoft-ecommerce.products.read']);
|
||||
$target = Product::factory()->create();
|
||||
Product::factory()->create(); // 노이즈
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->getJson('/api/modules/sirsoft-ecommerce/admin/products?search_field=all&search_keyword='.$target->id);
|
||||
|
||||
$response->assertOk();
|
||||
$data = $response->json('data.data');
|
||||
$ids = array_column($data, 'id');
|
||||
$this->assertContains($target->id, $ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* all 검색에서 상품코드로 매칭이 잡히는지 확인 (id 매칭 추가 후 회귀 방지)
|
||||
*/
|
||||
public function test_search_all_matches_by_product_code(): void
|
||||
{
|
||||
$user = $this->createAdminUser(['sirsoft-ecommerce.products.read']);
|
||||
$product = Product::factory()->create(['product_code' => 'ALLCODE1234ABCD']);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->getJson('/api/modules/sirsoft-ecommerce/admin/products?search_field=all&search_keyword=ALLCODE1234ABCD');
|
||||
|
||||
$response->assertOk();
|
||||
$ids = array_column($response->json('data.data'), 'id');
|
||||
$this->assertContains($product->id, $ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* code 검색 필드(잘못된 값)가 거부되는지 확인
|
||||
*/
|
||||
|
||||
-85
@@ -475,25 +475,6 @@ class PublicProductControllerTest extends ModuleTestCase
|
||||
$response->assertJsonPath('data.id', $product->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품 상세를 product_code(상품코드)로 조회할 수 있다 (#450 라우팅 code 전환)
|
||||
*/
|
||||
#[Test]
|
||||
public function test_show_returns_product_by_product_code(): void
|
||||
{
|
||||
// Given: product_code 를 가진 상품
|
||||
$product = Product::factory()->onSale()->create();
|
||||
$this->assertNotEmpty($product->product_code);
|
||||
|
||||
// When: 숫자 id 가 아닌 product_code 로 상세 조회
|
||||
$response = $this->getJson('/api/modules/sirsoft-ecommerce/products/'.$product->product_code);
|
||||
|
||||
// Then: 동일 상품이 반환되고 응답에 product_code 노출
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonPath('data.id', $product->id);
|
||||
$response->assertJsonPath('data.product_code', $product->product_code);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품 상세 조회 시 PublicProductResource 필드 구조 검증 테스트
|
||||
*/
|
||||
@@ -1060,70 +1041,4 @@ class PublicProductControllerTest extends ModuleTestCase
|
||||
$this->assertEquals(0, $data['review_count']);
|
||||
$this->assertEquals(0.0, $data['rating_avg']);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// abilities.can_update 게이트 테스트 (#450)
|
||||
// 유저 상품 상세에서 관리자 상품 수정 화면 진입 크로스링크 노출 여부
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 상품 수정 권한(sirsoft-ecommerce.products.update) 보유자는 abilities.can_update = true
|
||||
*
|
||||
* @scenario link=user_product_to_admin_edit, permitted=true
|
||||
* @effects product_edit_gate_true_shows_link
|
||||
*/
|
||||
#[Test]
|
||||
public function test_show_returns_can_update_true_for_product_update_permission_holder(): void
|
||||
{
|
||||
// Given: visible 상품 + 상품 수정 권한 보유 관리자
|
||||
$product = Product::factory()->onSale()->create();
|
||||
$admin = $this->createAdminUser(['sirsoft-ecommerce.products.update']);
|
||||
|
||||
// When: 권한 보유자가 유저 상품 상세 조회
|
||||
$response = $this->actingAs($admin, 'sanctum')
|
||||
->getJson('/api/modules/sirsoft-ecommerce/products/'.$product->id);
|
||||
|
||||
// Then: abilities.can_update = true
|
||||
$response->assertStatus(200);
|
||||
$this->assertTrue($response->json('data.abilities.can_update'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품 수정 권한 미보유 일반 사용자는 abilities.can_update = false
|
||||
*
|
||||
* @scenario link=user_product_to_admin_edit, permitted=false
|
||||
* @effects product_edit_gate_false_hides_link
|
||||
*/
|
||||
#[Test]
|
||||
public function test_show_returns_can_update_false_for_regular_user(): void
|
||||
{
|
||||
// Given
|
||||
$product = Product::factory()->onSale()->create();
|
||||
$user = $this->createUser();
|
||||
|
||||
// When
|
||||
$response = $this->actingAs($user, 'sanctum')
|
||||
->getJson('/api/modules/sirsoft-ecommerce/products/'.$product->id);
|
||||
|
||||
// Then
|
||||
$response->assertStatus(200);
|
||||
$this->assertFalse($response->json('data.abilities.can_update'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 비로그인(게스트)은 abilities.can_update = false
|
||||
*/
|
||||
#[Test]
|
||||
public function test_show_returns_can_update_false_for_guest(): void
|
||||
{
|
||||
// Given
|
||||
$product = Product::factory()->onSale()->create();
|
||||
|
||||
// When: 비로그인 조회
|
||||
$response = $this->getJson('/api/modules/sirsoft-ecommerce/products/'.$product->id);
|
||||
|
||||
// Then
|
||||
$response->assertStatus(200);
|
||||
$this->assertFalse($response->json('data.abilities.can_update'));
|
||||
}
|
||||
}
|
||||
-40
@@ -75,46 +75,6 @@ class UserProductInquiryControllerTest extends ModuleTestCase
|
||||
]);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// index() — 문의 목록 (마이페이지)
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 마이페이지 문의 목록의 상품 링크가 상품 id 가 아닌 product_code 로 나간다 (#450).
|
||||
*
|
||||
* 회귀: getUserInquiries 의 product.url 이 id 로 하드코딩되어 있어 클릭 시
|
||||
* /shop/products/{id} 로 이동하던 버그. product_code 기준으로 수정.
|
||||
*/
|
||||
#[Test]
|
||||
public function 문의_목록의_상품_링크는_product_code_로_나간다(): void
|
||||
{
|
||||
// Given: product_code 를 가진 상품에 대한 본인 문의 (setUp 의 $this->inquiry)
|
||||
$this->assertNotEmpty($this->product->product_code);
|
||||
|
||||
// When: 마이페이지 문의 목록 조회
|
||||
$response = $this->actingAs($this->user)
|
||||
->getJson('/api/modules/sirsoft-ecommerce/user/inquiries');
|
||||
|
||||
// Then: 응답의 product.url 과 product.product_code 가 상품코드 기준
|
||||
// (getUserInquiries 응답 구조는 data.items[] + data.meta)
|
||||
$response->assertOk();
|
||||
$item = collect($response->json('data.items'))
|
||||
->firstWhere('id', $this->inquiry->id);
|
||||
|
||||
$this->assertNotNull($item, '본인 문의가 목록에 포함되어야 합니다.');
|
||||
$this->assertSame($this->product->product_code, $item['product']['product_code']);
|
||||
$this->assertStringContainsString(
|
||||
'/products/'.$this->product->product_code,
|
||||
$item['product']['url'],
|
||||
'상품 링크는 product_code 기준이어야 합니다.'
|
||||
);
|
||||
$this->assertStringNotContainsString(
|
||||
'/products/'.$this->product->id,
|
||||
$item['product']['url'],
|
||||
'상품 링크에 상품 id 가 노출되면 안 됩니다.'
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// update() — 문의 수정
|
||||
// ========================================
|
||||
|
||||
+1
-1
@@ -164,7 +164,7 @@ class CheckoutVerificationGuardTest extends ModuleTestCase
|
||||
*
|
||||
* 기존 G1(훅 발화)·코어 가드(enforce 예외)·리스너 구독 테스트는 각 조각만 따로 검증한다.
|
||||
* 이 테스트는 그 셋이 실제 HTTP 흐름에서 연결되어 최종적으로 428 이 나오는지를 통합 검증한다.
|
||||
* (검수: 조각 테스트는 green 인데 실제로 본인인증이 발동하지 않던 결함의 회귀 가드)
|
||||
* (PO 검수: 조각 테스트는 green 인데 실제로 본인인증이 발동하지 않던 결함의 회귀 가드)
|
||||
*
|
||||
* @scenario policy=checkout, enabled=on, actor=member, verified_state=unverified, path=http_order_create
|
||||
*
|
||||
|
||||
-48
@@ -279,52 +279,4 @@ class ProductSearchIntegrationTest extends ModuleTestCase
|
||||
$this->assertContains('suspended', $statuses);
|
||||
}
|
||||
|
||||
/**
|
||||
* 통합검색에서 상품코드(product_code)로 상품이 검색되는지 확인
|
||||
*
|
||||
* FULLTEXT(name/description) 에 없는 상품코드도 보조필드 매칭으로 잡혀야 한다.
|
||||
*/
|
||||
public function test_search_matches_product_by_product_code(): void
|
||||
{
|
||||
// Arrange — name 에는 없는 고유 상품코드
|
||||
$product = Product::factory()->create([
|
||||
'name' => ['ko' => '상품코드검색 대상', 'en' => 'code search target'],
|
||||
'product_code' => 'UNIQCODE9988XYZ0',
|
||||
'display_status' => ProductDisplayStatus::VISIBLE,
|
||||
]);
|
||||
$this->flushProductFulltextIndex();
|
||||
|
||||
// Act — 상품코드로 통합검색
|
||||
$response = $this->getJson('/api/search?q=UNIQCODE9988XYZ0&type=products');
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(200);
|
||||
$data = $response->json('data');
|
||||
$this->assertGreaterThanOrEqual(1, $data['products_count'] ?? 0);
|
||||
$ids = collect($data['products'])->pluck('id')->toArray();
|
||||
$this->assertContains($product->id, $ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* 통합검색에서 상품 ID(숫자 키워드)로 상품이 검색되는지 확인
|
||||
*/
|
||||
public function test_search_matches_product_by_id(): void
|
||||
{
|
||||
// Arrange — 다른 상품과 구분되는 이름의 대상 상품
|
||||
$product = Product::factory()->create([
|
||||
'name' => ['ko' => '아이디검색 대상', 'en' => 'id search target'],
|
||||
'display_status' => ProductDisplayStatus::VISIBLE,
|
||||
]);
|
||||
$this->flushProductFulltextIndex();
|
||||
|
||||
// Act — 상품 id 숫자로 통합검색
|
||||
$response = $this->getJson('/api/search?q='.$product->id.'&type=products');
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(200);
|
||||
$data = $response->json('data');
|
||||
$ids = collect($data['products'] ?? [])->pluck('id')->toArray();
|
||||
$this->assertContains($product->id, $ids);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -120,7 +120,7 @@ await page.route('https://api.tosspayments.com/v1/payments/confirm', route => ro
|
||||
```
|
||||
|
||||
분기별 가맹점 측 처리(주문 상태 전환, 재고 차감, 알림 발송 등) 가 정확한지 검증.
|
||||
실 카드 결제 자동화는 권장하지 않음 — 수동 검증.
|
||||
실 카드 결제 자동화는 권장하지 않음 — PO 수동 검증.
|
||||
|
||||
## 참고
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@ class OrderStatusTransitionTest extends ModuleTestCase
|
||||
|
||||
/**
|
||||
* 취소 → 판매 반영 상태 복원(reactivation)은 허용, 결제 전 상태 복귀는 차단됨을 명시 단언합니다.
|
||||
* (취소 주문/옵션을 다시 판매 진행 상태로 되살리는 운영 기능 — 재고 재차감 동반. 2026-06-23)
|
||||
* (취소 주문/옵션을 다시 판매 진행 상태로 되살리는 운영 기능 — 재고 재차감 동반. PO 2026-06-23)
|
||||
*/
|
||||
public function test_cancelled_reactivation_to_sales_status_allowed_but_pre_payment_blocked(): void
|
||||
{
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ use Modules\Sirsoft\Ecommerce\Repositories\Contracts\MileageBalanceRepositoryInt
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
|
||||
/**
|
||||
* UserMileageCleanupListener 테스트 (회원 탈퇴/삭제 시 마일리지 정리 — 정책 확정 #8)
|
||||
* UserMileageCleanupListener 테스트 (회원 탈퇴/삭제 시 마일리지 정리 — PO 확정 #8)
|
||||
*/
|
||||
class UserMileageCleanupListenerTest extends ModuleTestCase
|
||||
{
|
||||
|
||||
@@ -482,7 +482,7 @@ class OrderRepositoryTest extends ModuleTestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* 주문대기(pending_order)는 통계에서 숨김 유지된다 (정책 확정, listHiddenValues).
|
||||
* 주문대기(pending_order)는 통계에서 숨김 유지된다 (PO 확정, listHiddenValues).
|
||||
*/
|
||||
public function test_user_statistics_hides_pending_order(): void
|
||||
{
|
||||
|
||||
@@ -367,7 +367,7 @@ class OrderResourceFieldsTest extends ModuleTestCase
|
||||
|
||||
/**
|
||||
* OrderResource 의 is_partially_cancelled 파생 플래그가 "취소 옵션 ∧ 잔여 활성 옵션" 일 때만 true 인지 확인.
|
||||
* (부분취소는 별도 order_status 가 아니라 파생 플래그로 표시 — 2026-06-22.)
|
||||
* (부분취소는 별도 order_status 가 아니라 파생 플래그로 표시 — PO 2026-06-22.)
|
||||
*/
|
||||
public function test_order_resource_exposes_is_partially_cancelled_flag(): void
|
||||
{
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
* 마일리지 기본 통화 사용 규칙 게이트 테스트 (M2)
|
||||
*
|
||||
* 기본 통화(base_currency)에 대한 마일리지 통화별 사용 단위 규칙이 설정되어 있지 않으면
|
||||
* 마일리지 사용이 불가해야 한다(정책).
|
||||
* 마일리지 사용이 불가해야 한다(PO 정책).
|
||||
* - isMileageUsable(): base 규칙 존재 시 true, 없으면 false
|
||||
* - getMaxUsable(): base 규칙 없으면 0
|
||||
* - validateUsage(): base 규칙 없으면 MileageValidationException
|
||||
|
||||
-228
@@ -1,228 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderFactory;
|
||||
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderOptionFactory;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\MileageTransactionTypeEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Models\MileageTransaction;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Services\OrderOptionService;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
|
||||
/**
|
||||
* 마일리지 적립 델타 멱등 + enabled 게이트 통일 테스트 (검수 후속 2·3)
|
||||
*
|
||||
* 후속-2: 수량을 나눠 구매확정 → 병합 시 목표 적립액(옵션 subtotal_earned)과
|
||||
* 기적립 purchase_earn 합계의 델타만큼 추가 적립되어 총액이 정확해야 한다.
|
||||
* (건수 멱등이 아니라 금액 델타 멱등, 방식 A = 옵션 기존 lot 증액.)
|
||||
* 후속-3: mileage.enabled=OFF 면 즉시적립(delay=0)도 발생하지 않아야 한다
|
||||
* (지연적립이 스케줄러 게이트로 막히는 것과 미지급 기준으로 통일).
|
||||
*/
|
||||
class MileageEarnDeltaAndGateTest extends ModuleTestCase
|
||||
{
|
||||
private OrderOptionService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->service = app(OrderOptionService::class);
|
||||
$this->writeMileageSettings();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$file = storage_path('framework/testing/modules/sirsoft-ecommerce/settings/mileage.json');
|
||||
if (file_exists($file)) {
|
||||
unlink($file);
|
||||
}
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* 마일리지 설정 파일을 작성합니다. (trigger=confirmed, delay 기본 0)
|
||||
*
|
||||
* @param array $overrides 덮어쓸 값
|
||||
*/
|
||||
private function writeMileageSettings(array $overrides = []): void
|
||||
{
|
||||
$path = storage_path('framework/testing/modules/sirsoft-ecommerce/settings');
|
||||
if (! is_dir($path)) {
|
||||
mkdir($path, 0755, true);
|
||||
}
|
||||
|
||||
$settings = array_merge([
|
||||
'enabled' => true,
|
||||
'default_earn_rate' => 1,
|
||||
'earn_trigger' => 'confirmed',
|
||||
'earn_delay_days' => 0,
|
||||
'currency_rules' => [['currency_code' => 'KRW', 'point_value' => 1, 'min_use_amount' => 0, 'use_unit' => 1, 'max_use_type' => 'percent', 'max_use_percent' => 100, 'max_use_value' => 0]],
|
||||
'expiry_enabled' => true,
|
||||
'expiry_days' => 365,
|
||||
'expiry_notification_enabled' => true,
|
||||
'expiry_notification_days_before' => 7,
|
||||
], $overrides);
|
||||
|
||||
file_put_contents($path.'/mileage.json', json_encode($settings, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
/**
|
||||
* 배송완료 상태 옵션(적립예정 있음)을 가진 회원 주문을 생성합니다.
|
||||
*
|
||||
* @param int $quantity 옵션 수량
|
||||
* @param float $earnPoints 적립예정 포인트 소계
|
||||
* @return Order
|
||||
*/
|
||||
private function makeDeliveredOrder(int $quantity, float $earnPoints): Order
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$order = OrderFactory::new()->create([
|
||||
'order_status' => OrderStatusEnum::DELIVERED,
|
||||
'user_id' => $user->id,
|
||||
'currency' => 'KRW',
|
||||
]);
|
||||
OrderOptionFactory::new()->forOrder($order)->create([
|
||||
'option_status' => OrderStatusEnum::DELIVERED,
|
||||
'delivered_at' => now()->subDay(),
|
||||
'quantity' => $quantity,
|
||||
'subtotal_earned_points_amount' => $earnPoints,
|
||||
]);
|
||||
|
||||
return $order->fresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* 해당 주문의 구매적립(purchase_earn) 총액을 반환합니다.
|
||||
*
|
||||
* @param int $orderId 주문 ID
|
||||
* @return float
|
||||
*/
|
||||
private function purchaseEarnTotal(int $orderId): float
|
||||
{
|
||||
return (float) MileageTransaction::where('order_id', $orderId)
|
||||
->where('type', MileageTransactionTypeEnum::PURCHASE_EARN->value)
|
||||
->sum('amount');
|
||||
}
|
||||
|
||||
// ---- 후속-2: 델타 멱등 ----
|
||||
|
||||
/**
|
||||
* 수량 2(적립예정 200P) 를 1개씩 두 번 구매확정하면 총 200P 가 적립된다.
|
||||
* (두 번째 확정이 첫 분할 레코드로 병합되어도 델타 100P 가 추가 적립되어야 함)
|
||||
*
|
||||
* @effects admin_split_then_merge_confirm_earns_full_delta
|
||||
*/
|
||||
public function test_split_then_merge_confirm_earns_full_amount(): void
|
||||
{
|
||||
$order = $this->makeDeliveredOrder(2, 200);
|
||||
$option = $order->options->first();
|
||||
|
||||
// 1차: 1개만 구매확정 → 분할 + 100P 적립
|
||||
$this->service->bulkChangeStatusWithQuantity(
|
||||
[['option_id' => $option->id, 'quantity' => 1]],
|
||||
OrderStatusEnum::CONFIRMED
|
||||
);
|
||||
$this->assertEqualsWithDelta(100.0, $this->purchaseEarnTotal($order->id), 0.5, '1차 확정 후 100P 적립되어야 함');
|
||||
|
||||
// 2차: 나머지 1개 구매확정 → 병합 + 델타 100P 추가 적립
|
||||
$remaining = $order->options()
|
||||
->whereIn('option_status', [OrderStatusEnum::DELIVERED->value])
|
||||
->first();
|
||||
$this->assertNotNull($remaining, '잔여(배송완료) 레코드가 남아 있어야 함');
|
||||
|
||||
$this->service->bulkChangeStatusWithQuantity(
|
||||
[['option_id' => $remaining->id, 'quantity' => 1]],
|
||||
OrderStatusEnum::CONFIRMED
|
||||
);
|
||||
|
||||
$this->assertEqualsWithDelta(200.0, $this->purchaseEarnTotal($order->id), 0.5, '나눠 확정 총합은 200P 여야 함');
|
||||
}
|
||||
|
||||
/**
|
||||
* 3분할(수량 3, 300P)을 1개씩 세 번 확정하면 누적 300P.
|
||||
*
|
||||
* @effects admin_three_way_split_confirm_accumulates
|
||||
*/
|
||||
public function test_three_way_split_confirm_accumulates_to_full(): void
|
||||
{
|
||||
$order = $this->makeDeliveredOrder(3, 300);
|
||||
|
||||
for ($i = 0; $i < 3; $i++) {
|
||||
$target = $order->fresh()->options()
|
||||
->where('option_status', OrderStatusEnum::DELIVERED->value)
|
||||
->first();
|
||||
$this->assertNotNull($target, "{$i}회차 확정 대상(배송완료)이 있어야 함");
|
||||
$this->service->bulkChangeStatusWithQuantity(
|
||||
[['option_id' => $target->id, 'quantity' => 1]],
|
||||
OrderStatusEnum::CONFIRMED
|
||||
);
|
||||
}
|
||||
|
||||
$this->assertEqualsWithDelta(300.0, $this->purchaseEarnTotal($order->id), 0.5, '3분할 누적 300P');
|
||||
}
|
||||
|
||||
/**
|
||||
* 전량 확정(분할 없음) 후 스케줄러/재확정 재실행해도 델타=0 이라 중복 적립 없음.
|
||||
*
|
||||
* @effects admin_full_confirm_delta_idempotent
|
||||
*/
|
||||
public function test_full_confirm_is_delta_idempotent(): void
|
||||
{
|
||||
$order = $this->makeDeliveredOrder(1, 150);
|
||||
$option = $order->options->first();
|
||||
|
||||
$this->service->bulkChangeStatusWithQuantity(
|
||||
[['option_id' => $option->id, 'quantity' => 1]],
|
||||
OrderStatusEnum::CONFIRMED
|
||||
);
|
||||
$this->assertEqualsWithDelta(150.0, $this->purchaseEarnTotal($order->id), 0.5);
|
||||
|
||||
// 스케줄러 재실행 → 델타 0 → 추가 없음
|
||||
Artisan::call('sirsoft-ecommerce:earn-mileage');
|
||||
$this->assertEqualsWithDelta(150.0, $this->purchaseEarnTotal($order->id), 0.5, '재실행 시 중복 적립 없어야 함');
|
||||
}
|
||||
|
||||
// ---- 후속-3: enabled 게이트 미지급 통일 ----
|
||||
|
||||
/**
|
||||
* enabled=OFF + delay=0: 관리자 CONFIRMED 전이 시 즉시적립이 발생하지 않는다.
|
||||
*
|
||||
* @effects admin_confirm_no_earn_when_disabled_immediate
|
||||
*/
|
||||
public function test_no_immediate_earn_when_mileage_disabled(): void
|
||||
{
|
||||
$this->writeMileageSettings(['enabled' => false, 'earn_delay_days' => 0]);
|
||||
$order = $this->makeDeliveredOrder(1, 120);
|
||||
$option = $order->options->first();
|
||||
|
||||
$this->service->bulkChangeStatusWithQuantity(
|
||||
[['option_id' => $option->id, 'quantity' => 1]],
|
||||
OrderStatusEnum::CONFIRMED
|
||||
);
|
||||
|
||||
$this->assertSame(0.0, $this->purchaseEarnTotal($order->id), '기능 OFF 면 즉시적립도 발생하지 않아야 함');
|
||||
// 전이 자체는 성공 (confirmed_at 기록)
|
||||
$this->assertNotNull($option->fresh()->confirmed_at, '기능 OFF 여도 구매확정 전이·시점 기록은 정상');
|
||||
}
|
||||
|
||||
/**
|
||||
* enabled=ON 대조군: delay=0 즉시적립 정상 발생 (회귀 대조).
|
||||
*
|
||||
* @effects admin_confirm_earns_when_enabled_immediate
|
||||
*/
|
||||
public function test_immediate_earn_when_mileage_enabled(): void
|
||||
{
|
||||
$order = $this->makeDeliveredOrder(1, 120);
|
||||
$option = $order->options->first();
|
||||
|
||||
$this->service->bulkChangeStatusWithQuantity(
|
||||
[['option_id' => $option->id, 'quantity' => 1]],
|
||||
OrderStatusEnum::CONFIRMED
|
||||
);
|
||||
|
||||
$this->assertEqualsWithDelta(120.0, $this->purchaseEarnTotal($order->id), 0.5, '기능 ON 이면 즉시적립 정상');
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -6820,7 +6820,7 @@ class OrderAdjustmentServiceTest extends ModuleTestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* §6.3 회귀: 부분취소 재계산 시 옵션 레벨 다통화(mc_) 컬럼이 재안분값으로 갱신된다 (정책 확정).
|
||||
* §6.3 회귀: 부분취소 재계산 시 옵션 레벨 다통화(mc_) 컬럼이 재안분값으로 갱신된다 (PO 확정).
|
||||
*
|
||||
* 결함: buildOptionUpdates 가 plain subtotal_* 만 갱신하고 mc_subtotal_* 는 부분취소 후 stale.
|
||||
*/
|
||||
@@ -6869,7 +6869,7 @@ class OrderAdjustmentServiceTest extends ModuleTestCase
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// §6.3 복합 상황: 배송정책 · 할인쿠폰 · 도서산간 · 상품별 정책 + 마일리지 (추가 요구)
|
||||
// §6.3 복합 상황: 배송정책 · 할인쿠폰 · 도서산간 · 상품별 정책 + 마일리지 (PO 추가 요구)
|
||||
// OrderCalculation 테스트 수준의 정확값 검증. 각 케이스는 적립 재안분값이
|
||||
// 환불 우선순위에 무관(우선순위는 refund split 만 변경)함을 함께 단언한다.
|
||||
// ================================================================
|
||||
|
||||
-270
@@ -1,270 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderFactory;
|
||||
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderOptionFactory;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\MileageTransactionTypeEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Models\MileageTransaction;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderOption;
|
||||
use Modules\Sirsoft\Ecommerce\Services\OrderOptionService;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
|
||||
/**
|
||||
* 관리자 주문옵션 구매확정(CONFIRMED) 전이 대칭성 테스트 (이슈 #61)
|
||||
*
|
||||
* 관리자 상태변경 경로(changeStatusWithQuantity / bulkChangeStatusWithQuantity)로
|
||||
* CONFIRMED 전이 시, 유저 셀프 확정(OrderService::confirmOption)과 동일하게
|
||||
* 1) 옵션 confirmed_at 기록 (전체수량 / 분할 양쪽)
|
||||
* 2) order-option.after_confirm 훅 발화 (즉시적립·구매확정 활동로그 트리거)
|
||||
* 3) delay=0 즉시 적립 + 멱등
|
||||
* 4) 부모 주문 헤더 confirmed_at 기록
|
||||
* 이 발생하는지 검증한다. delivered_at 회귀 가드도 함께 둔다.
|
||||
*/
|
||||
class OrderOptionServiceConfirmTest extends ModuleTestCase
|
||||
{
|
||||
private OrderOptionService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->service = app(OrderOptionService::class);
|
||||
$this->writeMileageSettings();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$file = storage_path('framework/testing/modules/sirsoft-ecommerce/settings/mileage.json');
|
||||
if (file_exists($file)) {
|
||||
unlink($file);
|
||||
}
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* 마일리지 설정 파일을 작성합니다. (trigger=confirmed, delay 기본 0)
|
||||
*
|
||||
* @param array $overrides 덮어쓸 값
|
||||
*/
|
||||
private function writeMileageSettings(array $overrides = []): void
|
||||
{
|
||||
$path = storage_path('framework/testing/modules/sirsoft-ecommerce/settings');
|
||||
if (! is_dir($path)) {
|
||||
mkdir($path, 0755, true);
|
||||
}
|
||||
|
||||
$settings = array_merge([
|
||||
'enabled' => true,
|
||||
'default_earn_rate' => 1,
|
||||
'earn_trigger' => 'confirmed',
|
||||
'earn_delay_days' => 0,
|
||||
'currency_rules' => [['currency_code' => 'KRW', 'point_value' => 1, 'min_use_amount' => 0, 'use_unit' => 1, 'max_use_type' => 'percent', 'max_use_percent' => 100, 'max_use_value' => 0]],
|
||||
'expiry_enabled' => true,
|
||||
'expiry_days' => 365,
|
||||
'expiry_notification_enabled' => true,
|
||||
'expiry_notification_days_before' => 7,
|
||||
], $overrides);
|
||||
|
||||
file_put_contents($path.'/mileage.json', json_encode($settings, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
/**
|
||||
* 배송완료 상태 옵션을 가진 주문을 생성합니다. (CONFIRMED 전이 전제 상태)
|
||||
*
|
||||
* @param int $quantity 옵션 수량
|
||||
* @param int|null $userId 주문자 (마일리지 적립 대상, null 이면 게스트)
|
||||
* @return Order
|
||||
*/
|
||||
private function makeDeliveredOrder(int $quantity = 1, ?int $userId = null): Order
|
||||
{
|
||||
$order = OrderFactory::new()->create([
|
||||
'order_status' => OrderStatusEnum::DELIVERED,
|
||||
'user_id' => $userId,
|
||||
'currency' => 'KRW',
|
||||
]);
|
||||
OrderOptionFactory::new()->forOrder($order)->create([
|
||||
'option_status' => OrderStatusEnum::DELIVERED,
|
||||
'delivered_at' => now()->subDay(),
|
||||
'quantity' => $quantity,
|
||||
'subtotal_earned_points_amount' => 500,
|
||||
]);
|
||||
|
||||
return $order->fresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체수량 CONFIRMED 전이 시 옵션 confirmed_at 이 기록된다.
|
||||
*
|
||||
* @scenario transition_path=change_with_quantity, target_status=confirmed, quantity=full
|
||||
*
|
||||
* @effects admin_full_quantity_confirm_stamps_confirmed_at
|
||||
*/
|
||||
public function test_full_quantity_confirmed_stamps_confirmed_at(): void
|
||||
{
|
||||
$order = $this->makeDeliveredOrder(2);
|
||||
$option = $order->options->first();
|
||||
|
||||
$this->service->changeStatusWithQuantity($option, OrderStatusEnum::CONFIRMED, $option->quantity);
|
||||
|
||||
$fresh = $option->fresh();
|
||||
$this->assertEquals(OrderStatusEnum::CONFIRMED, $fresh->option_status);
|
||||
$this->assertNotNull($fresh->confirmed_at, '전체수량 구매확정 시 confirmed_at 이 기록되어야 함');
|
||||
$this->assertEqualsWithDelta(now()->timestamp, $fresh->confirmed_at->timestamp, 5);
|
||||
}
|
||||
|
||||
/**
|
||||
* 부분수량 CONFIRMED 분할 전이 시 분할 레코드에 confirmed_at 이 기록된다.
|
||||
* (원본이 DELIVERED 였으면 분할 레코드는 delivered_at 계승 + confirmed_at 신규를 함께 보유)
|
||||
*
|
||||
* @scenario transition_path=change_with_quantity, target_status=confirmed, quantity=partial
|
||||
*
|
||||
* @effects admin_split_confirm_stamps_confirmed_at
|
||||
*/
|
||||
public function test_partial_quantity_confirmed_stamps_confirmed_at_on_split(): void
|
||||
{
|
||||
$order = $this->makeDeliveredOrder(5);
|
||||
$option = $order->options->first();
|
||||
|
||||
$result = $this->service->changeStatusWithQuantity($option, OrderStatusEnum::CONFIRMED, 2);
|
||||
|
||||
$split = $result['split'];
|
||||
$this->assertNotNull($split, '부분수량 전이는 분할 레코드를 생성해야 함');
|
||||
$this->assertEquals(OrderStatusEnum::CONFIRMED, $split->option_status);
|
||||
$this->assertNotNull($split->confirmed_at, '분할 CONFIRMED 레코드에 confirmed_at 이 기록되어야 함');
|
||||
$this->assertEqualsWithDelta(now()->timestamp, $split->confirmed_at->timestamp, 5);
|
||||
// 원본에서 계승한 delivered_at 은 정상(배송완료·구매확정 둘 다 실제 발생한 사건)
|
||||
$this->assertNotNull($split->delivered_at, '분할 레코드는 원본의 delivered_at 을 계승한다');
|
||||
}
|
||||
|
||||
/**
|
||||
* CONFIRMED 전이 시 order-option.after_confirm 훅이 발화되고,
|
||||
* 인자로 CONFIRMED 를 보유한 (병합 후) 생존 레코드가 전달된다.
|
||||
*
|
||||
* @scenario transition_path=change_with_quantity, target_status=confirmed, quantity=full
|
||||
*
|
||||
* @effects admin_confirm_fires_after_confirm_hook
|
||||
*/
|
||||
public function test_confirmed_transition_fires_after_confirm_hook(): void
|
||||
{
|
||||
$captured = [];
|
||||
$cb = function ($order, $option) use (&$captured) {
|
||||
$captured[] = $option;
|
||||
};
|
||||
HookManager::addAction('sirsoft-ecommerce.order-option.after_confirm', $cb, 10);
|
||||
|
||||
$order = $this->makeDeliveredOrder(1);
|
||||
$option = $order->options->first();
|
||||
|
||||
$this->service->changeStatusWithQuantity($option, OrderStatusEnum::CONFIRMED, $option->quantity);
|
||||
|
||||
HookManager::removeAction('sirsoft-ecommerce.order-option.after_confirm', $cb);
|
||||
|
||||
$this->assertCount(1, $captured, 'CONFIRMED 전이 시 after_confirm 훅이 1회 발화되어야 함');
|
||||
$this->assertInstanceOf(OrderOption::class, $captured[0]);
|
||||
$this->assertEquals(OrderStatusEnum::CONFIRMED, $captured[0]->option_status);
|
||||
}
|
||||
|
||||
/**
|
||||
* 분할 CONFIRMED 전이 시 after_confirm 훅 인자는 CONFIRMED 를 보유한 분할 레코드다.
|
||||
*
|
||||
* @scenario transition_path=change_with_quantity, target_status=confirmed, quantity=partial
|
||||
*
|
||||
* @effects admin_split_confirm_fires_after_confirm_hook_with_split
|
||||
*/
|
||||
public function test_partial_confirmed_transition_fires_after_confirm_hook_with_split(): void
|
||||
{
|
||||
$captured = [];
|
||||
$cb = function ($order, $option) use (&$captured) {
|
||||
$captured[] = $option->id;
|
||||
};
|
||||
HookManager::addAction('sirsoft-ecommerce.order-option.after_confirm', $cb, 10);
|
||||
|
||||
$order = $this->makeDeliveredOrder(5);
|
||||
$option = $order->options->first();
|
||||
|
||||
$result = $this->service->changeStatusWithQuantity($option, OrderStatusEnum::CONFIRMED, 2);
|
||||
|
||||
HookManager::removeAction('sirsoft-ecommerce.order-option.after_confirm', $cb);
|
||||
|
||||
$this->assertCount(1, $captured);
|
||||
$this->assertEquals($result['split']->id, $captured[0], 'after_confirm 은 CONFIRMED 를 보유한 분할 레코드를 넘겨야 함');
|
||||
}
|
||||
|
||||
/**
|
||||
* trigger=confirmed & delay=0 설정에서 관리자 CONFIRMED 전이 시 마일리지가 즉시 적립되고 멱등하다.
|
||||
*
|
||||
* @scenario transition_path=bulk_change_with_quantity, target_status=confirmed, earn_delay_days=0
|
||||
*
|
||||
* @effects admin_confirm_earns_mileage_immediately_when_delay_zero
|
||||
*/
|
||||
public function test_confirmed_transition_earns_mileage_immediately_when_delay_zero(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$order = $this->makeDeliveredOrder(1, $user->id);
|
||||
$option = $order->options->first();
|
||||
|
||||
$this->service->bulkChangeStatusWithQuantity(
|
||||
[['option_id' => $option->id, 'quantity' => $option->quantity]],
|
||||
OrderStatusEnum::CONFIRMED
|
||||
);
|
||||
|
||||
$this->assertSame(1, MileageTransaction::where('order_option_id', $option->id)
|
||||
->where('type', MileageTransactionTypeEnum::PURCHASE_EARN->value)->count(),
|
||||
'즉시 적립(delay=0) 이 1건 발생해야 함');
|
||||
|
||||
// 스케줄러가 뒤이어 돌아도 whereNotExists(EARN_TYPES) 멱등 가드로 중복 적립 없음
|
||||
Artisan::call('sirsoft-ecommerce:earn-mileage');
|
||||
$this->assertSame(1, MileageTransaction::where('order_option_id', $option->id)
|
||||
->where('type', MileageTransactionTypeEnum::PURCHASE_EARN->value)->count(),
|
||||
'즉시적립 후 스케줄러가 이중 적립하지 않아야 함');
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 옵션이 CONFIRMED 로 확정되면 부모 주문 헤더에도 confirmed_at 이 기록된다.
|
||||
*
|
||||
* @scenario transition_path=bulk_change_with_quantity, target_status=confirmed, order_fully_confirmed=true
|
||||
*
|
||||
* @effects admin_full_confirm_stamps_order_header_confirmed_at
|
||||
*/
|
||||
public function test_full_confirm_stamps_order_header_confirmed_at(): void
|
||||
{
|
||||
$order = $this->makeDeliveredOrder(1);
|
||||
$option = $order->options->first();
|
||||
|
||||
$this->service->bulkChangeStatusWithQuantity(
|
||||
[['option_id' => $option->id, 'quantity' => $option->quantity]],
|
||||
OrderStatusEnum::CONFIRMED
|
||||
);
|
||||
|
||||
$freshOrder = $order->fresh();
|
||||
$this->assertEquals(OrderStatusEnum::CONFIRMED, $freshOrder->order_status);
|
||||
$this->assertNotNull($freshOrder->confirmed_at, '주문 전체 확정 시 주문 헤더 confirmed_at 이 기록되어야 함');
|
||||
}
|
||||
|
||||
/**
|
||||
* delivered_at 회귀 가드: 전체수량 DELIVERED 전이 시 delivered_at 이 기록된다.
|
||||
*
|
||||
* @scenario transition_path=change_with_quantity, target_status=delivered, quantity=full
|
||||
*
|
||||
* @effects admin_full_quantity_delivered_stamps_delivered_at
|
||||
*/
|
||||
public function test_full_quantity_delivered_stamps_delivered_at(): void
|
||||
{
|
||||
$order = OrderFactory::new()->create(['order_status' => OrderStatusEnum::SHIPPING]);
|
||||
$option = OrderOptionFactory::new()->forOrder($order)->create([
|
||||
'option_status' => OrderStatusEnum::SHIPPING,
|
||||
'quantity' => 1,
|
||||
]);
|
||||
|
||||
$this->service->changeStatusWithQuantity($option->fresh(), OrderStatusEnum::DELIVERED, 1);
|
||||
|
||||
$fresh = $option->fresh();
|
||||
$this->assertEquals(OrderStatusEnum::DELIVERED, $fresh->option_status);
|
||||
$this->assertNotNull($fresh->delivered_at, '배송완료 전이 시 delivered_at 이 기록되어야 함');
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1208,7 +1208,7 @@ class OrderProcessingServiceTest extends ModuleTestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증 4조합: base/order 통화 전 경우의 수.
|
||||
* PO 검증 4조합: base/order 통화 전 경우의 수.
|
||||
*
|
||||
* @return array<string, array{0:string,1:string,2:float,3:array,4:int}>
|
||||
*/
|
||||
|
||||
+1
-1
@@ -98,7 +98,7 @@ class OrderServiceStockRedeductTest extends ModuleTestCase
|
||||
public function test_update_to_non_sales_status_is_blocked_by_transition_rule(): void
|
||||
{
|
||||
// 전이 규칙 도입(A30) 이후: 취소 → 결제 전 상태(결제대기) 복귀는 전이 게이트가 차단한다.
|
||||
// (취소 복원은 판매 반영 상태로만 허용 — 2026-06-23.)
|
||||
// (취소 복원은 판매 반영 상태로만 허용 — PO 2026-06-23.)
|
||||
// 차단되므로 update 가 OrderProcessingException 을 던지고, 재차감은 물론 상태/재고 모두 불변.
|
||||
[$order, $option] = $this->createCancelledOrderWithRestoredStock(stock: 8, quantity: 3);
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ description: |
|
||||
shouldConvertToFullCancel 이 담당(프론트 isFullCancel 휴리스틱 제거).
|
||||
- U20③: 취소 차단 메시지를 쿠폰 단정 없이 일반화.
|
||||
- U20④: cancelled_at native 컬럼에 전체·부분취소 모두 기록 + Resource 노출(cancelled_at_formatted).
|
||||
- 부분취소 모델 변경(2026-06-22): partial_cancelled 주문 상태를 제거. 부분취소는 별도 상태가 아니라
|
||||
- 부분취소 모델 변경(PO 2026-06-22): partial_cancelled 주문 상태를 제거. 부분취소는 별도 상태가 아니라
|
||||
"취소 옵션 + 잔여 활성 옵션 공존"이며, 주문 상태는 잔여 활성 옵션 기준으로 파생(syncParentOrderStatus).
|
||||
잔여 항목 재취소는 진행 상태(payment_complete 등)가 취소 가능 목록에 있어 자연 허용. 잔여 전량 취소 시 CANCELLED 전이.
|
||||
"일부 취소됨" 표시는 파생 플래그 is_partially_cancelled(취소옵션∧활성옵션)로 노출(목록·상세 보조 배지).
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ description: |
|
||||
(SHIPPING_HOLD↔PREPARING, SHIPPING→{SHIPPING_READY,PREPARING}, DELIVERED→SHIPPING)만 추가 허용.
|
||||
- CONFIRMED 되돌림 전부 금지.
|
||||
- CANCELLED 발 전이는 판매 반영 상태(salesEligibleStatuses)로의 복원(reactivation)만 허용
|
||||
(재고 재차감 동반 운영 기능, 2026-06-23). 결제 전 상태(pending_order/pending_payment) 복귀는 차단.
|
||||
(재고 재차감 동반 운영 기능, PO 2026-06-23). 결제 전 상태(pending_order/pending_payment) 복귀는 차단.
|
||||
- 취소(CANCELLED) 진입은 게이트가 통과 처리(OrderCancellationService 전담, 이중 관리 방지).
|
||||
- 3경로 모두 FormRequest withValidator 1차 차단(422 + 다국어) + Service 진입부 2차 도메인 불변식 가드.
|
||||
- 일괄은 all-or-nothing: 1건이라도 위반이면 전체 422, DB 무변경.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# cross product 자동 전개는 audit 실행 환경에 js-yaml 부재로 검출되지 않는다(다른 번들 동일).
|
||||
# base 통화 정책 axis 는 PHPUnit(OrderProcessingService/buildCurrencySnapshot/OrderAdjustmentService/
|
||||
# StoreEcommerceSettingsRequest)·Playwright 가 커버하며 green 이다.
|
||||
feature: base 통화 정책 + 변경 가드 + 결제/통지/부분환불 base 기준 (A2 — 신규)
|
||||
feature: base 통화 정책 + 변경 가드 + 결제/통지/부분환불 base 기준 (A2 — PO 신규)
|
||||
|
||||
description: |
|
||||
기본통화 = 모든 금액 테이블의 base(진실값). 비-base 선택 시 base 정수 × 스냅샷 환율로 PG 청구·통지·환불.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# cross product 자동 전개는 audit 실행 환경에 js-yaml 부재로 검출되지 않는다(다른 번들 동일).
|
||||
# 가입 통화 부여 axis 는 PHPUnit(AssignDefaultCurrencyOnRegisterListener/StoreEcommerceSettingsRequest
|
||||
# locales 검증)·Vitest(통화별 언어 Tag UI)가 커버하며 green 이다.
|
||||
feature: 가입 시 locale 기반 통화 부여 + 통화별 언어 다중 매핑 (A4 — 신규)
|
||||
feature: 가입 시 locale 기반 통화 부여 + 통화별 언어 다중 매핑 (A4 — PO 신규)
|
||||
|
||||
description: |
|
||||
가입/회원생성 시 브라우저/locale 기반 기본통화 부여(환경설정>언어/통화의 통화별 언어 Tag 매핑 기준).
|
||||
|
||||
Loaded 100 of 651 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user