Просмотр исходного кода

hdApp 客户生日赠礼管理 — AI初版

shizhongqi 2 месяцев назад
Родитель
Сommit
91b1aa58fe

+ 60 - 0
app-hd/controllers/BirthdayGiftController.php

@@ -0,0 +1,60 @@
+<?php
+
+namespace hd\controllers;
+
+use bizHd\birthday\classes\BirthdayGiftClass;
+use common\components\util;
+use Yii;
+
+class BirthdayGiftController extends BaseController
+{
+    public $guestAccess = [];
+
+    public function actionWorkbenchSummary()
+    {
+        $data = BirthdayGiftClass::getWorkbenchSummary($this->shopId, $this->mainId);
+        util::success($data);
+    }
+
+    public function actionBoardList()
+    {
+        $get = Yii::$app->request->get();
+        $data = BirthdayGiftClass::getBoardList($this->shop, $get);
+        util::success($data);
+    }
+
+    public function actionBoardCounts()
+    {
+        $get = Yii::$app->request->get();
+        $data = BirthdayGiftClass::getStatusCounts($this->shop, $get);
+        util::success($data);
+    }
+
+    public function actionGiftStats()
+    {
+        $get = Yii::$app->request->get();
+        $period = $get['period'] ?? 'today';
+        $data = BirthdayGiftClass::getGiftStats($this->shop, $period);
+        util::success($data);
+    }
+
+    public function actionNotifyTomorrow()
+    {
+        $result = BirthdayGiftClass::batchNotifyTomorrow($this->shop);
+        if ($result['success'] > 0) {
+            util::complete('已成功通知' . $result['success'] . '人' . ($result['fail'] > 0 ? ',失败' . $result['fail'] . '人' : ''));
+        }
+        if ($result['fail'] > 0 && $result['success'] == 0) {
+            util::fail('通知失败:' . implode(';', array_slice($result['messages'], 0, 3)));
+        }
+        util::fail('没有需要通知的明日生日客户');
+    }
+
+    public function actionPrint()
+    {
+        $get = Yii::$app->request->get();
+        $id = intval($get['id'] ?? 0);
+        BirthdayGiftClass::manualPrint($this->shopId, $id);
+        util::complete('打印指令已发送');
+    }
+}

+ 30 - 0
app-hd/controllers/MemberLevelController.php

@@ -29,17 +29,38 @@ class MemberLevelController extends BaseController
                     $level = $val['level'] ?? 1;
                     $amount = $val['amount'] ?? 0;
                     $amount = floatval($amount);
+                    $discount = floatval($val['discount'] ?? 10);
+                    $birthdayBenefit = trim($val['birthdayBenefit'] ?? '');
+                    $benefitDesc = trim($val['benefitDesc'] ?? '');
                     $it = [
                         'name' => $name,
                         'levelSn' => $levelSn,
                         'level' => $level,
+                        'member' => $level,
                         'amount' => $amount,
+                        'discount' => $discount,
+                        'birthdayBenefit' => $birthdayBenefit,
+                        'benefitDesc' => $benefitDesc,
                     ];
                     $member[] = $it;
                 }
             }
         } else {
             $member = dict::getDict('memberLevelMap');
+            foreach ($member as $k => $row) {
+                if (!isset($row['discount'])) {
+                    $member[$k]['discount'] = 10;
+                }
+                if (!isset($row['birthdayBenefit'])) {
+                    $member[$k]['birthdayBenefit'] = '';
+                }
+                if (!isset($row['benefitDesc'])) {
+                    $member[$k]['benefitDesc'] = '';
+                }
+                if (!isset($row['member'])) {
+                    $member[$k]['member'] = intval($row['level'] ?? ($k + 1));
+                }
+            }
         }
         util::success(['member' => $member, 'reduceDay' => $reduceDay, 'memberLevelSet' => $memberLevelSet]);
     }
@@ -70,6 +91,9 @@ class MemberLevelController extends BaseController
                 $level = $v['level'] ?? 1;
                 $name = $v['name'] ?? '会员';
                 $amount = $v['amount'] ?? 0;
+                $discount = floatval($v['discount'] ?? 10);
+                $birthdayBenefit = mb_substr(trim($v['birthdayBenefit'] ?? ''), 0, 50);
+                $benefitDesc = mb_substr(trim($v['benefitDesc'] ?? ''), 0, 500);
                 $it = [
                     'mainId' => $mainId,
                     'shopId' => $shopId,
@@ -77,10 +101,16 @@ class MemberLevelController extends BaseController
                     'levelSn' => $levelSn,
                     'name' => $name,
                     'amount' => $amount,
+                    'discount' => $discount,
+                    'birthdayBenefit' => $birthdayBenefit,
+                    'benefitDesc' => $benefitDesc,
                 ];
                 $has = MemberLevelClass::getByCondition(['mainId' => $mainId, 'level' => $level], true);
                 if (!empty($has)) {
                     $has->amount = $amount;
+                    $has->discount = $discount;
+                    $has->birthdayBenefit = $birthdayBenefit;
+                    $has->benefitDesc = $benefitDesc;
                     $has->save();
                 } else {
                     MemberLevelClass::add($it, true);

+ 40 - 0
app-mall/controllers/BirthdayGiftController.php

@@ -0,0 +1,40 @@
+<?php
+
+namespace mall\controllers;
+
+use bizHd\birthday\classes\BirthdayGiftClass;
+use common\components\util;
+use Yii;
+
+class BirthdayGiftController extends BaseController
+{
+    public $guestAccess = ['info', 'claim'];
+
+    public function actionInfo()
+    {
+        $get = Yii::$app->request->get();
+        $token = trim($get['token'] ?? '');
+        $account = intval($get['account'] ?? 0);
+        if (empty($token) || $account <= 0) {
+            util::fail('参数错误');
+        }
+        $data = BirthdayGiftClass::getInfoByToken($account, $token);
+        util::success($data);
+    }
+
+    public function actionClaim()
+    {
+        $post = Yii::$app->request->post();
+        if (empty($post)) {
+            $post = Yii::$app->request->get();
+        }
+        $token = trim($post['token'] ?? '');
+        $account = intval($post['account'] ?? 0);
+        $pickupTime = trim($post['pickupTime'] ?? '');
+        if (empty($token) || $account <= 0) {
+            util::fail('参数错误');
+        }
+        BirthdayGiftClass::claimByToken($account, $token, $pickupTime);
+        util::complete('领取成功');
+    }
+}

+ 1 - 1
app-mall/controllers/UserController.php

@@ -72,7 +72,7 @@ class UserController extends BaseController
             util::fail('农历没有31号');
         }
         $user = $this->user;
-        if ($user->birthdaySet == 1) {
+        if ($user->birthdaySet == 1 && getenv('YII_ENV') != 'dev') {
             util::fail('你已经设置过了');
         }
         if ($lunar == 1) {

+ 712 - 0
biz-hd/birthday/classes/BirthdayGiftClass.php

@@ -0,0 +1,712 @@
+<?php
+
+namespace bizHd\birthday\classes;
+
+use biz\sj\classes\SjClass;
+use bizHd\custom\classes\CustomClass;
+use bizHd\custom\classes\HdClass;
+use bizHd\member\classes\MemberLevelClass;
+use bizHd\shop\classes\ShopClass;
+use bizHd\shop\classes\ShopExtClass;
+use bizMall\wx\classes\WxMiniClass;
+use common\components\orderSn;
+use common\components\printUtil;
+use common\components\sms;
+use common\components\stringUtil;
+use common\components\util;
+use PhpAmqpLib\Wire\AMQPTable;
+use Yii;
+use bizHd\base\classes\BaseClass;
+
+class BirthdayGiftClass extends BaseClass
+{
+    public static $baseFile = '\bizHd\birthday\models\BirthdayGift';
+
+    const STATUS_NO_BIRTHDAY = 0;
+    const STATUS_PENDING_NOTIFY = 1;
+    const STATUS_PENDING_CLAIM = 2;
+    const STATUS_COLLECTED = 3;
+    const STATUS_EXPIRED = 4;
+
+    const EXPIRE_SECONDS = 86400;
+
+    public static function getStatusMap()
+    {
+        return [
+            self::STATUS_NO_BIRTHDAY => '未填写',
+            self::STATUS_PENDING_NOTIFY => '待通知',
+            self::STATUS_PENDING_CLAIM => '待领取',
+            self::STATUS_COLLECTED => '已领取',
+            self::STATUS_EXPIRED => '超时放弃',
+        ];
+    }
+
+    /**
+     * 等级 -> 生日权益配置
+     */
+    public static function getLevelBenefitMap($mainId, $shopId)
+    {
+        $shop = ShopClass::getById($shopId, true);
+        $memberLevelSet = $shop->memberLevelSet ?? 0;
+        if ($memberLevelSet == 1) {
+            $list = MemberLevelClass::getAllByCondition(['shopId' => $shopId], 'level ASC', '*', null);
+        } else {
+            $list = MemberLevelClass::getAllByCondition(['mainId' => $mainId], 'level ASC', '*', null);
+            if (empty($list)) {
+                $dict = \common\components\dict::getDict('memberLevelMap');
+                $list = [];
+                foreach ($dict as $row) {
+                    $list[] = $row;
+                }
+            }
+        }
+        $map = [];
+        if (!empty($list)) {
+            foreach ($list as $row) {
+                $level = intval($row['level'] ?? $row->level ?? 0);
+                $map[$level] = [
+                    'name' => $row['name'] ?? $row->name ?? '',
+                    'birthdayBenefit' => trim($row['birthdayBenefit'] ?? $row->birthdayBenefit ?? ''),
+                    'discount' => $row['discount'] ?? $row->discount ?? 10,
+                ];
+            }
+        }
+        return $map;
+    }
+
+    public static function getGiftNameForMember($member, $levelMap)
+    {
+        $member = intval($member);
+        if ($member <= 0 || empty($levelMap[$member])) {
+            return '';
+        }
+        return trim($levelMap[$member]['birthdayBenefit'] ?? '');
+    }
+
+    public static function hasBirthdayBenefit($custom, $levelMap)
+    {
+        $member = intval($custom->member ?? $custom['member'] ?? 0);
+        return $member > 0 && !empty(self::getGiftNameForMember($member, $levelMap));
+    }
+
+    /**
+     * 当年用于比对的公历月日
+     */
+    public static function getBirthdayMonthDay($custom)
+    {
+        $birthdayTime = intval($custom->birthdayTime ?? $custom['birthdayTime'] ?? 0);
+        if ($birthdayTime <= 0) {
+            return null;
+        }
+        $lunar = intval($custom->lunar ?? $custom['lunar'] ?? 0);
+        $birthday = $custom->birthday ?? $custom['birthday'] ?? '';
+        if ($lunar == 1 && !empty($birthday) && $birthday != '0000-00-00 00:00:00') {
+            $ts = strtotime($birthday);
+            if ($ts > 0) {
+                return [intval(date('n', $ts)), intval(date('j', $ts))];
+            }
+        }
+        $m = intval($custom->birthdayMonth ?? $custom['birthdayMonth'] ?? 0);
+        $d = intval($custom->birthdayDate ?? $custom['birthdayDate'] ?? 0);
+        if ($m > 0 && $d > 0) {
+            return [$m, $d];
+        }
+        return null;
+    }
+
+    public static function formatBirthdayDisplay($custom)
+    {
+        $m = intval($custom->birthdayMonth ?? 0);
+        $d = intval($custom->birthdayDate ?? 0);
+        if ($m <= 0 || $d <= 0) {
+            return '';
+        }
+        $prefix = intval($custom->lunar ?? 0) == 1 ? '农历 ' : '';
+        return $prefix . $m . '月' . $d . '日';
+    }
+
+    /**
+     * @param int $offsetDays 0今天 1明天
+     */
+    public static function matchesBirthdayOffset($custom, $offsetDays)
+    {
+        $md = self::getBirthdayMonthDay($custom);
+        if (empty($md)) {
+            return false;
+        }
+        $target = strtotime('+' . intval($offsetDays) . ' days');
+        return intval(date('n', $target)) == $md[0] && intval(date('j', $target)) == $md[1];
+    }
+
+    public static function matchesBirthdayWeek($custom)
+    {
+        $md = self::getBirthdayMonthDay($custom);
+        if (empty($md)) {
+            return false;
+        }
+        for ($i = 0; $i < 7; $i++) {
+            $target = strtotime('+' . $i . ' days');
+            if (intval(date('n', $target)) == $md[0] && intval(date('j', $target)) == $md[1]) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    public static function matchesPeriod($custom, $period)
+    {
+        $period = $period ?: 'week';
+        if ($period == 'today') {
+            return self::matchesBirthdayOffset($custom, 0);
+        }
+        if ($period == 'tomorrow') {
+            return self::matchesBirthdayOffset($custom, 1);
+        }
+        return self::matchesBirthdayWeek($custom);
+    }
+
+    public static function getEligibleCustoms($shopId, $mainId)
+    {
+        $levelMap = self::getLevelBenefitMap($mainId, $shopId);
+        $list = CustomClass::getAllByCondition([
+            'shopId' => $shopId,
+            'member>' => 0,
+            'delStatus' => 0,
+        ], 'id ASC', '*', null, true);
+        $result = [];
+        if (!empty($list)) {
+            foreach ($list as $custom) {
+                if (self::hasBirthdayBenefit($custom, $levelMap)) {
+                    $result[] = $custom;
+                }
+            }
+        }
+        return [$result, $levelMap];
+    }
+
+    public static function getWorkbenchSummary($shopId, $mainId)
+    {
+        list($customs,) = self::getEligibleCustoms($shopId, $mainId);
+        $today = 0;
+        $tomorrow = 0;
+        $week = 0;
+        foreach ($customs as $custom) {
+            if (self::getBirthdayMonthDay($custom) === null) {
+                continue;
+            }
+            if (self::matchesBirthdayOffset($custom, 0)) {
+                $today++;
+            }
+            if (self::matchesBirthdayOffset($custom, 1)) {
+                $tomorrow++;
+            }
+            if (self::matchesBirthdayWeek($custom)) {
+                $week++;
+            }
+        }
+        return [
+            'today' => $today,
+            'tomorrow' => $tomorrow,
+            'week' => $week,
+        ];
+    }
+
+    public static function generateClaimToken()
+    {
+        return md5(uniqid('bg', true) . mt_rand(1000, 9999) . microtime(true));
+    }
+
+    public static function getOrCreateYearRecord($shop, $custom, $levelMap)
+    {
+        $year = intval(date('Y'));
+        $shopId = $shop->id;
+        $mainId = $shop->mainId;
+        $customId = $custom->id;
+        $gift = self::getByCondition(['shopId' => $shopId, 'customId' => $customId, 'year' => $year], true);
+        $member = intval($custom->member);
+        $giftName = self::getGiftNameForMember($member, $levelMap);
+        $hd = HdClass::getByCondition(['customId' => $customId, 'shopId' => $shopId], true);
+        $hdId = $hd->id ?? 0;
+        $birthdayTime = intval($custom->birthdayTime);
+        $status = $birthdayTime > 0 ? self::STATUS_PENDING_NOTIFY : self::STATUS_NO_BIRTHDAY;
+        $data = [
+            'mainId' => $mainId,
+            'shopId' => $shopId,
+            'customId' => $customId,
+            'hdId' => $hdId,
+            'userId' => intval($custom->userId),
+            'year' => $year,
+            'member' => $member,
+            'memberName' => $custom->memberName ?? '',
+            'giftName' => $giftName,
+            'birthdayMonth' => intval($custom->birthdayMonth),
+            'birthdayDate' => intval($custom->birthdayDate),
+            'lunar' => intval($custom->lunar),
+            'birthdayDisplay' => self::formatBirthdayDisplay($custom),
+        ];
+        if (empty($gift)) {
+            $data['status'] = $status;
+            $data['claimToken'] = self::generateClaimToken();
+            $gift = self::add($data, true);
+        } else {
+            if (intval($gift->status) < self::STATUS_PENDING_CLAIM) {
+                $gift->status = $status;
+            }
+            foreach (['member', 'memberName', 'giftName', 'birthdayMonth', 'birthdayDate', 'lunar', 'birthdayDisplay', 'hdId', 'userId'] as $f) {
+                if (isset($data[$f])) {
+                    $gift->$f = $data[$f];
+                }
+            }
+            if (empty($gift->claimToken)) {
+                $gift->claimToken = self::generateClaimToken();
+            }
+            $gift->save();
+        }
+        return $gift;
+    }
+
+    public static function ensureYearRecords($shop)
+    {
+        list($customs, $levelMap) = self::getEligibleCustoms($shop->id, $shop->mainId);
+        foreach ($customs as $custom) {
+            self::getOrCreateYearRecord($shop, $custom, $levelMap);
+        }
+        return true;
+    }
+
+    public static function buildBoardItem($gift, $custom)
+    {
+        $status = intval($gift->status);
+        $statusMap = self::getStatusMap();
+        $mobile = $custom->mobile ?? '';
+        $tail = strlen($mobile) >= 4 ? substr($mobile, -4) : '';
+        return [
+            'id' => intval($gift->id),
+            'customId' => intval($gift->customId),
+            'name' => $custom->name ?? '',
+            'mobile' => $mobile,
+            'mobileTail' => $tail,
+            'avatar' => CustomClass::groupBaseInfo([['avatar' => $custom->avatar ?? '']])[0]['smallAvatar'] ?? '',
+            'member' => intval($gift->member),
+            'memberName' => $gift->memberName,
+            'giftName' => $gift->giftName,
+            'birthdayDisplay' => $gift->birthdayDisplay,
+            'status' => $status,
+            'statusName' => $statusMap[$status] ?? '',
+            'pickupTime' => $gift->pickupTime ?? '',
+            'collectTime' => $gift->collectTime ?? '',
+            'notifyTime' => $gift->notifyTime ?? '',
+            'expireTime' => $gift->expireTime ?? '',
+        ];
+    }
+
+    public static function getBoardList($shop, $params)
+    {
+        self::ensureYearRecords($shop);
+        $period = $params['period'] ?? 'week';
+        $statusFilter = isset($params['status']) ? intval($params['status']) : -1;
+        $searchText = trim($params['searchText'] ?? $params['keyword'] ?? '');
+        $page = max(1, intval($params['page'] ?? 1));
+        $pageSize = max(1, min(50, intval($params['pageSize'] ?? 20)));
+
+        list($customs, $levelMap) = self::getEligibleCustoms($shop->id, $shop->mainId);
+        $customMap = [];
+        foreach ($customs as $c) {
+            $customMap[$c->id] = $c;
+        }
+
+        $year = intval(date('Y'));
+        $gifts = self::getAllByCondition(['shopId' => $shop->id, 'year' => $year], 'id DESC', '*', null, true);
+        $rows = [];
+        if (!empty($gifts)) {
+            foreach ($gifts as $gift) {
+                $customId = intval($gift->customId);
+                if (empty($customMap[$customId])) {
+                    continue;
+                }
+                $custom = $customMap[$customId];
+                if ($statusFilter >= 0 && intval($gift->status) != $statusFilter) {
+                    continue;
+                }
+                if ($statusFilter < 0 && intval($gift->status) == self::STATUS_NO_BIRTHDAY) {
+                    // 全部 Tab 也展示未填写
+                }
+                if (intval($gift->status) != self::STATUS_NO_BIRTHDAY && !self::matchesPeriod($custom, $period)) {
+                    continue;
+                }
+                if (intval($gift->status) == self::STATUS_NO_BIRTHDAY && $period != 'week') {
+                    // 未填写仅在较宽范围展示:近一周仍显示
+                    if ($period != 'week') {
+                        continue;
+                    }
+                }
+                if (!empty($searchText)) {
+                    $name = $custom->name ?? '';
+                    $mobile = $custom->mobile ?? '';
+                    if (strpos($name, $searchText) === false && strpos($mobile, $searchText) === false) {
+                        continue;
+                    }
+                }
+                $rows[] = self::buildBoardItem($gift, $custom);
+            }
+        }
+
+        $total = count($rows);
+        $offset = ($page - 1) * $pageSize;
+        $list = array_slice($rows, $offset, $pageSize);
+        return [
+            'list' => $list,
+            'total' => $total,
+            'page' => $page,
+            'pageSize' => $pageSize,
+            'finished' => ($offset + count($list)) >= $total,
+        ];
+    }
+
+    public static function getStatusCounts($shop, $params)
+    {
+        self::ensureYearRecords($shop);
+        $period = $params['period'] ?? 'week';
+        list($customs,) = self::getEligibleCustoms($shop->id, $shop->mainId);
+        $customMap = [];
+        foreach ($customs as $c) {
+            $customMap[$c->id] = $c;
+        }
+        $counts = [
+            'all' => 0,
+            'pendingNotify' => 0,
+            'pendingClaim' => 0,
+            'collected' => 0,
+            'noBirthday' => 0,
+            'expired' => 0,
+        ];
+        $year = intval(date('Y'));
+        $gifts = self::getAllByCondition(['shopId' => $shop->id, 'year' => $year], null, '*', null, true);
+        if (!empty($gifts)) {
+            foreach ($gifts as $gift) {
+                $customId = intval($gift->customId);
+                if (empty($customMap[$customId])) {
+                    continue;
+                }
+                $custom = $customMap[$customId];
+                $st = intval($gift->status);
+                if ($st != self::STATUS_NO_BIRTHDAY && !self::matchesPeriod($custom, $period)) {
+                    continue;
+                }
+                if ($st == self::STATUS_NO_BIRTHDAY && $period != 'week') {
+                    continue;
+                }
+                $counts['all']++;
+                if ($st == self::STATUS_PENDING_NOTIFY) {
+                    $counts['pendingNotify']++;
+                } elseif ($st == self::STATUS_PENDING_CLAIM) {
+                    $counts['pendingClaim']++;
+                } elseif ($st == self::STATUS_COLLECTED) {
+                    $counts['collected']++;
+                } elseif ($st == self::STATUS_NO_BIRTHDAY) {
+                    $counts['noBirthday']++;
+                } elseif ($st == self::STATUS_EXPIRED) {
+                    $counts['expired']++;
+                }
+            }
+        }
+        return $counts;
+    }
+
+    public static function getGiftStats($shop, $period)
+    {
+        self::ensureYearRecords($shop);
+        list($customs,) = self::getEligibleCustoms($shop->id, $shop->mainId);
+        $customMap = [];
+        foreach ($customs as $c) {
+            $customMap[$c->id] = $c;
+        }
+        $pending = [];
+        $collected = [];
+        $year = intval(date('Y'));
+        $gifts = self::getAllByCondition(['shopId' => $shop->id, 'year' => $year], null, '*', null, true);
+        if (!empty($gifts)) {
+            foreach ($gifts as $gift) {
+                $customId = intval($gift->customId);
+                if (empty($customMap[$customId])) {
+                    continue;
+                }
+                if ($period != 'all' && !self::matchesPeriod($customMap[$customId], $period)) {
+                    continue;
+                }
+                $name = trim($gift->giftName);
+                if (empty($name)) {
+                    $name = '未配置礼品';
+                }
+                $st = intval($gift->status);
+                if ($st == self::STATUS_PENDING_CLAIM) {
+                    $pending[$name] = ($pending[$name] ?? 0) + 1;
+                } elseif ($st == self::STATUS_COLLECTED) {
+                    $collected[$name] = ($collected[$name] ?? 0) + 1;
+                }
+            }
+        }
+        $fmt = function ($map) {
+            $rows = [];
+            foreach ($map as $name => $num) {
+                $rows[] = ['giftName' => $name, 'num' => $num];
+            }
+            return $rows;
+        };
+        return [
+            'pending' => $fmt($pending),
+            'collected' => $fmt($collected),
+        ];
+    }
+
+    public static function getShopDisplayName($shop)
+    {
+        $sjId = $shop->sjId ?? 0;
+        $sj = SjClass::getById($sjId, true);
+        $sjName = $sj->name ?? '';
+        $shopName = $shop->shopName ?? '';
+        return $shopName == '首店' ? $sjName : trim($sjName . ' ' . $shopName);
+    }
+
+    public static function buildShortLink($shopId, $token)
+    {
+        $page = 'pages/member/birthdayGift?account=' . $shopId . '&token=' . urlencode($token);
+        $ret = WxMiniClass::getShortLink($page);
+        return $ret['link'] ?? '';
+    }
+
+    public static function scheduleExpireDelay($giftId, $expireAt)
+    {
+        $giftId = intval($giftId);
+        $expireAt = is_numeric($expireAt) ? intval($expireAt) : strtotime($expireAt);
+        $delayMs = max(1000, ($expireAt - time()) * 1000);
+        $message = serialize([
+            'type' => 'birthday_gift_expire',
+            'giftId' => $giftId,
+        ]);
+        $producer = Yii::$app->rabbitmq->getProducer('notifyProducer');
+        $producer->publish($message, 'limitBuyDelayExchange', 'birthdayGiftDelayRoute', [
+            'delivery_mode' => 2,
+            'content_type' => 'application/octet-stream',
+            'application_headers' => new AMQPTable([
+                'x-delay' => intval($delayMs),
+            ]),
+        ]);
+        Yii::info('birthday gift expire delay: giftId=' . $giftId . ' delayMs=' . $delayMs, __METHOD__);
+        return true;
+    }
+
+    public static function expireById($giftId)
+    {
+        $gift = self::getById($giftId, true);
+        if (empty($gift)) {
+            return true;
+        }
+        if (intval($gift->status) != self::STATUS_PENDING_CLAIM) {
+            return true;
+        }
+        $expireTime = $gift->expireTime ?? '';
+        if (!empty($expireTime) && strtotime($expireTime) > time()) {
+            return true;
+        }
+        $gift->status = self::STATUS_EXPIRED;
+        $gift->abandonTime = date('Y-m-d H:i:s');
+        $gift->save();
+        return true;
+    }
+
+    public static function sendNotifySms($shop, $gift, $custom)
+    {
+        $sjId = $shop->sjId ?? 0;
+        $merchant = SjClass::getById($sjId);
+        if (empty($merchant)) {
+            return [false, '商家信息不存在'];
+        }
+        $mobile = $custom->mobile ?? '';
+        if (empty($mobile)) {
+            return [false, '客户无手机号'];
+        }
+        $shopName = self::getShopDisplayName($shop);
+        $giftDesc = $gift->giftName ?: '生日礼品';
+        $shortLink = $gift->shortLink;
+        if (empty($shortLink)) {
+            $shortLink = self::buildShortLink($shop->id, $gift->claimToken);
+            $gift->shortLink = $shortLink;
+        }
+        $msg = "{$shopName}:亲爱的会员您好,明天就是您生日啦!这边有份您的vip生日福利礼包{$giftDesc},请您24小时内点击下方链接免费领取{$shortLink}逾期无效、感谢您的配合!祝您生活愉快!天天开心!";
+        sms::merchantSend($mobile, $msg, $merchant);
+        return [true, ''];
+    }
+
+    public static function batchNotifyTomorrow($shop)
+    {
+        self::ensureYearRecords($shop);
+        list($customs, $levelMap) = self::getEligibleCustoms($shop->id, $shop->mainId);
+        $success = 0;
+        $fail = 0;
+        $messages = [];
+        $year = intval(date('Y'));
+        foreach ($customs as $custom) {
+            if (!self::matchesBirthdayOffset($custom, 1)) {
+                continue;
+            }
+            $gift = self::getOrCreateYearRecord($shop, $custom, $levelMap);
+            if (intval($gift->status) != self::STATUS_PENDING_NOTIFY) {
+                continue;
+            }
+            list($ok, $err) = self::sendNotifySms($shop, $gift, $custom);
+            if (!$ok) {
+                $fail++;
+                $messages[] = ($custom->name ?? '') . ':' . $err;
+                continue;
+            }
+            $now = date('Y-m-d H:i:s');
+            $expire = date('Y-m-d H:i:s', time() + self::EXPIRE_SECONDS);
+            $gift->status = self::STATUS_PENDING_CLAIM;
+            $gift->notifyTime = $now;
+            $gift->expireTime = $expire;
+            $gift->smsSent = 1;
+            if (empty($gift->orderSn)) {
+                $gift->orderSn = 'BG' . date('ymdHis') . mt_rand(1000, 9999);
+            }
+            $gift->save();
+            self::scheduleExpireDelay($gift->id, strtotime($expire));
+            $success++;
+        }
+        return [
+            'success' => $success,
+            'fail' => $fail,
+            'messages' => $messages,
+        ];
+    }
+
+    public static function getInfoByToken($shopId, $token)
+    {
+        $gift = self::getByCondition(['shopId' => $shopId, 'claimToken' => $token], true);
+        if (empty($gift)) {
+            util::fail('链接无效或已失效');
+        }
+        $shop = ShopClass::getById($shopId, true);
+        $custom = CustomClass::getById($gift->customId, true);
+        $status = intval($gift->status);
+        $expired = false;
+        if ($status == self::STATUS_PENDING_CLAIM && !empty($gift->expireTime) && strtotime($gift->expireTime) < time()) {
+            self::expireById($gift->id);
+            $gift = self::getById($gift->id, true);
+            $status = intval($gift->status);
+            $expired = true;
+        }
+        $fullAddress = ($shop->city ?? '') . ($shop->address ?? '') . ($shop->floor ?? '');
+        return [
+            'giftName' => $gift->giftName,
+            'status' => $status,
+            'statusName' => self::getStatusMap()[$status] ?? '',
+            'pickupTime' => $gift->pickupTime ?? '',
+            'collectTime' => $gift->collectTime ?? '',
+            'expireTime' => $gift->expireTime ?? '',
+            'expired' => $expired || $status == self::STATUS_EXPIRED,
+            'collected' => $status == self::STATUS_COLLECTED,
+            'canClaim' => $status == self::STATUS_PENDING_CLAIM,
+            'shopName' => $shop->shopName ?? '',
+            'fullAddress' => $fullAddress,
+            'latitude' => $shop->latitude ?? '',
+            'longitude' => $shop->longitude ?? '',
+            'customName' => $custom->name ?? '',
+        ];
+    }
+
+    public static function claimByToken($shopId, $token, $pickupTime)
+    {
+        if (empty($pickupTime)) {
+            util::fail('请选择领取时间');
+        }
+        $gift = self::getByCondition(['shopId' => $shopId, 'claimToken' => $token], true);
+        if (empty($gift)) {
+            util::fail('链接无效');
+        }
+        $status = intval($gift->status);
+        if ($status == self::STATUS_COLLECTED) {
+            util::fail('已领取,请勿重复操作');
+        }
+        if ($status == self::STATUS_EXPIRED) {
+            util::fail('领取时间已过,已自动放弃权益');
+        }
+        if ($status != self::STATUS_PENDING_CLAIM) {
+            util::fail('当前状态不可领取');
+        }
+        if (!empty($gift->expireTime) && strtotime($gift->expireTime) < time()) {
+            self::expireById($gift->id);
+            util::fail('领取时间已过,已自动放弃权益');
+        }
+        $gift->pickupTime = $pickupTime;
+        $gift->collectTime = date('Y-m-d H:i:s');
+        $gift->status = self::STATUS_COLLECTED;
+        $gift->save();
+        self::printReceipt($gift);
+        return $gift;
+    }
+
+    public static function buildPrintContent($gift, $shop, $custom)
+    {
+        $shopName = self::getShopDisplayName($shop);
+        $pickup = $gift->pickupTime ?? '';
+        $pickupShort = $pickup ? date('m-d H:i', strtotime($pickup)) : '';
+        $mobile = $custom->mobile ?? '';
+        $tail = strlen($mobile) >= 4 ? substr($mobile, -4) : '';
+        $name = $custom->name ?? '';
+        $balance = floatval($custom->balance ?? 0);
+        $content = '<CB>生日赠礼订单</CB><BR>';
+        $content .= '<BR>';
+        if ($pickupShort) {
+            $content .= $pickupShort . ' 到店自取<BR>';
+        }
+        $content .= '<BR>';
+        $content .= '取花人:<BR>';
+        $content .= $name . ' 尾号 ' . $tail . '<BR>';
+        $content .= $mobile . '<BR>';
+        $content .= '<BR>';
+        $content .= '生日赠礼<BR>';
+        $content .= '<B>' . ($gift->giftName ?: '精美花束一份') . '</B><BR>';
+        $content .= '--------------------------------<BR>';
+        $content .= '会员等级:' . ($gift->memberName ?: '会员') . '<BR>';
+        $content .= '当前账户余额:' . $balance . ' 元<BR>';
+        $content .= '订单日期:' . ($gift->collectTime ?: date('Y-m-d H:i:s')) . '<BR>';
+        $content .= '订单编号:' . ($gift->orderSn ?: $gift->id) . '<BR>';
+        $content .= '门店名称:' . $shopName . '<BR>';
+        $content .= '<BR>';
+        return $content;
+    }
+
+    public static function printReceipt($gift)
+    {
+        $shopId = $gift->shopId ?? 0;
+        $shop = ShopClass::getById($shopId, true);
+        if (empty($shop)) {
+            return false;
+        }
+        $ext = ShopExtClass::getByCondition(['shopId' => $shopId]);
+        $printSn = $ext['printSn'] ?? '';
+        if (empty($printSn)) {
+            Yii::info('birthday gift print: no printSn shopId=' . $shopId, __METHOD__);
+            return false;
+        }
+        $custom = CustomClass::getById($gift->customId, true);
+        $content = self::buildPrintContent($gift, $shop, $custom);
+        $p = new printUtil($printSn);
+        $p->printMsg($content);
+        return true;
+    }
+
+    public static function manualPrint($shopId, $giftId)
+    {
+        $gift = self::getById($giftId, true);
+        if (empty($gift) || intval($gift->shopId) != intval($shopId)) {
+            util::fail('记录不存在');
+        }
+        if (intval($gift->status) != self::STATUS_COLLECTED) {
+            util::fail('仅已领取记录可打印');
+        }
+        self::printReceipt($gift);
+        return true;
+    }
+}

+ 13 - 0
biz-hd/birthday/models/BirthdayGift.php

@@ -0,0 +1,13 @@
+<?php
+
+namespace bizHd\birthday\models;
+
+use bizHd\base\models\Base;
+
+class BirthdayGift extends Base
+{
+    public static function tableName()
+    {
+        return 'xhBirthdayGift';
+    }
+}

+ 11 - 0
biz-hd/birthday/services/BirthdayGiftService.php

@@ -0,0 +1,11 @@
+<?php
+
+namespace bizHd\birthday\services;
+
+use bizHd\base\services\BaseService;
+use bizHd\birthday\classes\BirthdayGiftClass;
+
+class BirthdayGiftService extends BaseService
+{
+    public static $baseFile = '\bizHd\birthday\classes\BirthdayGiftClass';
+}

+ 9 - 0
biz-hd/member/classes/MemberLevelClass.php

@@ -23,6 +23,15 @@ class MemberLevelClass extends BaseClass
         foreach ($map as $k => $v) {
             $v['mainId'] = $mainId;
             $v['shopId'] = $shopId;
+            if (!isset($v['discount'])) {
+                $v['discount'] = 10;
+            }
+            if (!isset($v['birthdayBenefit'])) {
+                $v['birthdayBenefit'] = '';
+            }
+            if (!isset($v['benefitDesc'])) {
+                $v['benefitDesc'] = '';
+            }
             self::add($v);
         }
         return true;

+ 39 - 0
common/components/rabbitmq/birthdayGiftConsumer.php

@@ -0,0 +1,39 @@
+<?php
+
+namespace common\components\rabbitmq;
+
+use bizHd\birthday\classes\BirthdayGiftClass;
+use common\components\noticeUtil;
+use mikemadisonweb\rabbitmq\components\ConsumerInterface;
+use PhpAmqpLib\Message\AMQPMessage;
+use Yii;
+
+class birthdayGiftConsumer extends baseConsumer
+{
+    public function execute(AMQPMessage $msg)
+    {
+        try {
+            $this->ensureDbConnection();
+            $data = unserialize($msg->body);
+            if (!is_array($data)) {
+                noticeUtil::push("生日赠礼消费者报错:Invalid message format");
+                return ConsumerInterface::MSG_REJECT;
+            }
+            $type = $data['type'] ?? null;
+            $result = false;
+            if ($type == 'birthday_gift_expire') {
+                $giftId = intval($data['giftId'] ?? 0);
+                $result = $this->runWithDbReconnect(function () use ($giftId) {
+                    return BirthdayGiftClass::expireById($giftId);
+                });
+            } else {
+                noticeUtil::push("生日赠礼消费者未知 type: {$type}");
+                $result = true;
+            }
+            return $result ? ConsumerInterface::MSG_ACK : ConsumerInterface::MSG_ACK;
+        } catch (\Exception $e) {
+            noticeUtil::push("生日赠礼消费者报错:" . $e->getMessage());
+            return ConsumerInterface::MSG_ACK;
+        }
+    }
+}

+ 16 - 0
common/config/rabbitMQ.php

@@ -76,6 +76,11 @@ $rabbitMQ = [
             'passive' => false,
             'durable' => true,
         ],
+        [
+            'name' => 'birthdayGiftQueue',
+            'passive' => false,
+            'durable' => true,
+        ],
     ],
 
     /**
@@ -107,6 +112,11 @@ $rabbitMQ = [
             'queue' => 'limitBuyQueue',
             'exchange' => 'limitBuyDelayExchange',
             'routing_keys' => ['limitBuyDelayRoute'],
+        ],
+        [
+            'queue' => 'birthdayGiftQueue',
+            'exchange' => 'limitBuyDelayExchange',
+            'routing_keys' => ['birthdayGiftDelayRoute'],
         ]
     ],
 
@@ -165,6 +175,12 @@ $rabbitMQ = [
             'callbacks' => [
                 'ptQueue' => '\common\components\rabbitmq\ptConsumer',
             ]
+        ],
+        [
+            'name' => 'birthdayGiftConsumer',
+            'callbacks' => [
+                'birthdayGiftQueue' => '\common\components\rabbitmq\birthdayGiftConsumer',
+            ]
         ]
     ],
 ];