CRMEB/backend/app/dao/order/OrderCenterDao.php
吴昊天 b2fbcf257a fix(统计): 修正交易统计退款与环比数据
- 商品退款按 refund_status=已退款、refund_time 统计,兼容全额退款 pay_status=3
- 商品支付/余额支付按支付口径统计,退款在支出中单独列示
- 修复余额支付环比使用两个百分比再计算、无基期环比出现百万级数值的问题
2026-10-23 14:14:59 +08:00

523 lines
21 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
namespace app\dao\order;
use crmeb\basic\BaseDao;
use app\model\order\OrderCenter;
use app\model\user\User;
/**
* 订单中台数据访问层
* 提供订单中台表的基础数据库操作方法
*/
class OrderCenterDao extends BaseDao
{
/**
* 设置模型类名
* @return string 返回模型类名
*/
protected function setModel(): string
{
return OrderCenter::class;
}
public function getByIdForUpdate(int $id)
{
return $this->getModel()->where('id', $id)->lock(true)->find();
}
/**
* 按业务单号锁定中台订单。
* @param string $orderNo 订单号
* @return mixed
*/
public function getByOrderNoForUpdate(string $orderNo)
{
return $this->getModel()->where('order_no', $orderNo)->lock(true)->find();
}
/**
* 仅在订单仍未支付且扩展信息未变化时保存支付信息。
* @param int $id 订单ID
* @param array|string|null $previousExtra 原扩展信息(兼容 JSON 自动转换后的数组)
* @param string $extra 更新后的扩展信息
* @return bool
*/
public function updateUnpaidPaymentContext(int $id, array|string|null $previousExtra, string $extra): bool
{
// JSON 字段可能被 ORM 解码为数组,绑定 SQL 前转换回 JSON,保留原值并发校验。
if (is_array($previousExtra)) {
$previousExtra = json_encode($previousExtra, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
}
return $this->getModel()->where('id', $id)->where('pay_status', 0)
->whereRaw('extra_info <=> CAST(? AS JSON)', [$previousExtra])->update(['extra_info' => $extra]) === 1;
}
/**
* 获取分页列表
* @param array $where 查询条件
* @param string $field 查询字段
* @param int $page 页码
* @param int $limit 每页数量
* @param string $order 排序方式
* @param array $with 关联预加载
* @param bool $search 是否使用搜索器
* @return array 返回分页数据
*/
public function selectList(array $where = [], string $field = '*', int $page = 0, int $limit = 0, string $order = '', array $with = [], bool $search = false)
{
return $this->getModel()->where($where)->field($field)->order($order)->page($page, $limit)->select()->toArray();
}
/**
* 获取记录总数
* @param array $where 查询条件
* @param bool $search 是否使用搜索器
* @return int 返回记录数
*/
public function count(array $where = [], bool $search = true): int
{
return $this->getModel()->where($where)->count();
}
/**
* 用户订单范围查询:本人下单的订单 + 本人领取的礼品订单
* 两个条件均为索引字段(idx_uid / idx_gift_uid),支持索引合并
* @param int $uid 用户ID
* @return mixed 返回查询对象
*/
public function userOrderQuery(int $uid)
{
return $this->getModel()->whereRaw(
'(uid = :owner_uid OR gift_uid = :gift_uid)',
['owner_uid' => $uid, 'gift_uid' => $uid]
);
}
/**
* 保存或更新记录
* 根据条件判断是新增还是更新,支持幂等性操作
* 兜底机制:若按 order_no 找不到,再按 (source_type, source_id) 查找,
* 存在相同组合(stale record,来自旧代码用 store_order.id 作 source_id 或之前失败订单)则复用更新,
* 避免 uk_source_order 唯一键冲突。
* @param array $where 查询条件
* @param array $data 数据
* @return bool 返回操作结果
*/
public function saveOrUpdate(array $where, array $data): bool
{
// 1. 优先按 order_no 查找
$record = $this->getModel()->where($where)->find();
if ($record) {
$result = $record->save($data);
\think\facade\Log::info('[OrderCenterDao] update by order_no, order_no=' . ($where['order_no'] ?? '') . ', result=' . json_encode($result));
return (bool)$result;
}
// 2. 按 (source_type, source_id) 兜底查找 — 旧代码遗留的 stale record
$sourceType = $data['source_type'] ?? '';
$sourceId = $data['source_id'] ?? 0;
if ($sourceType && $sourceId) {
$stale = $this->getModel()
->where('source_type', $sourceType)
->where('source_id', $sourceId)
->find();
if ($stale) {
\think\facade\Log::warning('[OrderCenterDao] found stale center record, source_type=' . $sourceType . ', source_id=' . $sourceId . ', old_order_no=' . ($stale['order_no'] ?? '') . ', new_order_no=' . ($where['order_no'] ?? ''));
$result = $stale->save(array_merge($where, $data));
return (bool)$result;
}
}
// 3. 真正的新增
\think\facade\Log::info('[OrderCenterDao] create order_center, order_no=' . ($where['order_no'] ?? '') . ', data_keys=' . implode(',', array_keys($data)));
$model = $this->getModel()::create(array_merge($where, $data));
$insertId = $model->id ?? 0;
\think\facade\Log::info('[OrderCenterDao] create result, insert_id=' . $insertId);
return (bool)$insertId;
}
/**
* 更新记录
* @param int|string|array $id 主键ID或条件数组
* @param array $data 更新数据
* @param string|null $key 主键字段名
* @return mixed 返回操作结果
*/
public function update($id, array $data, ?string $key = null)
{
if (is_array($id)) {
$where = $id;
} else {
$where = [$key ?? $this->getPk() => $id];
}
return $this->getModel()->where($where)->update($data);
}
/**
* 获取单条记录
* @param int|string|array $id 主键ID或条件数组
* @param array|null $field 查询字段
* @param array|null $with 关联预加载
* @return mixed 返回记录对象或null
*/
public function get($id, ?array $field = [], ?array $with = [])
{
if (is_array($id)) {
$where = $id;
} else {
$where = [$this->getPk() => $id];
}
// 保持 BaseDao::get 的字段/关联契约;物流等窄查询不得无意读取整行订单数据。
return $this->getModel()->where($where)
->when(count($with), function ($query) use ($with) {
$query->with($with);
})
->field($field ?? ['*'])
->find();
}
/**
* 获取数据库查询对象
* 用于复杂查询场景
* @return mixed 返回数据库查询对象
*/
public function getDb()
{
return $this->getModel();
}
/**
* 获取订单总数量
* @param array $where 查询条件
* @return int 返回订单总数
*/
public function storeOrderCount(array $where = []): int
{
return $this->getModel()->where($where)->count();
}
/**
* 根据条件获取字段求和
* @param array $where 查询条件
* @param string $field 求和字段
* @param string $timeField 时间过滤字段(支付统计用 pay_time,退款统计用 refund_time)
* @return float 返回求和结果
*/
public function getWhereSumField(array $where, string $field, string $timeField = 'pay_time')
{
return $this->buildWhereQuery($where)
->when(isset($where['timeKey']), function ($query) use ($where, $timeField) {
$query->whereBetweenTime($timeField, $where['timeKey']['start_time'], $where['timeKey']['end_time']);
})
->sum($field);
}
/**
* 根据条件分组查询
* @param array $where 查询条件
* @param string $field 求和字段
* @param string $group 分组字段
* @param string $timeField 时间过滤字段(支付统计用 pay_time,退款统计用 refund_time)
* @return mixed 返回分组结果
*/
public function getGroupField(array $where, string $field, string $group, string $timeField = 'pay_time')
{
return $this->buildWhereQuery($where)
->when(isset($where['timeKey']), function ($query) use ($where, $field, $group, $timeField) {
$query->whereBetweenTime($timeField, $where['timeKey']['start_time'], $where['timeKey']['end_time']);
$timeUinx = "%H";
if ($where['timeKey']['days'] == 1) {
$timeUinx = "%H";
} elseif ($where['timeKey']['days'] == 30) {
$timeUinx = "%Y-%m-%d";
} elseif ($where['timeKey']['days'] == 365) {
$timeUinx = "%Y-%m";
} elseif ($where['timeKey']['days'] > 1 && $where['timeKey']['days'] < 30) {
$timeUinx = "%Y-%m-%d";
} elseif ($where['timeKey']['days'] > 30 && $where['timeKey']['days'] < 365) {
$timeUinx = "%Y-%m";
}
$query->field("sum($field) as number,FROM_UNIXTIME($group, '$timeUinx') as time");
$query->group("FROM_UNIXTIME($group, '$timeUinx')");
})
->order($timeField . ' ASC')->select()->toArray();
}
/**
* 统计指定时间范围内的订单数
* @param array $where 查询条件(含 timeKey 时间范围,其余条件为简单相等)
* @return int 订单数
*/
public function getOrderCountByWhere(array $where): int
{
return $this->buildWhereQuery($where)
->when(isset($where['timeKey']), function ($query) use ($where) {
$query->whereBetweenTime('pay_time', $where['timeKey']['start_time'], $where['timeKey']['end_time']);
})
->count();
}
/**
* 统计指定时间范围内的支付人数(去重 uid)
* @param array $where 查询条件(含 timeKey 时间范围,其余条件为简单相等)
* @return int 去重支付人数
*/
public function getPayPeopleCount(array $where): int
{
return $this->buildWhereQuery($where)
->when(isset($where['timeKey']), function ($query) use ($where) {
$query->whereBetweenTime('pay_time', $where['timeKey']['start_time'], $where['timeKey']['end_time']);
})
->group('uid')
->count();
}
/**
* 构建聚合查询基础条件
* 中台聚合方法不依赖模型搜索器:遍历 where 将简单相等/三元组条件直接应用,
* timeKey 由各聚合方法内部单独处理(whereBetweenTime),避免 search($where, false) 静默丢弃非搜索器条件
* @param array $where 查询条件
* @return \think\db\Query
*/
private function buildWhereQuery(array $where)
{
$query = $this->getModel();
foreach ($where as $key => $value) {
// timeKey 由调用方聚合逻辑单独处理,跳过
if (in_array($key, ['timeKey', 'store_stock', 'integral_time'], true)) {
continue;
}
if (is_array($value) && count($value) === 3) {
// 三元组条件:['field', 'op', 'value'],链式赋回保证条件累加
$query = $query->where($value[0], $value[1], $value[2]);
} else {
$query = $query->where($key, $value);
}
}
return $query;
}
/**
* 按下单用户集合获取推广订单分页列表(仅已支付且未退款)
* 新架构订单表无推广关系快照字段,推广关系由调用方按用户表实时解析后传入 uid 集合
* @param array $uids 下单用户ID集合
* @param array $where 筛选条件(data: [起, 止] 日期;order_id: 订单号模糊搜索)
* @param int $page 页码
* @param int $limit 每页数量
* @return array ['list' => 列表, 'count' => 总数]
*/
public function getSpreadOrderPageByUids(array $uids, array $where = [], int $page = 0, int $limit = 0): array
{
// 无下单用户集合时直接返回空结构,避免无效查询
if (empty($uids)) {
return ['list' => [], 'count' => 0];
}
$model = $this->getModel()
->where('uid', 'IN', $uids)
->where('pay_status', 1)
->where('refund_status', 0)
->when(!empty($where['data']), function ($query) use ($where) {
// 按下单时间范围筛选(data 为 [开始日期, 结束日期])
$query->whereBetween('add_time', [strtotime($where['data'][0]), strtotime($where['data'][1] . ' 23:59:59')]);
})
->when(!empty($where['order_id']), function ($query) use ($where) {
// 订单号模糊搜索
$query->whereLike('order_no', '%' . $where['order_id'] . '%');
});
$count = $model->count();
$list = $model->order('add_time desc')->page($page, $limit)->select()->toArray();
return compact('list', 'count');
}
/**
* 按下单人的归属字段分组统计订单(事业部/代理商/员工维度统计)
* 新架构订单表无 division_id/agent_id/staff_id 快照字段,归属关系实时取自用户表
* @param string $userField 用户表分组字段(division_id/agent_id/staff_id,白名单校验)
* @param array $time 时间范围 [开始日期, 结束日期]
* @param int $page 页码
* @param int $limit 每页数量
* @param string $sort 排序字段(为空时默认按订单数倒序)
* @param string $order 排序方向
* @return array ['list' => 列表, 'count' => 总数]
*/
public function statisticGroupByUserField(string $userField, array $time = [], int $page = 0, int $limit = 0, string $sort = '', string $order = ''): array
{
// 分组字段白名单校验,防止 SQL 注入
if (!in_array($userField, ['division_id', 'agent_id', 'staff_id'], true)) {
$userField = 'division_id';
}
// 取用户表真实表名(含前缀),拼接关联查询
$userTable = app()->make(User::class)->getName();
$model = $this->getModel()->alias('o')
->join($userTable . ' u', 'o.uid = u.uid')
->where('u.' . $userField, '>', 0)
->when(!empty($time), function ($query) use ($time) {
// 按下单时间范围筛选
$query->whereBetween('o.add_time', [strtotime($time[0]), strtotime($time[1] . ' 23:59:59')]);
});
// 分组后总数:先取全部分组键再计数(分组维度为事业部/代理商/员工,数量有限)
$count = count($model->distinct(true)->field('u.' . $userField)->select()->toArray());
// 默认按订单数倒序
$orderStr = $sort === '' ? 'order_sum desc' : $sort . ' ' . $order;
$list = $model->fieldRaw('u.' . $userField . ', COUNT(*) AS order_sum, SUM(o.pay_price) AS order_sum_price')
->group('u.' . $userField)->order($orderStr)->page($page, $limit)->select()->toArray();
return compact('list', 'count');
}
/**
* 统计用户集合的已支付未退款订单金额与数量(推广订单概况)
* @param array $uids 用户ID集合
* @return array ['order_price' => 订单金额合计, 'order_count' => 订单数量]
*/
public function getSpreadOrderStatByUids(array $uids): array
{
// 无用户集合时直接返回零值结构
if (empty($uids)) {
return ['order_price' => 0, 'order_count' => 0];
}
$res = $this->getModel()
->where('uid', 'IN', $uids)
->where('pay_status', 1)
->where('refund_status', 0)
->fieldRaw('IFNULL(SUM(pay_price),0) AS order_price, COUNT(*) AS order_count')
->find();
return $res ? $res->toArray() : ['order_price' => 0, 'order_count' => 0];
}
/**
* 订单图表分组统计
* 按周期对已支付订单进行分组,统计每天的订单量与销售额
* @param int $startTime 开始时间戳
* @param int $endTime 结束时间戳
* @param string $cycle 统计周期:thirtyday=按天、month=按月、其余=按年
* @return array 返回分组统计列表(day/count/price)
*/
public function orderCharts(int $startTime, int $endTime, string $cycle = 'thirtyday'): array
{
// 根据统计周期决定分组粒度
switch ($cycle) {
case 'thirtyday':
$format = '%Y-%m-%d';
break;
case 'month':
$format = '%Y-%m';
break;
default:
$format = '%Y';
break;
}
return $this->getModel()
->where('is_del', 0)
->where('pay_status', 1)
->whereBetween('pay_time', [$startTime, $endTime])
->fieldRaw("FROM_UNIXTIME(pay_time, '{$format}') as day, COUNT(*) as count, COALESCE(SUM(pay_price), 0) as price")
->group('day')
->order('day', 'asc')
->select()
->toArray();
}
/**
* 获取订单列表(导出用)
* 兼容旧 getOrderList 调用方,适配新订单中台字段:
* - 旧字段 status → 新表 status_code
* - 旧字段 pid/shipping_type 在新表不存在,直接忽略
* - 解析 extra_info 快照,组装联系人/地址/商品明细(_info)/表单信息
* @param array $where 查询条件
* @param array $field 查询字段
* @param int $page 页码
* @param int $limit 每页数量
* @param array $with 兼容旧调用方的关联参数(订单中台暂不使用)
* @return array 返回 ['data' => 订单列表, 'count' => 订单总数]
*/
public function getOrderList(array $where = [], array $field = ['*'], int $page = 0, int $limit = 0, array $with = []): array
{
$query = $this->getModel();
// 旧字段名映射到新表统一字段
$fieldMap = [
'status' => 'status_code',
'pay_status' => 'pay_status',
'refund_status' => 'refund_status',
'product_type' => 'product_type',
'order_no' => 'order_no',
'uid' => 'uid',
'pay_type' => 'pay_type',
];
foreach ($fieldMap as $old => $new) {
if (isset($where[$old]) && $where[$old] !== '' && $where[$old] !== null) {
$query->where($new, $where[$old]);
unset($where[$old]);
}
}
// 新表不存在的旧字段(pid/shipping_type)直接忽略
unset($where['pid'], $where['shipping_type']);
// 日期范围筛选
if (!empty($where['date_range'])) {
[$start, $end] = explode('-', (string)$where['date_range']);
$query->whereBetween('add_time', [strtotime(trim($start)), strtotime(trim($end)) + 86399]);
unset($where['date_range']);
}
// 剩余条件按简单相等处理
foreach ($where as $key => $value) {
if ($value === '' || $value === null) continue;
$query->where($key, $value);
}
$count = $query->count();
$query->field($field)->order('id desc');
if ($page > 0 && $limit > 0) {
$query->page($page, $limit);
}
$list = $query->select()->toArray();
// 解析 extra_info 快照,组装导出所需字段
foreach ($list as &$item) {
$extra = is_string($item['extra_info'] ?? null) ? (json_decode($item['extra_info'], true) ?: []) : ($item['extra_info'] ?? []);
$item['extra_info'] = $extra;
$contact = $extra['contact'] ?? [];
$item['real_name'] = $contact['name'] ?? '';
$item['user_phone'] = $contact['phone'] ?? '';
$addr = $contact['address'] ?? '';
$item['user_address'] = is_array($addr)
? implode('', array_filter([$addr['province'] ?? '', $addr['city'] ?? '', $addr['district'] ?? '', $addr['detail'] ?? '']))
: ($addr ?: '');
$item['custom_form'] = $extra['custom_form'] ?? [];
// 商品明细组装为旧 _info(cart_info)结构,供导出遍历
$info = [];
foreach ($extra['items'] ?? [] as $it) {
$info[] = [
'cart_info' => [
'productInfo' => [
'store_name' => $it['store_name'] ?? '',
'attrInfo' => $it['attr_info'] ?? [],
],
'cart_num' => $it['cart_num'] ?? 1,
'truePrice' => $it['true_price'] ?? ($it['price'] ?? '0.00'),
],
];
}
$item['_info'] = $info;
}
return ['data' => $list, 'count' => $count];
}
/**
* 按订单号批量获取普通商品订单的快递信息(发货单导出用)
* @param array $orderNos 订单号集合
* @return array 返回订单号 => 快递信息映射
*/
public function getDeliveryInfoByOrderNos(array $orderNos): array
{
if (empty($orderNos)) {
return [];
}
$list = \think\facade\Db::name('pt_normal_order')
->whereIn('order_no', $orderNos)
->field('order_no, delivery_name, delivery_id, delivery_type')
->select()
->toArray();
return array_column($list, null, 'order_no');
}
}