Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
L
loveisland
Project
Project
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
刘小敏
loveisland
Commits
e626a715
Commit
e626a715
authored
Jul 01, 2026
by
刘小敏
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat(agent): 新增代理商佣金结算与详情查看功能;代理商佣金结算脚本;
parent
f325cc9b
Hide whitespace changes
Inline
Side-by-side
Showing
9 changed files
with
926 additions
and
5 deletions
+926
-5
Goods.php
addons/shopro/service/commission/Goods.php
+7
-0
Agent.php
application/admin/controller/shopro/agent/Agent.php
+124
-0
agent.php
application/admin/lang/zh-cn/shopro/agent/agent.php
+24
-3
AgentOrderCommission.php
...ication/admin/model/shopro/agent/AgentOrderCommission.php
+46
-0
AgentRateLog.php
application/admin/model/shopro/agent/AgentRateLog.php
+14
-0
detail.html
application/admin/view/shopro/agent/agent/detail.html
+205
-0
command.php
application/command.php
+1
-0
AgentOrderCommission.php
application/command/AgentOrderCommission.php
+468
-0
agent.js
public/assets/js/backend/shopro/agent/agent.js
+37
-2
No files found.
addons/shopro/service/commission/Goods.php
View file @
e626a715
...
...
@@ -81,6 +81,13 @@ class Goods
{
if
(
isset
(
$this
->
commissionRules
[
$agentLevel
])
&&
isset
(
$this
->
commissionRules
[
$agentLevel
][
$commissionLevel
]))
{
$commissionRule
=
$this
->
commissionRules
[
$agentLevel
][
$commissionLevel
];
custom_log
(
json_encode
([
'unique_tag'
=>
'记录一下佣金规则'
,
'agentLevel'
=>
$agentLevel
,
'commissionLevel'
=>
$commissionLevel
,
'rule'
=>
$commissionRule
,
'allRules'
=>
$this
->
commissionRules
,
],
JSON_UNESCAPED_UNICODE
),
0
,
'commission'
);
return
$commissionRule
;
}
return
false
;
...
...
application/admin/controller/shopro/agent/Agent.php
View file @
e626a715
...
...
@@ -73,10 +73,22 @@ class Agent extends Backend
if
(
empty
(
$params
))
{
$this
->
error
(
__
(
'Parameter %s can not be empty'
,
''
));
}
$oldRate
=
$row
->
commission_rate
;
$result
=
false
;
\think\Db
::
startTrans
();
try
{
$result
=
$row
->
allowField
(
true
)
->
save
(
$params
);
// 佣金比例变更时记录日志
$newRate
=
isset
(
$params
[
'commission_rate'
])
?
round
(
floatval
(
$params
[
'commission_rate'
]),
2
)
:
null
;
if
(
$newRate
!==
null
&&
$newRate
!=
$oldRate
)
{
\app\admin\model\shopro\agent\AgentRateLog
::
create
([
'agent_id'
=>
$row
->
id
,
'old_rate'
=>
$oldRate
,
'new_rate'
=>
$newRate
,
'operator_id'
=>
$this
->
auth
->
id
??
0
,
'remark'
=>
'后台编辑'
,
]);
}
\think\Db
::
commit
();
}
catch
(
\Exception
$e
)
{
\think\Db
::
rollback
();
...
...
@@ -115,9 +127,32 @@ class Agent extends Backend
}
$ids
=
is_array
(
$ids
)
?
$ids
:
explode
(
','
,
$ids
);
// 查询旧佣金比例
$oldAgents
=
$this
->
model
->
where
(
'id'
,
'in'
,
$ids
)
->
column
(
'commission_rate'
,
'id'
);
$count
=
$this
->
model
->
where
(
'id'
,
'in'
,
$ids
)
->
update
([
'commission_rate'
=>
$rate
]);
if
(
$count
!==
false
)
{
// 记录变更日志
$operatorId
=
$this
->
auth
->
id
??
0
;
$logData
=
[];
foreach
(
$ids
as
$agentId
)
{
$oldRate
=
isset
(
$oldAgents
[
$agentId
])
?
$oldAgents
[
$agentId
]
:
null
;
if
(
$oldRate
!==
null
&&
$oldRate
!=
$rate
)
{
$logData
[]
=
[
'agent_id'
=>
$agentId
,
'old_rate'
=>
$oldRate
,
'new_rate'
=>
$rate
,
'operator_id'
=>
$operatorId
,
'remark'
=>
'批量修改'
,
'createtime'
=>
time
(),
];
}
}
if
(
!
empty
(
$logData
))
{
\app\admin\model\shopro\agent\AgentRateLog
::
insertAll
(
$logData
);
}
$this
->
success
(
"已成功更新
{
$count
}
条记录的佣金比例为
{
$rate
}
%"
);
}
else
{
$this
->
error
(
'更新失败'
);
...
...
@@ -125,6 +160,95 @@ class Agent extends Backend
}
/**
* 详情(弹窗)+ 佣金流水 Ajax 分页
*/
public
function
detail
(
$ids
=
null
)
{
// 容错:多层兜底获取代理商 ID
// Fast.api.open 以 iframe 方式打开,URL 格式可能是:
// ?ids=1&dialog=1 (新 JS,query 参数)
// /ids/1 (旧 JS,路径参数)
// /ids/1.html (旧 JS + url_html_suffix=html)
// 1) 方法参数(ThinkPHP 路由自动注入)
if
(
empty
(
$ids
))
{
// 2) param() — 兼容 query string (?ids=1) 和 url_common_param
$ids
=
$this
->
request
->
param
(
'ids'
);
}
if
(
empty
(
$ids
))
{
// 3) get() — 直接读 GET 参数
$ids
=
$this
->
request
->
get
(
'ids'
);
}
if
(
empty
(
$ids
))
{
// 4) pathinfo 正则解析 /ids/数字 或 /ids/数字.html
$pathinfo
=
$this
->
request
->
pathinfo
();
if
(
preg_match
(
'#/ids/(\d+)[.\b]#'
,
$pathinfo
,
$m
))
{
$ids
=
$m
[
1
];
}
}
// 临时调试日志(问题定位后可删除)
\think\Log
::
write
(
'[detail] ids来源检测: method='
.
var_export
(
func_get_arg
(
0
),
true
)
.
' param='
.
var_export
(
$this
->
request
->
param
(
'ids'
),
true
)
.
' get='
.
var_export
(
$this
->
request
->
get
(
'ids'
),
true
)
.
' pathinfo='
.
$this
->
request
->
pathinfo
()
.
' 最终ids='
.
var_export
(
$ids
,
true
),
'notice'
);
if
(
empty
(
$ids
))
{
if
(
$this
->
request
->
isAjax
())
{
return
json
([
'code'
=>
0
,
'msg'
=>
'缺少代理商ID'
,
'total'
=>
0
,
'rows'
=>
[]]);
}
$this
->
error
(
__
(
'Parameter %s can not be empty'
,
'ids'
));
}
$row
=
$this
->
model
->
with
(
'user'
)
->
find
(
$ids
);
if
(
!
$row
)
{
if
(
$this
->
request
->
isAjax
())
{
return
json
([
'code'
=>
0
,
'msg'
=>
'代理商不存在'
,
'total'
=>
0
,
'rows'
=>
[]]);
}
$this
->
error
(
__
(
'No Results were found'
));
}
$commissionModel
=
new
\app\admin\model\shopro\agent\AgentOrderCommission
;
// Ajax 分页获取佣金流水
if
(
$this
->
request
->
isAjax
())
{
$where
=
[
'agent_id'
=>
$row
->
id
];
$offset
=
$this
->
request
->
request
(
'offset'
,
0
,
'intval'
);
$limit
=
$this
->
request
->
request
(
'limit'
,
10
,
'intval'
);
$sort
=
$this
->
request
->
request
(
'sort'
,
'id'
);
$order
=
$this
->
request
->
request
(
'order'
,
'desc'
);
// 白名单校验排序字段,防止 SQL 注入
$allowSort
=
[
'id'
,
'commission_amount'
,
'createtime'
,
'order_createtime'
];
if
(
!
in_array
(
$sort
,
$allowSort
))
{
$sort
=
'id'
;
}
if
(
!
in_array
(
strtolower
(
$order
),
[
'asc'
,
'desc'
]))
{
$order
=
'desc'
;
}
$list
=
$commissionModel
->
where
(
$where
)
->
order
(
$sort
,
$order
)
->
limit
(
$offset
,
$limit
)
->
select
();
$total
=
$commissionModel
->
where
(
$where
)
->
count
();
return
json
([
'code'
=>
1
,
'total'
=>
$total
,
'rows'
=>
$list
,
]);
}
// 弹窗视图
$this
->
view
->
assign
(
'row'
,
$row
);
$this
->
view
->
assign
(
'statusList'
,
$commissionModel
->
statusList
());
return
$this
->
view
->
fetch
();
}
/**
* 查看
*/
public
function
index
()
...
...
application/admin/lang/zh-cn/shopro/agent/agent.php
View file @
e626a715
...
...
@@ -4,9 +4,9 @@ return [
'User_id'
=>
'用户ID'
,
'Province_id'
=>
'代理省份ID'
,
'Province_name'
=>
'省份名称'
,
'Real_name'
=>
'真实姓名
(快照)
'
,
'Id_card'
=>
'身份证号
(脱敏)
'
,
'Id_card_images'
=>
'身份证照片(JSON)'
,
'Real_name'
=>
'真实姓名'
,
'Id_card'
=>
'身份证号'
,
'Id_card_images'
=>
'身份证照片(JSON)'
,
'Commission_rate'
=>
'佣金比例(%)'
,
'Total_commission'
=>
'累计佣金'
,
'Status'
=>
'状态'
,
...
...
@@ -24,4 +24,25 @@ return [
'Remark'
=>
'备注'
,
'Batch_commission_rate'
=>
'批量修改佣金比例'
,
'Please_input_commission_rate'
=>
'请输入佣金比例(0-100)'
,
'Agent_info'
=>
'代理商资料'
,
'Agent_id'
=>
'代理商ID'
,
'User_name'
=>
'用户名'
,
'Commission_flow_logs'
=>
'代理商佣金流水'
,
'Order_sn'
=>
'订单号'
,
'Order_createtime'
=>
'订单创建时间'
,
'Reward_amount'
=>
'奖励金额(5%)'
,
'Aftersale_order_sn'
=>
'售后订单号'
,
'Order_status'
=>
'订单状态'
,
'Agent_user_id'
=>
'代理商用户ID'
,
'Buyer_user_id'
=>
'购买人用户ID'
,
'Receive_province_id'
=>
'收货省份ID'
,
'Receive_province_name'
=>
'收货省份名称'
,
'Goods_id'
=>
'商品ID'
,
'Goods_title'
=>
'商品标题'
,
'Goods_pay_price'
=>
'商品实付金额'
,
'Commission_rate_snapshot'
=>
'佣金比例(快照)'
,
'Commission_amount'
=>
'佣金金额'
,
'Commission_status'
=>
'结算状态'
,
'Order_confirm_time'
=>
'确认收货时间'
,
'Detail'
=>
'详情'
,
];
application/admin/model/shopro/agent/AgentOrderCommission.php
0 → 100644
View file @
e626a715
<?php
namespace
app\admin\model\shopro\agent
;
use
app\admin\model\shopro\Common
;
class
AgentOrderCommission
extends
Common
{
protected
$name
=
'shopro_agent_order_commission'
;
// 状态
const
STATUS_PENDING
=
0
;
// 待结算
const
STATUS_SETTLED
=
1
;
// 已结算
const
STATUS_CANCEL
=
-
1
;
// 已取消
const
STATUS_REFUND
=
-
2
;
// 已退款
protected
$type
=
[
'goods_pay_price'
=>
'decimal'
,
'commission_rate_snapshot'
=>
'decimal'
,
'commission_amount'
=>
'decimal'
,
];
public
function
statusList
()
{
return
[
self
::
STATUS_PENDING
=>
'待结算'
,
self
::
STATUS_SETTLED
=>
'已结算'
,
self
::
STATUS_CANCEL
=>
'已取消'
,
self
::
STATUS_REFUND
=>
'已退款'
,
];
}
public
function
getStatusTextAttr
(
$value
,
$data
)
{
$value
=
$value
??
(
$data
[
'status'
]
??
null
);
$map
=
$this
->
statusList
();
return
$map
[
$value
]
??
'-'
;
}
// 奖励金额 = 订单金额 * 5%
public
function
getRewardAmountAttr
(
$value
,
$data
)
{
$price
=
$data
[
'goods_pay_price'
]
??
0
;
return
round
(
$price
*
0.05
,
2
);
}
}
application/admin/model/shopro/agent/AgentRateLog.php
0 → 100644
View file @
e626a715
<?php
namespace
app\admin\model\shopro\agent
;
use
app\admin\model\shopro\Common
;
class
AgentRateLog
extends
Common
{
protected
$name
=
'shopro_agent_rate_log'
;
protected
$autoWriteTimestamp
=
'timestamp'
;
protected
$createTime
=
'createtime'
;
protected
$updateTime
=
false
;
}
application/admin/view/shopro/agent/agent/detail.html
0 → 100644
View file @
e626a715
<!-- 代理商详情弹窗 -->
<div
class=
"panel panel-default"
style=
"border:none;box-shadow:none;margin-bottom:0;"
>
<div
class=
"panel-body"
style=
"padding-top:0;"
>
<!-- 顶部:代理商资料 -->
<div
class=
"panel panel-default"
>
<div
class=
"panel-heading"
>
{:__('Agent_info')}
</div>
<div
class=
"panel-body"
>
<table
class=
"table table-bordered table-striped"
style=
"margin-bottom:0;"
>
<colgroup>
<col
width=
"15%"
><col
width=
"35%"
><col
width=
"15%"
><col
width=
"35%"
>
</colgroup>
<tbody>
<tr>
<td><strong>
{:__('Agent_id')}
</strong></td>
<td>
{$row.id}
</td>
<td><strong>
{:__('User_id')}
</strong></td>
<td>
{$row.user_id}
</td>
</tr>
<tr>
<td><strong>
{:__('User_name')}
</strong></td>
<td>
{$row->user->nickname ?? '-'}
</td>
<td><strong>
{:__('Province_name')}
</strong></td>
<td>
{$row.province_name}
</td>
</tr>
<tr>
<td><strong>
{:__(' ')}
</strong></td>
<td>
{$row.real_name}
</td>
<td><strong>
{:__('Id_card')}
</strong></td>
<td>
{$row.id_card}
</td>
</tr>
<tr>
<td><strong>
{:__('Commission_rate')}
</strong></td>
<td>
{$row.commission_rate}%
</td>
<td><strong>
{:__('Total_commission')}
</strong></td>
<td>
¥{$row.total_commission}
</td>
</tr>
<tr>
<td><strong>
{:__('Status')}
</strong></td>
<td>
{$statusList[$row.status] ?? $row.status}
</td>
<td><strong>
{:__('Createtime')}
</strong></td>
<td>
{$row.createtime|default='-'}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 底部:代理商佣金流水 -->
<div
class=
"panel panel-default"
>
<div
class=
"panel-heading"
>
{:__('Commission_flow_logs')}
</div>
<div
class=
"panel-body"
>
<table
id=
"commission-table"
class=
"table table-striped table-bordered table-hover table-nowrap"
width=
"100%"
>
</table>
</div>
</div>
</div>
</div>
<script
type=
"text/javascript"
>
require
([
'jquery'
,
'bootstrap'
,
'table'
],
function
(
$
,
undefined
,
Table
)
{
var
commissionTable
=
$
(
"#commission-table"
);
// AJAX 请求地址
var
detailUrl
=
"{:url('shopro/agent/agent/detail', ['ids' => $row['id'] ?? 0])}"
;
// 自定义 AJAX:发送分页参数,调用 params.success 返回数据
var
commissionAjax
=
function
(
params
)
{
$
.
ajax
({
url
:
detailUrl
,
data
:
{
offset
:
params
.
data
.
offset
||
0
,
limit
:
params
.
data
.
limit
||
10
},
type
:
'get'
,
dataType
:
'json'
,
success
:
function
(
res
)
{
params
.
success
({
total
:
res
.
total
||
0
,
rows
:
res
.
rows
||
[]
});
},
error
:
function
(
xhr
,
status
,
error
)
{
console
.
error
(
'[佣金流水] AJAX 请求失败:'
,
status
,
error
,
xhr
.
responseText
);
params
.
success
({
total
:
0
,
rows
:
[]
});
}
});
};
commissionTable
.
bootstrapTable
({
url
:
detailUrl
,
sidePagination
:
'server'
,
pagination
:
true
,
pageSize
:
10
,
pageList
:
[
10
,
20
,
50
],
showRefresh
:
false
,
showToggle
:
false
,
showColumns
:
false
,
method
:
'get'
,
queryParams
:
function
(
params
)
{
return
{
offset
:
params
.
offset
||
0
,
limit
:
params
.
limit
||
10
,
sort
:
params
.
sort
||
''
,
order
:
params
.
order
||
''
};
},
responseHandler
:
function
(
res
)
{
return
{
total
:
res
.
total
||
0
,
rows
:
res
.
rows
||
[]
};
},
ajax
:
commissionAjax
,
columns
:
[
[
{
field
:
'id'
,
title
:
'ID'
,
width
:
60
,
align
:
'center'
,
sortable
:
true
},
{
field
:
'order_id'
,
title
:
'{:__("Order_id")}'
,
width
:
70
,
align
:
'center'
},
{
field
:
'order_sn'
,
title
:
'{:__("Order_sn")}'
,
width
:
160
,
align
:
'center'
},
{
field
:
'order_createtime'
,
title
:
'{:__("Order_createtime")}'
,
width
:
150
,
align
:
'center'
,
formatter
:
function
(
value
)
{
if
(
!
value
)
return
'-'
;
var
d
=
new
Date
(
parseInt
(
value
)
*
1000
);
return
d
.
getFullYear
()
+
'-'
+
(
'0'
+
(
d
.
getMonth
()
+
1
)).
slice
(
-
2
)
+
'-'
+
(
'0'
+
d
.
getDate
()).
slice
(
-
2
)
+
' '
+
(
'0'
+
d
.
getHours
()).
slice
(
-
2
)
+
':'
+
(
'0'
+
d
.
getMinutes
()).
slice
(
-
2
)
+
':'
+
(
'0'
+
d
.
getSeconds
()).
slice
(
-
2
);
}
},
{
field
:
'reward_amount'
,
title
:
'{:__("Reward_amount")}'
,
width
:
100
,
align
:
'center'
,
formatter
:
function
(
value
,
row
)
{
var
price
=
row
.
goods_pay_price
||
0
;
var
reward
=
(
price
*
0.05
).
toFixed
(
2
);
return
'<span style="color:#f39c12;">¥'
+
reward
+
'</span>'
;
}
},
{
field
:
'aftersale_order_sn'
,
title
:
'{:__("Aftersale_order_sn")}'
,
width
:
160
,
align
:
'center'
,
formatter
:
function
(
value
)
{
return
value
||
'-'
;
}
},
{
field
:
'order_status'
,
title
:
'{:__("Order_status")}'
,
width
:
90
,
align
:
'center'
,
formatter
:
function
(
value
)
{
return
value
||
'-'
;
}
},
{
field
:
'agent_id'
,
title
:
'{:__("Agent_id")}'
,
width
:
70
,
align
:
'center'
},
{
field
:
'agent_user_id'
,
title
:
'{:__("Agent_user_id")}'
,
width
:
80
,
align
:
'center'
},
{
field
:
'buyer_user_id'
,
title
:
'{:__("Buyer_user_id")}'
,
width
:
80
,
align
:
'center'
},
{
field
:
'receive_province_name'
,
title
:
'{:__("Receive_province_name")}'
,
width
:
100
,
align
:
'center'
,
formatter
:
function
(
value
)
{
return
value
||
'-'
;
}
},
{
field
:
'goods_title'
,
title
:
'{:__("Goods_title")}'
,
width
:
150
,
align
:
'left'
,
formatter
:
function
(
value
)
{
return
value
||
'-'
;
}
},
{
field
:
'goods_pay_price'
,
title
:
'{:__("Goods_pay_price")}'
,
width
:
100
,
align
:
'center'
,
formatter
:
function
(
value
)
{
return
value
?
'¥'
+
parseFloat
(
value
).
toFixed
(
2
)
:
'-'
;
}
},
{
field
:
'commission_rate_snapshot'
,
title
:
'{:__("Commission_rate_snapshot")}'
,
width
:
100
,
align
:
'center'
,
formatter
:
function
(
value
)
{
return
value
!==
null
&&
value
!==
undefined
?
value
+
'%'
:
'-'
;
}
},
{
field
:
'commission_amount'
,
title
:
'{:__("Commission_amount")}'
,
width
:
100
,
align
:
'center'
,
formatter
:
function
(
value
)
{
return
value
?
'¥'
+
parseFloat
(
value
).
toFixed
(
2
)
:
'-'
;
}
},
{
field
:
'status'
,
title
:
'{:__("Commission_status")}'
,
width
:
80
,
align
:
'center'
,
formatter
:
function
(
value
)
{
var
statusList
=
{:
json_encode
(
$statusList
??
[])};
return
statusList
[
value
]
||
'-'
;
}
},
{
field
:
'order_confirm_time'
,
title
:
'{:__("Order_confirm_time")}'
,
width
:
150
,
align
:
'center'
,
formatter
:
function
(
value
)
{
if
(
!
value
)
return
'-'
;
var
d
=
new
Date
(
parseInt
(
value
)
*
1000
);
return
d
.
getFullYear
()
+
'-'
+
(
'0'
+
(
d
.
getMonth
()
+
1
)).
slice
(
-
2
)
+
'-'
+
(
'0'
+
d
.
getDate
()).
slice
(
-
2
)
+
' '
+
(
'0'
+
d
.
getHours
()).
slice
(
-
2
)
+
':'
+
(
'0'
+
d
.
getMinutes
()).
slice
(
-
2
)
+
':'
+
(
'0'
+
d
.
getSeconds
()).
slice
(
-
2
);
}
},
{
field
:
'createtime'
,
title
:
'{:__("Createtime")}'
,
width
:
150
,
align
:
'center'
,
formatter
:
function
(
value
)
{
if
(
!
value
)
return
'-'
;
var
d
=
new
Date
(
parseInt
(
value
)
*
1000
);
return
d
.
getFullYear
()
+
'-'
+
(
'0'
+
(
d
.
getMonth
()
+
1
)).
slice
(
-
2
)
+
'-'
+
(
'0'
+
d
.
getDate
()).
slice
(
-
2
)
+
' '
+
(
'0'
+
d
.
getHours
()).
slice
(
-
2
)
+
':'
+
(
'0'
+
d
.
getMinutes
()).
slice
(
-
2
)
+
':'
+
(
'0'
+
d
.
getSeconds
()).
slice
(
-
2
);
}
}
]
]
});
});
</script>
application/command.php
View file @
e626a715
...
...
@@ -25,5 +25,6 @@ return [
'app\command\TrackFailedRetry'
,
'app\command\DataStatsCenter'
,
'app\command\TestLogDb'
,
'app\command\AgentOrderCommission'
,
];
application/command/AgentOrderCommission.php
0 → 100644
View file @
e626a715
<?php
namespace
app\command
;
use
think\console\Command
;
use
think\console\Input
;
use
think\console\input\Option
;
use
think\console\Output
;
use
think\Db
;
/**
* 分销订单佣金结算定时任务
*
* 遍历已完成的分销订单,为每笔订单查找最近的两级代理商,
* 并根据各代理商的 commission_rate 发放佣金奖励,写入 fa_shopro_agent_order_commission 表。
*
* 定时执行建议:每小时执行一次(默认查36小时内订单)
* 命令:php think shopro:agent-order-commission
*
* 回溯补填(脚本异常停了N天后使用):
* php think shopro:agent-order-commission --days=3 # 回溯3天
* php think shopro:agent-order-commission --start="2026-06-28 00:00:00" --end="2026-07-01 00:00:00"
*
* 注意:每笔订单写入前有防重复检查,回溯范围扩大不会导致重复发佣。
*/
class
AgentOrderCommission
extends
Command
{
/**
* 控制台输出对象
* @var Output
*/
protected
$output
=
null
;
/**
* 查询时间范围(小时)
*/
const
LOOKBACK_HOURS
=
36
;
/**
* 分页大小(每页订单数)
*/
const
PAGE_SIZE
=
100
;
/**
* 配置命令信息
*/
protected
function
configure
()
{
$this
->
setName
(
'shopro:agent-order-commission'
)
->
setDescription
(
'分销订单佣金结算:遍历已完成订单,按代理商commission_rate发放佣金'
)
->
addOption
(
'days'
,
'd'
,
Option
::
VALUE_OPTIONAL
,
'回溯天数(覆盖默认36h窗口),例: --days=3'
,
null
)
->
addOption
(
'start'
,
's'
,
Option
::
VALUE_OPTIONAL
,
'起始时间,格式: Y-m-d H:i:s 或时间戳,例: --start="2026-06-28 00:00:00"'
,
null
)
->
addOption
(
'end'
,
'e'
,
Option
::
VALUE_OPTIONAL
,
'截止时间,格式: Y-m-d H:i:s 或时间戳,例: --end="2026-07-01 00:00:00"'
,
null
);
}
/**
* 自定义日志记录方法
*
* @param string $level 日志级别: info/warning/error
* @param string $message 日志内容
*/
protected
function
customLog
(
$level
,
$message
)
{
$timestamp
=
date
(
'Y-m-d H:i:s'
);
$formattedMessage
=
"[
{
$timestamp
}
] [
{
$level
}
]
{
$message
}
"
;
if
(
$this
->
output
)
{
switch
(
$level
)
{
case
'error'
:
$this
->
output
->
writeln
(
"<error>
{
$formattedMessage
}
</error>"
);
break
;
case
'warning'
:
$this
->
output
->
writeln
(
"<comment>
{
$formattedMessage
}
</comment>"
);
break
;
default
:
$this
->
output
->
writeln
(
"<info>
{
$formattedMessage
}
</info>"
);
break
;
}
}
custom_log
(
$message
,
0
,
'agent_commission'
,
$level
);
}
/**
* 命令执行入口方法
*
* @param Input $input
* @param Output $output
*/
protected
function
execute
(
Input
$input
,
Output
$output
)
{
$this
->
output
=
$output
;
$this
->
customLog
(
'info'
,
'========== 开始执行分销订单佣金结算任务 =========='
);
// 建议:为 fa_shopro_agent_order_commission 添加唯一索引防止并发重复
// ALTER TABLE fa_shopro_agent_order_commission ADD UNIQUE INDEX uk_order_agent (order_id, agent_id);
try
{
// 结算时间范围(通过命令行参数或默认36h窗口)
$endTime
=
time
();
$startTime
=
null
;
$startOption
=
$input
->
getOption
(
'start'
);
$endOption
=
$input
->
getOption
(
'end'
);
$daysOption
=
$input
->
getOption
(
'days'
);
if
(
$startOption
)
{
// 精确指定起始时间
$startTime
=
$this
->
parseTime
(
$startOption
);
}
elseif
(
$daysOption
&&
intval
(
$daysOption
)
>
0
)
{
// 按天数回溯
$startTime
=
$endTime
-
(
intval
(
$daysOption
)
*
86400
);
}
else
{
// 默认:36小时窗口
$startTime
=
$endTime
-
(
self
::
LOOKBACK_HOURS
*
3600
);
}
if
(
$endOption
)
{
$endTime
=
$this
->
parseTime
(
$endOption
);
}
// 时间范围校验
if
(
$startTime
>=
$endTime
)
{
$this
->
customLog
(
'error'
,
"时间范围无效: 起始时间 >= 截止时间"
);
return
;
}
$diffHours
=
round
((
$endTime
-
$startTime
)
/
3600
,
1
);
$this
->
customLog
(
'info'
,
"查询时间范围: "
.
date
(
'Y-m-d H:i:s'
,
$startTime
)
.
" ~ "
.
date
(
'Y-m-d H:i:s'
,
$endTime
)
.
" (跨度:
{
$diffHours
}
小时)"
);
// 1. 分页查询36小时内状态变更为 completed 的订单
$page
=
1
;
$totalProcessed
=
0
;
$successCount
=
0
;
$skipCount
=
0
;
$errorCount
=
0
;
while
(
true
)
{
$orders
=
$this
->
getCompletedOrders
(
$startTime
,
$endTime
,
$page
,
self
::
PAGE_SIZE
);
if
(
empty
(
$orders
))
{
$this
->
customLog
(
'info'
,
"第
{
$page
}
页无数据,分页遍历结束"
);
break
;
}
$pageTotal
=
count
(
$orders
);
$totalProcessed
+=
$pageTotal
;
$this
->
customLog
(
'info'
,
"第
{
$page
}
页: 获取
{
$pageTotal
}
笔订单"
);
if
(
$page
===
1
&&
$pageTotal
===
0
)
{
$this
->
customLog
(
'info'
,
'无待处理订单,任务结束'
);
return
;
}
// 遍历订单,查找两级代理商并结算佣金
foreach
(
$orders
as
$order
)
{
try
{
$result
=
$this
->
processOrder
(
$order
);
if
(
$result
===
true
)
{
$successCount
++
;
}
elseif
(
$result
===
'skip'
)
{
$skipCount
++
;
}
}
catch
(
\Exception
$e
)
{
$errorCount
++
;
$this
->
customLog
(
'error'
,
"订单[
{
$order
[
'order_sn'
]
}
]处理异常: "
.
$e
->
getMessage
());
}
}
// 如果本页数量少于分页大小,说明已是最后一页
if
(
$pageTotal
<
self
::
PAGE_SIZE
)
{
$this
->
customLog
(
'info'
,
"第
{
$page
}
页数据不足分页大小,已是最后一页"
);
break
;
}
$page
++
;
}
$this
->
customLog
(
'info'
,
"共处理
{
$totalProcessed
}
笔订单 - 成功:
{
$successCount
}
, 跳过:
{
$skipCount
}
, 异常:
{
$errorCount
}
"
);
$this
->
customLog
(
'info'
,
'========== 分销订单佣金结算任务执行完成 =========='
);
}
catch
(
\Exception
$e
)
{
$this
->
customLog
(
'error'
,
'[分销订单佣金结算任务执行失败]'
);
if
(
function_exists
(
'format_log_error_custom_name'
))
{
format_log_error_custom_name
(
$e
,
'[分销订单佣金结算任务失败]'
,
''
,
'agent_commission'
);
}
else
{
$this
->
customLog
(
'error'
,
$e
->
getMessage
()
.
"
\n
"
.
$e
->
getTraceAsString
());
}
}
}
/**
* 解析时间参数,支持时间戳数值和日期字符串两种格式
*
* @param string $value 时间值(时间戳或日期字符串)
* @return int 时间戳
* @throws \InvalidArgumentException
*/
protected
function
parseTime
(
$value
)
{
// 纯数字 → 当作时间戳
if
(
is_numeric
(
$value
)
&&
strlen
(
$value
)
>=
10
)
{
return
intval
(
$value
);
}
// 日期字符串 → strtotime 解析
$timestamp
=
strtotime
(
$value
);
if
(
$timestamp
===
false
)
{
throw
new
\InvalidArgumentException
(
"无法解析时间参数:
{
$value
}
,请使用 Y-m-d H:i:s 格式或时间戳"
);
}
return
$timestamp
;
}
/**
* 分页获取36小时内状态变更为 completed 的订单列表
*
* 注意:使用 updatetime 而非 createtime 过滤。
* 订单可能在创建很久之后才变为 completed,用 createtime 会导致遗漏。
*
* @param int $startTime 开始时间戳
* @param int $endTime 结束时间戳
* @param int $page 页码(从1开始)
* @param int $pageSize 每页数量
* @return array
*/
protected
function
getCompletedOrders
(
$startTime
,
$endTime
,
$page
=
1
,
$pageSize
=
100
)
{
$orders
=
Db
::
name
(
'shopro_order'
)
->
alias
(
'o'
)
->
field
(
'o.id, o.order_sn, o.user_id, o.pay_fee, o.order_amount, o.goods_amount, o.status, o.createtime, o.updatetime, o.ext'
)
->
where
(
'o.status'
,
'completed'
)
->
where
(
'o.updatetime'
,
'>='
,
$startTime
)
->
where
(
'o.updatetime'
,
'<='
,
$endTime
)
->
where
(
'o.pay_fee'
,
'>'
,
0
)
// 排除已删除的订单
->
whereNull
(
'o.deletetime'
)
->
order
(
'o.id'
,
'asc'
)
->
page
(
$page
,
$pageSize
)
->
select
();
return
$orders
?:
[];
}
/**
* 处理单笔订单的佣金结算
*
* @param array $order 订单数据
* @return string|bool 'skip'=跳过, true=成功
*/
protected
function
processOrder
(
$order
)
{
$orderId
=
$order
[
'id'
];
$orderSn
=
$order
[
'order_sn'
];
$buyerUserId
=
$order
[
'user_id'
];
$payFee
=
floatval
(
$order
[
'pay_fee'
]);
// 获取订单商品信息
$orderItems
=
$this
->
getOrderItems
(
$orderId
);
// 查找两级代理商
$agents
=
$this
->
findAgentChain
(
$buyerUserId
,
2
);
if
(
empty
(
$agents
))
{
$this
->
customLog
(
'info'
,
"订单[
{
$orderSn
}
]跳过: 未找到有效的上级代理商, 买家[
{
$buyerUserId
}
]"
);
return
'skip'
;
}
$processedCount
=
0
;
foreach
(
$agents
as
$agentInfo
)
{
$agentUserId
=
$agentInfo
[
'user_id'
];
$agentLevel
=
$agentInfo
[
'level'
];
// 1=一级, 2=二级
// 从 fa_shopro_agent 表获取该代理商的佣金比例及记录ID
$agentRecord
=
Db
::
name
(
'shopro_agent'
)
->
where
(
'user_id'
,
$agentUserId
)
->
field
(
'id, user_id, commission_rate'
)
->
find
();
if
(
!
$agentRecord
)
{
$this
->
customLog
(
'warning'
,
"订单[
{
$orderSn
}
]代理商user_id[
{
$agentUserId
}
]在shopro_agent表中不存在, 跳过"
);
continue
;
}
$recordAgentId
=
$agentRecord
[
'id'
];
$commissionRate
=
floatval
(
$agentRecord
[
'commission_rate'
]);
if
(
$commissionRate
<=
0
)
{
$this
->
customLog
(
'warning'
,
"订单[
{
$orderSn
}
]代理商[
{
$recordAgentId
}
]commission_rate为0或未设置, 跳过"
);
continue
;
}
// 计算佣金:订单支付金额 × commission_rate%
$commissionAmount
=
round
(
$payFee
*
(
$commissionRate
/
100
),
2
);
if
(
$commissionAmount
<=
0
)
{
$this
->
customLog
(
'warning'
,
"订单[
{
$orderSn
}
]代理商[
{
$recordAgentId
}
]计算佣金为0, 跳过"
);
continue
;
}
// 获取订单关联的商品信息(取第一个商品作为代表)
$goodsInfo
=
!
empty
(
$orderItems
)
?
$orderItems
[
0
]
:
[];
$goodsPayPrice
=
!
empty
(
$orderItems
)
?
array_sum
(
array_column
(
$orderItems
,
'pay_fee'
))
:
$payFee
;
// 获取收货地址中的省份信息
$provinceInfo
=
$this
->
getOrderProvince
(
$order
[
'ext'
]
??
''
);
// 构建佣金记录数据
$data
=
[
'agent_id'
=>
$recordAgentId
,
'agent_user_id'
=>
$agentRecord
[
'user_id'
]
??
$agentUserId
,
'order_id'
=>
$orderId
,
'order_sn'
=>
$orderSn
,
'buyer_user_id'
=>
$buyerUserId
,
'receive_province_id'
=>
$provinceInfo
[
'province_id'
]
??
0
,
'receive_province_name'
=>
$provinceInfo
[
'province_name'
]
??
''
,
'goods_id'
=>
$goodsInfo
[
'goods_id'
]
??
0
,
'goods_title'
=>
$goodsInfo
[
'goods_title'
]
??
''
,
'goods_pay_price'
=>
$goodsPayPrice
,
'commission_rate_snapshot'
=>
$commissionRate
,
'commission_amount'
=>
$commissionAmount
,
'order_status'
=>
'completed'
,
'aftersale_order_sn'
=>
''
,
'order_confirm_time'
=>
$order
[
'updatetime'
],
'order_createtime'
=>
$order
[
'createtime'
],
'status'
=>
0
,
// 0=待结算
'createtime'
=>
time
(),
'updatetime'
=>
time
(),
];
// 事务包裹:检查 + 写入,防止并发时重复插入
$inserted
=
false
;
Db
::
startTrans
();
try
{
// 在事务内再次检查(利用事务的隔离性增强并发安全)
$exists
=
Db
::
name
(
'shopro_agent_order_commission'
)
->
where
(
'order_id'
,
$orderId
)
->
where
(
'agent_id'
,
$recordAgentId
)
->
where
(
'status'
,
'in'
,
[
0
,
1
])
->
lock
(
true
)
// 行级锁,进一步阻止并发
->
find
();
if
(
$exists
)
{
Db
::
rollback
();
$this
->
customLog
(
'info'
,
"订单[
{
$orderSn
}
]代理商[
{
$recordAgentId
}
]第
{
$agentLevel
}
级已存在佣金记录(事务内), 跳过"
);
continue
;
}
// 写入佣金表
Db
::
name
(
'shopro_agent_order_commission'
)
->
insert
(
$data
);
Db
::
commit
();
$inserted
=
true
;
}
catch
(
\PDOException
$e
)
{
Db
::
rollback
();
// 捕获唯一索引冲突(如有索引),说明真的重复了
if
(
stripos
(
$e
->
getMessage
(),
'Duplicate entry'
)
!==
false
)
{
$this
->
customLog
(
'info'
,
"订单[
{
$orderSn
}
]代理商[
{
$recordAgentId
}
]唯一索引冲突,已存在记录, 跳过"
);
}
else
{
$this
->
customLog
(
'error'
,
"订单[
{
$orderSn
}
]写入异常: "
.
$e
->
getMessage
());
}
continue
;
}
catch
(
\Exception
$e
)
{
Db
::
rollback
();
$this
->
customLog
(
'error'
,
"订单[
{
$orderSn
}
]事务异常: "
.
$e
->
getMessage
());
continue
;
}
if
(
$inserted
)
{
$this
->
customLog
(
'info'
,
"订单[
{
$orderSn
}
]第
{
$agentLevel
}
级代理商[
{
$recordAgentId
}
]佣金: ¥
{
$commissionAmount
}
(订单金额: ¥
{
$payFee
}
)"
);
$processedCount
++
;
}
}
return
$processedCount
>
0
?
true
:
'skip'
;
}
/**
* 获取订单商品列表
*
* @param int $orderId 订单ID
* @return array
*/
protected
function
getOrderItems
(
$orderId
)
{
return
Db
::
name
(
'shopro_order_item'
)
->
where
(
'order_id'
,
$orderId
)
->
field
(
'id, goods_id, goods_title, goods_price, goods_num, pay_fee'
)
->
select
()
?:
[];
}
/**
* 递归查找用户的最近两级代理商
*
* 从买家开始向上追溯 parent_user_id,检查每一级是否为有效代理商,
* 最多向上追溯两级。仅收集状态为 normal 或 freeze 的代理商。
*
* @param int $buyerUserId 买家用户ID
* @param int $maxLevel 最大追溯层级(默认2级)
* @return array [['user_id' => xxx, 'level' => 1], ...]
*/
protected
function
findAgentChain
(
$buyerUserId
,
$maxLevel
=
2
)
{
$agents
=
[];
$currentUserId
=
$buyerUserId
;
$currentLevel
=
1
;
while
(
$currentLevel
<=
$maxLevel
)
{
// 查找当前用户的上级
$user
=
Db
::
name
(
'user'
)
->
where
(
'id'
,
$currentUserId
)
->
field
(
'id, parent_user_id'
)
->
find
();
if
(
!
$user
||
empty
(
$user
[
'parent_user_id'
])
||
$user
[
'parent_user_id'
]
<=
0
)
{
break
;
}
$parentUserId
=
$user
[
'parent_user_id'
];
// 检查上级是否为有效代理商
$agent
=
Db
::
name
(
'shopro_commission_agent'
)
->
where
(
'user_id'
,
$parentUserId
)
->
where
(
'status'
,
'in'
,
[
'normal'
,
'freeze'
])
->
field
(
'user_id, level'
)
->
find
();
if
(
$agent
)
{
$agents
[]
=
[
'user_id'
=>
$agent
[
'user_id'
],
'agent_user_id'
=>
$agent
[
'user_id'
],
'level'
=>
$currentLevel
,
];
}
// 继续向上追溯(即使上级不是代理商,也继续向上找)
$currentUserId
=
$parentUserId
;
$currentLevel
++
;
}
return
$agents
;
}
/**
* 获取订单的收货省份信息
*
* @param string $ext 订单 ext JSON 字段值
* @return array|null
*/
protected
function
getOrderProvince
(
$ext
)
{
if
(
empty
(
$ext
))
{
return
null
;
}
$ext
=
json_decode
(
$ext
,
true
);
if
(
is_array
(
$ext
)
&&
isset
(
$ext
[
'address'
]))
{
$address
=
$ext
[
'address'
];
if
(
isset
(
$address
[
'province_id'
]))
{
return
[
'province_id'
=>
$address
[
'province_id'
],
'province_name'
=>
$address
[
'province_name'
]
??
''
,
];
}
}
return
null
;
}
}
public/assets/js/backend/shopro/agent/agent.js
View file @
e626a715
...
...
@@ -7,7 +7,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
extend
:
{
index_url
:
'shopro/agent/agent/index'
+
location
.
search
,
// add_url: 'shopro/agent/agent/add',
edit_url
:
'shopro/agent/agent/edit'
,
//
edit_url: 'shopro/agent/agent/edit',
// del_url: 'shopro/agent/agent/del',
multi_url
:
'shopro/agent/agent/multi'
,
import_url
:
'shopro/agent/agent/import'
,
...
...
@@ -38,7 +38,31 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
{
field
:
'apply_id'
,
title
:
__
(
'Apply_id'
)},
{
field
:
'createtime'
,
title
:
__
(
'Createtime'
),
operate
:
'RANGE'
,
addclass
:
'datetimerange'
,
autocomplete
:
false
,
formatter
:
Table
.
api
.
formatter
.
datetime
},
{
field
:
'updatetime'
,
title
:
__
(
'Updatetime'
),
operate
:
'RANGE'
,
addclass
:
'datetimerange'
,
autocomplete
:
false
,
formatter
:
Table
.
api
.
formatter
.
datetime
},
{
field
:
'operate'
,
title
:
__
(
'Operate'
),
table
:
table
,
events
:
Table
.
api
.
events
.
operate
,
formatter
:
Table
.
api
.
formatter
.
operate
}
{
field
:
'operate'
,
title
:
__
(
'Operate'
),
table
:
table
,
events
:
Table
.
api
.
events
.
operate
,
buttons
:
[
{
name
:
'detail'
,
text
:
__
(
'Detail'
),
title
:
__
(
'Detail'
),
icon
:
'fa fa-list'
,
classname
:
'btn btn-info btn-xs btn-detail'
},
{
name
:
'edit'
,
text
:
__
(
'Edit'
),
title
:
__
(
'Edit'
),
icon
:
'fa fa-pencil'
,
classname
:
'btn btn-primary btn-xs btn-editone'
,
url
:
'shopro/agent/agent/edit'
},
{
name
:
'del'
,
text
:
__
(
'Delete'
),
title
:
__
(
'Delete'
),
icon
:
'fa fa-trash'
,
classname
:
'btn btn-danger btn-xs btn-delone'
,
url
:
'shopro/agent/agent/del'
}
],
formatter
:
Table
.
api
.
formatter
.
operate
}
]
]
});
...
...
@@ -46,6 +70,17 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
// 为表格绑定事件
Table
.
api
.
bindevent
(
table
);
// 弹窗查看详情
$
(
document
).
on
(
'click'
,
'.btn-detail'
,
function
()
{
var
id
=
$
(
this
).
closest
(
'tr'
).
data
(
'id'
)
||
Table
.
api
.
selectedids
(
table
);
if
(
!
id
)
return
;
Fast
.
api
.
open
(
'shopro/agent/agent/detail?ids='
+
id
,
__
(
'Detail'
),
{
callback
:
function
()
{
table
.
bootstrapTable
(
'refresh'
);
}
});
});
// 批量修改佣金比例按钮
$
(
document
).
on
(
'click'
,
'.btn-commission-rate'
,
function
()
{
var
ids
=
Table
.
api
.
selectedids
(
table
);
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment