diff --git a/adm/shop_admin/admin.shop.lib.php b/adm/shop_admin/admin.shop.lib.php
index b2be62547..ccd62bcc4 100644
--- a/adm/shop_admin/admin.shop.lib.php
+++ b/adm/shop_admin/admin.shop.lib.php
@@ -88,7 +88,8 @@ function change_status($od_id, $current_status, $change_status)
$sql = " update {$g5['g5_shop_order_table']} set od_status = '{$change_status}' where od_id = '{$od_id}' and od_status = '{$current_status}' ";
sql_query($sql, true);
- $sql = " update {$g5['g5_shop_cart_table']} set ct_status = '{$change_status}' where od_id = '{$od_id}' and ct_status = '{$current_status}' ";
+ $complete_time_sql = get_cart_complete_time_sql($change_status);
+ $sql = " update {$g5['g5_shop_cart_table']} set ct_complete_time = $complete_time_sql, ct_status = '{$change_status}' where od_id = '{$od_id}' and ct_status = '{$current_status}' ";
sql_query($sql, true);
}
diff --git a/adm/shop_admin/configform.php b/adm/shop_admin/configform.php
index 53c001164..ee048e6fb 100644
--- a/adm/shop_admin/configform.php
+++ b/adm/shop_admin/configform.php
@@ -587,8 +587,8 @@ if( function_exists('pg_setting_check') ){
|
-
- 주문 완료 일 이후에 포인트를 지급
+
+ 배송 완료 일 이후에 포인트를 지급
|
diff --git a/adm/shop_admin/orderformcartupdate.php b/adm/shop_admin/orderformcartupdate.php
index 94ceee6ea..f005cfbcc 100644
--- a/adm/shop_admin/orderformcartupdate.php
+++ b/adm/shop_admin/orderformcartupdate.php
@@ -322,8 +322,10 @@ for ($i=0; $i<$cnt; $i++)
$now = G5_TIME_YMDHIS;
$ct_history="\n$ct_status|{$member['mb_id']}|$now|$REMOTE_ADDR";
+ $complete_time_sql = get_cart_complete_time_sql($ct_status);
$sql = " update {$g5['g5_shop_cart_table']}
- set ct_point_use = '$point_use',
+ set ct_complete_time = $complete_time_sql,
+ ct_point_use = '$point_use',
ct_stock_use = '$stock_use',
ct_status = '$ct_status',
ct_history = CONCAT(ct_history,'$ct_history')
@@ -336,6 +338,11 @@ for ($i=0; $i<$cnt; $i++)
$arr_it_id[] = $ct['it_id'];
}
+// 완료 처리 직후 실행하여 0일 설정도 즉시 적용한다.
+if ($ct_status == '완료') {
+ save_order_point();
+}
+
// 상품 판매수량 반영
if(is_array($arr_it_id) && !empty($arr_it_id)) {
$unq_it_id = array_unique($arr_it_id);
diff --git a/adm/shop_admin/orderlistupdate.php b/adm/shop_admin/orderlistupdate.php
index c7b6c89b4..19b1542a3 100644
--- a/adm/shop_admin/orderlistupdate.php
+++ b/adm/shop_admin/orderlistupdate.php
@@ -161,6 +161,11 @@ for ($i=0; $i<$count_post_chk; $i++)
}
+// 일괄 완료 처리도 0일 설정이면 즉시 적립한다.
+if ($od_status == '완료') {
+ save_order_point();
+}
+
// SMS
$sms_count = count($sms_messages);
if($sms_count > 0) {
diff --git a/docs/order-completion-point.md b/docs/order-completion-point.md
new file mode 100644
index 000000000..40b595d4b
--- /dev/null
+++ b/docs/order-completion-point.md
@@ -0,0 +1,23 @@
+# 배송완료 기준 주문 포인트
+
+주문 포인트 지급 기간은 장바구니 상품·옵션 레코드(`ct_id`)별 배송완료 시각(`ct_complete_time`)부터 계산한다. 주문 접수 시각, 장바구니 생성 시각, 송장 등록 시각과는 구분한다.
+
+## 상태 변경과 지급
+
+- 관리자 주문 상세의 선택 상품 완료 처리와 주문 목록의 일괄 완료 처리에서 완료 시각을 기록한다.
+- 이미 완료된 상품을 다시 완료로 저장하면 기존 완료 시각을 유지한다.
+- 완료를 해제하면 완료 시각을 `NULL`로 초기화한다. 이후 다시 완료하면 새 시각부터 지급 기간을 계산한다.
+- 회원의 포인트가 있는 상품 중 완료 상태이고 아직 적립되지 않은 상품에 지급한다.
+- 설정이 0일이면 완료 처리 시 지급한다. 그 외에는 설정 기간 경과 후 관리자 주문 상세 조회 또는 완료 처리 시 지급한다. 별도 예약 작업으로 정시에 지급하는 방식은 아니다.
+
+## 기존 설치 업그레이드
+
+변경된 코드로 주문을 처리하기 전에 **환경설정 → DB업그레이드** 또는 CLI에서 `20260909_002_cart_complete_time` 마이그레이션을 적용한다. 선행 마이그레이션이 미적용 상태이면 먼저 적용해야 한다.
+
+기존 레코드는 완료 시각을 `NULL`로 유지한다. 기존 배송완료·미지급 상품은 지급을 추가로 늦추지 않도록 종전의 `ct_time` 기준을 적용한다. 완료 시각을 추정하거나 업그레이드 시각으로 덮어쓰지 않는다. 기존 상품도 완료를 해제한 후 다시 완료하면 새 기준을 적용한다. 이미 지급한 포인트는 마이그레이션으로 변경하지 않는다.
+
+신규 설치 스키마에도 같은 컬럼을 포함한다. 별도 확장 기능에서 상품 상태를 변경한다면 완료 시각 기록과 초기화도 함께 구현해야 한다. `get_cart_complete_time_sql()`의 반환식을 `ct_status` 대입보다 먼저 사용하면 기존 상태를 기준으로 완료 시각을 결정할 수 있다.
+
+## 검증
+
+`php tests/order_completion_point.php`는 `data/dbconfig.php`의 DB 연결로 세션 전용 임시 테이블을 만들어 상태 변경, 기존 상품 전환 및 지급 조건을 검증한다. 실제 주문·포인트 테이블은 변경하지 않으며 포인트 등록 함수는 테스트 대역을 사용한다. DB 계정에 임시 테이블 생성 권한이 필요하다.
diff --git a/install/gnuboard5shop.sql b/install/gnuboard5shop.sql
index 680cde423..4fb827d5d 100644
--- a/install/gnuboard5shop.sql
+++ b/install/gnuboard5shop.sql
@@ -58,6 +58,7 @@ CREATE TABLE IF NOT EXISTS `g5_shop_cart` (
`ct_direct` tinyint(4) NOT NULL DEFAULT '0',
`ct_select` tinyint(4) NOT NULL DEFAULT '0',
`ct_select_time` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
+ `ct_complete_time` datetime DEFAULT NULL,
PRIMARY KEY (`ct_id`),
KEY `od_id` (`od_id`),
KEY `it_id` (`it_id`),
diff --git a/lib/shop.lib.php b/lib/shop.lib.php
index 92dbefcb5..a969c9d63 100644
--- a/lib/shop.lib.php
+++ b/lib/shop.lib.php
@@ -2500,17 +2500,33 @@ function exists_inicis_shop_order($oid, $pp=array(), $od_time='', $od_ip='')
return '';
}
+// 상태 변경 UPDATE에서 ct_status를 대입하기 전에 사용한다.
+// 완료 상태에서 다시 저장하면 최초 완료 시각을 유지하고, 완료 해제 시 초기화한다.
+function get_cart_complete_time_sql($ct_status)
+{
+ if ($ct_status != '완료') {
+ return 'NULL';
+ }
+
+ return "CASE WHEN ct_status = '완료' THEN ct_complete_time ELSE '".G5_TIME_YMDHIS."' END";
+}
+
//------------------------------------------------------------------------------
// 주문포인트를 적립한다.
-// 설정일이 지난 포인트 부여되지 않은 배송완료된 장바구니 자료에 포인트 부여
-// 설정일이 0 이면 주문서 완료 설정 시점에서 포인트를 바로 부여합니다.
+// 배송완료 시각(ct_complete_time)부터 설정일이 지난 미적립 상품에 포인트 부여
+// 설정일이 0 이면 배송완료 처리 시 포인트를 바로 부여합니다.
//------------------------------------------------------------------------------
function save_order_point($ct_status="완료")
{
global $g5, $default;
+ // 완료 시각이 없는 기존 상품에만 종전의 장바구니 생성 시각 기준을 유지한다.
$beforedays = date("Y-m-d H:i:s", ( time() - (86400 * (int)$default['de_point_days']) ) ); // 86400초는 하루
- $sql = " select * from {$g5['g5_shop_cart_table']} where ct_status = '$ct_status' and ct_point_use = '0' and ct_time <= '$beforedays' ";
+ $sql = " select * from {$g5['g5_shop_cart_table']}
+ where ct_status = '$ct_status'
+ and ct_point_use = '0'
+ and ((ct_complete_time is not null and ct_complete_time <= '$beforedays')
+ or (ct_complete_time is null and ct_time <= '$beforedays')) ";
$result = sql_query($sql);
for ($i=0; $row=sql_fetch_array($result); $i++) {
// 회원 ID 를 얻는다.
diff --git a/migrations/20260909_002_cart_complete_time.sql b/migrations/20260909_002_cart_complete_time.sql
new file mode 100644
index 000000000..06d56ea34
--- /dev/null
+++ b/migrations/20260909_002_cart_complete_time.sql
@@ -0,0 +1,4 @@
+-- @description 상품별 배송완료 시각 추가 (기존 완료 상품은 종전 포인트 지급 기준 유지)
+-- @if-table-exists {{g5_shop_cart_table}}
+-- @if-column-missing {{g5_shop_cart_table}} ct_complete_time
+ALTER TABLE `{{g5_shop_cart_table}}` ADD `ct_complete_time` datetime DEFAULT NULL AFTER `ct_select_time`;
diff --git a/tests/order_completion_point.php b/tests/order_completion_point.php
new file mode 100644
index 000000000..3595e6819
--- /dev/null
+++ b/tests/order_completion_point.php
@@ -0,0 +1,112 @@
+set_charset('utf8mb4');
+$g5['g5_shop_cart_table'] = 'test_completion_cart_'.getmypid();
+$g5['g5_shop_order_table'] = 'test_completion_order_'.getmypid();
+$cart = $g5['g5_shop_cart_table'];
+$order = $g5['g5_shop_order_table'];
+$points = array();
+function sql_query($sql, $error = true) { global $db; return $db->query($sql); }
+function sql_fetch_array($result) { return $result->fetch_assoc(); }
+function sql_fetch($sql) { return sql_fetch_array(sql_query($sql)); }
+function insert_point($member, $amount, $content, $table, $id, $action) {
+ global $points;
+ $points[] = array($member, $amount, $action);
+}
+function expect_completion($condition, $message) {
+ if (!$condition) throw new Exception($message);
+}
+function completion_row($id) {
+ global $cart;
+ return sql_fetch("SELECT * FROM $cart WHERE ct_id = $id");
+}
+function complete_item($id, $status) {
+ // 관리자 개별 처리의 실제 UPDATE 구문을 실행한다.
+ global $cart, $root, $g5;
+ $source = file_get_contents($root.'/adm/shop_admin/orderformcartupdate.php');
+ $start = strpos($source, ' $complete_time_sql = get_cart_complete_time_sql($ct_status);');
+ $end = strpos($source, ' sql_query($sql);', $start) + strlen(' sql_query($sql);');
+ $ct_status = $status;
+ $ct_id = $id;
+ $ct = completion_row($id);
+ $od_id = $ct['od_id'];
+ $point_use = $ct['ct_point_use'];
+ $stock_use = 0;
+ $ct_history = '';
+ eval(substr($source, $start, $end - $start));
+}
+sql_query("CREATE TEMPORARY TABLE $order (od_id bigint PRIMARY KEY, mb_id varchar(255), od_status varchar(255))");
+sql_query("CREATE TEMPORARY TABLE $cart (ct_id int PRIMARY KEY, od_id bigint, ct_status varchar(255), ct_point_use int DEFAULT 0, ct_point int DEFAULT 10, ct_qty int DEFAULT 2, ct_stock_use int DEFAULT 0, ct_history text, ct_time datetime, ct_select_time datetime)");
+$old = date('Y-m-d H:i:s', time() - 86400 * 30);
+$now = G5_TIME_YMDHIS;
+sql_query("INSERT INTO $order VALUES (1, 'member', '배송'), (2, '', '완료')");
+sql_query("INSERT INTO $cart (ct_id, od_id, ct_status, ct_time, ct_select_time, ct_history) VALUES (1, 1, '완료', '$old', '$old', ''), (2, 1, '배송', '$old', '$old', '')");
+$migration = g5_migration_parse_file($root.'/migrations/20260909_002_cart_complete_time.sql');
+expect_completion(!isset($migration['error']), '마이그레이션 파싱');
+foreach ($migration['statements'] as $statement) {
+ sql_query(g5_migration_replace_placeholders($statement['sql']));
+}
+expect_completion(completion_row(1)['ct_complete_time'] === null, '기존 완료 시각은 NULL 유지');
+complete_item(1, '완료');
+expect_completion(completion_row(1)['ct_complete_time'] === null, '기존 완료 재저장 시 종전 정책 유지');
+change_status(1, '배송', '완료');
+expect_completion(completion_row(2)['ct_complete_time'] === $now, '일괄 완료 시각 기록');
+expect_completion(completion_row(1)['ct_complete_time'] === null, '일괄 처리에서 기존 완료 상품 유지');
+complete_item(2, '배송');
+expect_completion(completion_row(2)['ct_complete_time'] === null, '개별 완료 해제');
+complete_item(2, '완료');
+expect_completion(completion_row(2)['ct_complete_time'] === $now, '개별 재완료 시각 기록');
+sql_query("UPDATE $cart SET ct_complete_time = '$old' WHERE ct_id = 2");
+complete_item(2, '완료');
+expect_completion(completion_row(2)['ct_complete_time'] === $old, '완료 재저장 시 최초 시각 유지');
+change_status(1, '완료', '배송');
+expect_completion(completion_row(2)['ct_complete_time'] === null, '일괄 완료 해제');
+change_status(1, '배송', '완료');
+expect_completion(completion_row(2)['ct_complete_time'] === $now, '일괄 재완료');
+
+// 날짜 경계와 적립 루프를 실제 SQL로 검증한다.
+sql_query("DELETE FROM $cart");
+$cutoff = date('Y-m-d H:i:s', time() - 86400 * 7);
+$future = date('Y-m-d H:i:s', time() + 86400);
+$rows = array(
+ array(1, 1, '완료', 0, $old, $now), // 오래된 장바구니, 오늘 완료
+ array(2, 1, '완료', 0, $old, $cutoff), // 정확히 7일 경과
+ array(3, 1, '완료', 0, $old, $old),
+ array(4, 1, '배송', 0, $old, $old),
+ array(5, 1, '취소', 0, $old, $old),
+ array(6, 1, '완료', 1, $old, $old),
+ array(7, 1, '완료', 0, $old, null), // 기존 완료
+ array(8, 1, '완료', 0, $now, null), // 기존 기준에서도 대기
+ array(9, 2, '완료', 0, $old, $old), // 비회원
+ array(10, 1, '완료', 0, $old, $future)
+);
+foreach ($rows as $row) {
+ list($id, $od_id, $status, $used, $created, $completed) = $row;
+ $completed_sql = $completed === null ? 'NULL' : "'$completed'";
+ sql_query("INSERT INTO $cart (ct_id, od_id, ct_status, ct_point_use, ct_time, ct_complete_time) VALUES ($id, $od_id, '$status', $used, '$created', $completed_sql)");
+}
+$default = array('de_point_days' => 7);
+save_order_point();
+expect_completion($points === array(array('member', 20, '1,2'), array('member', 20, '1,3'), array('member', 20, '1,7')), '배송완료 기간, 기존 정책, 상태, 회원, 수량 검증');
+save_order_point();
+expect_completion(count($points) === 3, '중복 지급 방지');
+$default['de_point_days'] = 0;
+save_order_point();
+expect_completion(count($points) === 5, '0일이면 오늘 완료 및 기존 당일 상품 지급');
+expect_completion(completion_row(10)['ct_point_use'] == 0, '미래 완료 시각 제외');
+foreach (array('orderformcartupdate.php', 'orderlistupdate.php') as $file) {
+ $source = file_get_contents($root.'/adm/shop_admin/'.$file);
+ expect_completion(strpos($source, 'save_order_point();') !== false, $file.' 완료 후 적립 호출');
+}
+echo "배송완료 시각 기록·유지·초기화, 부분 완료, 기존 상품 전환, 7일·0일 지급, 중복 지급 방지 통과\n";