| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000 |
- <?php
- // +----------------------------------------------------------------------
- // | saiadmin [ saiadmin快速开发框架 ]
- // +----------------------------------------------------------------------
- namespace plugin\saiadmin\app\logic\merchant;
- use plugin\saiadmin\app\model\merchant\Merchant;
- use plugin\saiadmin\app\model\merchant\MerchantDevice;
- use plugin\saiadmin\app\model\system\SystemDept;
- use plugin\saiadmin\app\model\system\SystemUser;
- use plugin\saiadmin\basic\think\BaseLogic;
- use plugin\saiadmin\exception\ApiException;
- use plugin\saiadmin\service\OpenSpoutWriter;
- use support\Response;
- use support\think\Db;
- /**
- * 商户业务逻辑
- *
- * 旧系统通过 admin_user_dependen.pid 查询直属商户;新系统将同一关系收敛为
- * sa_merchant.agent_id,并继续在所有读写操作中校验当前代理的数据边界。
- */
- class MerchantLogic extends BaseLogic
- {
- private const PRODUCT_CODES = [
- 'vehicle' => '车载充电器',
- 'k-battery' => 'K霸宝',
- 'bluetooth' => '蓝牙线充',
- 'power-bank' => '充电宝',
- 'password' => '密码线充',
- 'password-bluetooth' => '密码蓝牙线充',
- ];
- /**
- * 旧项目 device_depend_type_deal 中与当前平台产品对应的设备类型。
- */
- private const DEVICE_TYPE_NAMES = [
- 1 => '充电底座',
- 100 => 'K霸宝',
- 101 => '充电宝',
- 102 => 'K霸宝 Blue',
- 103 => '密码线充',
- 104 => '蓝牙线充',
- 105 => '密码蓝牙线充',
- 107 => '车载充电器',
- ];
- private const WRITABLE_FIELDS = [
- 'name', 'store_phone', 'storefront_image', 'industry', 'region', 'address',
- 'description', 'products', 'profit_method', 'profit_rate', 'contact', 'phone',
- 'permissions', 'distributed', 'status', 'agent_id', 'agent_type', 'parent_merchant_id',
- ];
- public function __construct()
- {
- $this->model = new Merchant();
- }
- /**
- * 商户分页列表。
- */
- public function indexList(array $where): array
- {
- $query = $this->buildListQuery($where);
- [$orderField, $orderType] = $this->resolveOrder($where);
- $page = max(1, (int) request()->input('page', 1));
- $limit = min(100, max(1, (int) request()->input('limit', 10)));
- $result = $query->order($orderField, $orderType)->paginate([
- 'page' => $page,
- 'list_rows' => $limit,
- ])->toArray();
- $result['summary'] = $this->typeSummary($where);
- return $this->hydratePage($result);
- }
- /**
- * 按当前筛选条件导出全部有权访问的商户。
- */
- public function export(array $where): Response
- {
- $query = $this->buildListQuery($where);
- [$orderField, $orderType] = $this->resolveOrder($where);
- $result = $this->hydratePage([
- 'data' => $query->order($orderField, $orderType)->select()->toArray(),
- ]);
- $data = [];
- foreach ($result['data'] as $row) {
- $data[] = [
- 'id' => (int) $row['id'],
- 'name' => $this->excelSafe($row['name']),
- 'store_phone' => $this->excelSafe($row['store_phone']),
- 'phone' => $this->excelSafe($row['phone']),
- 'address' => $this->excelSafe($row['address']),
- 'total_amount' => (float) $row['total_amount'],
- 'total_income' => (float) $row['total_income'],
- 'withdrawable_amount' => (float) $row['withdrawable_amount'],
- 'order_count' => (int) $row['order_count'],
- 'device_count' => (int) $row['device_count'],
- 'yesterday_usage_rate' => (float) $row['yesterday_usage_rate'],
- 'three_day_usage_rate' => (float) $row['three_day_usage_rate'],
- 'seven_day_usage_rate' => (float) $row['seven_day_usage_rate'],
- 'agent_name' => $this->excelSafe($row['agent_name']),
- 'agent_phone' => $this->excelSafe($row['agent_phone']),
- 'profit_user_name' => $this->excelSafe($row['profit_user_name']),
- 'profit_user_phone' => $this->excelSafe($row['profit_user_phone']),
- 'profit_method' => $row['profit_method'],
- 'profit_rate' => (float) $row['profit_rate'],
- 'region' => $this->excelSafe($row['region']),
- 'industry' => $this->excelSafe($row['industry']),
- 'device_type' => $this->excelSafe($row['device_type']),
- 'distributed' => (int) $row['distributed'] === 1 ? '已铺货' : '未铺货',
- 'status' => (int) $row['status'] === 1 ? '启用' : '停用',
- 'created_at' => $row['created_at'],
- ];
- }
- $fileName = '商户数据_' . date('Ymd_His') . '.xlsx';
- $writer = new OpenSpoutWriter($fileName);
- $writer->setWidth([10, 24, 16, 16, 36, 15, 15, 15, 12, 12, 14, 14, 14, 18, 16, 18, 16, 14, 12, 24, 20, 24, 12, 10, 22]);
- $writer->setHeader([
- '商户ID', '商户名称', '商户电话', '登录手机号', '商户地址',
- '总交易额', '总收益', '可提现金额', '订单量', '设备数',
- '昨日使用率(%)', '近三天使用率(%)', '近七天使用率(%)',
- '上级代理', '代理电话', '分润人', '分润人电话', '分成方式', '分润比例(%)',
- '城市区域', '行业分类', '设备类型', '铺货状态', '商户状态', '创建时间',
- ]);
- $writer->setData($data);
- return response()->download($writer->returnFile(), urlencode($fileName));
- }
- /**
- * 读取一个当前用户有权访问的商户。
- */
- public function readAccessible(int $id): array
- {
- return $this->formatMerchant($this->findAccessible($id)->toArray(), $this->userMapForMerchant($id));
- }
- /**
- * 新建直属商户。
- */
- public function addMerchant(array $data): int
- {
- $payload = $this->sanitize($data);
- $payload['agent_id'] = $this->resolveAgentId((int) ($data['agent_id'] ?? 0));
- $payload['parent_merchant_id'] = $this->resolveParentMerchantId(
- (int) ($data['parent_merchant_id'] ?? 0)
- );
- if ($payload['parent_merchant_id'] !== null) {
- $parent = $this->findAccessible((int) $payload['parent_merchant_id']);
- $payload['agent_id'] = (int) $parent->agent_id;
- }
- $this->assertPhoneUnique($payload['phone']);
- $payload['password'] = password_hash((string) $data['password'], PASSWORD_DEFAULT);
- return (int) $this->add($payload);
- }
- /**
- * 修改商户基础资料和分润资料。
- */
- public function editMerchant(int $id, array $data): bool
- {
- $model = $this->findAccessible($id);
- $payload = $this->sanitize($data);
- unset($payload['parent_merchant_id']);
- if ($this->currentAdminId() !== 1) {
- unset($payload['agent_id']);
- } elseif (isset($payload['agent_id'])) {
- $payload['agent_id'] = $this->resolveAgentId((int) $payload['agent_id']);
- }
- $this->assertPhoneUnique($payload['phone'], $id);
- return $model->save($payload) !== false;
- }
- /**
- * 重置商户登录密码。
- */
- public function resetPassword(int $id, string $password): bool
- {
- $model = $this->findAccessible($id);
- return $model->save(['password' => password_hash($password, PASSWORD_DEFAULT)]) !== false;
- }
- /**
- * 查询商户当前绑定的设备。
- */
- public function boundDevices(int $merchantId): array
- {
- $this->findAccessible($merchantId);
- $bindings = Db::table('sa_merchant_device_binding')
- ->where('merchant_id', $merchantId)
- ->order('bind_time', 'desc')
- ->select()
- ->toArray();
- if ($bindings === []) {
- return [];
- }
- $deviceIds = array_map('intval', array_column($bindings, 'device_id'));
- $devices = MerchantDevice::whereIn('id', $deviceIds)->select()->toArray();
- $deviceMap = [];
- foreach ($devices as $device) {
- $deviceMap[(int) $device['id']] = $device;
- }
- $result = [];
- foreach ($bindings as $binding) {
- $device = $deviceMap[(int) $binding['device_id']] ?? [];
- $type = (int) ($device['goods_type'] ?? $binding['depend_type'] ?? 0);
- $result[] = [
- 'id' => (int) $binding['device_id'],
- 'name' => (string) ($device['goods_name'] ?? ''),
- 'sn' => (string) $binding['device_sn'],
- 'device_code' => (string) ($device['device_code'] ?? ''),
- 'model' => (string) ($device['model'] ?? ''),
- 'type' => $type,
- 'type_name' => self::DEVICE_TYPE_NAMES[$type] ?? '其他设备',
- 'status' => (int) ($device['status'] ?? 0),
- 'status_name' => $this->deviceStatusName((int) ($device['status'] ?? 0)),
- 'online_status' => (int) ($device['device_status'] ?? 0),
- 'bind_time' => (string) $binding['bind_time'],
- ];
- }
- return $result;
- }
- /**
- * 将设备绑定到商户。
- *
- * 迁移自旧系统 Store::bindZuo2Store:校验设备存在和管理权,阻止设备重复铺货,
- * 写入当前关系及铺货流水,并同步商户设备数与铺货状态。
- */
- public function bindDevices(int $merchantId, array $deviceSns): array
- {
- $merchant = $this->findAccessible($merchantId);
- $sns = $this->normalizeDeviceSns($deviceSns);
- $operatorId = $this->currentAdminId();
- return $this->transaction(function () use ($merchant, $merchantId, $sns, $operatorId) {
- $deviceRows = Db::table('sa_device')
- ->whereIn('goods_sn', $sns)
- ->whereNull('delete_time')
- ->lock(true)
- ->select()
- ->toArray();
- $deviceMap = [];
- foreach ($deviceRows as $device) {
- $deviceMap[(string) $device['goods_sn']] = $device;
- }
- $deviceIds = array_map('intval', array_column($deviceRows, 'id'));
- $bindingMap = [];
- if ($deviceIds !== []) {
- $bindingRows = Db::table('sa_merchant_device_binding')
- ->whereIn('device_id', $deviceIds)
- ->lock(true)
- ->select()
- ->toArray();
- foreach ($bindingRows as $binding) {
- $bindingMap[(int) $binding['device_id']] = $binding;
- }
- }
- $now = date('Y-m-d H:i:s');
- $bindings = [];
- $records = [];
- $success = [];
- $alreadyBound = [];
- $boundElsewhere = [];
- $notFound = [];
- $notOwned = [];
- foreach ($sns as $sn) {
- if (!isset($deviceMap[$sn])) {
- $notFound[] = $sn;
- continue;
- }
- $device = $deviceMap[$sn];
- if ($operatorId !== 1 && (int) $device['manage_agent_id'] !== $operatorId) {
- $notOwned[] = $sn;
- continue;
- }
- $deviceId = (int) $device['id'];
- if (isset($bindingMap[$deviceId])) {
- if ((int) $bindingMap[$deviceId]['merchant_id'] === $merchantId) {
- $alreadyBound[] = $sn;
- } else {
- $boundElsewhere[] = $sn;
- }
- continue;
- }
- $type = (int) $device['goods_type'];
- $bindings[] = [
- 'device_id' => $deviceId,
- 'device_sn' => $sn,
- 'depend_type' => $type,
- 'merchant_id' => $merchantId,
- 'status' => 1,
- 'operator_id' => $operatorId,
- 'bind_time' => $now,
- 'create_time' => $now,
- 'update_time' => $now,
- ];
- $records[] = [
- 'device_id' => $deviceId,
- 'device_sn' => $sn,
- 'depend_type' => $type,
- 'merchant_id' => $merchantId,
- 'action_type' => 1,
- 'operator_id' => $operatorId,
- 'action_time' => $now,
- 'created_by' => $operatorId,
- 'create_time' => $now,
- ];
- $success[] = $sn;
- }
- if ($bindings !== []) {
- Db::table('sa_merchant_device_binding')->insertAll($bindings);
- Db::table('sa_merchant_device_binding_record')->insertAll($records);
- $this->syncMerchantDeviceSnapshot($merchant);
- }
- $message = $success === [] ? '没有可绑定的新设备' : '成功绑定 ' . count($success) . ' 台设备';
- return [
- 'message' => $message,
- 'bind_success_sn' => $success,
- 'has_binded_sn' => $alreadyBound,
- 'binded_other_store_sn' => $boundElsewhere,
- 'not_find_sn' => $notFound,
- 'not_owned_sn' => $notOwned,
- 'all_action_sn' => $sns,
- ];
- });
- }
- /**
- * 解除设备与指定商户的绑定,并保留解绑流水。
- */
- public function unbindDevice(int $merchantId, int $deviceId): bool
- {
- $merchant = $this->findAccessible($merchantId);
- $operatorId = $this->currentAdminId();
- return (bool) $this->transaction(function () use ($merchant, $merchantId, $deviceId, $operatorId) {
- $binding = Db::table('sa_merchant_device_binding')
- ->where('device_id', $deviceId)
- ->where('merchant_id', $merchantId)
- ->lock(true)
- ->find();
- if (empty($binding)) {
- throw new ApiException('设备未绑定到当前商户');
- }
- $device = Db::table('sa_device')->where('id', $deviceId)->whereNull('delete_time')->find();
- if (empty($device)) {
- throw new ApiException('设备不存在');
- }
- if ($operatorId !== 1 && (int) $device['manage_agent_id'] !== $operatorId) {
- throw new ApiException('无权解绑该设备');
- }
- $now = date('Y-m-d H:i:s');
- Db::table('sa_merchant_device_binding')
- ->where('device_id', $deviceId)
- ->where('merchant_id', $merchantId)
- ->delete();
- Db::table('sa_merchant_device_binding_record')->insert([
- 'device_id' => $deviceId,
- 'device_sn' => (string) $binding['device_sn'],
- 'depend_type' => (int) $binding['depend_type'],
- 'merchant_id' => $merchantId,
- 'action_type' => 2,
- 'operator_id' => $operatorId,
- 'action_time' => $now,
- 'created_by' => $operatorId,
- 'create_time' => $now,
- ]);
- Db::table('sa_device')->where('id', $deviceId)->update([
- 'is_active' => 0,
- 'active_time' => null,
- 'updated_by' => $operatorId,
- 'update_time' => $now,
- ]);
- $this->syncMerchantDeviceSnapshot($merchant);
- return true;
- });
- }
- /**
- * 获取商户可提现金额更新方式。
- *
- * update_mode 对应旧系统 crond_money_type:0 实时更新,1 定时更新。
- * update_day 为 1 至 31;0 在定时模式下表示每月最后一天。
- */
- public function readWithdrawalSetting(int $merchantId): array
- {
- $merchant = $this->findAccessible($merchantId);
- return [
- 'merchant_id' => (int) $merchant->id,
- 'update_mode' => (int) ($merchant->withdraw_update_mode ?? 0),
- 'update_day' => (int) ($merchant->withdraw_update_day ?? 0),
- ];
- }
- /**
- * 保存商户可提现金额更新方式。
- */
- public function updateWithdrawalSetting(int $merchantId, int $mode, int $day): bool
- {
- $merchant = $this->findAccessible($merchantId);
- if (!in_array($mode, [0, 1], true)) {
- throw new ApiException('请选择正确的更新方式');
- }
- if ($mode === 1 && ($day < 0 || $day > 31)) {
- throw new ApiException('请选择正确的更新日期');
- }
- return $merchant->save([
- 'withdraw_update_mode' => $mode,
- 'withdraw_update_day' => $mode === 0 ? 0 : $day,
- ]) !== false;
- }
- /**
- * 获取商户营业时间描述。
- *
- * 旧系统将营业时间作为展示文本保存,默认营业时间为 09:00-22:00。
- */
- public function readBusinessTime(int $merchantId): array
- {
- $merchant = $this->findAccessible($merchantId);
- $businessTime = trim((string) ($merchant->business_time_des ?? ''));
- return [
- 'merchant_id' => (int) $merchant->id,
- 'business_time_des' => $businessTime !== '' ? $businessTime : '09:00-22:00',
- ];
- }
- /**
- * 保存商户营业时间描述。
- */
- public function updateBusinessTime(int $merchantId, string $businessTime): bool
- {
- $merchant = $this->findAccessible($merchantId);
- $businessTime = trim($businessTime);
- if ($businessTime === '') {
- throw new ApiException('请填写营业时间');
- }
- if (mb_strlen($businessTime) > 64) {
- throw new ApiException('营业时间不能超过 64 个字符');
- }
- return $merchant->save(['business_time_des' => $businessTime]) !== false;
- }
- /**
- * 获取商户按日统计。
- *
- * 数据口径迁移自旧项目 statistics_shop_oneday:金额以分存储、按日汇总,
- * 同时保留微信、支付宝、余额三种支付方式的金额和订单量拆分。
- */
- public function statistics(int $merchantId, string $startDate = '', string $endDate = ''): array
- {
- $merchant = $this->findAccessible($merchantId);
- [$start, $end] = $this->normalizeStatisticsRange($startDate, $endDate);
- $rows = Db::table('sa_merchant_daily_statistics')
- ->where('merchant_id', $merchantId)
- ->where('stat_date', 'between', [$start, $end])
- ->order('stat_date', 'asc')
- ->select()
- ->toArray();
- $rowMap = [];
- foreach ($rows as $row) {
- $rowMap[(string) $row['stat_date']] = $row;
- }
- $daily = [];
- $summary = [
- 'total_amount_cents' => 0,
- 'total_income_cents' => 0,
- 'total_orders' => 0,
- 'usage_rate_sum' => 0.0,
- ];
- $payment = [
- 'wechat' => ['amount_cents' => 0, 'orders' => 0],
- 'alipay' => ['amount_cents' => 0, 'orders' => 0],
- 'balance' => ['amount_cents' => 0, 'orders' => 0],
- ];
- $cursor = new \DateTimeImmutable($start);
- $lastDay = new \DateTimeImmutable($end);
- while ($cursor <= $lastDay) {
- $date = $cursor->format('Y-m-d');
- $row = $rowMap[$date] ?? [];
- $amountCents = (int) ($row['turnover_cents'] ?? 0);
- $incomeCents = (int) ($row['income_cents'] ?? 0);
- $orders = (int) ($row['order_count'] ?? 0);
- $usageRate = round((float) ($row['device_usage_rate'] ?? 0), 2);
- $wechatAmountCents = (int) ($row['wechat_turnover_cents'] ?? 0);
- $alipayAmountCents = (int) ($row['alipay_turnover_cents'] ?? 0);
- $balanceAmountCents = (int) ($row['balance_turnover_cents'] ?? 0);
- $wechatOrders = (int) ($row['wechat_order_count'] ?? 0);
- $alipayOrders = (int) ($row['alipay_order_count'] ?? 0);
- $balanceOrders = (int) ($row['balance_order_count'] ?? 0);
- $daily[] = [
- 'date' => $date,
- 'amount' => $this->centsToMoney($amountCents),
- 'income' => $this->centsToMoney($incomeCents),
- 'orders' => $orders,
- 'usage_rate' => $usageRate,
- 'device_count' => (int) ($row['device_count'] ?? $merchant->device_count ?? 0),
- 'wechat_amount' => $this->centsToMoney($wechatAmountCents),
- 'alipay_amount' => $this->centsToMoney($alipayAmountCents),
- 'balance_amount' => $this->centsToMoney($balanceAmountCents),
- 'wechat_orders' => $wechatOrders,
- 'alipay_orders' => $alipayOrders,
- 'balance_orders' => $balanceOrders,
- ];
- $summary['total_amount_cents'] += $amountCents;
- $summary['total_income_cents'] += $incomeCents;
- $summary['total_orders'] += $orders;
- $summary['usage_rate_sum'] += $usageRate;
- $payment['wechat']['amount_cents'] += $wechatAmountCents;
- $payment['wechat']['orders'] += $wechatOrders;
- $payment['alipay']['amount_cents'] += $alipayAmountCents;
- $payment['alipay']['orders'] += $alipayOrders;
- $payment['balance']['amount_cents'] += $balanceAmountCents;
- $payment['balance']['orders'] += $balanceOrders;
- $cursor = $cursor->modify('+1 day');
- }
- $dayCount = count($daily);
- return [
- 'merchant' => [
- 'id' => (int) $merchant->id,
- 'name' => (string) $merchant->name,
- ],
- 'range' => ['start_date' => $start, 'end_date' => $end, 'day_count' => $dayCount],
- 'summary' => [
- 'total_amount' => $this->centsToMoney($summary['total_amount_cents']),
- 'total_income' => $this->centsToMoney($summary['total_income_cents']),
- 'total_orders' => $summary['total_orders'],
- 'average_usage_rate' => $dayCount > 0
- ? round($summary['usage_rate_sum'] / $dayCount, 2)
- : 0.0,
- ],
- 'payment' => [
- 'wechat' => [
- 'amount' => $this->centsToMoney($payment['wechat']['amount_cents']),
- 'orders' => $payment['wechat']['orders'],
- ],
- 'alipay' => [
- 'amount' => $this->centsToMoney($payment['alipay']['amount_cents']),
- 'orders' => $payment['alipay']['orders'],
- ],
- 'balance' => [
- 'amount' => $this->centsToMoney($payment['balance']['amount_cents']),
- 'orders' => $payment['balance']['orders'],
- ],
- ],
- 'daily' => $daily,
- ];
- }
- /**
- * 导出所选时间范围内的商户每日统计明细。
- */
- public function statisticsExport(int $merchantId, string $startDate = '', string $endDate = ''): Response
- {
- $statistics = $this->statistics($merchantId, $startDate, $endDate);
- $data = [];
- foreach ($statistics['daily'] as $row) {
- $data[] = [
- 'date' => $row['date'],
- 'amount' => $row['amount'],
- 'income' => $row['income'],
- 'orders' => $row['orders'],
- 'usage_rate' => $row['usage_rate'],
- 'wechat_amount' => $row['wechat_amount'],
- 'wechat_orders' => $row['wechat_orders'],
- 'alipay_amount' => $row['alipay_amount'],
- 'alipay_orders' => $row['alipay_orders'],
- 'balance_amount' => $row['balance_amount'],
- 'balance_orders' => $row['balance_orders'],
- 'device_count' => $row['device_count'],
- ];
- }
- $safeMerchantName = preg_replace('/[\\\\\/:*?"<>|]+/u', '_', $statistics['merchant']['name']);
- $fileName = sprintf(
- '%s_数据统计_%s_%s.xlsx',
- $safeMerchantName ?: '商户',
- str_replace('-', '', $statistics['range']['start_date']),
- str_replace('-', '', $statistics['range']['end_date'])
- );
- $writer = new OpenSpoutWriter($fileName);
- $writer->setWidth([14, 14, 14, 12, 14, 14, 12, 14, 12, 14, 12, 12]);
- $writer->setHeader([
- '日期', '交易额(元)', '收益额(元)', '订单量', '使用率(%)',
- '微信金额(元)', '微信订单量', '支付宝金额(元)', '支付宝订单量',
- '余额金额(元)', '余额订单量', '设备数',
- ]);
- $writer->setData($data);
- return response()->download($writer->returnFile(), urlencode($fileName));
- }
- /**
- * 软删除商户。禁止跨代理越权删除。
- */
- public function destroyAccessible(array|int|string $ids): bool
- {
- $idList = is_array($ids) ? $ids : explode(',', (string) $ids);
- $idList = array_values(array_unique(array_filter(array_map('intval', $idList))));
- if ($idList === []) {
- throw new ApiException('请选择要删除的商户');
- }
- return (bool) $this->transaction(function () use ($idList) {
- foreach ($idList as $id) {
- $this->findAccessible($id)->delete();
- }
- return true;
- });
- }
- private function normalizeDeviceSns(array $deviceSns): array
- {
- $sns = [];
- foreach ($deviceSns as $value) {
- $sn = trim((string) $value);
- if ($sn === '') {
- continue;
- }
- if (strlen($sn) > 60) {
- throw new ApiException('设备 SN 码不能超过 60 个字符');
- }
- $sns[$sn] = $sn;
- }
- $sns = array_values($sns);
- if ($sns === []) {
- throw new ApiException('请至少输入一个设备 SN 码');
- }
- if (count($sns) > 100) {
- throw new ApiException('单次最多绑定 100 台设备');
- }
- return $sns;
- }
- private function syncMerchantDeviceSnapshot(Merchant $merchant): void
- {
- $count = Db::table('sa_merchant_device_binding')
- ->where('merchant_id', (int) $merchant->id)
- ->count();
- $merchant->save([
- 'device_count' => $count,
- 'distributed' => $count > 0 ? 1 : 0,
- ]);
- }
- private function deviceStatusName(int $status): string
- {
- return match ($status) {
- 1 => '运营中',
- 2 => '报修',
- default => '未启用',
- };
- }
- private function buildListQuery(array $where)
- {
- $query = $this->model->where('id', '>', 0);
- $this->applyDataScope(
- $query,
- (string) ($where['scope'] ?? 'own'),
- (int) ($where['agent_id'] ?? 0)
- );
- $name = trim((string) ($where['name'] ?? ''));
- if ($name !== '') {
- $query->whereLike('name', '%' . $name . '%');
- }
- $phone = trim((string) ($where['phone'] ?? ''));
- if ($phone !== '') {
- $query->where(function ($subQuery) use ($phone) {
- $subQuery->whereLike('store_phone', '%' . $phone . '%')
- ->whereOr('phone', 'like', '%' . $phone . '%');
- });
- }
- if (($where['distributed'] ?? '') !== '') {
- $query->where('distributed', (int) $where['distributed']);
- }
- if (($where['status'] ?? '') !== '') {
- $query->where('status', (int) $where['status']);
- }
- $profitMethod = trim((string) ($where['profit_method'] ?? ''));
- if ($profitMethod !== '') {
- $query->where('profit_method', $profitMethod);
- }
- $agentType = trim((string) ($where['agent_type'] ?? ''));
- if ($agentType !== '') {
- $query->where('agent_type', $agentType);
- }
- $deviceCategory = trim((string) ($where['device_category'] ?? ''));
- if ($deviceCategory !== '' && $deviceCategory !== 'all' && isset(self::PRODUCT_CODES[$deviceCategory])) {
- $query->whereLike('products', '%"' . self::PRODUCT_CODES[$deviceCategory] . '"%');
- }
- return $query;
- }
- /**
- * 返回当前数据范围和筛选条件下的代理类型数量。
- * agent_type 本身不参与统计,避免切换分类后其它分类数量变成 0。
- */
- private function typeSummary(array $where): array
- {
- $countWhere = $where;
- $countWhere['agent_type'] = '';
- $summary = [
- 'all' => $this->buildListQuery($countWhere)->count(),
- 'city-partner' => 0,
- 'agent' => 0,
- 'salesperson' => 0,
- ];
- foreach (['city-partner', 'agent', 'salesperson'] as $type) {
- $typedWhere = $countWhere;
- $typedWhere['agent_type'] = $type;
- $summary[$type] = $this->buildListQuery($typedWhere)->count();
- }
- return array_map('intval', $summary);
- }
- private function resolveOrder(array $where): array
- {
- return match ((string) ($where['sort'] ?? 'default')) {
- 'amount_desc' => ['total_amount', 'DESC'],
- 'income_desc' => ['total_income', 'DESC'],
- 'created_asc' => ['id', 'ASC'],
- default => ['id', 'DESC'],
- };
- }
- private function excelSafe(mixed $value): string
- {
- $value = (string) $value;
- return preg_match('/^[=+\-@]/u', $value) === 1 ? "'" . $value : $value;
- }
- private function normalizeStatisticsRange(string $startDate, string $endDate): array
- {
- if ($startDate === '' && $endDate === '') {
- $startDate = date('Y-m-01');
- $endDate = date('Y-m-d');
- }
- if (!$this->isValidDate($startDate) || !$this->isValidDate($endDate)) {
- throw new ApiException('请选择正确的统计日期');
- }
- $start = new \DateTimeImmutable($startDate);
- $end = new \DateTimeImmutable($endDate);
- if ($start > $end) {
- throw new ApiException('开始日期不能晚于结束日期');
- }
- $dayCount = (int) $start->diff($end)->days + 1;
- if ($dayCount > 366) {
- throw new ApiException('单次最多查询 366 天数据');
- }
- return [$start->format('Y-m-d'), $end->format('Y-m-d')];
- }
- private function isValidDate(string $value): bool
- {
- $date = \DateTimeImmutable::createFromFormat('!Y-m-d', $value);
- $errors = \DateTimeImmutable::getLastErrors();
- return $date !== false
- && ($errors === false || ($errors['warning_count'] === 0 && $errors['error_count'] === 0))
- && $date->format('Y-m-d') === $value;
- }
- private function centsToMoney(int $cents): string
- {
- return number_format($cents / 100, 2, '.', '');
- }
- private function sanitize(array $data): array
- {
- $payload = [];
- foreach (self::WRITABLE_FIELDS as $field) {
- if (array_key_exists($field, $data)) {
- $payload[$field] = $data[$field];
- }
- }
- foreach (['name', 'store_phone', 'storefront_image', 'industry', 'region', 'address', 'contact', 'phone', 'agent_type'] as $field) {
- if (array_key_exists($field, $payload)) {
- $payload[$field] = trim((string) $payload[$field]);
- }
- }
- $payload['description'] = (string) ($payload['description'] ?? '');
- $payload['products'] = array_values(array_unique(array_filter(array_map('strval', $payload['products'] ?? []))));
- $payload['permissions'] = array_values(array_unique(array_filter(array_map('strval', $payload['permissions'] ?? []))));
- $payload['profit_method'] = trim((string) ($payload['profit_method'] ?? ''));
- if (!in_array($payload['profit_method'], ['比例分成', '不分成', '入驻费'], true)) {
- throw new ApiException('请选择正确的分成方式');
- }
- $payload['profit_rate'] = (float) ($payload['profit_rate'] ?? 0);
- $payload['distributed'] = (int) ($payload['distributed'] ?? 0);
- $payload['status'] = (int) ($payload['status'] ?? 1);
- $payload['agent_type'] = $payload['agent_type'] !== '' ? $payload['agent_type'] : 'agent';
- return $payload;
- }
- private function assertPhoneUnique(string $phone, int $exceptId = 0): void
- {
- $query = $this->model->where('phone', $phone);
- if ($exceptId > 0) {
- $query->where('id', '<>', $exceptId);
- }
- if ($query->count() > 0) {
- throw new ApiException('该手机号码已绑定其他商户');
- }
- }
- private function resolveAgentId(int $requestedAgentId): int
- {
- $currentId = $this->currentAdminId();
- if ($currentId !== 1) {
- return $currentId;
- }
- $agentId = $requestedAgentId > 0 ? $requestedAgentId : $currentId;
- if (SystemUser::where('id', $agentId)->count() === 0) {
- throw new ApiException('直属代理不存在');
- }
- return $agentId;
- }
- private function resolveParentMerchantId(int $parentMerchantId): ?int
- {
- if ($parentMerchantId <= 0) {
- return null;
- }
- $parent = $this->findAccessible($parentMerchantId);
- if ((int) ($parent->parent_merchant_id ?? 0) > 0) {
- throw new ApiException('只能在主商户下添加分店');
- }
- return (int) $parent->id;
- }
- private function findAccessible(int $id): Merchant
- {
- $query = $this->model->where('id', $id);
- if ($this->currentAdminId() !== 1) {
- $allowedAgentIds = array_merge([$this->currentAdminId()], $this->subordinateAgentIds());
- $query->whereIn('agent_id', array_values(array_unique($allowedAgentIds)));
- }
- $model = $query->findOrEmpty();
- if ($model->isEmpty()) {
- throw new ApiException('商户不存在或无权操作');
- }
- return $model;
- }
- private function applyDataScope($query, string $scope, int $requestedAgentId): void
- {
- $currentId = $this->currentAdminId();
- if ($currentId === 1) {
- if ($requestedAgentId > 0) {
- $query->where('agent_id', $requestedAgentId);
- }
- return;
- }
- if ($scope !== 'subordinate') {
- $query->where('agent_id', $currentId);
- return;
- }
- $agentIds = $this->subordinateAgentIds();
- if ($requestedAgentId > 0) {
- $agentIds = in_array($requestedAgentId, $agentIds, true) ? [$requestedAgentId] : [];
- }
- if ($agentIds === []) {
- $query->where('agent_id', 0);
- return;
- }
- $query->whereIn('agent_id', $agentIds);
- }
- /**
- * SaiAdmin 使用部门层级表达代理上下级,等价替代旧表 admin_user_dependen.pid。
- */
- private function subordinateAgentIds(): array
- {
- $dept = $this->adminInfo['deptList'] ?? [];
- if (empty($dept['id']) || !isset($dept['level'])) {
- return [];
- }
- $levelPrefix = (string) $dept['level'] . (int) $dept['id'] . ',';
- $deptIds = SystemDept::whereLike('level', $levelPrefix . '%')->column('id');
- if ($deptIds === []) {
- return [];
- }
- return array_map('intval', SystemUser::whereIn('dept_id', $deptIds)->column('id'));
- }
- private function currentAdminId(): int
- {
- return (int) ($this->adminInfo['id'] ?? 0);
- }
- private function hydratePage(array $result): array
- {
- $rows = $result['data'] ?? [];
- $userIds = [];
- foreach ($rows as $row) {
- $userIds[] = (int) ($row['agent_id'] ?? 0);
- $userIds[] = (int) ($row['profit_user_id'] ?? 0);
- }
- $userMap = $this->loadUserMap($userIds);
- foreach ($rows as $index => $row) {
- $rows[$index] = $this->formatMerchant($row, $userMap);
- }
- $result['data'] = $rows;
- return $result;
- }
- private function userMapForMerchant(int $merchantId): array
- {
- $merchant = $this->model->where('id', $merchantId)->field('agent_id,profit_user_id')->findOrEmpty();
- if ($merchant->isEmpty()) {
- return [];
- }
- return $this->loadUserMap([(int) $merchant->agent_id, (int) $merchant->profit_user_id]);
- }
- private function loadUserMap(array $ids): array
- {
- $ids = array_values(array_unique(array_filter(array_map('intval', $ids))));
- if ($ids === []) {
- return [];
- }
- $users = SystemUser::whereIn('id', $ids)->field('id,username,realname,phone')->select()->toArray();
- $map = [];
- foreach ($users as $user) {
- $map[(int) $user['id']] = $user;
- }
- return $map;
- }
- private function formatMerchant(array $row, array $userMap): array
- {
- $agent = $userMap[(int) ($row['agent_id'] ?? 0)] ?? [];
- $profitUser = $userMap[(int) ($row['profit_user_id'] ?? 0)] ?? [];
- $products = $row['products'] ?? [];
- if (is_string($products)) {
- $products = json_decode($products, true) ?: [];
- }
- $permissions = $row['permissions'] ?? [];
- if (is_string($permissions)) {
- $permissions = json_decode($permissions, true) ?: [];
- }
- $row['products'] = $products;
- $row['permissions'] = $permissions;
- $row['device_type'] = implode('、', $products);
- $row['agent_name'] = (string) ($agent['realname'] ?? $agent['username'] ?? '');
- $row['agent_phone'] = (string) ($agent['phone'] ?? '');
- $row['profit_user_name'] = (string) ($profitUser['realname'] ?? $profitUser['username'] ?? $row['contact'] ?? '');
- $row['profit_user_phone'] = (string) ($profitUser['phone'] ?? $row['phone'] ?? '');
- $row['created_at'] = (string) ($row['create_time'] ?? '');
- unset($row['password']);
- return $row;
- }
- }
|