Răsfoiți Sursa

Merge branch 'dev' of http://git.huaml.com/zhh/huahuibao into dev

shish 1 lună în urmă
părinte
comite
79c2ebb968

+ 28 - 7
.env.example

@@ -1,11 +1,11 @@
 #环境 local dev test production
 YII_ENV=local
-YII_DEBUG=true
-
-# CLI PHP used by detached Yii console processes
-PHP_CLI_BINARY=/usr/local/bin/php
-
-DB_DSN='mysql:host=118.178.193.23;dbname=huahuibao'
+YII_DEBUG=true
+
+# CLI PHP used by detached Yii console processes
+PHP_CLI_BINARY=/usr/local/bin/php
+
+DB_DSN='mysql:host=118.178.193.23;dbname=huahuibao'
 DB_DSN_CONSOLE='mysql:host=118.178.193.23;dbname=huahuibao'
 DB_DSN_BAK='mysql:host=118.178.193.23;dbname=hhb'
 DB_USERNAME='root'
@@ -55,4 +55,25 @@ HD_NEW_APK_VERSION=1.0.0
 HD_NEW_APK_VERSION_MUST_UPDATE=0
 
 #订单有效时长
-ORDER_VALID_TIME=30
+ORDER_VALID_TIME=30
+
+# 链科云打印 v3(参考 https://github.com/liankenet/cloud_printer_demo/tree/main/php )
+# 启用后 printUtil 走链科接口,不再调用飞鹅
+LIANKENET_ENABLED=0
+LIANKENET_API_KEY=
+LIANKENET_DEVICE_ID=
+LIANKENET_DEVICE_KEY=
+# 可选:服务地址、调试、超时
+LIANKENET_SERVER=https://cloud.liankenet.com/
+LIANKENET_DEBUG=0
+LIANKENET_TIMEOUT=10
+# 可选:指定 USB 口与驱动名,不填则自动取设备第一台打印机
+LIANKENET_DEVICE_PORT=
+LIANKENET_PRINTER_MODEL=
+# 纸张代码,A4=9,小票机请按打印机参数配置
+LIANKENET_PAPER_SIZE=9
+# A4 配送单可单独指定纸张代码,不填则沿用 LIANKENET_PAPER_SIZE
+LIANKENET_A4_PAPER_SIZE=9
+# 配送单页脚客服电话,不填则用门店电话
+LIANKENET_SERVICE_PHONE=
+LIANKENET_COPIES=1

+ 26 - 10
app-hd/controllers/CustomController.php

@@ -201,7 +201,6 @@ class CustomController extends BaseController
     {
         $get = Yii::$app->request->get();
         $type = $get['type'] ?? 0;
-        $searchStyle = $get['searchStyle'] ?? 0;
         $where = ['shopId' => $this->shopId];
         $all = CustomClass::getCount($where);
 
@@ -227,6 +226,31 @@ class CustomController extends BaseController
             $sort = 'visitTime DESC';
         }
 
+        $where = array_merge($where, $this->getListSearchWhere($get));
+        $list = CustomService::getCustomList($where, $sort);
+        $list['all'] = $all; //全部的总人数
+        $list['balance'] = $all; //余额排行的总人数
+        $list['buyAmount'] = $all; //消费排行的总人数
+        $list['birth'] = CustomClass::getCount(array_merge(['shopId' => $this->shopId], ['birthdayTime>' => 0])); //有设置生日的总人数
+        $list['deleted'] = CustomClass::getCount(array_merge(['shopId' => $this->shopId], ['delStatus' => 1])); //被删除的总人数
+        $list['vip'] = CustomClass::getCount(array_merge(['shopId' => $this->shopId], ['vip' => 1])); //是vip的总人数
+
+        util::success($list);
+    }
+
+    //客户余额统计 ssh 20260708
+    public function actionBalanceStat()
+    {
+        $get = Yii::$app->request->get();
+        $where = array_merge(['shopId' => $this->shopId], $this->getListSearchWhere($get));
+        $respond = CustomClass::getBalanceStat($where);
+        util::success($respond);
+    }
+
+    private function getListSearchWhere($get)
+    {
+        $where = [];
+        $searchStyle = $get['searchStyle'] ?? 0;
         $name = $get['name'] ?? '';
         if (empty($name)) {
             $name = $get['keyword'] ?? '';
@@ -245,15 +269,7 @@ class CustomController extends BaseController
                 }
             }
         }
-        $list = CustomService::getCustomList($where, $sort);
-        $list['all'] = $all; //全部的总人数
-        $list['balance'] = $all; //余额排行的总人数
-        $list['buyAmount'] = $all; //消费排行的总人数
-        $list['birth'] = CustomClass::getCount(array_merge(['shopId' => $this->shopId], ['birthdayTime>' => 0])); //有设置生日的总人数
-        $list['deleted'] = CustomClass::getCount(array_merge(['shopId' => $this->shopId], ['delStatus' => 1])); //被删除的总人数
-        $list['vip'] = CustomClass::getCount(array_merge(['shopId' => $this->shopId], ['vip' => 1])); //是vip的总人数
-
-        util::success($list);
+        return $where;
     }
 
     //获取客户信息 ssh 20250510

+ 82 - 1
app-hd/controllers/OrderController.php

@@ -31,10 +31,15 @@ use common\components\payUtil;
 use common\services\xhPayToolService;
 use common\components\util;
 use common\components\stringUtil;
+use common\components\deliverySlipPrintUtil;
+use common\components\greetingCardPrintUtil;
+use common\components\cloudPrintUtil;
+use common\components\cloudPrintPaperSpec;
 use bizHd\promote\services\CouponService;
 use common\components\httpUtil;
 use biz\wx\classes\WxMessageClass;
 use common\components\lakala\Lakala;
+use yii\helpers\ArrayHelper;
 
 class OrderController extends BaseController
 {
@@ -270,6 +275,16 @@ class OrderController extends BaseController
         util::complete('操作成功');
     }
 
+    //批量确认取货 待发货的自取订单 每次500个
+    public function actionBatchFetch()
+    {
+        $where = ["status" => 2, "sendType" => 1, "shopId" => $this->shopId];
+        $orderList = OrderService::getLimitList("id",$where,500,"id desc");
+        $ids = ArrayHelper::getColumn($orderList,"id");
+        OrderService::updateByIds($ids, ["status" => 4]);
+        util::complete($ids);
+    }
+
     //确认发货
     public function actionSend()
     {
@@ -1480,10 +1495,76 @@ class OrderController extends BaseController
         util::success($data);
     }
 
+    /**
+     * 链科云打印纸张类型列表
+     */
+    public function actionCloudPrintPaperTypes()
+    {
+        util::success(['list' => cloudPrintPaperSpec::listTypes()]);
+    }
+
+    /**
+     * 解析打印请求中的纸张参数
+     */
+    protected function parseCloudPrintPaperOptions()
+    {
+        $get = Yii::$app->request->get();
+        $options = [];
+        if (!empty($get['paperType'])) {
+            $options['paperType'] = $get['paperType'];
+        }
+        if (!empty($get['paperSize'])) {
+            $options['paperSize'] = intval($get['paperSize']);
+        }
+        return $options;
+    }
+
+    /**
+     * 链科云打印鲜花配送单
+     */
+    public function actionCloudPrintDeliverySlip()
+    {
+        if (!cloudPrintUtil::isEnabled()) {
+            util::fail('链科云打印未启用,请配置 .env 中 LIANKENET_*');
+        }
+        $id = intval(Yii::$app->request->get('id', 0));
+        if ($id <= 0) {
+            util::fail('订单id不对');
+        }
+        try {
+            deliverySlipPrintUtil::printByOrderId($id, $this->mainId, $this->parseCloudPrintPaperOptions());
+        } catch (\Throwable $e) {
+            Yii::error('配送单云打印失败: ' . $e->getMessage(), __METHOD__);
+            util::fail($e->getMessage());
+        }
+        util::complete('已提交打印');
+    }
+
+    /**
+     * 链科云打印节日贺卡(文案暂写死)
+     */
+    public function actionCloudPrintGreetingCard()
+    {
+        if (!cloudPrintUtil::isEnabled()) {
+            util::fail('链科云打印未启用,请配置 .env 中 LIANKENET_*');
+        }
+        $id = intval(Yii::$app->request->get('id', 0));
+        if ($id <= 0) {
+            util::fail('订单id不对');
+        }
+        try {
+            greetingCardPrintUtil::printByOrderId($id, $this->mainId, $this->parseCloudPrintPaperOptions());
+        } catch (\Throwable $e) {
+            Yii::error('节日贺卡云打印失败: ' . $e->getMessage(), __METHOD__);
+            util::fail($e->getMessage());
+        }
+        util::complete('已提交打印');
+    }
+
     //运费计算  lqh 2021.4.12  暂反回固定
     public function actionFreight()
     {
         util::success(['sedCost' => 10]);
     }
 
-}
+}

+ 86 - 8
app-hd/controllers/RechargeController.php

@@ -12,6 +12,7 @@ use bizHd\order\classes\OrderClass;
 use bizHd\recharge\classes\RechargeSqClass;
 use bizHd\wx\classes\WxOpenClass;
 use bizMall\wx\classes\WxMiniClass;
+use common\components\dateUtil;
 use common\components\dict;
 use common\components\imgUtil;
 use common\components\miniUtil;
@@ -230,18 +231,95 @@ class RechargeController extends BaseController
     {
         $get = Yii::$app->request->get();
         $customId = $get['customId'] ?? 0;
-        $custom = \bizHd\custom\classes\CustomClass::getById($customId, true);
-        if (empty($custom)) {
-            util::fail('没有找到客户');
+        $where = ['shopId' => $this->shopId, 'status' => 1];
+        if (!empty($customId)) {
+            $custom = \bizHd\custom\classes\CustomClass::getById($customId, true);
+            if (empty($custom)) {
+                util::fail('没有找到客户');
+            }
+            if ($custom->shopId != $this->shopId) {
+                util::fail('不是你的客户');
+            }
+            $where['customId'] = $customId;
         }
-        if ($custom->shopId != $this->shopId) {
-            util::fail('不是你的客户');
+
+        $searchTime = $get['searchTime'] ?? '';
+        if (!empty($searchTime)) {
+            $startTime = $get['startTime'] ?? '';
+            $endTime = $get['endTime'] ?? '';
+            $period = dateUtil::formatTime($searchTime, $startTime, $endTime);
+            $where['addTime'] = ['between', [$period['startTime'], $period['endTime']]];
         }
-        $where = ['shopId' => $this->shopId, 'customId' => $customId];
-        $list = RechargeClass::getRechargeList($where);
+
+        $channelKey = $get['channelKey'] ?? 'all';
+        $io = null;
+        if (isset($get['io']) && $get['io'] !== '') {
+            if (!in_array((string)$get['io'], ['0', '1'], true)) {
+                util::fail('io参数错误');
+            }
+            $io = (int)$get['io'];
+        }
+        $list = \bizHd\recharge\classes\RechargeClass::getRechargeList($where, $channelKey, $io);
         util::success($list);
     }
 
+    //充值统计
+    public function actionStat()
+    {
+        $lookMoney = \bizGhs\shop\classes\ShopAdminClass::lookMoneyPower($this->shopAdmin, $this->shop);
+        if ($lookMoney == 0) {
+            util::fail('无法查看');
+        }
+
+        $shopAdmin = $this->shopAdmin;
+        if (isset($shopAdmin->super) == false || $shopAdmin->super != 1) {
+            util::fail('超管才能查看');
+        }
+
+//        $adminId = $shopAdmin->adminId ?? 0;
+//        if (in_array($adminId, [2366, 2812, 4004, 3405, 3407])) {
+//            util::fail('暂无权限');
+//        }
+
+        $get = Yii::$app->request->get();
+        $data = \bizHd\recharge\classes\RechargeClass::getStatProfile($this->shopId, $get);
+        util::success($data);
+    }
+
+    //充值记录退款:财务权限;线上原路退,线下扣减客户余额。
+    public function actionRefund()
+    {
+        $staff = $this->shopAdmin;
+        if (!isset($staff->finance) || intval($staff->finance) === 0) {
+            util::fail('请有财务权限的人操作');
+        }
+
+        $post = Yii::$app->request->post();
+        $id = intval($post['id'] ?? 0);
+        if ($id <= 0) {
+            util::fail('参数错误');
+        }
+
+        $cacheKey = 'hd_recharge_refund_' . $id;
+        $has = Yii::$app->redis->executeCommand('GET', [$cacheKey]);
+        if (!empty($has)) {
+            util::fail('请5秒之后再提交');
+        }
+        Yii::$app->redis->executeCommand('SETEX', [$cacheKey, 5, 'has']);
+
+        $connection = Yii::$app->db;
+        $transaction = $connection->beginTransaction();
+        try {
+            \bizHd\recharge\classes\RechargeClass::refundPaidRecharge($id, $this->shop, $staff);
+            $transaction->commit();
+            util::complete('退款成功');
+        } catch (\Exception $e) {
+            $transaction->rollBack();
+            Yii::info('充值退款失败:' . $e->getMessage());
+            util::fail($e->getMessage() ?: '退款失败');
+        }
+    }
+
     //免费续期活动 ssh 2021.2.21
     public function actionRenew()
     {
@@ -454,4 +532,4 @@ class RechargeController extends BaseController
         util::success(['deadline' => $deadline]);
     }
 
-}
+}

+ 120 - 0
app-hd/controllers/ShopNoticeController.php

@@ -0,0 +1,120 @@
+<?php
+
+namespace hd\controllers;
+
+use bizHd\shop\classes\ShopNoticeClass;
+use common\components\util;
+use Yii;
+
+/**
+ * 零售花店通知公告
+ */
+class ShopNoticeController extends BaseController
+{
+    /**
+     * 管理端公告列表
+     */
+    public function actionList()
+    {
+        $get = Yii::$app->request->get();
+        $list = ShopNoticeClass::searchList([
+            'mainId' => $this->mainId,
+            'title' => $get['title'] ?? '',
+            'isDel' => 0,
+            'order' => 'id DESC',
+        ]);
+        util::success($list);
+    }
+
+    /**
+     * 客户端公告列表(仅显示中的公告)
+     */
+    public function actionShowList()
+    {
+        $get = Yii::$app->request->get();
+        $list = ShopNoticeClass::searchList([
+            'mainId' => $this->mainId,
+            'title' => $get['title'] ?? '',
+            'position' => $get['position'] ?? '',
+            'isDel' => 0,
+            'status' => 1,
+            'order' => 'sort DESC, id DESC',
+        ]);
+        util::success($list);
+    }
+
+    /**
+     * 新增公告
+     */
+    public function actionAdd()
+    {
+        $post = Yii::$app->request->post();
+        $post['mainId'] = $this->mainId;
+        $post['staffId'] = intval($this->shopAdminId);
+        $id = ShopNoticeClass::addNotice($post);
+        $info = ShopNoticeClass::getDetail($id);
+        util::success($info);
+    }
+
+    /**
+     * 更新公告
+     */
+    public function actionUpdate()
+    {
+        $post = Yii::$app->request->post();
+        $id = intval($post['id'] ?? 0);
+        if ($id <= 0) {
+            util::fail('公告id不对');
+        }
+        $info = ShopNoticeClass::getById($id);
+        ShopNoticeClass::valid($info, $this->mainId);
+        $post['staffId'] = intval($this->shopAdminId);
+        ShopNoticeClass::updateNotice($id, $post);
+        util::complete('修改成功');
+    }
+
+    /**
+     * 公告详情
+     */
+    public function actionDetail()
+    {
+        $id = intval(Yii::$app->request->get('id', 0));
+        if ($id <= 0) {
+            util::fail('公告id不对');
+        }
+        $info = ShopNoticeClass::getById($id);
+        ShopNoticeClass::valid($info, $this->mainId);
+        util::success(ShopNoticeClass::getDetail($id));
+    }
+
+    /**
+     * 软删除公告
+     */
+    public function actionDelete()
+    {
+        $id = intval(Yii::$app->request->post('id', Yii::$app->request->get('id', 0)));
+        if ($id <= 0) {
+            util::fail('公告id不对');
+        }
+        $info = ShopNoticeClass::getById($id);
+        ShopNoticeClass::valid($info, $this->mainId);
+        ShopNoticeClass::deleteNotice($id, intval($this->shopAdminId));
+        util::complete('删除成功');
+    }
+
+    /**
+     * 上下架公告
+     */
+    public function actionUpdateStatus()
+    {
+        $id = intval(Yii::$app->request->post('id', Yii::$app->request->get('id', 0)));
+        $status = intval(Yii::$app->request->post('status', Yii::$app->request->get('status', 0)));
+        if ($id <= 0) {
+            util::fail('公告id不对');
+        }
+        $info = ShopNoticeClass::getById($id);
+        ShopNoticeClass::valid($info, $this->mainId);
+        ShopNoticeClass::updateStatus($id, $status, intval($this->shopAdminId));
+        util::complete('操作成功');
+    }
+}

+ 1 - 1
biz-hd/birthday/classes/BirthdayGiftClass.php

@@ -806,7 +806,7 @@ class BirthdayGiftClass extends BaseClass
             return [false, '短信发送失败'];
         }
 
-        // TODO 记录到 xhSms
+        // 记录到 xhSms
         $sms = new SmsClass();
         $sms->add([
             'mainId' => $shop->mainId,

+ 27 - 0
biz-hd/custom/classes/CustomClass.php

@@ -27,6 +27,33 @@ class CustomClass extends BaseClass
 
     public static $baseFile = '\bizHd\custom\models\Custom';
 
+    public static function getBalanceStat($where)
+    {
+        $cacheKey = self::getBalanceStatCacheKey($where);
+        $cached = \Yii::$app->redis->executeCommand('GET', [$cacheKey]);
+        if ($cached !== false && $cached !== null && $cached !== '') {
+            $data = json_decode($cached, true);
+            if (is_array($data)) {
+                return $data;
+            }
+        }
+
+        $countWhere = array_merge($where, ['balance>' => 0]);
+        $count = self::getCount($countWhere);
+        $totalBalance = self::sum($where, 'balance');
+        $data = [
+            'count' => intval($count),
+            'totalBalance' => bcadd($totalBalance ?: '0', '0', 2),
+        ];
+        \Yii::$app->redis->executeCommand('SETEX', [$cacheKey, 60, json_encode($data)]);
+        return $data;
+    }
+
+    public static function getBalanceStatCacheKey($where)
+    {
+        return 'hd_custom_balance_stat:' . md5(json_encode($where, JSON_UNESCAPED_UNICODE));
+    }
+
     /**
      * 修改客户生日
      * @param $customId

+ 494 - 2
biz-hd/recharge/classes/RechargeClass.php

@@ -17,17 +17,97 @@ use bizHd\shop\classes\ShopExtClass;
 use bizHd\shop\classes\MainClass;
 use bizHd\user\classes\UserGrowthClass;
 use bizHd\user\classes\UserIntegralClass;
+use common\components\dateUtil;
 use common\components\dict;
 use common\components\lakala\Lakala;
 use common\components\noticeUtil;
+use common\components\orderSn;
 use common\components\util;
 use Yii;
 use bizHd\base\classes\BaseClass;
+use yii\db\Expression;
 
 class RechargeClass extends BaseClass
 {
 
     public static $baseFile = '\bizHd\recharge\models\Recharge';
+    const STAT_CACHE_TTL = 300; //统计数据缓存时长
+
+    //充值记录
+    public static function getRechargeList($where, $channelKey = 'all', $io = null)
+    {
+        switch ($channelKey) {
+            case 'system':
+                $where['onlinePay'] = 2;
+                break;
+            case 'online_0':
+                $where['onlinePay'] = 2;
+                $where['payWay'] = 0;
+                break;
+            case 'online_1':
+                $where['onlinePay'] = 2;
+                $where['payWay'] = 1;
+                break;
+            case 'pay_0':
+                $where['onlinePay!='] = 2;
+                $where['payWay'] = 0;
+                break;
+            case 'pay_1':
+                $where['onlinePay!='] = 2;
+                $where['payWay'] = 1;
+                break;
+            case 'pay_4':
+                $where['onlinePay!='] = 2;
+                $where['payWay'] = 4;
+                break;
+            case 'pay_5':
+                $where['onlinePay!='] = 2;
+                $where['payWay'] = 5;
+                break;
+            case 'other':
+                $where['onlinePay!='] = 2;
+                $where['payWay'] = ['not in', [0, 1, 4, 5]];
+                break;
+            default:
+                break;
+        }
+
+        if ($io === null || $io === '') {
+            return self::getList('*', $where, 'addTime DESC,id DESC');
+        }
+
+        $model = self::getModel();
+        $query = $model->conditionQuery($where)->select('*');
+        if ((int)$io === 1) {
+            $query->andWhere('IFNULL(amount, 0) + IFNULL(giveAmount, 0) > 0');
+        } else {
+            $query->andWhere(['isRefund' => 1]);
+        }
+
+        return self::getRechargePageList($query, 'addTime DESC,id DESC');
+    }
+
+    private static function getRechargePageList($query, $order = '')
+    {
+        $get = Yii::$app->request->get();
+        $page = isset($get['page']) ? $get['page'] : 1;
+        Yii::$app->params['page'] = $page;
+        $pageSize = isset($get['pageSize']) && !empty($get['pageSize']) ? $get['pageSize'] : Yii::$app->params['pageSize'];
+        $offset = ($page - 1) * $pageSize;
+
+        $clone = clone $query;
+        $count = $clone->count();
+        $totalPage = ceil($count / $pageSize);
+        $data['totalNum'] = $count;
+        $data['totalPage'] = $totalPage;
+        $data['moreData'] = $totalPage > $page ? 1 : 0;
+
+        if (!empty($order)) {
+            $query->orderBy($order);
+        }
+        $data['list'] = $query->offset($offset)->limit($pageSize)->asArray()->all();
+        return $data;
+    }
 
     public static function addRecharge($data)
     {
@@ -91,7 +171,7 @@ class RechargeClass extends BaseClass
     }
 
     //第三支付后流程 ssh 2021.4.27
-    public static function thirdPay($payWay, $orderSn, $totalFee, $attach)
+    public static function thirdPay($payWay, $orderSn, $totalFee, $attach, $transactionId = '')
     {
         $recharge = RechargeClass::getByCondition(['orderSn' => $orderSn], true);
         if (empty($recharge)) {
@@ -112,6 +192,8 @@ class RechargeClass extends BaseClass
             'onlinePay' => 2,
             'side' => 1,
         ];
+        $recharge->returnCode = $transactionId;
+        $recharge->save(false);
         self::complete($recharge, $payWay, $params);
         return $recharge;
     }
@@ -382,6 +464,11 @@ class RechargeClass extends BaseClass
             }
         }
 
+        $recharge->settleId = $settleId;
+        $recharge->settleAmount = $settleAmount;
+        $recharge->settleNo = $settleNo;
+        $recharge->save();
+
         $remark = $recharge->remark;
         $change = [
             'customId' => $customId,
@@ -521,6 +608,236 @@ class RechargeClass extends BaseClass
         return $returnChange;
     }
 
+    /**
+     * 花店后台:对已付款客户充值单退款。线上原路退,线下扣减客户余额;xhRecharge.isRefund 标记退款状态。
+     */
+    public static function refundPaidRecharge($rechargeId, $shop, $staff)
+    {
+        $rechargeId = intval($rechargeId);
+        if ($rechargeId <= 0) {
+            util::fail('参数错误');
+        }
+        $recharge = self::getLockById($rechargeId);
+        if (empty($recharge)) {
+            util::fail('没有找到充值记录');
+        }
+        if (intval($recharge->mainId ?? 0) !== intval($shop->mainId ?? 0) || intval($recharge->shopId ?? 0) !== intval($shop->id ?? 0)) {
+            util::fail('没有权限操作该充值记录');
+        }
+        if (intval($recharge->payStatus ?? 0) !== 1 || intval($recharge->status ?? 0) !== 1) {
+            util::fail('仅已付款的充值可退款');
+        }
+        if (self::isRechargeRefunded($recharge)) {
+            util::fail('该充值已退款');
+        }
+
+        $amount = bcadd((string)($recharge->amount ?? '0'), '0', 2);
+        if (bccomp($amount, '0', 2) <= 0) {
+            util::fail('充值金额有误');
+        }
+
+        $customId = intval($recharge->customId ?? 0);
+        $hdId = intval($recharge->hdId ?? 0);
+        $custom = CustomClass::getLockById($customId);
+        $hd = HdClass::getLockById($hdId);
+        if (empty($custom) || empty($hd)) {
+            util::fail('没有找到客户余额账户');
+        }
+
+        $isOnline = intval($recharge->onlinePay ?? 0) === 2;
+        $payWay = intval($recharge->payWay ?? -1);
+        $wxPay = intval(dict::getDict('payWay', 'wxPay'));
+        $aliPay = intval(dict::getDict('payWay', 'alipay'));
+        $cashPay = intval(dict::getDict('payWay', 'cash'));
+        $onlineMain = null;
+
+        if ($isOnline) {
+            if ($payWay !== $wxPay && $payWay !== $aliPay) {
+                util::fail('暂不支持该支付方式原路退款');
+            }
+            $returnCode = trim((string)($recharge->returnCode ?? ''));
+            if ($returnCode === '') {
+                util::fail('缺少支付流水号,无法原路退款');
+            }
+            $onlineMain = MainClass::getLockById($shop->mainId ?? 0);
+            if (empty($onlineMain)) {
+                util::fail('没有找到资产信息');
+            }
+            if (bccomp((string)($onlineMain->balance ?? '0'), $amount, 2) < 0) {
+                util::fail('门店余额不足,无法退款');
+            }
+            self::refundOnlineByLakala($recharge, $shop, $amount, $returnCode);
+        }
+
+        $giveAmount = bcadd((string)($recharge->giveAmount ?? '0'), '0', 2);
+        if (bccomp($giveAmount, '0', 2) < 0) {
+            $giveAmount = '0.00';
+        }
+        $currentGive = bcadd((string)($hd->balanceGive ?? '0'), '0', 2);
+        $deductGive = '0.00';
+        if (bccomp($giveAmount, '0', 2) === 1 && bccomp($currentGive, '0', 2) === 1) {
+            $deductGive = bccomp($currentGive, $giveAmount, 2) >= 0 ? $giveAmount : $currentGive;
+        }
+        $deductPay = bcadd($amount, bcsub($giveAmount, $deductGive, 2), 2);
+        $totalDeduct = bcadd($deductPay, $deductGive, 2);
+
+        $custom->balancePay = bcsub((string)($custom->balancePay ?? '0'), $deductPay, 2);
+        $hd->balancePay = bcsub((string)($hd->balancePay ?? '0'), $deductPay, 2);
+        if (bccomp($deductGive, '0', 2) === 1) {
+            $custom->balanceGive = bcsub((string)($custom->balanceGive ?? '0'), $deductGive, 2);
+            $hd->balanceGive = bcsub((string)($hd->balanceGive ?? '0'), $deductGive, 2);
+        }
+        $custom->balance = bcsub((string)($custom->balance ?? '0'), $totalDeduct, 2);
+        $hd->balance = bcsub((string)($hd->balance ?? '0'), $totalDeduct, 2);
+        $custom->save(false);
+        $hd->save(false);
+
+        if (floatval($custom->balance) != floatval($hd->balance) || floatval($custom->balancePay) != floatval($hd->balancePay) || floatval($custom->balanceGive) != floatval($hd->balanceGive)) {
+            util::fail('账户余额有问题,请联系管理员');
+        }
+
+        $staffId = intval($staff->id ?? 0);
+        $staffName = (string)($staff->name ?? '');
+        $capitalType = dict::getDict('capitalType', 'customRechargeRefund', 'id');
+        $event = '充值退款(操作人:' . $staffName . ')单号:' . ($recharge->orderSn ?? '');
+        $changeBase = [
+            'hdId' => $hdId,
+            'hdName' => $hd->name ?? ($recharge->hdName ?? ''),
+            'customId' => $customId,
+            'customName' => $custom->name ?? ($recharge->customName ?? ''),
+            'relateId' => $rechargeId,
+            'onlinePay' => $recharge->onlinePay ?? 1,
+            'capitalType' => $capitalType,
+            'side' => 0,
+            'payWay' => $payWay,
+            'event' => $event,
+            'staffId' => $staffId,
+            'staffName' => $staffName,
+            'shopId' => $recharge->shopId ?? 0,
+            'mainId' => $recharge->mainId ?? 0,
+            'remark' => '',
+        ];
+
+        BalanceChangeClass::add(array_merge($changeBase, [
+            'amount' => $totalDeduct,
+            'balance' => $hd->balance,
+            'io' => 0,
+        ]), true);
+
+        BalancePayChangeClass::add(array_merge($changeBase, [
+            'amount' => $deductPay,
+            'balance' => $hd->balancePay,
+            'io' => 0,
+        ]), true);
+
+        if (bccomp($deductGive, '0', 2) === 1) {
+            BalanceGiveChangeClass::add(array_merge($changeBase, [
+                'amount' => $deductGive,
+                'balance' => $hd->balanceGive,
+                'io' => 0,
+            ]), true);
+        }
+
+        if ($isOnline) {
+            $main = $onlineMain ?: MainClass::getLockById($shop->mainId ?? 0);
+            if (empty($main)) {
+                util::fail('没有找到资产信息');
+            }
+            ShopClass::customRechargeRefundReduceBalance($main, $shop, $recharge, dict::getDict('capitalType', 'xhRecharge', 'id'), $staffName);
+        } elseif ($payWay === $cashPay) {
+            self::cashRechargeRefundReduceMoney($recharge, $shop, $staffName, $amount);
+        }
+
+        self::markRechargeRefunded($recharge);
+        $recharge->balance = $hd->balance;
+        $recharge->save(false);
+
+        return $recharge;
+    }
+
+    protected static function cashRechargeRefundReduceMoney($recharge, $shop, $staffName, $amount)
+    {
+        $main = MainClass::getLockById($shop->mainId ?? 0);
+        if (empty($main)) {
+            util::fail('没有找到现金账户');
+        }
+        if (bccomp((string)($main->money ?? '0'), $amount, 2) < 0) {
+            util::fail('现金不足' . floatval($amount) . '元');
+        }
+        $main->money = bcsub((string)$main->money, $amount, 2);
+        $main->save(false);
+        ShopMoneyClass::addData([
+            'mainId' => $shop->mainId ?? 0,
+            'sjId' => 0,
+            'amount' => $amount,
+            'balance' => $main->money,
+            'io' => 0,
+            'staffId' => $recharge->staffId ?? 0,
+            'staffName' => $staffName,
+            'customName' => $recharge->customName ?? '',
+            'ptStyle' => dict::getDict('ptStyle', 'hd'),
+            'capitalType' => dict::getDict('capitalType', 'xhRecharge', 'id'),
+            'event' => ($recharge->customName ?? '客户') . '现金充值退款' . floatval($amount) . '元',
+            'remark' => '操作人:' . $staffName,
+        ]);
+    }
+
+    protected static function refundOnlineByLakala($recharge, $shop, $amount, $returnCode)
+    {
+        $termNo = $shop->lklScanTermNo ?? '';
+        if (intval($recharge->payWay ?? 0) === intval(dict::getDict('payWay', 'alipay'))) {
+            $termNo = $shop->lklB2BTermNo ?? '';
+        }
+        if (empty($shop->lklSjNo) || empty($termNo)) {
+            util::fail('门店未配置拉卡拉商户信息');
+        }
+        $merchantPrivateKeyPath = Yii::getAlias('@vendor/lakala') . '/production/api_private_key.pem';
+        $lklCertificatePath = Yii::getAlias('@vendor/lakala') . '/production/lkl-apigw-v1.cer';
+        $laResource = new Lakala([
+            'appid' => 'OP00002119',
+            'serial_no' => '018b08cfddbd',
+            'merchant_no' => $shop->lklSjNo,
+            'term_no' => $termNo,
+            'merchantPrivateKeyPath' => $merchantPrivateKeyPath,
+            'lklCertificatePath' => $lklCertificatePath,
+        ]);
+        $response = $laResource->refund([
+            'refundSn' => orderSn::getRechargeSn(),
+            'orderSn' => $recharge->orderSn ?? '',
+            'refundAmount' => bcmul($amount, 100),
+            'refundReason' => '充值退款',
+            'thirdNo' => $returnCode,
+        ]);
+        if (!isset($response['code']) || $response['code'] != 'BBS00000') {
+            util::fail('原路退款失败:' . ($response['msg'] ?? '退款失败'));
+        }
+    }
+
+    protected static function isRechargeRefunded($recharge)
+    {
+        if (empty($recharge)) {
+            return true;
+        }
+        if (self::modelHasAttr($recharge, 'isRefund')) {
+            return intval($recharge->isRefund ?? 0) === 1;
+        }
+        return false;
+    }
+
+    protected static function markRechargeRefunded($recharge)
+    {
+        if (empty($recharge) || !self::modelHasAttr($recharge, 'isRefund')) {
+            return;
+        }
+        $recharge->isRefund = 1;
+        $recharge->save(false, ['isRefund']);
+    }
+
+    protected static function modelHasAttr($model, $attr)
+    {
+        return is_object($model) && method_exists($model, 'hasAttribute') && $model->hasAttribute($attr);
+    }
+
     /**
      * 售后付款单是否已经充值过了 ssh 20250801
      */
@@ -533,4 +850,179 @@ class RechargeClass extends BaseClass
         }
     }
 
-}
+    public static function getStatProfile($shopId, $params = [])
+    {
+        $searchTime = $params['searchTime'] ?? 'today';
+        $startTime = $params['startTime'] ?? '';
+        $endTime = $params['endTime'] ?? '';
+        $range = dateUtil::formatTime($searchTime, $startTime, $endTime);
+        $start = $range['startTime'];
+        $end = $range['endTime'];
+        $cacheKey = 'hd_recharge_stat:' . $shopId . '_' . md5($start . '_' . $end);
+        $cache = Yii::$app->redis->executeCommand('GET', [$cacheKey]);
+        if (!empty($cache)) {
+            $data = json_decode($cache, true);
+            if (is_array($data)) {
+                return $data;
+            }
+        }
+
+        $summary = self::getRechargeStatSummary($shopId, $start, $end);
+        $channelList = self::getRechargeStatChannelList($shopId, $start, $end);
+        $customerList = self::getRechargeStatCustomerList($shopId, $start, $end);
+
+        $data = [
+            'summary' => $summary,
+            'channelList' => $channelList,
+            'customerList' => $customerList,
+            'startTime' => $start,
+            'endTime' => $end,
+        ];
+        Yii::$app->redis->executeCommand('SETEX', [$cacheKey, self::STAT_CACHE_TTL, json_encode($data, JSON_UNESCAPED_UNICODE)]);
+        return $data;
+    }
+
+    private static function getPaidQuery($shopId, $start, $end)
+    {
+        $model = self::getActiveRecord();
+        return $model::find()
+            ->where([
+                'shopId' => $shopId,
+                'payStatus' => 1,
+                'status' => 1,
+            ])
+            ->andWhere(['between', 'addTime', $start, $end]);
+    }
+
+    private static function getRechargeStatSummary($shopId, $start, $end)
+    {
+        $row = self::getPaidQuery($shopId, $start, $end)
+            ->select([
+                'rechargeCount' => new Expression('SUM(CASE WHEN IFNULL(amount, 0) + IFNULL(giveAmount, 0) > 0 THEN 1 ELSE 0 END)'),
+                'reduceCount' => new Expression('SUM(CASE WHEN IFNULL(isRefund, 0) = 1 THEN 1 ELSE 0 END)'),
+                'realAmount' => new Expression('IFNULL(SUM(amount), 0)'),
+                'giveAmount' => new Expression('IFNULL(SUM(giveAmount), 0)'),
+                'reduceAmount' => new Expression('IFNULL(SUM(CASE WHEN IFNULL(isRefund, 0) = 1 THEN amount ELSE 0 END), 0)'),
+            ])
+            ->asArray()
+            ->one();
+
+        $realAmount = $row['realAmount'] ?? 0;
+        $giveAmount = $row['giveAmount'] ?? 0;
+        $reduceAmount = $row['reduceAmount'] ?? 0;
+        return [
+            'rechargeAmount' => self::moneyAdd($realAmount, $giveAmount),
+            'rechargeCount' => intval($row['rechargeCount'] ?? 0),
+            'reduceAmount' => self::moneyValue($reduceAmount),
+            'reduceCount' => intval($row['reduceCount'] ?? 0),
+            'realAmount' => self::moneyValue($realAmount),
+        ];
+    }
+
+    private static function getRechargeStatChannelList($shopId, $start, $end)
+    {
+        $rows = self::getPaidQuery($shopId, $start, $end)
+            ->select([
+                'channelKey' => new Expression("IF(onlinePay = 2, CONCAT('online_', payWay), CONCAT('pay_', payWay))"),
+                'rechargeCount' => new Expression('SUM(CASE WHEN IFNULL(amount, 0) + IFNULL(giveAmount, 0) > 0 THEN 1 ELSE 0 END)'),
+                'reduceCount' => new Expression('SUM(CASE WHEN IFNULL(isRefund, 0) = 1 THEN 1 ELSE 0 END)'),
+                'realAmount' => new Expression('IFNULL(SUM(amount), 0)'),
+                'giveAmount' => new Expression('IFNULL(SUM(giveAmount), 0)'),
+                'reduceAmount' => new Expression('IFNULL(SUM(CASE WHEN IFNULL(isRefund, 0) = 1 THEN amount ELSE 0 END), 0)'),
+            ])
+            ->groupBy(new Expression("IF(onlinePay = 2, CONCAT('online_', payWay), CONCAT('pay_', payWay))"))
+            ->asArray()
+            ->all();
+
+        $payWayName = dict::getDict('payWayName');
+        $defaultList = [
+            'online_0' => '线上微信',
+            'online_1' => '线上支付宝',
+            'pay_0' => '线下微信',
+            'pay_1' => '线下支付宝',
+            'pay_4' => '现金',
+            'pay_5' => '银行卡',
+        ];
+        $map = [];
+        foreach ($rows as $row) {
+            $key = $row['channelKey'] ?? '';
+            $payWay = intval(str_replace('pay_', '', $key));
+            $map[$key] = self::buildStatRow(
+                $defaultList[$key] ?? ($payWayName[$payWay] ?? '其它'),
+                $row
+            );
+        }
+
+        $list = [];
+        foreach ($defaultList as $key => $name) {
+            $list[] = $map[$key] ?? self::emptyStatRow($name);
+            unset($map[$key]);
+        }
+        foreach ($map as $item) {
+            $list[] = $item;
+        }
+        return $list;
+    }
+
+    private static function getRechargeStatCustomerList($shopId, $start, $end)
+    {
+        $rows = self::getPaidQuery($shopId, $start, $end)
+            ->select([
+                'customId',
+                'customName',
+                'rechargeCount' => new Expression('SUM(CASE WHEN IFNULL(amount, 0) + IFNULL(giveAmount, 0) > 0 THEN 1 ELSE 0 END)'),
+                'reduceCount' => new Expression('SUM(CASE WHEN IFNULL(isRefund, 0) = 1 THEN 1 ELSE 0 END)'),
+                'realAmount' => new Expression('IFNULL(SUM(amount), 0)'),
+                'giveAmount' => new Expression('IFNULL(SUM(giveAmount), 0)'),
+                'reduceAmount' => new Expression('IFNULL(SUM(CASE WHEN IFNULL(isRefund, 0) = 1 THEN amount ELSE 0 END), 0)'),
+            ])
+            ->groupBy(['customId', 'customName'])
+            ->orderBy(new Expression('IFNULL(SUM(amount), 0) + IFNULL(SUM(giveAmount), 0) DESC'))
+            ->limit(100)
+            ->asArray()
+            ->all();
+
+        $list = [];
+        foreach ($rows as $row) {
+            $list[] = self::buildStatRow($row['customName'] ?: '未命名', $row);
+        }
+        return $list;
+    }
+
+    private static function buildStatRow($name, $row)
+    {
+        $realAmount = $row['realAmount'] ?? 0;
+        $giveAmount = $row['giveAmount'] ?? 0;
+        return [
+            'customId' => $row['customId'] ?? 0,
+            'name' => $name,
+            'rechargeAmount' => self::moneyAdd($realAmount, $giveAmount),
+            'rechargeCount' => intval($row['rechargeCount'] ?? 0),
+            'reduceAmount' => self::moneyValue($row['reduceAmount'] ?? 0),
+            'reduceCount' => intval($row['reduceCount'] ?? 0),
+            'realAmount' => self::moneyValue($realAmount),
+        ];
+    }
+
+    private static function emptyStatRow($name)
+    {
+        return [
+            'name' => $name,
+            'rechargeAmount' => '0.00',
+            'rechargeCount' => 0,
+            'reduceAmount' => '0.00',
+            'reduceCount' => 0,
+            'realAmount' => '0.00',
+        ];
+    }
+
+    private static function moneyAdd($left, $right)
+    {
+        return self::moneyValue(bcadd((string)$left, (string)$right, 2));
+    }
+
+    private static function moneyValue($amount)
+    {
+        return number_format((float)$amount, 2, '.', '');
+    }
+}

+ 269 - 0
biz-hd/shop/classes/ShopNoticeClass.php

@@ -0,0 +1,269 @@
+<?php
+
+namespace bizHd\shop\classes;
+
+use bizHd\base\classes\BaseClass;
+use common\components\util;
+use Yii;
+use yii\db\Expression;
+
+/**
+ * 零售花店通知公告业务类
+ */
+class ShopNoticeClass extends BaseClass
+{
+    public static $baseFile = '\bizHd\shop\models\ShopNotice';
+
+    /** 展示位置:商城首页 */
+    const POSITION_HOME = 1;
+    /** 展示位置:分类菜单 */
+    const POSITION_CATEGORY = 2;
+    /** 展示位置:购物车 */
+    const POSITION_CART = 4;
+    /** 展示位置:订单提交 */
+    const POSITION_ORDER = 8;
+
+    /**
+     * 展示位置文案映射
+     */
+    public static function getPositionMap(): array
+    {
+        return [
+            self::POSITION_HOME => '商城首页',
+            self::POSITION_CATEGORY => '分类菜单',
+            self::POSITION_CART => '购物车',
+            self::POSITION_ORDER => '订单提交',
+        ];
+    }
+
+    /**
+     * 通用列表查询,供管理端与客户端复用
+     */
+    public static function searchList(array $params): array
+    {
+        $get = Yii::$app->request->get();
+        $page = isset($get['page']) ? max(1, intval($get['page'])) : 1;
+        $pageSize = !empty($get['pageSize']) ? intval($get['pageSize']) : Yii::$app->params['pageSize'];
+
+        $query = self::getModel()::find()->where(['mainId' => intval($params['mainId'])]);
+
+        if (isset($params['isDel'])) {
+            $query->andWhere(['isDel' => intval($params['isDel'])]);
+        }
+        if (isset($params['status'])) {
+            $query->andWhere(['status' => intval($params['status'])]);
+        }
+        if (!empty($params['title'])) {
+            $query->andWhere(['like', 'title', trim($params['title'])]);
+        }
+        if (!empty($params['position'])) {
+            $position = intval($params['position']);
+            $query->andWhere(new Expression('FIND_IN_SET(:position, [[position]])', [':position' => (string)$position]));
+        }
+
+        $order = !empty($params['order']) ? $params['order'] : 'id DESC';
+        $totalNum = (int)$query->count();
+        $totalPage = $pageSize > 0 ? (int)ceil($totalNum / $pageSize) : 0;
+        $list = $query->orderBy($order)
+            ->offset(($page - 1) * $pageSize)
+            ->limit($pageSize)
+            ->asArray()
+            ->all();
+
+        return [
+            'totalNum' => $totalNum,
+            'totalPage' => $totalPage,
+            'moreData' => $page < $totalPage ? 1 : 0,
+            'list' => self::groupBaseInfo($list),
+        ];
+    }
+
+    /**
+     * 组装列表展示字段
+     */
+    public static function groupBaseInfo(array $list): array
+    {
+        $positionMap = self::getPositionMap();
+        foreach ($list as $key => $item) {
+            $positions = self::formatPositionToArr($item['position'] ?? '');
+            $labels = [];
+            foreach ($positions as $pos) {
+                if (isset($positionMap[$pos])) {
+                    $labels[] = $positionMap[$pos];
+                }
+            }
+            $list[$key]['positions'] = $positions;
+            $list[$key]['positionLabels'] = $labels;
+        }
+        return $list;
+    }
+
+    /**
+     * 多选位置转逗号分隔字符串
+     */
+    public static function formatPositionToStr($positions): string
+    {
+        if (is_string($positions)) {
+            $positions = array_filter(array_map('trim', explode(',', $positions)));
+        }
+        if (!is_array($positions)) {
+            return '';
+        }
+        $valid = array_keys(self::getPositionMap());
+        $positions = array_values(array_unique(array_map('intval', $positions)));
+        $positions = array_values(array_intersect($positions, $valid));
+        sort($positions);
+        return implode(',', $positions);
+    }
+
+    /**
+     * 逗号分隔字符串转位置数组
+     */
+    public static function formatPositionToArr(string $position): array
+    {
+        if ($position === '') {
+            return [];
+        }
+        $positions = array_filter(array_map('trim', explode(',', $position)));
+        return array_values(array_map('intval', $positions));
+    }
+
+    /**
+     * 校验公告表单数据
+     */
+    public static function validateNoticeData(array $data): array
+    {
+        $title = trim($data['title'] ?? '');
+        $summary = trim($data['summary'] ?? '');
+        if ($title === '') {
+            util::fail('请输入公告标题');
+        }
+        if (mb_strlen($title) > 20) {
+            util::fail('标题不能超过20个字');
+        }
+        if ($summary === '') {
+            util::fail('请输入公告简介');
+        }
+        if (mb_strlen($summary) > 200) {
+            util::fail('公告简介不能超过200个字');
+        }
+
+        $position = self::formatPositionToStr($data['position'] ?? '');
+        if ($position === '') {
+            util::fail('请选择展示位置');
+        }
+
+        $content = $data['content'] ?? '';
+        if ($content === '' || $content === '[]') {
+            util::fail('请添加公告内容');
+        }
+        if (is_string($content)) {
+            $contentArr = json_decode($content, true);
+            if (!is_array($contentArr) || empty($contentArr)) {
+                util::fail('公告内容格式不正确');
+            }
+        }
+
+        return [
+            'title' => $title,
+            'summary' => $summary,
+            'content' => is_string($content) ? $content : json_encode($content, JSON_UNESCAPED_UNICODE),
+            'position' => $position,
+            'sort' => intval($data['sort'] ?? 0),
+            'status' => intval($data['status'] ?? 0) === 1 ? 1 : 0,
+        ];
+    }
+
+    /**
+     * 新增公告
+     */
+    public static function addNotice(array $data): int
+    {
+        $noticeData = self::validateNoticeData($data);
+        if (!empty($data['mainId'])) {
+            $noticeData['mainId'] = intval($data['mainId']);
+        }
+        if (!empty($data['staffId'])) {
+            $noticeData['staffId'] = intval($data['staffId']);
+        }
+        if ($noticeData['status'] === 1) {
+            $noticeData['publishTime'] = date('Y-m-d H:i:s');
+        }
+        $result = self::add($noticeData);
+        return is_array($result) ? intval($result['id'] ?? 0) : intval($result);
+    }
+
+    /**
+     * 更新公告
+     */
+    public static function updateNotice(int $id, array $data): void
+    {
+        $noticeData = self::validateNoticeData($data);
+        $info = self::getById($id);
+        if (empty($info)) {
+            util::fail('公告不存在');
+        }
+        // 从隐藏变为显示时更新上架时间
+        if (intval($info['status']) !== 1 && $noticeData['status'] === 1) {
+            $noticeData['publishTime'] = date('Y-m-d H:i:s');
+        }
+        if (!empty($data['staffId'])) {
+            $noticeData['staffId'] = intval($data['staffId']);
+        }
+        self::updateById($id, $noticeData);
+    }
+
+    /**
+     * 公告详情
+     */
+    public static function getDetail(int $id): array
+    {
+        $info = self::getById($id);
+        if (empty($info)) {
+            util::fail('公告不存在');
+        }
+        $list = self::groupBaseInfo([$info]);
+        return current($list);
+    }
+
+    /**
+     * 软删除公告
+     */
+    public static function deleteNotice(int $id, int $staffId = 0): void
+    {
+        $updateData = ['isDel' => 1];
+        if ($staffId > 0) {
+            $updateData['staffId'] = $staffId;
+        }
+        self::updateById($id, $updateData);
+    }
+
+    /**
+     * 上下架公告
+     */
+    public static function updateStatus(int $id, int $status, int $staffId = 0): void
+    {
+        $status = $status === 1 ? 1 : 0;
+        $updateData = ['status' => $status];
+        if ($status === 1) {
+            $updateData['publishTime'] = date('Y-m-d H:i:s');
+        }
+        if ($staffId > 0) {
+            $updateData['staffId'] = $staffId;
+        }
+        self::updateById($id, $updateData);
+    }
+
+    /**
+     * 校验公告归属权限
+     */
+    public static function valid(array $info, int $mainId): void
+    {
+        if (empty($info) || intval($info['mainId']) !== intval($mainId)) {
+            util::fail('没有权限操作该公告');
+        }
+        if (intval($info['isDel']) === 1) {
+            util::fail('公告已删除');
+        }
+    }
+}

+ 16 - 0
biz-hd/shop/models/ShopNotice.php

@@ -0,0 +1,16 @@
+<?php
+
+namespace bizHd\shop\models;
+
+use bizHd\base\models\Base;
+
+/**
+ * 零售花店通知公告
+ */
+class ShopNotice extends Base
+{
+    public static function tableName()
+    {
+        return 'xhShopNotice';
+    }
+}

+ 216 - 0
common/components/CloudPrinter.php

@@ -0,0 +1,216 @@
+<?php
+
+namespace common\components;
+
+/**
+ * 链科云打印 v3 API 客户端(参考 cloud_printer_demo/php)
+ */
+class CloudPrinterApiException extends \Exception
+{
+}
+
+class CloudPrinterHttpException extends \Exception
+{
+}
+
+class CloudPrinter
+{
+    /** @var string */
+    public $apiKey = '';
+
+    /** @var string */
+    public $server = 'https://cloud.liankenet.com/';
+
+    /** @var int */
+    public $timeout = 10;
+
+    /** @var bool */
+    public $debug = false;
+
+    /** @var string */
+    public $deviceId = '';
+
+    /** @var string */
+    public $deviceKey = '';
+
+    public function __construct($apiKey, $deviceId, $deviceKey, $debug = false, $timeout = 10, $server = '')
+    {
+        $this->apiKey = (string)$apiKey;
+        $this->deviceId = (string)$deviceId;
+        $this->deviceKey = (string)$deviceKey;
+        $this->debug = (bool)$debug;
+        $this->timeout = max(1, intval($timeout));
+        if (!empty($server)) {
+            $this->server = rtrim((string)$server, '/') . '/';
+        }
+    }
+
+    /**
+     * 统一 HTTP 请求
+     */
+    private function requests($method, $endpoint, $fields = [], $contentType = 'application/json')
+    {
+        $curl = curl_init();
+        $headers = ['ApiKey: ' . $this->apiKey];
+        $postFields = $fields;
+
+        if ($method === 'POST') {
+            if ($contentType === 'application/json') {
+                $postFields = json_encode($fields, JSON_UNESCAPED_UNICODE);
+                $headers[] = 'Content-Type: application/json';
+            }
+            // multipart/form-data 由 curl 自动带 boundary,不能手动设 Content-Type
+        }
+
+        curl_setopt_array($curl, [
+            CURLOPT_URL => $this->server . ltrim($endpoint, '/'),
+            CURLOPT_RETURNTRANSFER => true,
+            CURLOPT_MAXREDIRS => 1,
+            CURLOPT_TIMEOUT => $this->timeout,
+            CURLOPT_FOLLOWLOCATION => true,
+            CURLINFO_HEADER_OUT => $this->debug,
+            CURLOPT_VERBOSE => $this->debug,
+            CURLOPT_CUSTOMREQUEST => $method,
+            CURLOPT_POSTFIELDS => $postFields,
+            CURLOPT_HTTPHEADER => $headers,
+        ]);
+
+        $response = curl_exec($curl);
+        $info = curl_getinfo($curl);
+        $errno = curl_errno($curl);
+        $error = curl_error($curl);
+        curl_close($curl);
+
+        if ($this->debug) {
+            \Yii::info('链科云打印请求: ' . json_encode($info, JSON_UNESCAPED_UNICODE), __METHOD__);
+        }
+
+        if ($response === false) {
+            throw new CloudPrinterHttpException($error ?: 'curl 请求失败', $errno);
+        }
+        if (intval($info['http_code'] ?? 0) !== 200) {
+            throw new CloudPrinterHttpException('HTTP ' . ($info['http_code'] ?? 0), intval($info['http_code'] ?? 0));
+        }
+
+        $data = json_decode($response);
+        if (!is_object($data)) {
+            throw new CloudPrinterApiException('接口返回格式错误');
+        }
+        if (intval($data->code ?? 0) !== 200) {
+            throw new CloudPrinterApiException($data->msg ?? '链科云打印接口错误', intval($data->code ?? 0));
+        }
+
+        return $data;
+    }
+
+    /** 获取设备信息 */
+    public function getDeviceInfo()
+    {
+        $data = [
+            'deviceId' => $this->deviceId,
+            'deviceKey' => $this->deviceKey,
+        ];
+        $response = $this->requests('GET', 'api/device/device_info?' . http_build_query($data));
+        return $response->data;
+    }
+
+    /** 异步刷新设备信息(含打印机列表) */
+    public function asyncRefreshDeviceInfo()
+    {
+        $data = [
+            'deviceId' => $this->deviceId,
+            'deviceKey' => $this->deviceKey,
+        ];
+        return $this->requests('GET', 'api/device/async_refresh_device_info?' . http_build_query($data));
+    }
+
+    /** 获取打印机列表 */
+    public function getPrinterList()
+    {
+        $data = [
+            'deviceId' => $this->deviceId,
+            'deviceKey' => $this->deviceKey,
+        ];
+        $response = $this->requests('GET', 'api/external_api/printer_list?' . http_build_query($data));
+        return $response->data->row ?? [];
+    }
+
+    /** 获取打印机参数 */
+    public function getPrinterParams($printerModel)
+    {
+        $data = [
+            'deviceId' => $this->deviceId,
+            'deviceKey' => $this->deviceKey,
+            'printerModel' => $printerModel,
+        ];
+        $response = $this->requests('GET', 'api/print/printer_params?' . http_build_query($data));
+        return $response->data;
+    }
+
+    /**
+     * 发起打印任务(文件上传)
+     *
+     * @param string $devicePort USB 口
+     * @param string $printerModel 驱动名
+     * @param int $paperSize 纸张代码,A4 一般为 9
+     * @param \CURLFile $file 待打印文件
+     * @param array $optionalArray 额外参数(份数、颜色等)
+     */
+    public function addJob($devicePort, $printerModel, $paperSize, $file, $optionalArray = [])
+    {
+        $data = array_merge([
+            'deviceId' => $this->deviceId,
+            'deviceKey' => $this->deviceKey,
+            'devicePort' => $devicePort,
+            'printerModel' => $printerModel,
+            'dmPaperSize' => $paperSize,
+            'jobFile' => $file,
+        ], $optionalArray);
+
+        $response = $this->requests('POST', 'api/print/job', $data, 'multipart/form-data');
+        \Yii::info("发起打印任务",json_encode($response->data));
+        return $response->data;
+    }
+
+    /**
+     * 通过本地文件路径发起打印
+     */
+    public function addJobByPath($devicePort, $printerModel, $paperSize, $filePath, $optionalArray = [])
+    {
+        $realPath = realpath($filePath);
+        if ($realPath === false || !is_file($realPath)) {
+            throw new CloudPrinterApiException('打印文件不存在');
+        }
+        $mime = function_exists('mime_content_type') ? mime_content_type($realPath) : '';
+        if (empty($mime)) {
+            $mime = 'application/octet-stream';
+        }
+        $file = new \CURLFile($realPath, $mime, basename($realPath));
+        return $this->addJob($devicePort, $printerModel, $paperSize, $file, $optionalArray);
+    }
+
+    /** 查询任务状态 */
+    public function getJobStatus($devicePort, $taskId)
+    {
+        $data = [
+            'deviceId' => $this->deviceId,
+            'deviceKey' => $this->deviceKey,
+            'devicePort' => $devicePort,
+            'task_id' => $taskId,
+        ];
+        $response = $this->requests('GET', 'api/print/job?' . http_build_query($data));
+        return $response->data;
+    }
+
+    /** 取消任务 */
+    public function cancelJob($devicePort, $taskId)
+    {
+        $data = [
+            'deviceId' => $this->deviceId,
+            'deviceKey' => $this->deviceKey,
+            'devicePort' => $devicePort,
+            'task_id' => $taskId,
+        ];
+        return $this->requests('DELETE', 'api/print/job?' . http_build_query($data));
+    }
+}

+ 165 - 0
common/components/cloudPrintPaperSpec.php

@@ -0,0 +1,165 @@
+<?php
+
+namespace common\components;
+
+/**
+ * 链科云打印纸张规格(HTML 排版 + dmPaperSize)
+ */
+class cloudPrintPaperSpec
+{
+    /** 场景:配送单 */
+    const SCENE_DELIVERY_SLIP = 'delivery_slip';
+    /** 场景:节日贺卡 */
+    const SCENE_GREETING_CARD = 'greeting_card';
+    /** 场景:默认 */
+    const SCENE_DEFAULT = 'default';
+
+    /**
+     * 内置纸张类型(paperType → 链科代码与 HTML @page)
+     * dmPaperSize 参考 Windows DMPAPER,具体以打印机参数为准
+     */
+    protected static function presets()
+    {
+        return [
+            'a4' => [
+                'label' => 'A4',
+                'dmPaperSize' => 9,
+                'pageSize' => 'A4 portrait',
+                'pageMargin' => '10mm',
+                'contentWidth' => '190mm',
+                'cardWidth' => '140mm',
+                'cardHeight' => '200mm',
+            ],
+            'a5' => [
+                'label' => 'A5',
+                'dmPaperSize' => 11,
+                'pageSize' => 'A5 portrait',
+                'pageMargin' => '8mm',
+                'contentWidth' => '128mm',
+                'cardWidth' => '100mm',
+                'cardHeight' => '148mm',
+            ],
+            'b5' => [
+                'label' => 'B5',
+                'dmPaperSize' => 13,
+                'pageSize' => 'B5 portrait',
+                'pageMargin' => '8mm',
+                'contentWidth' => '176mm',
+                'cardWidth' => '120mm',
+                'cardHeight' => '170mm',
+            ],
+            'letter' => [
+                'label' => 'Letter',
+                'dmPaperSize' => 1,
+                'pageSize' => 'letter portrait',
+                'pageMargin' => '10mm',
+                'contentWidth' => '190mm',
+                'cardWidth' => '140mm',
+                'cardHeight' => '200mm',
+            ],
+            // 贺卡常用竖版 10×15cm
+            'card_10x15' => [
+                'label' => '贺卡10×15cm',
+                'dmPaperSize' => 0,
+                'pageSize' => '100mm 150mm',
+                'pageMargin' => '5mm',
+                'contentWidth' => '90mm',
+                'cardWidth' => '90mm',
+                'cardHeight' => '130mm',
+            ],
+            // 贺卡竖版 13×18cm
+            'card_13x18' => [
+                'label' => '贺卡13×18cm',
+                'dmPaperSize' => 0,
+                'pageSize' => '130mm 180mm',
+                'pageMargin' => '6mm',
+                'contentWidth' => '118mm',
+                'cardWidth' => '118mm',
+                'cardHeight' => '160mm',
+            ],
+        ];
+    }
+
+    /**
+     * 解析纸张规格
+     *
+     * @param array $options scene, paperType, paperSize(dmPaperSize 数值优先)
+     */
+    public static function resolve(array $options = [])
+    {
+        $scene = (string)($options['scene'] ?? self::SCENE_DEFAULT);
+        $paperType = strtolower(trim((string)($options['paperType'] ?? '')));
+        $paperSize = intval($options['paperSize'] ?? 0);
+
+        if ($paperType === '') {
+            $paperType = strtolower(trim((string)self::envByScene($scene, 'PAPER_TYPE')));
+        }
+        if ($paperSize <= 0) {
+            $paperSize = intval(self::envByScene($scene, 'PAPER_SIZE'));
+        }
+        if ($paperType === '') {
+            $paperType = 'a4';
+        }
+
+        $presets = self::presets();
+        if (!isset($presets[$paperType])) {
+            throw new CloudPrinterApiException('不支持的纸张类型: ' . $paperType);
+        }
+
+        $spec = $presets[$paperType];
+        $dmPaperSize = $paperSize > 0 ? $paperSize : intval($spec['dmPaperSize']);
+        if ($dmPaperSize <= 0) {
+            $dmPaperSize = intval(getenv('LIANKENET_PAPER_SIZE') ?: 9);
+        }
+
+        return [
+            'paperType' => $paperType,
+            'label' => $spec['label'],
+            'dmPaperSize' => $dmPaperSize,
+            'pageSize' => $spec['pageSize'],
+            'pageMargin' => $spec['pageMargin'],
+            'contentWidth' => $spec['contentWidth'],
+            'cardWidth' => $spec['cardWidth'],
+            'cardHeight' => $spec['cardHeight'],
+        ];
+    }
+
+    /**
+     * 返回可选纸张类型列表(给前端或配置参考)
+     */
+    public static function listTypes()
+    {
+        $list = [];
+        foreach (self::presets() as $key => $item) {
+            $list[] = [
+                'paperType' => $key,
+                'label' => $item['label'],
+                'defaultDmPaperSize' => $item['dmPaperSize'],
+            ];
+        }
+        return $list;
+    }
+
+    /**
+     * 按场景读取 .env:LIANKENET_DELIVERY_SLIP_PAPER_TYPE 等
+     */
+    protected static function envByScene($scene, $suffix)
+    {
+        $map = [
+            self::SCENE_DELIVERY_SLIP => 'LIANKENET_DELIVERY_SLIP_' . $suffix,
+            self::SCENE_GREETING_CARD => 'LIANKENET_GREETING_CARD_' . $suffix,
+            self::SCENE_DEFAULT => 'LIANKENET_' . $suffix,
+        ];
+        $key = $map[$scene] ?? $map[self::SCENE_DEFAULT];
+        $value = getenv($key);
+        if ($value === false || $value === '') {
+            if ($scene !== self::SCENE_DEFAULT && $suffix === 'PAPER_TYPE') {
+                return getenv('LIANKENET_PAPER_TYPE') ?: '';
+            }
+            if ($scene !== self::SCENE_DEFAULT && $suffix === 'PAPER_SIZE') {
+                return getenv('LIANKENET_PAPER_SIZE') ?: 0;
+            }
+        }
+        return $value;
+    }
+}

+ 272 - 0
common/components/cloudPrintUtil.php

@@ -0,0 +1,272 @@
+<?php
+
+namespace common\components;
+
+use Yii;
+
+/**
+ * 链科云打印 v3 业务封装,配置读取 .env
+ */
+class cloudPrintUtil
+{
+    /** @var CloudPrinter|null */
+    protected static $client;
+
+    /** @var array|null */
+    protected static $printerConfig;
+
+    /**
+     * 是否启用链科云打印(.env LIANKENET_ENABLED=1 且密钥齐全)
+     */
+    public static function isEnabled()
+    {
+        if (getenv('LIANKENET_ENABLED') != '1') {
+            return false;
+        }
+        return !empty(getenv('LIANKENET_API_KEY'))
+            && !empty(getenv('LIANKENET_DEVICE_ID'))
+            && !empty(getenv('LIANKENET_DEVICE_KEY'));
+    }
+
+    /**
+     * 从 .env 创建链科客户端
+     */
+    public static function createClient()
+    {
+        if (!self::isEnabled()) {
+            throw new CloudPrinterApiException('链科云打印未启用或配置不完整');
+        }
+        if (self::$client instanceof CloudPrinter) {
+            return self::$client;
+        }
+
+        self::$client = new CloudPrinter(
+            getenv('LIANKENET_API_KEY'),
+            getenv('LIANKENET_DEVICE_ID'),
+            getenv('LIANKENET_DEVICE_KEY'),
+            getenv('LIANKENET_DEBUG') == '1',
+            intval(getenv('LIANKENET_TIMEOUT') ?: 10),
+            getenv('LIANKENET_SERVER') ?: 'https://cloud.liankenet.com/'
+        );
+
+        return self::$client;
+    }
+
+    /**
+     * 打印订单小票(与 printUtil::printMsg 入参一致,便于切换)
+     */
+    public function printMsg($content, $shop = null, $orderSn = '')
+    {
+        $plainText = $this->feieContentToText($content);
+        if ($plainText === '') {
+            return false;
+        }
+
+        $chunks = $this->splitContent($plainText, 4500);
+        foreach ($chunks as $chunk) {
+            $this->printTextChunk($chunk, $shop, $orderSn);
+        }
+        return true;
+    }
+
+    /**
+     * 打印标签内容(链科走文本任务,飞鹅标签指令无法直接复用)
+     */
+    public function printLabelMsg($content)
+    {
+        $plainText = $this->feieContentToText($content);
+        if ($plainText === '') {
+            return false;
+        }
+        return $this->printTextChunk($plainText);
+    }
+
+    /**
+     * 直接打印本地文件
+     */
+    public function printFile($filePath, $optionalArray = [], $paperSize = null)
+    {
+        $client = self::createClient();
+        $printer = $this->resolvePrinterConfig($client);
+        $paperSize = $paperSize ?? intval(getenv('LIANKENET_PAPER_SIZE') ?: 9);
+        return $client->addJobByPath(
+            $printer['port'],
+            $printer['model'],
+            $paperSize,
+            $filePath,
+            $optionalArray
+        );
+    }
+
+    /**
+     * 打印 HTML 页面(A4 配送单等排版页面)
+     */
+    public function printHtml($html, $paperSize = null, $optionalArray = [])
+    {
+        $html = trim((string)$html);
+        if ($html === '') {
+            return false;
+        }
+        $tempFile = $this->createTempHtmlFile($html);
+        try {
+            return $this->printFile($tempFile, $optionalArray, $paperSize);
+        } finally {
+            if (is_file($tempFile)) {
+                @unlink($tempFile);
+            }
+        }
+    }
+
+    /**
+     * 生成可打印 HTML 临时文件
+     */
+    protected function createTempHtmlFile($html)
+    {
+        $tempFile = tempnam(sys_get_temp_dir(), 'lk_print_');
+        if ($tempFile === false) {
+            throw new CloudPrinterApiException('无法创建临时打印文件');
+        }
+        $target = $tempFile . '.html';
+        @unlink($tempFile);
+        file_put_contents($target, $html);
+        return $target;
+    }
+
+    /**
+     * 飞鹅排版标签转纯文本
+     */
+    protected function feieContentToText($content)
+    {
+        $text = str_ireplace(['<BR>', '<br>', '<br/>', '<br />'], "\n", (string)$content);
+        $text = preg_replace('/<\/?CB>/i', '', $text);
+        $text = preg_replace('/<\/?B>/i', '', $text);
+        $text = preg_replace('/<\/?C>/i', '', $text);
+        $text = preg_replace('/<QR>.*?<\/QR>/is', '', $text);
+        $text = preg_replace('/<[^>]+>/', '', $text);
+        $text = str_replace(["\r\n", "\r"], "\n", $text);
+        return trim($text);
+    }
+
+    /**
+     * 长内容分段,避免单文件过大
+     */
+    protected function splitContent($text, $maxLength = 4500)
+    {
+        if (strlen($text) <= $maxLength) {
+            return [$text];
+        }
+        $lines = explode("\n", $text);
+        $chunks = [];
+        $current = '';
+        foreach ($lines as $line) {
+            $candidate = $current === '' ? $line : $current . "\n" . $line;
+            if (strlen($candidate) > $maxLength) {
+                if ($current !== '') {
+                    $chunks[] = $current;
+                }
+                $current = $line;
+            } else {
+                $current = $candidate;
+            }
+        }
+        if ($current !== '') {
+            $chunks[] = $current;
+        }
+        return $chunks;
+    }
+
+    /**
+     * 提交一段文本为打印任务
+     */
+    protected function printTextChunk($text, $shop = null, $orderSn = '')
+    {
+        $tempFile = $this->createTempTextFile($text);
+        try {
+            $client = self::createClient();
+            $printer = $this->resolvePrinterConfig($client);
+            $paperSize = intval(getenv('LIANKENET_PAPER_SIZE') ?: 9);
+            $client->addJobByPath(
+                $printer['port'],
+                $printer['model'],
+                $paperSize,
+                $tempFile,
+                $this->buildOptionalParams()
+            );
+        } catch (\Throwable $e) {
+            Yii::error('链科云打印失败: ' . $e->getMessage(), __METHOD__);
+            $shopName = '';
+            if (is_object($shop)) {
+                $shopName = ($shop->merchantName ?? '') . ($shop->shopName ?? '');
+            }
+            noticeUtil::push(
+                trim($shopName . ' 链科云打印未成功,订单号:' . $orderSn . ',原因:' . $e->getMessage()),
+                '15280215347'
+            );
+            return false;
+        } finally {
+            if (is_file($tempFile)) {
+                @unlink($tempFile);
+            }
+        }
+        return true;
+    }
+
+    /**
+     * 生成 UTF-8 BOM 文本临时文件,便于 Windows 打印盒识别中文
+     */
+    protected function createTempTextFile($text)
+    {
+        $tempFile = tempnam(sys_get_temp_dir(), 'lk_print_');
+        if ($tempFile === false) {
+            throw new CloudPrinterApiException('无法创建临时打印文件');
+        }
+        $target = $tempFile . '.txt';
+        @unlink($tempFile);
+        file_put_contents($target, "\xEF\xBB\xBF" . $text);
+        return $target;
+    }
+
+    /**
+     * 解析打印机 USB 口与驱动名:优先 .env,否则取列表首台
+     */
+    protected function resolvePrinterConfig(CloudPrinter $client)
+    {
+        if (is_array(self::$printerConfig)) {
+            return self::$printerConfig;
+        }
+
+        $port = trim((string)getenv('LIANKENET_DEVICE_PORT'));
+        $model = trim((string)getenv('LIANKENET_PRINTER_MODEL'));
+        if ($port !== '' && $model !== '') {
+            self::$printerConfig = ['port' => $port, 'model' => $model];
+            return self::$printerConfig;
+        }
+
+        $list = $client->getPrinterList();
+        if (empty($list)) {
+            throw new CloudPrinterApiException('链科设备下没有可用打印机');
+        }
+        $printer = $list[0];
+        self::$printerConfig = [
+            'port' => $printer->port ?? '',
+            'model' => $printer->driver_name ?? '',
+        ];
+        if (self::$printerConfig['port'] === '' || self::$printerConfig['model'] === '') {
+            throw new CloudPrinterApiException('链科打印机信息不完整');
+        }
+        return self::$printerConfig;
+    }
+
+    /**
+     * 可选打印参数(份数、颜色、方向等,按 v3 文档扩展)
+     */
+    protected function buildOptionalParams()
+    {
+        $params = [];
+        $copies = intval(getenv('LIANKENET_COPIES') ?: 1);
+        if ($copies > 1) {
+            $params['dmCopies'] = $copies;
+        }
+        return $params;
+    }
+}

+ 8 - 0
common/components/dateUtil.php

@@ -99,6 +99,14 @@ class dateUtil
                     $endTime = date("Ymd", strtotime($endTime));
                 }
                 break;
+            case 'all':
+                $startTime = "2015-01-01 00:00:00"; //用项目起始日期做开始时间
+                $endTime = date("Y-m-d 23:59:59");
+                if ($Stat) {
+                    $startTime = "20150101";
+                    $endTime = date("Ymd");
+                }
+                break;
             default:
                 //今天
                 $startTime = date("Y-m-d 00:00:00");

+ 201 - 0
common/components/deliverySlipPrintUtil.php

@@ -0,0 +1,201 @@
+<?php
+
+namespace common\components;
+
+use biz\shop\classes\ShopClass;
+use bizHd\order\classes\OrderGoodsClass;
+use bizHd\order\services\OrderService;
+use Yii;
+
+/**
+ * A4 鲜花配送单云打印
+ */
+class deliverySlipPrintUtil
+{
+    /**
+     * 根据零售订单 ID 打印 A4 配送单
+     */
+    public static function printByOrderId($orderId, $mainId = 0, array $printOptions = [])
+    {
+        $order = OrderService::getById($orderId);
+        if (empty($order)) {
+            throw new CloudPrinterApiException('订单不存在');
+        }
+        if ($mainId > 0) {
+            OrderService::valid($order, $mainId);
+        }
+
+        $data = self::buildDataFromOrder($order);
+        return self::printByData($data, $printOptions);
+    }
+
+    /**
+     * 直接按模板数据打印
+     */
+    public static function printByData(array $data, array $printOptions = [])
+    {
+        if (!cloudPrintUtil::isEnabled()) {
+            throw new CloudPrinterApiException('链科云打印未启用,请先在 .env 配置 LIANKENET_*');
+        }
+
+        $paper = cloudPrintPaperSpec::resolve(array_merge([
+            'scene' => cloudPrintPaperSpec::SCENE_DELIVERY_SLIP,
+        ], $printOptions));
+        $data['paper'] = $paper;
+
+        $html = self::renderHtml($data);
+        return (new cloudPrintUtil())->printHtml($html, $paper['dmPaperSize']);
+    }
+
+    /**
+     * 组装配送单展示数据
+     */
+    public static function buildDataFromOrder(array $order)
+    {
+        $shopId = intval($order['shopId'] ?? 0);
+        $shop = ShopClass::getById($shopId, true);
+        $shopName = '';
+        if (!empty($shop)) {
+            $merchantName = $shop->merchantName ?? '';
+            $branchName = $shop->shopName ?? '';
+            $shopName = ($branchName === '首店' || $branchName === '') ? $merchantName : trim($merchantName . ' ' . $branchName);
+        }
+        if ($shopName === '') {
+            $shopName = '花掌柜';
+        }
+
+        $reachPeriodMap = [0 => '上午', 1 => '下午', 2 => '晚上'];
+        $reachPeriod = $reachPeriodMap[intval($order['reachPeriod'] ?? 0)] ?? '';
+        $reachDate = trim((string)($order['reachDate'] ?? ''));
+        if ($reachDate === '' || $reachDate === '0000-00-00') {
+            $reachDateLabel = '立即发单';
+            $reachDateTime = date('m月d日 H:i');
+        } else {
+            $reachDateLabel = '预约配送';
+            $reachDateTime = date('m月d日', strtotime($reachDate)) . ' ' . $reachPeriod;
+        }
+
+        $bookName = $order['bookName'] ?? '';
+        $bookMobile = $order['bookMobile'] ?? '';
+        if (intval($order['anonymity'] ?? 0) === 1) {
+            $bookName = '--';
+            $bookMobile = '--';
+        }
+
+        $fullAddress = trim((string)($order['fullAddress'] ?? ''));
+        $showAddress = trim((string)($order['showAddress'] ?? ''));
+        if ($showAddress !== '' && strpos($fullAddress, $showAddress) === false) {
+            $fullAddress .= '(' . $showAddress . ')';
+        }
+
+        $productName = trim((string)($order['orderName'] ?? ''));
+        $goodsNum = max(1, intval($order['goodsNum'] ?? 1));
+        if ($productName === '') {
+            $productName = '鲜花';
+        }
+
+        $cover = self::resolveOrderCover($order);
+        $distanceText = '';
+        $distance = intval($order['sendDistance'] ?? ($order['deliveryDistance'] ?? 0));
+        if ($distance > 0) {
+            $distanceText = $distance >= 1000
+                ? ('直线 ' . round($distance / 1000, 1) . '公里')
+                : ('直线 ' . $distance . '米');
+        }
+
+        $servicePhone = trim((string)getenv('LIANKENET_SERVICE_PHONE'));
+        if ($servicePhone === '' && !empty($shop)) {
+            $servicePhone = trim((string)($shop->telephone ?? ''));
+        }
+
+        $sendNum = (string)($order['sendNum'] ?? '');
+        $batchDay = str_pad((string)date('j'), 2, '0', STR_PAD_LEFT);
+
+        return [
+            'title' => $shopName . ' 鲜花配送单',
+            'batchNo' => '#O' . $batchDay . ' [#' . $sendNum . ']',
+            'sendNum' => $sendNum,
+            'receiveUserName' => $order['receiveUserName'] ?? '',
+            'receiveMobile' => $order['receiveMobile'] ?? '',
+            'fullAddress' => $fullAddress,
+            'distanceText' => $distanceText,
+            'reachDateLabel' => $reachDateLabel,
+            'reachDateTime' => $reachDateTime,
+            'productName' => $productName,
+            'productCount' => (string)$goodsNum,
+            'productImage' => self::imageToDataUri($cover),
+            'cardInfo' => $order['cardInfo'] ?? '',
+            'bookName' => $bookName,
+            'bookMobile' => $bookMobile,
+            'orderSn' => $order['orderSn'] ?? '',
+            'servicePhone' => $servicePhone,
+        ];
+    }
+
+    /**
+     * 渲染 A4 HTML
+     */
+    public static function renderHtml(array $data)
+    {
+        if (empty($data['paper'])) {
+            $data['paper'] = cloudPrintPaperSpec::resolve([
+                'scene' => cloudPrintPaperSpec::SCENE_DELIVERY_SLIP,
+            ]);
+        }
+        $template = Yii::getAlias('@common/templates/delivery-slip-a4.php');
+        if (!is_file($template)) {
+            throw new CloudPrinterApiException('配送单模板不存在');
+        }
+        ob_start();
+        include $template;
+        return (string)ob_get_clean();
+    }
+
+    /**
+     * 取订单商品封面图
+     */
+    protected static function resolveOrderCover(array $order)
+    {
+        $cover = trim((string)($order['cover'] ?? ''));
+        if ($cover !== '') {
+            return imgUtil::groupImg($cover) . '?x-oss-process=image/resize,m_fill,h_300,w_300';
+        }
+
+        $goodsList = OrderGoodsClass::getGoodsListById(intval($order['id'] ?? 0));
+        if (!empty($goodsList)) {
+            $first = $goodsList[0];
+            $shortCover = $first['cover'] ?? '';
+            if ($shortCover !== '') {
+                return imgUtil::groupImg($shortCover) . '?x-oss-process=image/resize,m_fill,h_300,w_300';
+            }
+        }
+        return '';
+    }
+
+    /**
+     * 远程图片转 data URI,避免打印盒访问不了外网图
+     */
+    protected static function imageToDataUri($url)
+    {
+        $url = trim((string)$url);
+        if ($url === '') {
+            return '';
+        }
+        $binary = @file_get_contents($url);
+        if ($binary === false || $binary === '') {
+            return $url;
+        }
+        $mime = 'image/jpeg';
+        if (function_exists('finfo_buffer')) {
+            $finfo = finfo_open(FILEINFO_MIME_TYPE);
+            if ($finfo) {
+                $detected = finfo_buffer($finfo, $binary);
+                if (!empty($detected)) {
+                    $mime = $detected;
+                }
+                finfo_close($finfo);
+            }
+        }
+        return 'data:' . $mime . ';base64,' . base64_encode($binary);
+    }
+}

+ 83 - 0
common/components/greetingCardPrintUtil.php

@@ -0,0 +1,83 @@
+<?php
+
+namespace common\components;
+
+use bizHd\order\services\OrderService;
+use Yii;
+
+/**
+ * 订单节日贺卡云打印(文字暂写死,纸张规格可配置)
+ */
+class greetingCardPrintUtil
+{
+    /**
+     * 根据订单 ID 打印节日贺卡
+     */
+    public static function printByOrderId($orderId, $mainId = 0, array $printOptions = [])
+    {
+        $order = OrderService::getById($orderId);
+        if (empty($order)) {
+            throw new CloudPrinterApiException('订单不存在');
+        }
+        if ($mainId > 0) {
+            OrderService::valid($order, $mainId);
+        }
+
+        return self::printByData(self::buildDefaultData(), $printOptions);
+    }
+
+    /**
+     * 直接打印贺卡
+     */
+    public static function printByData(array $data = [], array $printOptions = [])
+    {
+        if (!cloudPrintUtil::isEnabled()) {
+            throw new CloudPrinterApiException('链科云打印未启用,请先在 .env 配置 LIANKENET_*');
+        }
+
+        $paper = cloudPrintPaperSpec::resolve(array_merge([
+            'scene' => cloudPrintPaperSpec::SCENE_GREETING_CARD,
+        ], $printOptions));
+        $data['paper'] = $paper;
+
+        $html = self::renderHtml($data);
+        return (new cloudPrintUtil())->printHtml($html, $paper['dmPaperSize']);
+    }
+
+    /**
+     * 默认贺卡文案(写死,后续可改为订单 cardInfo / 收花人姓名等)
+     */
+    public static function buildDefaultData()
+    {
+        return [
+            'cardTitle' => '生日快乐贺卡',
+            'toText' => 'To 陈小姐',
+            'titleText' => '生日快乐',
+            'messageLines' => [
+                '愿你今天被鲜花和温柔包围,',
+                '生日快乐,天天开心。',
+            ],
+            'fromText' => 'From 你的朋友',
+        ];
+    }
+
+    /**
+     * 渲染贺卡 HTML
+     */
+    public static function renderHtml(array $data)
+    {
+        $data = array_merge(self::buildDefaultData(), $data);
+        if (empty($data['paper'])) {
+            $data['paper'] = cloudPrintPaperSpec::resolve([
+                'scene' => cloudPrintPaperSpec::SCENE_GREETING_CARD,
+            ]);
+        }
+        $template = Yii::getAlias('@common/templates/greeting-card-a4.php');
+        if (!is_file($template)) {
+            throw new CloudPrinterApiException('节日贺卡模板不存在');
+        }
+        ob_start();
+        include $template;
+        return (string)ob_get_clean();
+    }
+}

+ 1 - 1
common/components/payUtil.php

@@ -32,7 +32,7 @@ class payUtil
         switch ($capitalType) {
             case dict::getDict('capitalType', 'xhRecharge', 'id'):
                 //散客向花店充值
-                RechargeClass::thirdPay($payWay, $orderSn, $totalFee, $attach);
+                RechargeClass::thirdPay($payWay, $orderSn, $totalFee, $attach, $transactionId);
                 break;
             case dict::getDict('capitalType', 'xhPurchase', 'id'):
                 //零售采购

+ 241 - 0
common/templates/delivery-slip-a4.php

@@ -0,0 +1,241 @@
+<?php
+/**
+ * A4 鲜花配送单 HTML 模板(链科云打印,HTML 直打)
+ *
+ * @var array $data
+ */
+$escape = function ($value) {
+    return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8');
+};
+$paper = $data['paper'] ?? [
+    'pageSize' => 'A4 portrait',
+    'pageMargin' => '10mm',
+    'contentWidth' => '190mm',
+];
+?>
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <title><?= $escape($data['title'] ?? '鲜花配送单') ?></title>
+    <style>
+        @page {
+            size: <?= $escape($paper['pageSize']) ?>;
+            margin: <?= $escape($paper['pageMargin']) ?>;
+        }
+        * {
+            margin: 0;
+            padding: 0;
+            box-sizing: border-box;
+            font-family: "SimSun", "宋体", serif;
+        }
+        body {
+            margin: 0;
+            padding: 0;
+            background: #ffffff;
+        }
+        /* 外层 table 居中:兼容 Windows 打印 HTML 时 margin:auto 失效 */
+        .page-center {
+            width: 100%;
+            border: 0;
+            border-collapse: collapse;
+        }
+        .delivery-wrap {
+            width: <?= $escape($paper['contentWidth']) ?>;
+            max-width: 100%;
+            margin: 0 auto;
+            text-align: left;
+        }
+        .header-top {
+            text-align: center;
+            padding: 4mm 0;
+            position: relative;
+        }
+        .title {
+            font-size: 32px;
+            font-weight: bold;
+        }
+        .order-no {
+            position: absolute;
+            right: 0;
+            top: 4mm;
+            font-size: 22px;
+        }
+        table.sheet {
+            width: 100%;
+            border-collapse: collapse;
+        }
+        table.sheet td {
+            padding: 4mm 5mm;
+            vertical-align: top;
+            font-size: 24px;
+            border: 1px solid #000;
+            background: transparent;
+        }
+        table.sheet td:first-child {
+            width: 38mm;
+        }
+        tr.time-row td,
+        tr.card-row td,
+        tr.name-row td,
+        tr.addr-row td {
+            padding-top: 8mm;
+            padding-bottom: 8mm;
+        }
+        tr.addr-row td:last-child {
+            text-align: left;
+        }
+        .addr-block {
+            display: block;
+            line-height: 1.5;
+        }
+        .addr-distance {
+            display: block;
+            margin-top: 6mm;
+            line-height: 1.5;
+        }
+        .goods-label {
+            vertical-align: middle !important;
+            line-height: 1.4;
+        }
+        .goods-row {
+            display: block;
+            overflow: hidden;
+        }
+        .goods-img {
+            float: left;
+            width: 52mm;
+            height: 62mm;
+            margin-right: 5mm;
+            background: #eee;
+        }
+        .goods-img img {
+            width: 100%;
+            height: 100%;
+            object-fit: cover;
+        }
+        .goods-text {
+            padding-top: 2mm;
+            overflow: hidden;
+        }
+        .goods-name {
+            font-size: 24px;
+            font-weight: bold;
+            margin-bottom: 3mm;
+        }
+        .goods-count {
+            font-weight: bold;
+        }
+        .goods-count .num {
+            font-size: 48px;
+        }
+        .bottom-tip {
+            padding: 5mm 0 0;
+            text-align: center;
+            font-size: 23px;
+            font-weight: bold;
+        }
+        .footer-bottom {
+            text-align: center;
+        }
+        .circle-num-wrap {
+            margin-top: 8mm;
+            text-align: center;
+        }
+        .circle-num {
+            display: inline-block;
+            width: 52mm;
+            height: 52mm;
+            border: 3px solid #000;
+            border-radius: 50%;
+            text-align: center;
+            line-height: 52mm;
+            font-size: 28mm;
+            font-weight: bold;
+            background: transparent;
+        }
+        @media print {
+            body {
+                padding: 0;
+                margin: 0;
+            }
+            .delivery-wrap {
+                width: <?= $escape($paper['contentWidth']) ?>;
+                margin: 0 auto;
+            }
+        }
+    </style>
+</head>
+<body>
+<table class="page-center" cellpadding="0" cellspacing="0" border="0">
+    <tr>
+        <td align="center" valign="top">
+            <div class="delivery-wrap">
+                <div class="header-top">
+                    <div class="title"><?= $escape($data['title'] ?? '鲜花配送单') ?></div>
+                    <div class="order-no"><?= $escape($data['batchNo'] ?? '') ?></div>
+                </div>
+
+                <table class="sheet">
+                    <tr class="name-row">
+                        <td>顾客姓名</td>
+                        <td><?= $escape($data['receiveUserName'] ?? '') ?></td>
+                        <td>顾客电话</td>
+                        <td><strong><?= $escape($data['receiveMobile'] ?? '') ?></strong></td>
+                    </tr>
+                    <tr class="addr-row">
+                        <td>顾客地址</td>
+                        <td colspan="3">
+                            <span class="addr-block"><strong><?= $escape($data['fullAddress'] ?? '') ?></strong></span>
+                            <?php if (!empty($data['distanceText'])): ?>
+                            <span class="addr-distance">配送距离: <?= $escape($data['distanceText']) ?></span>
+                            <?php endif; ?>
+                            配送距离:直线304米
+                        </td>
+                    </tr>
+                    <tr class="time-row">
+                        <td>配送时间</td>
+                        <td colspan="3">
+                            <strong><?= $escape($data['reachDateLabel'] ?? '') ?></strong>
+                            <strong><?= $escape($data['reachDateTime'] ?? '') ?></strong>
+                        </td>
+                    </tr>
+                    <tr>
+                        <td class="goods-label">商品描述<br>参考照片</td>
+                        <td colspan="3">
+                            <div class="goods-row">
+                                <div class="goods-img">
+                                    <?php if (!empty($data['productImage'])): ?>
+                                    <img src="<?= $escape($data['productImage']) ?>" alt="<?= $escape($data['productName'] ?? '商品图') ?>">
+                                    <?php endif; ?>
+                                </div>
+                                <div class="goods-text">
+                                    <div class="goods-name"><?= $escape($data['productName'] ?? '') ?></div>
+                                    <div class="goods-count">共 <span class="num"><?= $escape($data['productCount'] ?? '1') ?></span> 个</div>
+                                </div>
+                            </div>
+                        </td>
+                    </tr>
+                    <tr class="card-row">
+                        <td>贺卡内容</td>
+                        <td colspan="3"><?= nl2br($escape($data['cardInfo'] ?? '')) ?></td>
+                    </tr>
+                </table>
+                <div class="footer-bottom">
+                    <?php if (!empty($data['servicePhone'])): ?>
+                    <div class="bottom-tip">
+                        如在制作,配送环节出现问题或疑问,欢迎随时致电: <?= $escape($data['servicePhone']) ?>
+                    </div>
+                    <?php endif; ?>
+                    <?php if (!empty($data['sendNum'])): ?>
+                    <div class="circle-num-wrap">
+                        <span class="circle-num"><?= $escape($data['sendNum']) ?></span>
+                    </div>
+                    <?php endif; ?>
+                </div>
+            </div>
+        </td>
+    </tr>
+</table>
+</body>
+</html>

+ 153 - 0
common/templates/greeting-card-a4.php

@@ -0,0 +1,153 @@
+<?php
+/**
+ * A4 节日贺卡 HTML 模板(链科云打印)
+ *
+ * @var array $data
+ */
+$escape = function ($value) {
+    return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8');
+};
+$paper = $data['paper'] ?? [
+    'pageSize' => 'A4 portrait',
+    'pageMargin' => '10mm',
+    'contentWidth' => '190mm',
+    'cardWidth' => '140mm',
+    'cardHeight' => '200mm',
+];
+?>
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <title><?= $escape($data['cardTitle'] ?? '节日贺卡') ?></title>
+    <style>
+        @page {
+            size: <?= $escape($paper['pageSize']) ?>;
+            margin: <?= $escape($paper['pageMargin']) ?>;
+        }
+        * {
+            margin: 0;
+            padding: 0;
+            box-sizing: border-box;
+        }
+        body {
+            margin: 0;
+            padding: 0;
+            background: #ffffff;
+            font-family: "Microsoft YaHei", "PingFang SC", "SimSun", sans-serif;
+        }
+        .page-center {
+            width: 100%;
+            border: 0;
+            border-collapse: collapse;
+        }
+        .card-page {
+            width: <?= $escape($paper['contentWidth']) ?>;
+            max-width: 100%;
+            margin: 0 auto;
+            padding: 15mm 0;
+        }
+        .greeting-card {
+            position: relative;
+            width: <?= $escape($paper['cardWidth']) ?>;
+            height: <?= $escape($paper['cardHeight']) ?>;
+            margin: 0 auto;
+            background: #ffffff;
+            border-radius: 8mm;
+            box-shadow: 0 2mm 8mm rgba(0, 0, 0, 0.08);
+            overflow: hidden;
+            text-align: center;
+        }
+        /* 左上粉紫光晕 */
+        .glow-pink {
+            position: absolute;
+            left: -20mm;
+            top: -20mm;
+            width: 70mm;
+            height: 70mm;
+            background: radial-gradient(circle, rgba(255, 182, 193, 0.55) 0%, rgba(230, 190, 255, 0.25) 45%, transparent 70%);
+            pointer-events: none;
+        }
+        /* 右下暖黄光晕 */
+        .glow-yellow {
+            position: absolute;
+            right: -15mm;
+            bottom: -15mm;
+            width: 65mm;
+            height: 65mm;
+            background: radial-gradient(circle, rgba(255, 220, 120, 0.55) 0%, rgba(255, 235, 180, 0.2) 50%, transparent 72%);
+            pointer-events: none;
+        }
+        .card-inner {
+            position: relative;
+            z-index: 1;
+            height: 100%;
+            padding: 18mm 14mm 16mm;
+        }
+        .card-to {
+            margin-top: 6mm;
+            font-size: 20px;
+            color: #888888;
+            letter-spacing: 1px;
+        }
+        .card-title {
+            margin-top: 22mm;
+            font-size: 52px;
+            font-weight: bold;
+            color: #8B2942;
+            letter-spacing: 6px;
+        }
+        .card-message {
+            margin-top: 16mm;
+            font-size: 22px;
+            line-height: 2;
+            color: #555555;
+        }
+        .card-message p {
+            margin: 0;
+        }
+        .card-from {
+            position: absolute;
+            right: 14mm;
+            bottom: 16mm;
+            font-size: 20px;
+            color: #666666;
+            text-align: right;
+        }
+        @media print {
+            body {
+                padding: 0;
+                margin: 0;
+            }
+            .card-page {
+                width: <?= $escape($paper['contentWidth']) ?>;
+                margin: 0 auto;
+            }
+        }
+    </style>
+</head>
+<body>
+<table class="page-center" cellpadding="0" cellspacing="0" border="0">
+    <tr>
+        <td align="center" valign="top">
+            <div class="card-page">
+                <div class="greeting-card">
+                    <div class="glow-pink"></div>
+                    <div class="glow-yellow"></div>
+                    <div class="card-inner">
+                        <div class="card-to"><?= $escape($data['toText'] ?? '') ?></div>
+                        <div class="card-title"><?= $escape($data['titleText'] ?? '') ?></div>
+                        <div class="card-message">
+                            <?php foreach (($data['messageLines'] ?? []) as $line): ?>
+                            <p><?= $escape($line) ?></p>
+                            <?php endforeach; ?>
+                        </div>
+                        <div class="card-from"><?= $escape($data['fromText'] ?? '') ?></div>
+                    </div>
+                </div>
+            </div>
+        </td>
+    </tr>
+</table>
+</body>
+</html>

+ 38 - 0
scripts/sql/optimize_xh_recharge_hd_stat.sql

@@ -0,0 +1,38 @@
+-- =============================================================================
+-- hdApp 充值记录/统计查询索引建议 - xhRecharge
+-- =============================================================================
+-- 当前查询形态:
+-- 1. /recharge/list:
+--    shopId + status,按 addTime DESC,id DESC 分页;
+--    可选 customId / onlinePay + payWay / isRefund。
+-- 2. /recharge/stat:
+--    shopId + payStatus + status + addTime 时间范围;
+--    按渠道或客户聚合,并用 isRefund 统计减少金额。
+--
+-- 当前表已有索引:
+-- PRIMARY(id), orderSn(orderSn), custom_status(customId,status),
+-- main_custom_status(mainId,customId,status)。
+-- 这些索引缺少 shopId、payStatus、addTime、isRefund,对当前 hdApp 统计和分页查询帮助有限。
+--
+-- 执行前建议先在目标环境 EXPLAIN /recharge/list 与 /recharge/stat 的 SQL;
+-- 大表请在低峰期执行,并按 MySQL 版本确认 ALGORITHM/LOCK 支持情况。
+-- =============================================================================
+
+ALTER TABLE `xhRecharge`
+    ADD COLUMN `isRefund` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否退款:0不是 1是' AFTER status;
+
+ALTER TABLE `xhRecharge`
+    ADD INDEX `idx_hd_recharge_list` (`shopId`, `status`, `addTime`),
+    ALGORITHM = INPLACE,
+    LOCK = NONE;
+
+ALTER TABLE `xhRecharge`
+    ADD INDEX `idx_hd_recharge_stat` (`shopId`, `payStatus`, `status`, `addTime`),
+    ALGORITHM = INPLACE,
+    LOCK = NONE;
+
+-- 如果客户维度明细页仍然慢,再评估增加这个索引;不要在未验证前盲目增加写入开销。
+-- ALTER TABLE `xhRecharge`
+--     ADD INDEX `idx_hd_recharge_custom_list` (`shopId`, `customId`, `status`, `addTime`),
+--     ALGORITHM = INPLACE,
+--     LOCK = NONE;