diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e9bf63..3b9451a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,38 @@ 本项目的所有重要变更都会记录在此文件中。 +## [4.6.0] - 2026-05-15 + +### 新增 + +- 企业购活动增加口令码参与方式 +- 企业购活动增加活动基础流量漏斗统计。 + +### 变更 + +- **管理端**: 企业购的员工验证去除企业码验证方式 +- **移动商城**: 重构企业购用户流程和优化企业购UIUX + +### 修复 + +- 修复部分已知bug + +## [4.5.0] - 2026-04-24 + +### 新增 + +- H5商城新增邮箱注册验证码激活注册方式。 +- H5商城新增邮箱密码、邮箱验证码登录方式。 +- H5商城新增邮箱注册账号可通过邮件重置密码。 + +### 变更 + +- **管理端**: 会员列表增加注册邮箱数据列 + +### 修复 + +- 修复部分已知bug + ## [4.4.0] - 2026-03-27 ### 新增 diff --git a/composer.json b/composer.json index a2417d9..6a177b6 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "laravel/lumen", - "version": "4.5.1", + "version": "4.6.0", "description": "The Laravel Lumen Framework.", "keywords": [ "framework", diff --git a/database/migrations/Version20260515163622.php b/database/migrations/Version20260515163622.php new file mode 100644 index 0000000..22737cb --- /dev/null +++ b/database/migrations/Version20260515163622.php @@ -0,0 +1,32 @@ +abortIf($this->connection->getDatabasePlatform()->getName() != 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('CREATE TABLE employee_purchase_activity_enterprise_behavior_log (id BIGINT AUTO_INCREMENT NOT NULL COMMENT \'主键\', company_id BIGINT NOT NULL COMMENT \'公司ID\', activity_id BIGINT NOT NULL COMMENT \'活动ID\', enterprise_id BIGINT NOT NULL COMMENT \'企业ID\', user_id BIGINT DEFAULT NULL COMMENT \'会员用户ID\', behavior_type VARCHAR(32) NOT NULL COMMENT \'行为类型\', result_status VARCHAR(16) DEFAULT NULL COMMENT \'行为结果,仅口令验证: success|fail\', visitor_key VARCHAR(64) DEFAULT NULL COMMENT \'未登录去重键\', ref_id BIGINT DEFAULT NULL COMMENT \'关联业务ID\', extra JSON DEFAULT NULL COMMENT \'扩展(DC2Type:json_array)\', created INT NOT NULL COMMENT \'创建时间戳\', INDEX idx_ep_aebl_company_activity (company_id, activity_id), INDEX idx_ep_aebl_act_ent_type (activity_id, enterprise_id, behavior_type), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB COMMENT = \'内购活动企业行为流水\' '); + $this->addSql('CREATE TABLE employee_purchase_activity_enterprise_participate_user (id BIGINT AUTO_INCREMENT NOT NULL COMMENT \'主键\', company_id BIGINT NOT NULL COMMENT \'公司ID\', activity_id BIGINT NOT NULL COMMENT \'活动ID\', enterprise_id BIGINT NOT NULL COMMENT \'企业ID\', user_id BIGINT NOT NULL COMMENT \'会员用户ID\', created INT NOT NULL COMMENT \'创建时间戳\', INDEX idx_ep_aepu_activity_ent (activity_id, enterprise_id), UNIQUE INDEX uk_ep_aepu_scope_user (company_id, activity_id, enterprise_id, user_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB COMMENT = \'内购活动企业参与名额已占用用户\' '); + $this->addSql('CREATE TABLE employee_purchase_activity_passphrase_enterprises (id BIGINT AUTO_INCREMENT NOT NULL COMMENT \'主键\', company_id BIGINT NOT NULL COMMENT \'公司ID\', activity_id BIGINT NOT NULL COMMENT \'活动ID\', enterprise_id BIGINT NOT NULL COMMENT \'企业ID\', participate_quota INT UNSIGNED DEFAULT 0 NOT NULL COMMENT \'可参与名额\', passphrase_limitfee INT UNSIGNED DEFAULT 0 NOT NULL COMMENT \'口令通道额度(分),按企业\', passphrase_code VARCHAR(64) NOT NULL COMMENT \'口令编码\', created INT NOT NULL, updated INT DEFAULT NULL, INDEX idx_ep_ape_company_activity (company_id, activity_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB COMMENT = \'内购活动口令绑定企业\' '); + $this->addSql('CREATE TABLE employee_purchase_store_home_page (id BIGINT UNSIGNED AUTO_INCREMENT NOT NULL, company_id BIGINT NOT NULL COMMENT \'公司ID\', distributor_id INT DEFAULT 0 NOT NULL COMMENT \'门店ID\', template_name VARCHAR(64) DEFAULT NULL COMMENT \'小程序模板名称\', page_name VARCHAR(255) NOT NULL COMMENT \'页面名称\', page_description VARCHAR(500) NOT NULL COMMENT \'页面描述\', page_share_title VARCHAR(255) DEFAULT NULL COMMENT \'分享标题\', page_share_desc VARCHAR(500) DEFAULT NULL COMMENT \'分享描述\', page_share_imageUrl VARCHAR(500) DEFAULT NULL COMMENT \'分享图片\', is_open INT DEFAULT 1 NOT NULL COMMENT \'是否开启\', weapp_customize_page_id BIGINT UNSIGNED DEFAULT NULL, created INT DEFAULT NULL, updated INT DEFAULT NULL, INDEX idx_company_id (company_id), INDEX idx_distributor_id (distributor_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB COMMENT = \'内购模版(门店首页配置)\' '); + $this->addSql('ALTER TABLE employee_purchase_activities ADD is_passphrase_enabled TINYINT(1) DEFAULT \'0\' NOT NULL COMMENT \'是否开启口令通道\''); + $this->addSql('ALTER TABLE employee_purchase_enterprises CHANGE auth_type auth_type VARCHAR(20) NOT NULL COMMENT \'登录类型,mobile:手机号,account:账号,email:邮箱,qr_code:二维码,no_verify:无需验证\''); + $this->addSql('ALTER TABLE employee_purchase_orders_rel_activity ADD participate_quota_order_consumed TINYINT(1) DEFAULT \'0\' NOT NULL COMMENT \'创单是否因本单扣减口令参与名额\''); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema): void + { + } +} \ No newline at end of file diff --git a/docs/employeepurchase-passphrase-frontend.md b/docs/employeepurchase-passphrase-frontend.md new file mode 100644 index 0000000..f9ad9e9 --- /dev/null +++ b/docs/employeepurchase-passphrase-frontend.md @@ -0,0 +1,473 @@ +# 内购活动 · 口令通道 · 前端对接说明 + +本文说明:**批量生成口令码**、**创建/更新活动时提交口令企业配置** 的请求方式与示例,以及 **C 端(小程序/H5)行为流水上报**:**扫码/进入**、**口令校验**。 + +> **鉴权**:与其它管理端内购接口一致,请求头需带 JWT,例如: +> `Authorization: Bearer ` +> **基础路径**:以实际部署为准。常见为 `{站点}/api`,下文记为 `{API_BASE}`。 +> 若网关还有版本段(如 `/api/v1`),请将下列路径接在版本根路径之后。 + +> **C 端(frontapi)**:内购流水为 **单一接口** `.../activity/behavior-report`,挂在 Dingo `v1` + 前缀 **`h5app`** 下,例如: +> `{FRONTAPI_BASE}/v1/h5app/wxapp/employeepurchase/activity/behavior-report` +> `{FRONTAPI_BASE}` 与小程序/H5 现有域名一致(常见同 `{API_BASE}`)。若网关省略 `v1` 或合并前缀,以实际为准。 + +--- + +## 零、体系总览(完整说明) + +本节从**产品/数据/接口/代码/部署**串起全文,便于评审与交接;细节仍分章展开。 + +### 0.1 能力边界(本文档覆盖什么) + +| 能力 | 说明 | +|------|------| +| **管理端 · 口令配置** | 批量生成口令码(不落库)、创建/更新活动时提交 `passphrase_enterprises`、读活动详情中带出口令企业行 | +| **管理端 · 行为统计** | 按活动聚合各参与企业的扫码 PV/UV、口令验证成功人数(UV)、绑定/下单 UV 等 | +| **C 端 · 行为流水** | **一个 URL**:上报 **扫码/进入**、**口令校验**;口令每次尝试(成败)均落库并带 `result_status` | +| **C 端 · 员工绑定流水** | **`POST .../wxapp/employee/auth`**(需 JWT):活动内员工身份绑定**成功**后,若请求携带 **`activity_id`** 且企业参与该活动,服务端写入 **`bind`** 流水(供 `bind_user_count`) | +| **服务端 · 下单流水** | 内购单在 **`NormalOrderPaySuccessEvent`**(支付成功)时,若该用户在该活动+企业下存在 **扫码绑定** 的 **`bind`** 流水(`extra.bind_channel=qr_code`),才自动写 **`order`** 流水(`ref_id`=订单号);**不处理取消/退款**(不删、不冲正) | +| **未覆盖** | **亲友邀请绑定**(`bindRelative`)当前不写该流水表;非内购订单不写本表 | + +### 0.2 数据表 + +| 表名 | 迁移 | 作用 | +|------|------|------| +| `employee_purchase_activity_passphrase_enterprises` | 随活动/口令功能演进 | 活动维度下「企业 ↔ 口令码、名额」绑定;C 端口令比对读此表 | +| `employee_purchase_activity_enterprise_behavior_log` | **`Version20260409160000`** 建表(含 **`result_status`** 列) | 一条记录 = 一次行为;`behavior_type` 区分扫码/口令/绑定/下单等 | + +**流水表主要字段(概念)**:`company_id`、`activity_id`、`enterprise_id`、`user_id`(可空)、`behavior_type`、`visitor_key`(可空,未登录 UV)、`ref_id`(可空;**`order` 行为存订单号**)、`extra`(可空)、`result_status`(**仅 `passphrase_verify` 使用**:`success` / `fail`)、`created`。 + +### 0.3 接口总览 + +**管理端**(均需 JWT,基路径 `{API_BASE}`,常含版本前缀,以环境为准): + +| 用途 | 方法 | 路径(模式) | +|------|------|----------------| +| 批量生成口令码 | POST | `{API_BASE}/employeepurchase/passphrase-codes/generate` | +| 按活动生成口令码 | POST | `{API_BASE}/employeepurchase/activity/{activityId}/passphrase-codes/generate` | +| 创建活动(含口令配置) | POST | `{API_BASE}/employeepurchase/activity` | +| 更新活动(含口令替换/关闭清空) | PUT | `{API_BASE}/employeepurchase/activity/{activityId}` | +| 活动详情(含 `passphrase_enterprises`) | GET | `{API_BASE}/employeepurchase/activity/{activityId}` | +| 各企业行为聚合统计 | GET | `{API_BASE}/employeepurchase/activity/{activityId}/enterprise-behavior-stats` | + +**C 端**(frontapi,`{FRONTAPI_BASE}/v1/h5app/...`): + +| 用途 | 方法 | 路径 | 鉴权 | +|------|------|------|------| +| **扫码 + 口令校验(统一入口)** | POST | `/wxapp/employeepurchase/activity/behavior-report` | `frontnoauth:h5app`;可选 JWT | +| **员工身份绑定(可触发 bind 流水)** | POST | `/wxapp/employee/auth` | `dingoguard:h5app` + JWT | + +`behavior-report`:Body 用 **`behavior_type`**:`scan` 或 `passphrase_verify`。可选 **`Authorization: Bearer`**:有效 JWT 时 **`company_id`、`user_id` 以 token 为准**,body 中的 `company_id` 会被忽略。 + +`employee/auth`:表单/Body 除原有字段外,建议传 **`activity_id`**(当前内购活动),以便绑定成功后写入 **`bind`** 流水;不传则**不写**绑定流水(不影响绑定成功)。 + +**路由名(Laravel/Dingo)**:`front.wxapp.employeepurchase.activity.behavior_report`;控制器:`EmployeePurchaseBundle\Http\FrontApi\V1\Action\Activity@reportActivityBehavior`。 + +### 0.4 行为类型与统计口径 + +| `behavior_type` | 含义 | `result_status` | 管理端 `enterprise-behavior-stats` 相关字段 | +|-----------------|------|-----------------|---------------------------------------------| +| `scan` | 扫码/进入 | 始终 `NULL` | `scan_count`(PV)、`scan_user_count`(UV,按 `user_id` 或 `visitor_key`) | +| `passphrase_verify` | 口令尝试 | **`success` / `fail`** | `passphrase_verify_user_count` = **验证成功 UV**(`fail` 不计入;历史 `NULL` 仍按成功口径兼容) | +| `bind` | **活动员工账号绑定**(`employee/auth` 成功且传有效 `activity_id`) | `NULL` | `bind_user_count`(UV,按 `user_id`);**`extra` 含 `bind_channel`**(与请求 `auth_type` 一致,如 `qr_code`) | +| `order` | 内购订单**支付成功**且存在 **扫码绑定** 流水(同上 `bind` + `bind_channel=qr_code`) | `NULL` | `order_user_count`(UV,按 `user_id`;**同订单幂等只记一条**) | + +**支付成功时,怎么知道是哪个活动?** +支付网关回调里通常**只有订单号**,不会直接带 `activity_id`。本项目的约定是: + +1. **订单类型**:`orders_associations`(`OrderAssociationService::getOrder`)里 **`order_type === 'normal'` 且 `order_class === 'employee_purchase'`** 才是内购实体单。字符串 **`normal_employee_purchase`** 是订单模块里 **`GetOrderServiceTrait`** 把二者拼起来选 `EmployeePurchaseBundle` 的 `NormalOrderService` 用的**路由键**,**不是**关联表里 `order_type` 列的原值。 +2. **活动维度**:内购订单**创建**时会在 **`employee_purchase_orders_rel_activity`** 落一行(`NormalOrderService::createExtend` → `OrdersRelActivityService::create`),字段含 **`order_id`、`activity_id`、`enterprise_id`、`user_id`**。 +3. **记流水**:支付成功监听里用 `order_id` 查这张表,读出 `activity_id` / `enterprise_id` / `user_id`,再查行为表是否存在 **`behavior_type=bind` 且 `extra.bind_channel=qr_code`** 的同维度流水;满足才写 `order`。若查不到关联行,说明不是按内购链路建的单,**不写**行为流水。 + +因此:**「活动下支付」= 该订单在下单环节已绑定到具体活动**;支付阶段只是**回溯**这张关联表,而不是在支付时猜测活动。 + +**口令校验逻辑(摘要)**:活动须存在且 `enterprise_id` 为活动参与企业;须 **开启口令通道**;口令表须有对应行;用户输入与库中 `passphrase_code` 经规范化后 **`hash_equals`** 比对。失败场景仍 **HTTP 200**,`data.verified === false`,并写 **`fail`** 流水;**活动不存在**等业务错误抛错且 **不写口令流水**。 + +### 0.5 服务端代码入口(便于改查) + +| 模块 | 路径 | +|------|------| +| C 端路由 | `routes/frontapi/employeepurchase.php` | +| C 端上报 | `EmployeePurchaseBundle\Http\FrontApi\V1\Action\Activity::reportActivityBehavior` 及私有方法 `writeActivityScanLog`、`verifyActivityPassphraseCore` | +| 口令比对 | `EmployeePurchaseBundle\Services\ActivitiesService`(`getPassphraseCodeForActivityEnterprise`、`isActivityEnterprisePassphraseMatch`) | +| 流水写入与聚合 | `EmployeePurchaseBundle\Services\ActivityEnterpriseBehaviorLogService`(`writeBehaviorLog`、`recordEmployeePurchaseOrderPaid`、`getAggregatedStatsForAdmin`) | +| 员工绑定与 bind 流水 | `EmployeePurchaseBundle\Services\EmployeesService::authentication`(成功后 `tryWriteEmployeeBindBehaviorLog`) | +| 支付成功与 order 流水 | `OrdersBundle\Events\NormalOrderPaySuccessEvent` → `EmployeePurchaseBundle\Listeners\EmployeePurchaseOrderPaySuccessListener` | +| 管理端统计接口 | `EmployeePurchaseBundle\Http\Api\V1\Action\Activity::getActivityEnterpriseBehaviorStats` | +| 流水仓储 | `EmployeePurchaseBundle\Repositories\ActivityEnterpriseBehaviorLogRepository` | +| 流水实体 | `EmployeePurchaseBundle\Entities\ActivityEnterpriseBehaviorLog` | + +**Service 约定**:`writeBehaviorLog` / `record` 第 9 参数为 `result_status`;**仅** `behavior_type === passphrase_verify` 时允许且必填 `success`/`fail`,其它类型传状态会报错。 + +### 0.6 部署与迁移 + +1. 执行 Doctrine 迁移:**`Version20260409160000`**(若尚未执行)创建流水表(**已包含 `result_status` 列**)。 +2. 发布包含上述路由与控制器的代码版本。 +3. **前端**:仅对接 **`behavior-report`**;历史上若曾使用 `scan-report`、`passphrase-verify` 等旧路径,需全部切换为 **`behavior_type` 分派**。 +4. **OpenAPI/Swagger**:C 端注解在 `FrontApi\V1\Action\Activity` 的 `reportActivityBehavior` 上。 +5. **订单流水**:监听器在 `OrdersBundle\Providers\EventServiceProvider` 中注册 `NormalOrderPaySuccessEvent`。 + +### 0.7 下文章节索引 + +| 章节 | 内容 | +|------|------| +| **一~二** | 管理端:生成口令码、创建/更新活动保存口令企业 | +| **三~五** | 推荐流程、读活动详情、常见错误 | +| **六** | 流水表语义、`result_status`、管理端聚合接口、Service 写入说明 | +| **七** | C 端 **`behavior-report`** 请求/响应字段与示例 | + +--- + +**活动详情(含口令企业 + 完整 `enterprise`)** 即管理端: + +`GET https://demo-ecshopx.ishopex.cn/api/employeepurchase/activity/{activityId}` + +示例:`GET https://demo-ecshopx.ishopex.cn/api/employeepurchase/activity/150`(`150` 为活动 ID)。 + +--- + +## 一、批量生成口令码 + +生成 **8 位**「数字 + 英文大小写」字符串;服务端会与库内已有口令去重后返回(**不落库**,仅给表单填值用)。 + +### 1.1 新建活动(尚无 `activity_id`)— 推荐 + +与本公司下、所有活动已占用的口令去重;企业须为当前公司下(店铺账号则为当前店铺可见)的内购企业。 + +**请求** + +```http +POST {API_BASE}/employeepurchase/passphrase-codes/generate +Content-Type: application/json +Authorization: Bearer +``` + +**Body(JSON)** + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `enterprise_ids` | `number[]` | 是 | 需要生成口令的企业 ID 列表 | +| `count` | `number` | 否 | 每个企业生成几条,默认 `1`,最大 `50` | +| `activity_id` | `number` | 否 | 新建场景**不传**或传 `0` | + +**限制(服务端)**:最多 100 个企业;`enterprise_ids.length * count` 不超过 500。 + +**响应 `data` 示例** + +```json +{ + "list": [ + { "enterprise_id": 101, "passphrase_codes": ["a3Bc9XyZ"] }, + { "enterprise_id": 102, "passphrase_codes": ["m7Np2QkL", "v4Rt8WsD"] } + ] +} +``` + +**cURL 示例** + +```bash +curl -sS -X POST "${API_BASE}/employeepurchase/passphrase-codes/generate" \ + -H "Authorization: Bearer YOUR_JWT" \ + -H "Content-Type: application/json" \ + -d '{ + "enterprise_ids": [101, 102], + "count": 1 + }' +``` + +**前端 `fetch` 示例** + +```javascript +const res = await fetch(`${API_BASE}/employeepurchase/passphrase-codes/generate`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + enterprise_ids: [101, 102], + count: 1, + }), +}); +const json = await res.json(); +// 按项目实际解析 Dingo 包装,口令列表一般在 json.data.list +``` + +--- + +### 1.2 编辑已有活动(带 `activity_id`) + +**方式 A:路径里带活动 ID(与旧版兼容)** + +```http +POST {API_BASE}/employeepurchase/activity/{activityId}/passphrase-codes/generate +Content-Type: application/json +``` + +路径中的 `activityId` 优先;**忽略** body 里的 `activity_id`。 +去重范围:**该活动**下已保存的口令;企业须为该活动的**参与企业**。 + +**cURL 示例** + +```bash +curl -sS -X POST "${API_BASE}/employeepurchase/activity/12345/passphrase-codes/generate" \ + -H "Authorization: Bearer YOUR_JWT" \ + -H "Content-Type: application/json" \ + -d '{"enterprise_ids":[101,102],"count":1}' +``` + +**方式 B:仍用 1.1 的 URL,body 里带 `activity_id`** + +```json +{ + "activity_id": 12345, + "enterprise_ids": [101, 102], + "count": 1 +} +``` + +--- + +## 二、保存口令企业配置(写入数据库) + +口令企业与 **`employee_purchase_activity_passphrase_enterprises`** 的同步,通过 **创建活动** / **更新活动** 接口完成,**无单独「只保存口令」接口**。 + +### 2.1 `passphrase_enterprises` 每项结构 + +| 字段 | 类型 | 必填(开启口令时) | 说明 | +|------|------|-------------------|------| +| `enterprise_id` | number | 是 | 企业 ID,且必须出现在本次请求的**活动参与企业** `enterprise_id` 列表中 | +| `participate_quota` | number | 是 | 可参与名额,**必须 > 0**;该企业在本活动**已有**口令行时,**不得低于已保存值**(不可下调) | +| `passphrase_code` | string | 是 | 口令码,1~64 字符(推荐用生成接口的 8 位) | + +**别名(后端已支持)** + +- `participate_quota` 可写 `quota` +- `passphrase_code` 可写 `code` + +**数组示例** + +```json +[ + { "enterprise_id": 101, "participate_quota": 50, "passphrase_code": "a3Bc9XyZ" }, + { "enterprise_id": 102, "participate_quota": 30, "passphrase_code": "m7Np2QkL" } +] +``` + +同时还需传(与原有逻辑一致): + +- `is_passphrase_enabled`:开启口令通道时为 `true` / `1` / `"true"` 等(与现有活动布尔字段处理一致) +- `passphrase_limitfee`:口令通道额度,**分**,整数且 ≥ 0 + +**校验要点(后端)** + +- 开启口令时:`passphrase_enterprises` 非空,且每行字段合法。 +- 同一活动内:口令码不重复;每个 `enterprise_id` 只出现一行。 +- 全公司范围内:口令码不能与**其它活动**已占用冲突(更新当前活动时会排除本活动旧数据)。 +- 更新口令配置时:同一 `enterprise_id` 若已有存储行,则本次 `participate_quota` **不得小于**库中已保存值。 + +**管理端 UI(`purchase.vue`)** + +- 活动详情为「进行中」(`status === ongoing`)且已开启口令时:界面仅允许编辑口令企业表中的 **可参与名额**;保存按钮在未开口令的进行中活动上禁用。强校验仍以接口为准。 + +--- + +### 2.2 创建活动 `POST {API_BASE}/employeepurchase/activity` + +管理端创建活动多为 **multipart/form-data**(含图片等字段)。此时 `passphrase_enterprises` 常作为 **JSON 字符串** 放在一个表单项里。 + +**表单项示例(节选)** + +| 字段 | 示例值 | +|------|--------| +| `is_passphrase_enabled` | `1` | +| `passphrase_limitfee` | `10000` | +| `passphrase_enterprises` | `[{"enterprise_id":101,"participate_quota":50,"passphrase_code":"a3Bc9XyZ"}]` | +| `enterprise_id[]` | `101`(多条重复 key,视你们表单封装而定) | + +若整条请求用 **application/json**(且网关/后端允许),也可直接传数组类型字段(需与现有创建接口对 Content-Type 的约定一致)。 + +**逻辑顺序建议** + +1. 用户选好参与企业 `enterprise_id`。 +2. 调 **1.1 生成接口**,把返回的 `passphrase_codes` 填到各企业行。 +3. 用户可改名额等,再与其它活动字段一并 **POST 创建**。 + +--- + +### 2.3 更新活动 `PUT {API_BASE}/employeepurchase/activity/{activityId}` + +- 请求体字段与创建类似。 +- **只要请求里带有键名 `passphrase_enterprises`**(即使值来自空数组),后端会按 **整表替换** 该活动的口令企业数据。 +- 若本次将 `is_passphrase_enabled` 置为关闭,后端会**清空**口令企业表并处理额度,无需再传 `passphrase_enterprises`。 + +**JSON 示例(若接口支持 JSON 更新)** + +```http +PUT {API_BASE}/employeepurchase/activity/12345 +Content-Type: application/json +Authorization: Bearer +``` + +```json +{ + "name": "活动名称", + "title": "活动标题", + "is_passphrase_enabled": true, + "passphrase_limitfee": 10000, + "passphrase_enterprises": [ + { "enterprise_id": 101, "participate_quota": 50, "passphrase_code": "a3Bc9XyZ" } + ] +} +``` + +> 注意:你们环境若更新仍要求带齐 `pages_template_id`、`pic`、`share_pic` 等必填项,请按现有活动更新接口文档补全;上表仅强调口令相关字段。 + +### 2.4 管理端 ecshopx-admin(与本仓库字段对齐) + +- **活动创建/编辑页**:`ecshopx-admin/src/view/marketing/employee/purchase.vue` + - 与 **§2.1** 一致提交:`is_passphrase_enabled`、`passphrase_limitfee`(**分**,整数 ≥ 0)、`passphrase_enterprises`(**JSON 字符串**;后端亦接受别名 `quota`、`code`)。 + - **开启口令**时展示「口令码额度」(元,写入时换算为分);**亲友**整块表单项(含标签)通过 `SpForm` 的 `isShow` 隐藏。 + - 开启口令时仍填写 **员工购买时间**;**员工购买额度**在口令模式下不展示,提交以 `employee_limitfee` 为准。 + - 创建/更新走 `POST /employeepurchase/activity`、`PUT /employeepurchase/activity/{activityId}`,由 `src/api/marketing.js` 发出。 +- **批量生成口令**:`ecshopx-admin/src/api/marketing.js` 中的 `generatePassphraseCodes`、`generatePassphraseCodesByActivity`,请求体与 **§1.1 / §1.2** 一致:`enterprise_ids` 为 **数字数组**,可选 `count`。 + +--- + +## 三、推荐前端流程(简图) + +1. 选参与企业 → `enterprise_ids` +2. `POST .../passphrase-codes/generate` → 得到每企业 `passphrase_codes` +3. 表格绑定:`enterprise_id`、`participate_quota`、选一条 `passphrase_code` +4. 提交创建或更新:带上 `is_passphrase_enabled`、`passphrase_limitfee`、`passphrase_enterprises` + +--- + +## 四、活动详情中读取口令配置 + +与线上一致:`GET {API_BASE}/employeepurchase/activity/{activityId}`(例如 `GET .../api/employeepurchase/activity/150`)。返回里包含 `passphrase_enterprises`:每条为口令表一行(`id`、`enterprise_id`、`participate_quota`、`passphrase_code`、`created`、`updated` 等),`enterprise` 为 **与 `GET /enterprise/{id}`(getEnterpriseInfo)一致的企业详情**(含邮箱通道时的 `relay_host`、`smtp_port`、`email_user`、`email_password`、`email_suffix`,以及与企业列表一致的 **`distributor_name`**、`is_employee_check_enabled` 字符串等)。若企业已删或查不到,则 `enterprise` 为 `{ "id": }`。 + +--- + +## 五、常见错误提示(文案以实际返回为准) + +| 场景 | 可能提示 | +|------|----------| +| 开启口令但未传企业口令列表 | 开启口令通道时请配置口令企业信息 | +| 企业不在活动参与列表中 | 口令企业须为活动参与企业 | +| 名额 ≤ 0 | 可参与名额须大于0 | +| 名额低于已保存值 | `{企业名称(企业 ID x)|企业 ID x}:本活动可参与名额不得低于已保存值(已保存:N,本次提交:M)` | +| 口令与其它活动冲突 | 口令编码已被其它活动占用:xxx | +| 生成接口企业非法 | 企业不存在或无权操作 | + +--- + +## 六、活动企业行为流水与统计表(管理端) + +数据表:`employee_purchase_activity_enterprise_behavior_log`(迁移 **`Version20260409160000`** 建表,已含 `result_status`)。**一行 = 一次行为**,通过 `behavior_type` 区分。 + +**行为类型常量**(写入时需与服务端一致): + +| 值 | 说明 | +|----|------| +| `scan` | 扫码 / 进入活动页(可重复,算 PV) | +| `passphrase_verify` | 口令验证(**每次尝试一条**,成功/失败由 `result_status` 区分) | +| `bind` | **活动员工身份绑定**成功(见 `POST .../employee/auth` + 可选 `activity_id`) | +| `order` | 内购订单支付成功且用户为 **扫码绑定**(存在 `bind` 且 `extra.bind_channel=qr_code`),`ref_id` 为订单号 | + +**`result_status`(可选列,仅口令验证使用)**:`success` 表示校验与库中口令一致,`fail` 表示不一致或未开启口令等失败场景;其它 `behavior_type` 该列为 `NULL`。 + +**C 端 HTTP 接口(扫码、口令)**:见下文 **「七、C 端行为流水统一上报」**;**同一 URL**,`behavior_type` 区分 `scan` / `passphrase_verify`;带有效 JWT 时 `company_id`、`user_id` 来自登录态,**勿在 body 伪造会员身份**。 + +**管理端实时聚合接口**: + +```http +GET {API_BASE}/employeepurchase/activity/{activityId}/enterprise-behavior-stats +Authorization: Bearer +``` + +示例:`GET https://demo-ecshopx.ishopex.cn/api/employeepurchase/activity/150/enterprise-behavior-stats` + +返回 `data.list`:每行对应一个**活动参与企业**,字段含 `enterprise_id`、`enterprise_name`、`enterprise_sn`、`logo`、`scan_count`、`scan_user_count`、`passphrase_verify_user_count`(**仅统计验证成功 UV**,`result_status=success`)、`bind_user_count`、`order_user_count`。 + +**写入流水**:**扫码、口令** 使用 **第七章** `behavior-report`;**员工绑定** 在 **`POST .../wxapp/employee/auth`** 成功时由服务端自动写入(须传 **`activity_id`**),并写入 **`extra.bind_channel`**。**内购订单支付成功** 由 **`NormalOrderPaySuccessEvent`** 监听器在满足 **扫码绑定流水** 条件时写 **`order`**(**取消/退款不删流水**)。其它场景若需扩展仍可调用 **`writeBehaviorLog(...)`** / **`record(...)`**。`passphrase_verify` 若直接调 Service,须传入第 9 参数 **`result_status`**(`success` / `fail`)。 + +--- + +## 七、C 端行为流水统一上报 + +**一个接口**完成:**扫码/进入** 与 **口令校验**。注册于 **`routes/frontapi/employeepurchase.php`**,`$api->version('v1', ...)`,URL 常含 **`/v1/`**,前缀 **`h5app`**。 + +**路径(接在 `{FRONTAPI_BASE}/v1` 之后)** + +| 方法 | 路径 | +|------|------| +| POST | `/h5app/wxapp/employeepurchase/activity/behavior-report` | + +**示例** + +```http +POST https://{域名}/api/v1/h5app/wxapp/employeepurchase/activity/behavior-report +Content-Type: application/json +Authorization: Bearer # 可选;有效则按登录态解析 company_id、user_id +``` + +**鉴权与 `company_id`(`frontnoauth:h5app`)** + +- 请求**带有效 JWT** 时:中间件会解析出会员身份,`company_id`、`user_id` **以 token 为准**(body 里的 `company_id` **会被忽略**,避免伪造)。 +- **未登录**:须在 body 传 **`company_id`**(与活动所属公司一致)。 +- **UV**:未登录强烈建议传 **`visitor_key`**(如 openid 摘要,≤64);已登录以 **`user_id`** 去重。 + +--- + +### 7.1 公共 Body 字段 + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `behavior_type` | string | 是 | `scan` 或 `passphrase_verify` | +| `company_id` | number | 未登录必填 | 已登录可省略(由 JWT 决定) | +| `activity_id` | number | 是 | 活动 ID | +| `enterprise_id` | number | 是 | 企业 ID,须为该活动参与企业(扫码、口令均校验) | +| `visitor_key` | string | 否 | 未登录建议传 | + +--- + +### 7.2 `behavior_type = scan`(扫码 / 进入) + +**响应 `data` 示例** + +```json +{ "behavior_type": "scan", "status": true, "log_id": 123456 } +``` + +活动不存在、企业未参与该活动 → 错误响应(不写流水)。 + +--- + +### 7.3 `behavior_type = passphrase_verify`(口令校验) + +与库表 **`employee_purchase_activity_passphrase_enterprises`** 中该活动-企业的 `passphrase_code` 比对;**每次请求无论成败都写一条流水**,`result_status` 为 `success` 或 `fail`。 + +**额外 Body 字段** + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `passphrase_code` | string | 与 `code` 二选一 | 用户输入口令 | +| `code` | string | 与上一项二选一 | 同义别名 | + +- 活动不存在 → 错误,**不写流水**。 +- 口令错误、未开启口令、无绑定行等 → **HTTP 200**,`verified: false`,并写 **fail** 流水。 + +**响应 `data` 示例** + +```json +{ "behavior_type": "passphrase_verify", "verified": true, "log_id": 123457 } +``` + +```json +{ "behavior_type": "passphrase_verify", "verified": false, "log_id": 123458 } +``` + +--- + +文档版本:与仓库内 `EmployeePurchaseBundle` 口令与行为流水实现同步维护;**完整总览见上文「零、体系总览」**。 diff --git a/routes/api/employeepurchase.php b/routes/api/employeepurchase.php index 7837075..44eaf6e 100644 --- a/routes/api/employeepurchase.php +++ b/routes/api/employeepurchase.php @@ -61,8 +61,18 @@ $api->version('v1', function($api) { $api->get('/employeepurchase/activities', ['name'=> '获取员工内购活动列表','middleware'=>'activated', 'as' => 'employeepurchase.activity.list', 'uses' =>'Activity@getActivityList']); // 内购活动亲友数据 $api->get('/employeepurchase/activity/users', ['name'=> '获取员工内购活动亲友列表','middleware'=>'activated', 'as' => 'employeepurchase.activity.users', 'uses' =>'Activity@getActivityUsers']); + // 活动各企业行为流水实时聚合(须写在 /activity/{activityId} 之前,避免路径被误匹配) + $api->get('/employeepurchase/activity/{activityId}/enterprise-behavior-stats', ['name'=> '活动企业行为统计','middleware'=>'activated', 'as' => 'employeepurchase.activity.enterprise_behavior_stats', 'uses' =>'Activity@getActivityEnterpriseBehaviorStats']); + // 下载活动企业行为统计(Excel) + $api->get('/employeepurchase/activity/{activityId}/enterprise-behavior-stats/download', ['name'=> '下载活动企业行为统计','middleware'=>'activated', 'as' => 'employeepurchase.activity.enterprise_behavior_stats.download', 'uses' =>'Activity@downloadActivityEnterpriseBehaviorStats']); + // 下载活动参与企业扫码小程序码(Excel) + $api->get('/employeepurchase/activity/{activityId}/download-qrcode', ['name'=> '下载活动企业小程序码','middleware'=>'activated', 'as' => 'employeepurchase.activity.download_qrcode', 'uses' =>'Activity@downloadActivityQrcode']); // 获取员工内购活动详情 $api->get('/employeepurchase/activity/{activityId}', ['name'=> '获取员工内购活动详情','middleware'=>'activated', 'as' => 'employeepurchase.activity.info', 'uses' =>'Activity@getActivityInfo']); + // 批量生成口令编码:activity_id 可选(新建活动可不传);传则按活动内去重,不传则按公司下去重 + $api->post('/employeepurchase/passphrase-codes/generate', ['name'=> '批量生成口令码','middleware'=>'activated', 'as' => 'employeepurchase.passphrase_codes.generate', 'uses' =>'Activity@generatePassphraseCodes']); + // 兼容:活动已存在时仍可用路径传 activity_id + $api->post('/employeepurchase/activity/{activityId}/passphrase-codes/generate', ['name'=> '批量生成活动口令码(路径)','middleware'=>'activated', 'as' => 'employeepurchase.activity.passphrase_codes.generate', 'uses' =>'Activity@generatePassphraseCodesByActivity']); // 创建员工内购活动 $api->post('/employeepurchase/activity', ['name'=> '创建员工内购活动','middleware'=>'activated', 'as' => 'employeepurchase.activity.create', 'uses' =>'Activity@createActivity']); // 更新员工内购活动 @@ -79,5 +89,12 @@ $api->version('v1', function($api) { $api->post('/employeepurchase/activity/end/{activityId}', ['name'=> '结束内购活动','middleware'=>'activated', 'as' => 'employeepurchase.activity.end', 'uses' =>'Activity@endActivity']); // 提前开始内购活动 $api->post('/employeepurchase/activity/ahead/{activityId}', ['name'=> '提前开始内购活动','middleware'=>'activated', 'as' => 'employeepurchase.activity.ahead', 'uses' =>'Activity@aheadActivity']); + + // 企业购门店首页(按 distributor_id;经销商仅本店) + $api->get('/employeepurchase/store-home-page', ['name' => '内购模版列表', 'middleware' => 'activated', 'as' => 'employeepurchase.store_home_page.list', 'uses' => 'StoreHomePage@getList']); + $api->post('/employeepurchase/store-home-page', ['name' => '创建内购模版', 'middleware' => 'activated', 'as' => 'employeepurchase.store_home_page.create', 'uses' => 'StoreHomePage@create']); + $api->get('/employeepurchase/store-home-page/{id}', ['name' => '内购模版详情', 'middleware' => 'activated', 'as' => 'employeepurchase.store_home_page.info', 'uses' => 'StoreHomePage@getInfo']); + $api->put('/employeepurchase/store-home-page/{id}', ['name' => '更新内购模版', 'middleware' => 'activated', 'as' => 'employeepurchase.store_home_page.update', 'uses' => 'StoreHomePage@update']); + $api->delete('/employeepurchase/store-home-page/{id}', ['name' => '删除内购模版', 'middleware' => 'activated', 'as' => 'employeepurchase.store_home_page.delete', 'uses' => 'StoreHomePage@delete']); }); }); \ No newline at end of file diff --git a/routes/frontapi/employeepurchase.php b/routes/frontapi/employeepurchase.php index 5965fe0..4caf94b 100644 --- a/routes/frontapi/employeepurchase.php +++ b/routes/frontapi/employeepurchase.php @@ -19,6 +19,13 @@ $api->version('v1', function($api) { $api->get('/wxapp/employee/email/vcode', ['name'=> '获取邮箱验证码', 'as' => 'front.wxapp.employeepurchase.email.vcode.get', 'uses' =>'Employee@sendEmailVcode']); // 验证员工的白名单,返回白名单下的企业 $api->post('/wxapp/employee/check', ['name'=> '员工验证', 'as' => 'front.wxapp.employeepurchase.employee.check', 'uses' =>'Employee@employeeCheck']); + // 内购活动行为流水统一上报:扫码(scan) / 口令校验(passphrase_verify);带有效 JWT 时 company_id、user_id 来自登录态 + $api->post('/wxapp/employeepurchase/activity/behavior-report', ['name'=> '内购活动行为流水上报', 'as' => 'front.wxapp.employeepurchase.activity.behavior_report', 'uses' =>'Activity@reportActivityBehavior']); + + + // 获取活动详情 基础信息 无需授权 + $api->get('/wxapp/employeepurchase/activity/detail', ['name'=> '获取活动详情', 'as' => 'front.wxapp.employeepurchase.activity.detail', 'uses' =>'Activity@getActivityDetail']); + }); $api->group(['prefix' => 'h5app', 'namespace' => 'EmployeePurchaseBundle\Http\FrontApi\V1\Action', 'middleware' => ['dingoguard:h5app', 'api.auth'], 'providers' => 'jwt'], function($api) { // 获取用户所在企业列表 @@ -42,13 +49,19 @@ $api->version('v1', function($api) { $api->get('/wxapp/employeepurchase/is_open', ['name'=> '是否开启内购', 'as' => 'front.wxapp.employeepurchase.is_open', 'uses' =>'Activity@isOpen']); // 获取可参与的活动列表 $api->get('/wxapp/employeepurchase/activities', ['name'=> '获取可参与的活动列表', 'as' => 'front.wxapp.employeepurchase.activity.list', 'uses' =>'Activity@getActivityList']); + // 当前登录用户在某活动+企业下是否具备有效内购资格(白名单/家属);列表、详情、加购等节点按需调用 + $api->get('/wxapp/employeepurchase/internal-sale-eligibility', ['name'=> '内购资格校验', 'as' => 'front.wxapp.employeepurchase.internal_sale_eligibility', 'uses' =>'Activity@getInternalSaleEligibility']); + // 内购模版详情(含 resolved_pages_template_id);字面路径较长者居前 + $api->get('/wxapp/employeepurchase/store-home-page/{id}', ['name'=> '内购模版详情ToC', 'as' => 'front.wxapp.employeepurchase.store_home_page.detail', 'uses' =>'StoreHomePage@getDetail']); + + // 字面路径须在 /activity/{activity_id}(若未来新增)之前;较长路径先于较短路径更安全。 + // 获取活动商品关联的分类 + $api->get('/wxapp/employeepurchase/activity/items/category', ['name'=> '获取活动商品关联的分类', 'as' => 'front.wxapp.employeepurchase.activity.item.category', 'uses' =>'Activity@getActivityItemCategory']); // 获取活动商品列表 $api->get('/wxapp/employeepurchase/activity/items', ['name'=> '获取活动商品列表', 'as' => 'front.wxapp.employeepurchase.activity.item.list', 'uses' =>'Activity@getActivityItemList']); // 获取活动商品详情 $api->get('/wxapp/employeepurchase/activity/item/{item_id}', ['name'=> '获取活动商品详情', 'as' => 'front.wxapp.employeepurchase.activity.item.detail', 'uses' =>'Activity@getActivityItemDetail']); - // 获取活动商品关联的分类 - $api->get('/wxapp/employeepurchase/activity/items/category', ['name'=> '获取活动商品关联的分类', 'as' => 'front.wxapp.employeepurchase.activity.item.category', 'uses' =>'Activity@getActivityItemCategory']); // 内购购物车新增 $api->post('/wxapp/employeepurchase/cart', ['name'=> '内购购物车新增', 'as' => 'front.wxapp.employeepurchase.cart.add', 'uses' =>'Cart@cartDataAdd']); diff --git a/src/EmployeePurchaseBundle/Entities/Activities.php b/src/EmployeePurchaseBundle/Entities/Activities.php index 9f42ba5..55e236c 100644 --- a/src/EmployeePurchaseBundle/Entities/Activities.php +++ b/src/EmployeePurchaseBundle/Entities/Activities.php @@ -222,6 +222,13 @@ class Activities */ private $discount_description; + /** + * @var boolean + * + * @ORM\Column(name="is_passphrase_enabled", type="boolean", options={"comment":"是否开启口令通道", "default":false}) + */ + private $is_passphrase_enabled = false; + /** * @var \DateTime $created * @@ -919,4 +926,24 @@ class Activities { return $this->discount_description; } + + /** + * @param bool $isPassphraseEnabled + * + * @return Activities + */ + public function setIsPassphraseEnabled($isPassphraseEnabled) + { + $this->is_passphrase_enabled = $isPassphraseEnabled; + + return $this; + } + + /** + * @return bool + */ + public function getIsPassphraseEnabled() + { + return $this->is_passphrase_enabled; + } } diff --git a/src/EmployeePurchaseBundle/Entities/ActivityEnterpriseBehaviorLog.php b/src/EmployeePurchaseBundle/Entities/ActivityEnterpriseBehaviorLog.php new file mode 100644 index 0000000..accb0b2 --- /dev/null +++ b/src/EmployeePurchaseBundle/Entities/ActivityEnterpriseBehaviorLog.php @@ -0,0 +1,154 @@ +id; + } + + public function setCompanyId($v) + { + $this->company_id = $v; + + return $this; + } + + public function setActivityId($v) + { + $this->activity_id = $v; + + return $this; + } + + public function setEnterpriseId($v) + { + $this->enterprise_id = $v; + + return $this; + } + + public function setUserId($v) + { + $this->user_id = $v; + + return $this; + } + + public function setBehaviorType($v) + { + $this->behavior_type = $v; + + return $this; + } + + public function setResultStatus($v) + { + $this->result_status = $v; + + return $this; + } + + public function setVisitorKey($v) + { + $this->visitor_key = $v; + + return $this; + } + + public function setRefId($v) + { + $this->ref_id = $v; + + return $this; + } + + public function setExtra($v) + { + $this->extra = $v; + + return $this; + } + + public function setCreated($v) + { + $this->created = $v; + + return $this; + } +} diff --git a/src/EmployeePurchaseBundle/Entities/ActivityEnterpriseParticipateUser.php b/src/EmployeePurchaseBundle/Entities/ActivityEnterpriseParticipateUser.php new file mode 100644 index 0000000..fbd15a2 --- /dev/null +++ b/src/EmployeePurchaseBundle/Entities/ActivityEnterpriseParticipateUser.php @@ -0,0 +1,133 @@ +id; + } + + public function setCompanyId($companyId) + { + $this->company_id = $companyId; + + return $this; + } + + public function getCompanyId() + { + return $this->company_id; + } + + public function setActivityId($activityId) + { + $this->activity_id = $activityId; + + return $this; + } + + public function getActivityId() + { + return $this->activity_id; + } + + public function setEnterpriseId($enterpriseId) + { + $this->enterprise_id = $enterpriseId; + + return $this; + } + + public function getEnterpriseId() + { + return $this->enterprise_id; + } + + public function setUserId($userId) + { + $this->user_id = $userId; + + return $this; + } + + public function getUserId() + { + return $this->user_id; + } + + public function setCreated($created) + { + $this->created = $created; + + return $this; + } + + public function getCreated() + { + return $this->created; + } +} diff --git a/src/EmployeePurchaseBundle/Entities/ActivityPassphraseEnterprises.php b/src/EmployeePurchaseBundle/Entities/ActivityPassphraseEnterprises.php new file mode 100644 index 0000000..5f2fee4 --- /dev/null +++ b/src/EmployeePurchaseBundle/Entities/ActivityPassphraseEnterprises.php @@ -0,0 +1,184 @@ +id; + } + + public function setCompanyId($companyId) + { + $this->company_id = $companyId; + + return $this; + } + + public function getCompanyId() + { + return $this->company_id; + } + + public function setActivityId($activityId) + { + $this->activity_id = $activityId; + + return $this; + } + + public function getActivityId() + { + return $this->activity_id; + } + + public function setEnterpriseId($enterpriseId) + { + $this->enterprise_id = $enterpriseId; + + return $this; + } + + public function getEnterpriseId() + { + return $this->enterprise_id; + } + + public function setParticipateQuota($participateQuota) + { + $this->participate_quota = $participateQuota; + + return $this; + } + + public function getParticipateQuota() + { + return $this->participate_quota; + } + + public function setPassphraseLimitfee($passphraseLimitfee) + { + $this->passphrase_limitfee = $passphraseLimitfee; + + return $this; + } + + public function getPassphraseLimitfee() + { + return $this->passphrase_limitfee; + } + + public function setPassphraseCode($passphraseCode) + { + $this->passphrase_code = $passphraseCode; + + return $this; + } + + public function getPassphraseCode() + { + return $this->passphrase_code; + } + + public function setCreated($created) + { + $this->created = $created; + + return $this; + } + + public function getCreated() + { + return $this->created; + } + + public function setUpdated($updated = null) + { + $this->updated = $updated; + + return $this; + } + + public function getUpdated() + { + return $this->updated; + } +} diff --git a/src/EmployeePurchaseBundle/Entities/Enterprises.php b/src/EmployeePurchaseBundle/Entities/Enterprises.php index 259ea7b..05d9b7a 100644 --- a/src/EmployeePurchaseBundle/Entities/Enterprises.php +++ b/src/EmployeePurchaseBundle/Entities/Enterprises.php @@ -102,7 +102,7 @@ class Enterprises /** * @var string * - * @ORM\Column(name="auth_type", type="string", length=20, options={"comment":"登录类型,mobile:手机号,account:账号,email:邮箱,qr_code:二维码"}) + * @ORM\Column(name="auth_type", type="string", length=20, options={"comment":"登录类型,mobile:手机号,account:账号,email:邮箱,qr_code:二维码,no_verify:无需验证"}) */ private $auth_type = 'mobile'; diff --git a/src/EmployeePurchaseBundle/Entities/OrdersRelActivity.php b/src/EmployeePurchaseBundle/Entities/OrdersRelActivity.php index 71b5912..f92e482 100644 --- a/src/EmployeePurchaseBundle/Entities/OrdersRelActivity.php +++ b/src/EmployeePurchaseBundle/Entities/OrdersRelActivity.php @@ -82,6 +82,13 @@ class OrdersRelActivity */ private $close_modify_time; + /** + * @var bool + * + * @ORM\Column(name="participate_quota_order_consumed", type="boolean", options={"comment":"创单是否因本单扣减口令参与名额", "default":false}) + */ + private $participate_quota_order_consumed = false; + /** * Set orderId * @@ -249,4 +256,24 @@ class OrdersRelActivity { return $this->close_modify_time; } + + /** + * @param bool|int $v + * + * @return OrdersRelActivity + */ + public function setParticipateQuotaOrderConsumed($v) + { + $this->participate_quota_order_consumed = (bool) $v; + + return $this; + } + + /** + * @return bool + */ + public function getParticipateQuotaOrderConsumed() + { + return (bool) $this->participate_quota_order_consumed; + } } diff --git a/src/EmployeePurchaseBundle/Entities/StoreHomePage.php b/src/EmployeePurchaseBundle/Entities/StoreHomePage.php new file mode 100644 index 0000000..6a2da94 --- /dev/null +++ b/src/EmployeePurchaseBundle/Entities/StoreHomePage.php @@ -0,0 +1,266 @@ +id; + } + + public function setCompanyId($companyId) + { + $this->company_id = $companyId; + + return $this; + } + + public function getCompanyId() + { + return $this->company_id; + } + + public function setDistributorId($distributorId) + { + $this->distributor_id = $distributorId; + + return $this; + } + + public function getDistributorId() + { + return $this->distributor_id; + } + + public function setTemplateName($templateName) + { + $this->template_name = $templateName; + + return $this; + } + + public function getTemplateName() + { + return $this->template_name; + } + + public function setPageName($pageName) + { + $this->page_name = $pageName; + + return $this; + } + + public function getPageName() + { + return $this->page_name; + } + + public function setPageDescription($pageDescription) + { + $this->page_description = $pageDescription; + + return $this; + } + + public function getPageDescription() + { + return $this->page_description; + } + + public function setPageShareTitle($pageShareTitle) + { + $this->page_share_title = $pageShareTitle; + + return $this; + } + + public function getPageShareTitle() + { + return $this->page_share_title; + } + + public function setPageShareDesc($pageShareDesc) + { + $this->page_share_desc = $pageShareDesc; + + return $this; + } + + public function getPageShareDesc() + { + return $this->page_share_desc; + } + + public function setPageShareImageUrl($pageShareImageUrl) + { + $this->page_share_imageUrl = $pageShareImageUrl; + + return $this; + } + + public function getPageShareImageUrl() + { + return $this->page_share_imageUrl; + } + + public function setIsOpen($isOpen) + { + $this->is_open = $isOpen; + + return $this; + } + + public function getIsOpen() + { + return $this->is_open; + } + + public function setWeappCustomizePageId($weappCustomizePageId) + { + $this->weapp_customize_page_id = $weappCustomizePageId; + + return $this; + } + + public function getWeappCustomizePageId() + { + return $this->weapp_customize_page_id; + } + + public function setCreated($created) + { + $this->created = $created; + + return $this; + } + + public function getCreated() + { + return $this->created; + } + + public function setUpdated($updated) + { + $this->updated = $updated; + + return $this; + } + + public function getUpdated() + { + return $this->updated; + } +} diff --git a/src/EmployeePurchaseBundle/Http/Api/V1/Action/Activity.php b/src/EmployeePurchaseBundle/Http/Api/V1/Action/Activity.php index 44cc42a..01371f8 100644 --- a/src/EmployeePurchaseBundle/Http/Api/V1/Action/Activity.php +++ b/src/EmployeePurchaseBundle/Http/Api/V1/Action/Activity.php @@ -23,6 +23,9 @@ use App\Http\Controllers\Controller as Controller; use CompanysBundle\Ego\CompanysActivationEgo; use EmployeePurchaseBundle\Services\ActivitiesService; +use EmployeePurchaseBundle\Services\ActivityEnterpriseBehaviorLogService; +use EmployeePurchaseBundle\Support\ActivityListDisplayStatusQuery; +use EspierBundle\Jobs\ExportFileJob; use GoodsBundle\Services\ItemsCategoryService; class Activity extends Controller @@ -50,6 +53,8 @@ class Activity extends Controller * @SWG\Property( property="relative_limitfee", type="integer", description="亲友可使用额度"), * @SWG\Property( property="minimum_amount", type="integer", description="订单最低金额"), * @SWG\Property( property="close_modify_hours_after_activity", type="integer", description="活动结束后多少小时内可以修改收货地址"), + * @SWG\Property( property="is_passphrase_enabled", type="boolean", description="是否开启口令通道"), + * @SWG\Property( property="passphrase_enterprises", type="array", description="口令绑定企业;每项含 participate_quota、passphrase_limitfee(分)、passphrase_code 等;额度在各行配置,活动上无总额字段。enterprise 与单条企业详情接口一致(含邮箱 SMTP 字段、distributor_name 等)", @SWG\Items(type="object")), * @SWG\Property( property="created", type="integer", description="创建时间"), * @SWG\Property( property="updated", type="integer", description="修改时间"), * ) @@ -80,6 +85,8 @@ class Activity extends Controller * @SWG\Parameter( name="relative_limitfee", in="formData", description="亲友可使用额度", type="integer", required=false), * @SWG\Parameter( name="minimum_amount", in="formData", description="订单最低金额", type="integer", required=true), * @SWG\Parameter( name="close_modify_hours_after_activity", in="formData", description="活动结束后多少小时内可以修改收货地址", type="integer", required=true), + * @SWG\Parameter( name="is_passphrase_enabled", in="formData", description="是否开启口令通道 0/1", type="integer", required=false), + * @SWG\Parameter( name="passphrase_enterprises", in="formData", description="口令企业(JSON 数组):每项 enterprise_id、participate_quota、passphrase_limitfee(分)、passphrase_code;别名 quota、code、limit_fee。开启口令时必填,校验通过后写入 employee_purchase_activity_passphrase_enterprises", type="string", required=false), * @SWG\Response( * response=200, * description="成功返回结构", @@ -98,12 +105,13 @@ class Activity extends Controller $companyId = $authInfo['company_id']; $distributor_id = $authInfo['distributor_id']; $operator_id = $authInfo['operator_id']; - $params = $request->all('name', 'title', 'pages_template_id', 'pic', 'share_pic', 'enterprise_id', 'display_time', 'employee_begin_time', 'employee_end_time', 'employee_limitfee', 'if_relative_join', 'invite_limit', 'relative_begin_time', 'relative_end_time', 'if_share_limitfee', 'relative_limitfee', 'minimum_amount', 'close_modify_hours_after_activity', 'price_display_config', 'is_discount_description_enabled', 'discount_description'); + $params = $request->all('name', 'title', 'pages_template_id', 'pic', 'share_pic', 'enterprise_id', 'display_time', 'employee_begin_time', 'employee_end_time', 'employee_limitfee', 'if_relative_join', 'invite_limit', 'relative_begin_time', 'relative_end_time', 'if_share_limitfee', 'relative_limitfee', 'minimum_amount', 'close_modify_hours_after_activity', 'price_display_config', 'is_discount_description_enabled', 'discount_description', 'is_passphrase_enabled', 'passphrase_enterprises'); $params['company_id'] = $companyId; // 处理布尔值参数:支持字符串 'true'/'1' 和整数 1/0 $params['if_relative_join'] = isset($params['if_relative_join']) && ($params['if_relative_join'] === 'true' || $params['if_relative_join'] === '1' || $params['if_relative_join'] === 1 || $params['if_relative_join'] === true) ? 1 : 0; $params['if_share_limitfee'] = isset($params['if_share_limitfee']) && ($params['if_share_limitfee'] === 'true' || $params['if_share_limitfee'] === '1' || $params['if_share_limitfee'] === 1 || $params['if_share_limitfee'] === true) ? 1 : 0; $params['is_discount_description_enabled'] = isset($params['is_discount_description_enabled']) && ($params['is_discount_description_enabled'] === 'true' || $params['is_discount_description_enabled'] === '1' || $params['is_discount_description_enabled'] === 1 || $params['is_discount_description_enabled'] === true) ? 1 : 0; + $params['is_passphrase_enabled'] = isset($params['is_passphrase_enabled']) && ($params['is_passphrase_enabled'] === 'true' || $params['is_passphrase_enabled'] === '1' || $params['is_passphrase_enabled'] === 1 || $params['is_passphrase_enabled'] === true) ? 1 : 0; $rules = [ 'name' => ['required', '请输入活动名称'], 'title' => ['required', '请输入活动标题'], @@ -114,12 +122,12 @@ class Activity extends Controller 'display_time' => ['required', '请选择活动预热时间'], 'employee_begin_time' => ['required', '请选择员工购买开始时间'], 'employee_end_time' => ['required', '请选择员工购买结束时间'], - 'employee_limitfee' => ['required', '请输入员工可使用额度'], + 'employee_limitfee' => ['exclude_if:is_passphrase_enabled,1|required|integer|min:1', '员工可使用额度须大于 0(单位:分)'], 'invite_limit' => ['required_if:if_relative_join,1', '请输入员工可邀请亲友人数上限'], 'relative_begin_time' => ['required_if:if_relative_join,1', '请选择亲友购买开始时间'], 'relative_end_time' => ['required_if:if_relative_join,1', '请选择亲友购买结束时间'], 'if_share_limitfee' => ['required_if:if_relative_join,1', '请选择亲友是否共享员工额度'], - 'relative_limitfee' => ['required_if:if_share_limitfee,0', '请填写亲友可使用额度'], + 'relative_limitfee' => ['exclude_if:is_passphrase_enabled,1|exclude_unless:if_relative_join,1|exclude_if:if_share_limitfee,1|required|integer|min:1', '亲友可使用额度须大于 0(单位:分)'], 'minimum_amount' => ['required', '请填写订单最低金额'], 'close_modify_hours_after_activity' => ['required', '请填写活动结束后多少小时内可以修改收货地址'], 'price_display_config' => ['required', '请设置活动价格展示'], @@ -157,6 +165,7 @@ class Activity extends Controller * @SWG\Parameter( name="display_time_begin", in="query", description="预热时间", type="integer"), * @SWG\Parameter( name="display_time_end", in="query", description="预热时间", type="integer"), * @SWG\Parameter( name="enterprise_id", in="query", description="参与企业ID", type="integer"), + * @SWG\Parameter( name="status", in="query", description="活动展示态筛选,支持单个或多项(逗号分隔或重复参数)。可选:not_started,warm_up,ongoing,pending,cancel,over;多项为 OR 关系", type="string"), * @SWG\Parameter( name="page", in="query", description="页码,默认1", type="integer"), * @SWG\Parameter( name="pageSize", in="query", description="每页数量,默认20", type="integer"), * @SWG\Response( @@ -178,7 +187,7 @@ class Activity extends Controller */ public function getActivityList(Request $request) { - $params = $request->all('page', 'pageSize', 'name', 'display_time_begin', 'buy_time_begin', 'buy_time_end', 'enterprise_id', 'status', 'distributor_id'); + $params = $request->all('page', 'pageSize', 'name', 'display_time_begin', 'buy_time_begin', 'buy_time_end', 'enterprise_id', 'distributor_id'); $rules = [ 'page' => ['required|integer|min:1','分页参数错误'], 'pageSize' => ['required|integer|min:1|max:100','每页显示数量最大100'], @@ -216,38 +225,21 @@ class Activity extends Controller $filter['enterprise_id'] = $params['enterprise_id']; } $now = time(); - if ($params['status']) { - switch ($params['status']) { - //未开始 - case 'not_started': - $filter['display_time|gt'] = $now; - $filter['status'] = 'active'; - break; - //预热中 - case 'warm_up': - $filter['status'] = 'warm_up'; - break; - //进行中 - case 'ongoing': - $filter['status'] = 'ongoing'; - break; - //已暂停 - case 'pending': - $filter['status'] = 'pending'; - break; - //已取消 - case 'cancel': - $filter['status'] = 'cancel'; - break; - //已结束 - case 'over': - $filter['status'] = 'over'; - } + $statusOr = ActivityListDisplayStatusQuery::statusSlugsForFilterOrNull($request->input('status')); + if ($statusOr !== null) { + $filter['status|or'] = $statusOr; } $activitiesService = new ActivitiesService(); $result = $activitiesService->getActivityList($filter, '*', $page, $pageSize); + $statsMap = []; + if (!empty($result['list'])) { + $activityIdsForStats = array_map('intval', array_column($result['list'], 'id')); + $behaviorStatsService = new ActivityEnterpriseBehaviorLogService(); + $statsMap = $behaviorStatsService->getAggregatedStatsTotalsByActivityIds($companyId, $activityIdsForStats); + } + foreach ($result['list'] as $key => $row) { if ($row['display_time'] > $now && $row['status'] == 'active') { $result['list'][$key]['status'] = 'not_started'; @@ -273,11 +265,67 @@ class Activity extends Controller $result['list'][$key]['status'] = 'over'; $result['list'][$key]['status_desc'] = '已结束'; } + + $aid = (int) ($result['list'][$key]['id'] ?? 0); + $st = $statsMap[$aid] ?? [ + 'scan_count' => 0, + 'scan_user_count' => 0, + 'passphrase_verify_user_count' => 0, + 'bind_user_count' => 0, + 'order_user_count' => 0, + ]; + $result['list'][$key]['scan_count'] = $st['scan_count']; + $result['list'][$key]['scan_user_count'] = $st['scan_user_count']; + $result['list'][$key]['passphrase_verify_user_count'] = $st['passphrase_verify_user_count']; + $result['list'][$key]['bind_user_count'] = $st['bind_user_count']; + $result['list'][$key]['order_user_count'] = $st['order_user_count']; } return $this->response->array($result); } + /** + * 导出活动下参与企业的扫码落地页小程序码链接(Excel) + * + * @param int $activityId + */ + public function downloadActivityQrcode($activityId, Request $request) + { + $authInfo = app('auth')->user()->get(); + $companyId = (int) $authInfo['company_id']; + $distributorScopeId = null; + if (($authInfo['operator_type'] ?? '') == 'distributor') { + $distributorScopeId = (int) $authInfo['distributor_id']; + } + + $filter = [ + 'activity_id' => (int) $activityId, + 'company_id' => $companyId, + 'operator_id' => (int) ($authInfo['operator_id'] ?? 0), + ]; + if ($distributorScopeId !== null) { + $filter['distributor_id'] = $distributorScopeId; + } + + // 提前校验活动与权限,避免提交无效导出任务 + $activitiesService = new ActivitiesService(); + $activitiesService->buildActivityEnterpriseQrcodeExportRows( + $companyId, + (int) $activityId, + $distributorScopeId + ); + + $gotoJob = (new ExportFileJob( + 'employee_purchase_activity_qrcode', + $companyId, + $filter, + $filter['operator_id'] + ))->onQueue('slow'); + app('Illuminate\Contracts\Bus\Dispatcher')->dispatch($gotoJob); + + return response()->json(['status' => true]); + } + /** * @SWG\Get( * path="/employeepurchase/activity/{activityId}", @@ -334,9 +382,107 @@ class Activity extends Controller $result['status_desc'] = '已结束'; } $result['is_discount_description_enabled'] = $result['is_discount_description_enabled'] === true ? 'true' : 'false'; + $result['is_passphrase_enabled'] = !empty($result['is_passphrase_enabled']) ? 'true' : 'false'; + $result['passphrase_enterprises'] = $activitiesService->getPassphraseEnterpriseList($filter['company_id'], $activityId); return $this->response->array($result); } + /** + * @SWG\Get( + * path="/employeepurchase/activity/{activityId}/enterprise-behavior-stats", + * summary="活动各企业行为流水聚合统计", + * tags={"内购"}, + * description="基于 employee_purchase_activity_enterprise_behavior_log 实时聚合:扫码次数/人数、口令验证成功人数(UV,含 result_status=success;失败尝试不计入)、绑定人数、下单人数(order 行为 UV,内购支付成功写入,与绑定渠道无关);行集合为活动参与企业", + * operationId="getActivityEnterpriseBehaviorStats", + * @SWG\Parameter( name="Authorization", in="header", description="JWT验证token", required=true, type="string"), + * @SWG\Parameter( name="activityId", in="path", description="活动ID", type="integer", required=true), + * @SWG\Response( + * response=200, + * description="成功", + * @SWG\Schema( + * @SWG\Property( property="data", type="object", + * @SWG\Property( property="list", type="array", + * @SWG\Items( + * type="object", + * @SWG\Property( property="enterprise_id", type="integer" ), + * @SWG\Property( property="enterprise_name", type="string" ), + * @SWG\Property( property="enterprise_sn", type="string" ), + * @SWG\Property( property="logo", type="string" ), + * @SWG\Property( property="scan_count", type="integer", description="扫码次数(PV)" ), + * @SWG\Property( property="scan_user_count", type="integer", description="扫码人数(UV)" ), + * @SWG\Property( property="passphrase_verify_user_count", type="integer", description="口令验证成功人数(UV),失败流水不计入" ), + * @SWG\Property( property="bind_user_count", type="integer" ), + * @SWG\Property( property="order_user_count", type="integer", description="内购支付成功写入的 order 行为用户数(UV)" ), + * ), + * ), + * ), + * ), + * ), + * @SWG\Response( response="default", description="错误返回结构", @SWG\Schema( type="array", @SWG\Items(ref="#/definitions/EmployeePurchaseErrorRespones") ) ) + * ) + */ + public function getActivityEnterpriseBehaviorStats($activityId, Request $request) + { + $authInfo = app('auth')->user()->get(); + $companyId = (int) $authInfo['company_id']; + $distributorScopeId = null; + if (($authInfo['operator_type'] ?? '') == 'distributor') { + $distributorScopeId = (int) $authInfo['distributor_id']; + } + + $service = new ActivityEnterpriseBehaviorLogService(); + $result = $service->getAggregatedStatsForAdmin($companyId, (int) $activityId, $distributorScopeId); + + return $this->response->array($result); + } + + /** + * 下载活动企业行为统计(Excel) + * + * @param int $activityId + */ + public function downloadActivityEnterpriseBehaviorStats($activityId, Request $request) + { + $authInfo = app('auth')->user()->get(); + $companyId = (int) $authInfo['company_id']; + $distributorScopeId = null; + if (($authInfo['operator_type'] ?? '') == 'distributor') { + $distributorScopeId = (int) $authInfo['distributor_id']; + } + + $filter = [ + 'activity_id' => (int) $activityId, + 'company_id' => $companyId, + 'operator_id' => (int) ($authInfo['operator_id'] ?? 0), + ]; + if ($distributorScopeId !== null) { + $filter['distributor_id'] = $distributorScopeId; + } + + // 提前校验活动与统计可访问性,避免提交无效导出任务 + $activitiesService = new ActivitiesService(); + $activityFilter = [ + 'company_id' => $companyId, + 'id' => (int) $activityId, + ]; + if ($distributorScopeId !== null) { + $activityFilter['distributor_id'] = $distributorScopeId; + } + $activitiesService->getInfo($activityFilter); + $service = new ActivityEnterpriseBehaviorLogService(); + $service->getAggregatedStatsForAdmin($companyId, (int) $activityId, $distributorScopeId); + + $gotoJob = (new ExportFileJob( + 'employee_purchase_activity_scan_stats', + $companyId, + $filter, + $filter['operator_id'] + ))->onQueue('slow'); + app('Illuminate\Contracts\Bus\Dispatcher')->dispatch($gotoJob); + + return response()->json(['status' => true]); + } + /** * @SWG\Put( * path="/employeepurchase/activity/{activityId}", @@ -364,6 +510,8 @@ class Activity extends Controller * @SWG\Parameter( name="relative_limitfee", in="formData", description="亲友可使用额度", type="integer", required=false), * @SWG\Parameter( name="minimum_amount", in="formData", description="订单最低金额", type="integer", required=true), * @SWG\Parameter( name="close_modify_hours_after_activity", in="formData", description="活动结束后多少小时内可以修改收货地址", type="integer", required=true), + * @SWG\Parameter( name="is_passphrase_enabled", in="formData", description="是否开启口令通道 0/1", type="integer", required=false), + * @SWG\Parameter( name="passphrase_enterprises", in="formData", description="传此字段则整表替换口令企业数据(JSON 数组),结构同创建;关闭口令时会清空口令企业表", type="string", required=false), * @SWG\Response( * response=200, * description="成功返回结构", @@ -382,10 +530,25 @@ class Activity extends Controller $companyId = $authInfo['company_id']; $distributor_id = $authInfo['distributor_id']; $operator_id = $authInfo['operator_id']; - $params = $request->all('name', 'title', 'pages_template_id', 'pic', 'share_pic', 'enterprise_id', 'display_time', 'employee_begin_time', 'employee_end_time', 'employee_limitfee', 'if_relative_join', 'invite_limit', 'relative_begin_time', 'relative_end_time', 'if_share_limitfee', 'relative_limitfee', 'minimum_amount', 'close_modify_hours_after_activity', 'price_display_config', 'is_discount_description_enabled', 'discount_description',); - $params['if_relative_join'] = isset($params['if_relative_join']) && ($params['if_relative_join'] === 'true' || $params['if_relative_join'] === '1') ? 1 : 0; - $params['if_share_limitfee'] = isset($params['if_share_limitfee']) && ($params['if_share_limitfee'] === 'true' || $params['if_share_limitfee'] === '1') ? 1 : 0; + $params = $request->all('name', 'title', 'pages_template_id', 'pic', 'share_pic', 'enterprise_id', 'display_time', 'employee_begin_time', 'employee_end_time', 'employee_limitfee', 'if_relative_join', 'invite_limit', 'relative_begin_time', 'relative_end_time', 'if_share_limitfee', 'relative_limitfee', 'minimum_amount', 'close_modify_hours_after_activity', 'price_display_config', 'is_discount_description_enabled', 'discount_description', 'is_passphrase_enabled', 'passphrase_enterprises'); + // 与 createActivity 一致:JSON 常传整数 1/0 或布尔,勿仅用 === '1'(否则 if_relative_join 恒为 0,亲友配置保存后不生效) + $params['if_relative_join'] = isset($params['if_relative_join']) && ($params['if_relative_join'] === 'true' || $params['if_relative_join'] === '1' || $params['if_relative_join'] === 1 || $params['if_relative_join'] === true) ? 1 : 0; + $params['if_share_limitfee'] = isset($params['if_share_limitfee']) && ($params['if_share_limitfee'] === 'true' || $params['if_share_limitfee'] === '1' || $params['if_share_limitfee'] === 1 || $params['if_share_limitfee'] === true) ? 1 : 0; $params['is_discount_description_enabled'] = isset($params['is_discount_description_enabled']) && ($params['is_discount_description_enabled'] === 'true' || $params['is_discount_description_enabled'] === '1' || $params['is_discount_description_enabled'] === 1 || $params['is_discount_description_enabled'] === true) ? 1 : 0; + $params['__passphrase_sync'] = 'none'; + if (array_key_exists('passphrase_enterprises', $params)) { + $params['__passphrase_sync'] = 'replace'; + } + if (array_key_exists('is_passphrase_enabled', $params)) { + $params['is_passphrase_enabled'] = isset($params['is_passphrase_enabled']) && ($params['is_passphrase_enabled'] === 'true' || $params['is_passphrase_enabled'] === '1' || $params['is_passphrase_enabled'] === 1 || $params['is_passphrase_enabled'] === true) ? 1 : 0; + if (!$params['is_passphrase_enabled']) { + $params['__passphrase_sync'] = 'clear'; + } + } + if (!array_key_exists('is_passphrase_enabled', $params)) { + $existingActivity = (new ActivitiesService())->getInfo(['company_id' => $companyId, 'id' => (int) $activityId]); + $params['is_passphrase_enabled'] = !empty($existingActivity['is_passphrase_enabled']) ? 1 : 0; + } $rules = [ 'name' => ['required', '请输入活动名称'], 'title' => ['required', '请输入活动标题'], @@ -396,12 +559,12 @@ class Activity extends Controller 'display_time' => ['required', '请选择活动预热时间'], 'employee_begin_time' => ['required', '请选择员工购买开始时间'], 'employee_end_time' => ['required', '请选择员工购买结束时间'], - 'employee_limitfee' => ['required', '请输入员工可使用额度'], + 'employee_limitfee' => ['exclude_if:is_passphrase_enabled,1|required|integer|min:1', '员工可使用额度须大于 0(单位:分)'], 'invite_limit' => ['required_if:if_relative_join,1', '请输入员工可邀请亲友人数上限'], 'relative_begin_time' => ['required_if:if_relative_join,1', '请选择亲友购买开始时间'], 'relative_end_time' => ['required_if:if_relative_join,1', '请选择亲友购买结束时间'], 'if_share_limitfee' => ['required_if:if_relative_join,1', '请选择亲友是否共享员工额度'], - 'relative_limitfee' => ['required_if:if_share_limitfee,0', '请填写亲友可使用额度'], + 'relative_limitfee' => ['exclude_if:is_passphrase_enabled,1|exclude_unless:if_relative_join,1|exclude_if:if_share_limitfee,1|required|integer|min:1', '亲友可使用额度须大于 0(单位:分)'], 'minimum_amount' => ['required', '请填写订单最低金额'], 'close_modify_hours_after_activity' => ['required', '请填写活动结束后多少小时内可以修改收货地址'], ]; @@ -1027,4 +1190,138 @@ class Activity extends Controller return $this->response->array($result); } + + /** + * @SWG\Post( + * path="/employeepurchase/passphrase-codes/generate", + * summary="批量生成口令编码", + * tags={"内购"}, + * description="8 位数字+英文大小写。新建活动可不传 activity_id:与本公司下已有口令去重,企业须为本公司(及店铺可见)内购企业;编辑活动可传 activity_id:与该活动已保存口令去重,企业须为活动参与企业", + * operationId="generatePassphraseCodes", + * @SWG\Parameter( name="Authorization", in="header", description="JWT验证token", required=true, type="string"), + * @SWG\Parameter( + * name="body", + * in="body", + * required=true, + * @SWG\Schema( + * type="object", + * required={"enterprise_ids"}, + * @SWG\Property( property="activity_id", type="integer", description="活动ID,新建可不传或传0;不传则按公司维度去重" ), + * @SWG\Property( property="enterprise_ids", type="array", description="企业ID列表", @SWG\Items(type="integer") ), + * @SWG\Property( property="count", type="integer", description="每个企业生成条数,默认1,最大50" ), + * ) + * ), + * @SWG\Response( + * response=200, + * description="成功", + * @SWG\Schema( + * @SWG\Property( property="data", type="object", + * @SWG\Property( property="list", type="array", + * @SWG\Items( + * type="object", + * @SWG\Property( property="enterprise_id", type="integer" ), + * @SWG\Property( property="passphrase_codes", type="array", @SWG\Items(type="string") ), + * ), + * ), + * ), + * ), + * ), + * @SWG\Response( response="default", description="错误返回结构", @SWG\Schema( type="array", @SWG\Items(ref="#/definitions/EmployeePurchaseErrorRespones") ) ) + * ) + */ + public function generatePassphraseCodes(Request $request) + { + return $this->doGeneratePassphraseCodes($request, 0); + } + + /** + * @SWG\Post( + * path="/employeepurchase/activity/{activityId}/passphrase-codes/generate", + * summary="批量生成口令编码(路径带活动ID,兼容旧调用)", + * tags={"内购"}, + * description="与 POST /employeepurchase/passphrase-codes/generate 相同,activityId 以路径为准(忽略 body 内 activity_id)", + * operationId="generatePassphraseCodesByActivity", + * @SWG\Parameter( name="Authorization", in="header", description="JWT验证token", required=true, type="string"), + * @SWG\Parameter( name="activityId", in="path", description="活动ID", type="integer", required=true), + * @SWG\Parameter( + * name="body", + * in="body", + * required=true, + * @SWG\Schema( + * type="object", + * required={"enterprise_ids"}, + * @SWG\Property( property="enterprise_ids", type="array", @SWG\Items(type="integer") ), + * @SWG\Property( property="count", type="integer" ), + * ) + * ), + * @SWG\Response( + * response=200, + * description="成功", + * @SWG\Schema( + * @SWG\Property( property="data", type="object", + * @SWG\Property( property="list", type="array", + * @SWG\Items( + * type="object", + * @SWG\Property( property="enterprise_id", type="integer" ), + * @SWG\Property( property="passphrase_codes", type="array", @SWG\Items(type="string") ), + * ), + * ), + * ), + * ), + * ), + * @SWG\Response( response="default", description="错误返回结构", @SWG\Schema( type="array", @SWG\Items(ref="#/definitions/EmployeePurchaseErrorRespones") ) ) + * ) + */ + public function generatePassphraseCodesByActivity($activityId, Request $request) + { + return $this->doGeneratePassphraseCodes($request, (int) $activityId); + } + + /** + * @param int $activityIdFromPath 大于 0 时优先使用路径活动ID + */ + private function doGeneratePassphraseCodes(Request $request, $activityIdFromPath) + { + $authInfo = app('auth')->user()->get(); + $companyId = (int) $authInfo['company_id']; + $distributorScopeId = null; + if (($authInfo['operator_type'] ?? '') == 'distributor') { + $distributorScopeId = (int) $authInfo['distributor_id']; + } + + $bodyActivity = $request->input('activity_id'); + if ($bodyActivity === null || $bodyActivity === '') { + $bodyActivityId = 0; + } else { + $bodyActivityId = (int) $bodyActivity; + } + $activityId = $activityIdFromPath > 0 ? $activityIdFromPath : $bodyActivityId; + + $rawIds = $request->input('enterprise_ids'); + if (is_string($rawIds)) { + $decoded = json_decode($rawIds, true); + $enterpriseIds = is_array($decoded) ? $decoded : array_filter(explode(',', $rawIds)); + } elseif (is_array($rawIds)) { + $enterpriseIds = $rawIds; + } else { + $enterpriseIds = []; + } + + $count = $request->input('count', 1); + if ($count === null || $count === '') { + $count = 1; + } + $count = (int) $count; + + $activitiesService = new ActivitiesService(); + $result = $activitiesService->generatePassphraseCodesForEnterprises( + $companyId, + $enterpriseIds, + $count, + $activityId, + $distributorScopeId + ); + + return $this->response->array($result); + } } diff --git a/src/EmployeePurchaseBundle/Http/Api/V1/Action/Enterprise.php b/src/EmployeePurchaseBundle/Http/Api/V1/Action/Enterprise.php index fd28c2a..035eee4 100644 --- a/src/EmployeePurchaseBundle/Http/Api/V1/Action/Enterprise.php +++ b/src/EmployeePurchaseBundle/Http/Api/V1/Action/Enterprise.php @@ -28,6 +28,46 @@ use WechatBundle\Services\OpenPlatform; class Enterprise extends Controller { + /** 内购企业登录类型(列表筛选白名单) */ + private const AUTH_TYPES_FOR_LIST = ['mobile', 'account', 'email', 'qr_code', 'no_verify']; + + /** + * 解析列表「登录类型」筛选:支持 query 参数 auth_type 或 auth_types;逗号分隔或数组多选(如 auth_type[]=no_verify) + * + * @return string[] 去重后的合法类型;空数组表示不按登录类型筛选 + */ + private function parseAuthTypeFilterForList(Request $request): array + { + $raw = $request->input('auth_type', $request->input('auth_types')); + if ($raw === null || $raw === '' || $raw === []) { + return []; + } + $tokens = []; + if (is_array($raw)) { + foreach ($raw as $v) { + if (!is_string($v) && !is_numeric($v)) { + continue; + } + $s = trim((string) $v); + if ($s === '') { + continue; + } + foreach (preg_split('/\s*,\s*/', $s, -1, PREG_SPLIT_NO_EMPTY) as $p) { + $tokens[] = trim($p); + } + } + } else { + $s = trim((string) $raw); + if ($s !== '') { + foreach (preg_split('/\s*,\s*/', $s, -1, PREG_SPLIT_NO_EMPTY) as $p) { + $tokens[] = trim($p); + } + } + } + + return array_values(array_unique(array_intersect($tokens, self::AUTH_TYPES_FOR_LIST))); + } + /** * @SWG\Post( * path="/enterprise", @@ -97,8 +137,8 @@ class Enterprise extends Controller if (in_array($params['auth_type'], ['mobile', 'account'])) { // 验证方式=手机号、账号时,is_employee_check_enabled='true' $params['is_employee_check_enabled'] = 'true'; - } else if (in_array($params['auth_type'], ['email'])) { - // 验证方式=邮箱,is_employee_check_enabled='false' + } else if (in_array($params['auth_type'], ['email', 'no_verify'])) { + // 验证方式=邮箱、无需验证,is_employee_check_enabled='false' $params['is_employee_check_enabled'] = 'false'; } $params['is_employee_check_enabled'] = $params['is_employee_check_enabled'] == 'true' ? true : false; @@ -174,8 +214,8 @@ class Enterprise extends Controller if (in_array($params['auth_type'], ['mobile', 'account'])) { // 验证方式=手机号、账号时,is_employee_check_enabled='true' $params['is_employee_check_enabled'] = 'true'; - } else if (in_array($params['auth_type'], ['email'])) { - // 验证方式=邮箱,is_employee_check_enabled='false' + } else if (in_array($params['auth_type'], ['email', 'no_verify'])) { + // 验证方式=邮箱、无需验证,is_employee_check_enabled='false' $params['is_employee_check_enabled'] = 'false'; } $params['is_employee_check_enabled'] = $params['is_employee_check_enabled'] == 'true' ? true : false; @@ -200,6 +240,10 @@ class Enterprise extends Controller * @SWG\Parameter( name="name", in="query", description="供应商名称", required=false, type="string" ), * @SWG\Parameter( name="disabled", in="query", description="禁用 0 否 1 是", required=false, type="string" ), * @SWG\Parameter( name="distributorId", in="query", description="店铺ID,平台=0", required=false, type="string" ), + * @SWG\Parameter( name="distributor_id", in="query", description="店铺ID,平台=0(与 distributorId 二选一)", required=false, type="string" ), + * @SWG\Parameter( name="auth_type", in="query", description="登录类型筛选:mobile/account/email/qr_code/no_verify;多个用英文逗号分隔,或与 auth_types 数组同传", required=false, type="string" ), + * @SWG\Parameter( name="auth_types", in="query", description="登录类型多选(数组),与 auth_type 等价", required=false, type="array", @SWG\Items(type="string") ), + * @SWG\Parameter( name="finderId", in="query", description="前端 Finder 标识,服务端忽略", required=false, type="integer" ), * @SWG\Response( response=200, description="成功返回结构", @SWG\Schema( * @SWG\Property( property="data", type="object", * @SWG\Property( property="total_count", type="string", example="2", description="自行更改字段描述"), @@ -269,8 +313,11 @@ class Enterprise extends Controller $filter['id'] = $params['enterprise_id']; } - if (isset($params['auth_type']) && $params['auth_type']) { - $filter['auth_type'] = $params['auth_type']; + $authTypeFilter = $this->parseAuthTypeFilterForList($request); + if (count($authTypeFilter) === 1) { + $filter['auth_type'] = $authTypeFilter[0]; + } elseif (count($authTypeFilter) > 1) { + $filter['auth_type'] = $authTypeFilter; } if (isset($params['is_employee_check_enabled'])) { diff --git a/src/EmployeePurchaseBundle/Http/Api/V1/Action/StoreHomePage.php b/src/EmployeePurchaseBundle/Http/Api/V1/Action/StoreHomePage.php new file mode 100644 index 0000000..8b17db2 --- /dev/null +++ b/src/EmployeePurchaseBundle/Http/Api/V1/Action/StoreHomePage.php @@ -0,0 +1,114 @@ +service = new StoreHomePageService(); + } + + public function getList(Request $request) + { + $auth = app('auth')->user()->get(); + $companyId = (int) $auth['company_id']; + $authDistributorId = (int) ($auth['distributor_id'] ?? 0); + $page = max(1, (int) $request->input('page', 1)); + $pageSize = max(1, min(100, (int) $request->input('pageSize', 20))); + $filterDist = $request->input('distributor_id'); + $filterDistributorId = ($filterDist === null || $filterDist === '') ? null : (int) $filterDist; + if (($auth['operator_type'] ?? '') === 'distributor') { + $filterDistributorId = null; + } + + $result = $this->service->getList($companyId, $authDistributorId, $page, $pageSize, $filterDistributorId); + + return $this->response->array([ + 'list' => $result['list'], + 'total_count' => $result['total_count'], + ]); + } + + public function create(Request $request) + { + $auth = app('auth')->user()->get(); + $companyId = (int) $auth['company_id']; + $authDistributorId = (int) ($auth['distributor_id'] ?? 0); + + $params = $request->all( + 'template_name', + 'page_name', + 'page_description', + 'page_share_title', + 'page_share_desc', + 'page_share_imageUrl', + 'is_open' + ); + $row = $this->service->createRow($companyId, $authDistributorId, $params); + + return $this->response->array($row); + } + + public function getInfo($id) + { + $auth = app('auth')->user()->get(); + $companyId = (int) $auth['company_id']; + $authDistributorId = (int) ($auth['distributor_id'] ?? 0); + if (!$id) { + throw new ResourceException('id 必传'); + } + $row = $this->service->getById($companyId, $authDistributorId, (int) $id); + + return $this->response->array($row); + } + + public function update(Request $request, $id) + { + $auth = app('auth')->user()->get(); + $companyId = (int) $auth['company_id']; + $authDistributorId = (int) ($auth['distributor_id'] ?? 0); + if (!$id) { + throw new ResourceException('id 必传'); + } + $params = $request->all( + 'template_name', + 'page_name', + 'page_description', + 'page_share_title', + 'page_share_desc', + 'page_share_imageUrl', + 'is_open' + ); + $row = $this->service->updateRow($companyId, $authDistributorId, (int) $id, $params); + + return $this->response->array($row); + } + + public function delete($id) + { + $auth = app('auth')->user()->get(); + $companyId = (int) $auth['company_id']; + $authDistributorId = (int) ($auth['distributor_id'] ?? 0); + if (!$id) { + throw new ResourceException('id 必传'); + } + $this->service->deleteRow($companyId, $authDistributorId, (int) $id); + + return $this->response->array(['status' => true]); + } +} diff --git a/src/EmployeePurchaseBundle/Http/FrontApi/V1/Action/Activity.php b/src/EmployeePurchaseBundle/Http/FrontApi/V1/Action/Activity.php index 6717c1d..08000f5 100644 --- a/src/EmployeePurchaseBundle/Http/FrontApi/V1/Action/Activity.php +++ b/src/EmployeePurchaseBundle/Http/FrontApi/V1/Action/Activity.php @@ -23,6 +23,11 @@ use Dingo\Api\Exception\ResourceException; use App\Http\Controllers\Controller as BaseController; use EmployeePurchaseBundle\Services\ActivitiesService; +use EmployeePurchaseBundle\Support\ActivityListDisplayStatusQuery; +use EmployeePurchaseBundle\Services\EmployeesService; +use EmployeePurchaseBundle\Services\RelativesService; +use EmployeePurchaseBundle\Services\ActivityEnterpriseBehaviorLogService; +use EmployeePurchaseBundle\Services\PassphraseVerifiedRedisService; use GoodsBundle\Services\ItemsCategoryService; use EmployeePurchaseBundle\Services\ActivityItemsService; use CompanysBundle\Services\SettingService as ItemSettingService; @@ -69,6 +74,7 @@ class Activity extends BaseController * operationId="getActivityList", * @SWG\Parameter( name="Authorization", in="header", description="JWT验证token", required=true, type="string"), * @SWG\Parameter( name="activity_name", in="query", description="活动名称", type="string"), + * @SWG\Parameter( name="status", in="query", description="活动展示态筛选,支持逗号分隔或重复参数,可选 not_started,warm_up,ongoing,pending,cancel,over;多项为 OR;不传则保持原有过滤", type="string"), * @SWG\Parameter( name="page", in="query", description="页码,默认1", type="integer"), * @SWG\Parameter( name="pageSize", in="query", description="每页数量,默认20", type="integer"), * @SWG\Response( @@ -104,6 +110,9 @@ class Activity extends BaseController * @SWG\Property( property="is_employee", type="integer", description="是否员工"), * @SWG\Property( property="is_relative", type="integer", description="是否家属"), * @SWG\Property( property="rel_enterprise", type="string", description="员工/家属关联的企业"), + * @SWG\Property( property="is_passphrase_enabled", type="integer", description="是否开启口令通道 0/1"), + * @SWG\Property( property="auth_type", type="string", description="当前行关联内购企业认证方式:email/account/mobile/qr_code/no_verify 等"), + * @SWG\Property( property="passphrase_user_verified", type="integer", description="当前登录用户是否已在该活动+本企业下口令校验成功;未开口令为 0"), * ), * ), * ), @@ -137,9 +146,18 @@ class Activity extends BaseController return $this->response->array(['total_count' => "0", "list" => []]); } + $statusOr = ActivityListDisplayStatusQuery::statusSlugsForFilterOrNull($request->input('status')); + if ($statusOr !== null) { + $filter['status|or'] = $statusOr; + } + $activitiesService = new ActivitiesService(); $result = $activitiesService->getUserActivities($filter, '*', $page, $pageSize, ['display_time' => 'ASC']); + $companyId = (int) $filter['company_id']; + $userId = (int) $filter['user_id']; + $passphraseVerifiedSvc = new PassphraseVerifiedRedisService(); + $now = time(); foreach ($result['list'] as $key => $row) { if ($row['display_time'] < $now && $row['employee_begin_time'] > $now && $row['relative_begin_time'] > $now && $row['status'] == 'active') { @@ -156,6 +174,16 @@ class Activity extends BaseController } $result['list'][$key]['price_display_config'] = json_decode($row['price_display_config'], true); $result['list'][$key]['is_discount_description_enabled'] = $row['is_discount_description_enabled'] == 1 ? 'true' : 'false'; + + $activityId = (int) ($row['id'] ?? 0); + $rowEnterpriseId = (int) ($row['enterprise_id'] ?? 0); + $result['list'][$key]['is_passphrase_enabled'] = !empty($row['is_passphrase_enabled']) ? 1 : 0; + $result['list'][$key]['auth_type'] = isset($row['auth_type']) && $row['auth_type'] !== null && $row['auth_type'] !== '' + ? (string) $row['auth_type'] + : ''; + $result['list'][$key]['passphrase_user_verified'] = !empty($row['is_passphrase_enabled']) && $userId > 0 && $activityId > 0 && $rowEnterpriseId > 0 + ? ($passphraseVerifiedSvc->isVerified($companyId, $activityId, $rowEnterpriseId, $userId) ? 1 : 0) + : 0; } //根据参数判断,是否需要追加额度 if(!empty($params['need_aggregate'])){ @@ -165,6 +193,123 @@ class Activity extends BaseController return $this->response->array($result); } + /** + * @SWG\Get( + * path="/wxapp/employeepurchase/internal-sale-eligibility", + * summary="内购资格校验(当前用户)", + * tags={"内购"}, + * description="按活动+企业校验登录用户是否具备有效内购资格(白名单员工且未禁用,或有效家属)。列表进详情、参与活动、加购等节点按需调用,避免活动列表逐条查库。", + * operationId="getInternalSaleEligibility", + * @SWG\Parameter( name="Authorization", in="header", description="JWT验证token", required=true, type="string"), + * @SWG\Parameter( name="activity_id", in="query", description="活动ID", required=true, type="integer"), + * @SWG\Parameter( name="enterprise_id", in="query", description="企业ID", required=true, type="integer"), + * @SWG\Response( + * response=200, + * description="成功", + * @SWG\Schema( + * @SWG\Property( property="data", type="object", + * @SWG\Property( property="internal_sale_eligible", type="integer", description="1=有资格,0=无(如白名单已删/禁用)"), + * @SWG\Property( property="eligible_as", type="string", description="employee=有效员工白名单,relative=有效家属,none=无"), + * ), + * ), + * ), + * @SWG\Response( response="default", description="错误返回结构", @SWG\Schema( type="array", @SWG\Items(ref="#/definitions/EmployeePurchaseErrorRespones") ) ) + * ) + */ + public function getInternalSaleEligibility(Request $request) + { + $authInfo = $request->get('auth'); + + $params = $request->all('activity_id', 'enterprise_id'); + $rules = [ + 'activity_id' => ['required', '活动ID必填'], + 'enterprise_id' => ['required', '企业ID必填'], + ]; + $error = validator_params($params, $rules); + if ($error) { + throw new ResourceException($error); + } + + $companyId = (int) $authInfo['company_id']; + $userId = (int) $authInfo['user_id']; + $activityId = (int) $params['activity_id']; + $enterpriseId = (int) $params['enterprise_id']; + + $activitiesService = new ActivitiesService(); + $activity = $activitiesService->getInfo(['company_id' => $companyId, 'id' => $activityId]); + if (!$activity) { + throw new ResourceException('活动不存在'); + } + if (!in_array($params['enterprise_id'], $activity['enterprise_id'])) { + throw new ResourceException('企业不参与该活动'); + } + + $employeesService = new EmployeesService(); + $relativesService = new RelativesService(); + + $eligibleAs = 'none'; + if ($employeesService->check($companyId, $enterpriseId, $userId)) { + $eligibleAs = 'employee'; + } elseif ($relativesService->check($companyId, $enterpriseId, $activityId, $userId)) { + $eligibleAs = 'relative'; + } + + return $this->response->array([ + 'internal_sale_eligible' => $eligibleAs !== 'none' ? 1 : 0, + 'eligible_as' => $eligibleAs, + ]); + } + + /** + * @SWG\Get( + * path="/wxapp/employeepurchase/activity/detail", + * summary="获取活动详情", + * tags={"内购"}, + * description="获取活动详情;勿使用 /activity/{activity_id} 路径——会与 /activity/items 等字面段在 FastRoute 中冲突(BadRouteException)", + * operationId="getActivityDetail", + * @SWG\Parameter( name="Authorization", in="header", description="JWT验证token", required=true, type="string"), + * @SWG\Parameter( name="activity_id", in="query", description="活动ID", type="integer", required=true), + * @SWG\Parameter( name="company_id", in="query", description="公司ID", type="integer", required=true), + * @SWG\Response( + * response=200, + * description="成功返回结构", + * @SWG\Schema( + * @SWG\Property( property="data", type="object", + * @SWG\Property( property="activity_id", type="integer", description="活动ID"), + * @SWG\Property( property="name", type="string", description="活动名称"), + * @SWG\Property( property="title", type="string", description="活动标题"), + * @SWG\Property( property="pages_template_id", type="integer", description="活动首页关联模版"), + * @SWG\Property( property="share_pic", type="string", description="活动分享图片"), + * @SWG\Property( property="pic", type="string", description="活动图片"), + * ), + * ), + * ), + * @SWG\Response( response="default", description="错误返回结构", @SWG\Schema( type="array", @SWG\Items(ref="#/definitions/EmployeePurchaseErrorRespones") ) ) + * ) + */ + public function getActivityDetail(Request $request){ + $params = $request->all('activity_id','company_id'); + $rules = [ + 'activity_id' => ['required|integer', '活动ID必填'], + 'company_id' => ['required|integer', '公司ID必填'], + ]; + $error = validator_params($params, $rules); + if ($error) { + throw new ResourceException($error); + } + $activitiesService = new ActivitiesService(); + $activity = $activitiesService->getInfo(['company_id' => $params['company_id'], 'id' => $params['activity_id']]); + if(!$activity){ + throw new ResourceException('活动不存在'); + } + $result['activity_id'] = $activity['id']; + $result['name'] = $activity['name']; + $result['title'] = $activity['title']; + $result['pages_template_id'] = (int) ($activity['pages_template_id'] ?? 0); + $result['share_pic'] = $activity['share_pic']; + $result['pic'] = $activity['pic']; + return $this->response->array($result); + } /** * @SWG\GET( * path="/wxapp/employeepurchase/activity/items", @@ -302,6 +447,8 @@ class Activity extends BaseController * @SWG\Property( property="company_id", type="integer", description="公司ID"), * @SWG\Property( property="activity_price", type="integer", description="活动价"), * @SWG\Property( property="store", type="integer", description="库存"), + * @SWG\Property( property="limit_fee", type="integer", description="每人限购金额,单位分;0 表示无限购"), + * @SWG\Property( property="limit_num", type="integer", description="每人限购数量;0 表示无限购"), * ), * ), * ) @@ -352,9 +499,13 @@ class Activity extends BaseController if (!$activity['if_share_store']) { $result['store'] = $activityItemList[$result['item_id']]['activity_store']; } + $result['limit_fee'] = (int) ($activityItemList[$result['item_id']]['limit_fee'] ?? 0); + $result['limit_num'] = (int) ($activityItemList[$result['item_id']]['limit_num'] ?? 0); } else { $result['store'] = 0; $result['approve_status'] = 'instock'; + $result['limit_fee'] = 0; + $result['limit_num'] = 0; } if (isset($result['nospec']) && ($result['nospec'] === false || $result['nospec'] === 'false') || $result['nospec'] === 0 || $result['nospec'] === '0') { @@ -364,9 +515,13 @@ class Activity extends BaseController if (!$activity['if_share_store']) { $result['spec_items'][$key]['store'] = $activityItemList[$item['item_id']]['activity_store']; } + $result['spec_items'][$key]['limit_fee'] = (int) ($activityItemList[$item['item_id']]['limit_fee'] ?? 0); + $result['spec_items'][$key]['limit_num'] = (int) ($activityItemList[$item['item_id']]['limit_num'] ?? 0); } else { $result['spec_items'][$key]['store'] = 0; $result['spec_items'][$key]['approve_status'] = 'instock'; + $result['spec_items'][$key]['limit_fee'] = 0; + $result['spec_items'][$key]['limit_num'] = 0; } } $result['store'] = array_sum(array_column($result['spec_items'], 'store')); @@ -388,6 +543,13 @@ class Activity extends BaseController // 与 Api Activity::getActivityItemList -> getActivityItemList 一致:主商品 + 规格行多语言 $localized = $activitiesService->applyMultiLangToActivityItemList([$result]); $result = $localized[0]; + if (!is_array($result['intro'])) { + json_decode($result['intro']); + + if (json_last_error() === JSON_ERROR_NONE) { + $result['intro'] = json_decode($result['intro'], true); + } + } return $this->response->array($result); } @@ -506,4 +668,185 @@ class Activity extends BaseController $result = $activityItemsService->fetchActivityItemsCategory($authInfo['company_id'], $params['activity_id']); return $this->response->array($result); } + + /** + * @SWG\Post( + * path="/wxapp/employeepurchase/activity/behavior-report", + * summary="内购活动行为流水统一上报", + * tags={"内购"}, + * description="同一 URL 支持 scan(扫码/进入)与 passphrase_verify(口令校验)。未登录须传 company_id;请求带有效 JWT 时 company_id、user_id 以登录态为准(勿伪造 user_id)。口令校验成败均写流水并带 result_status。", + * operationId="reportActivityBehavior", + * @SWG\Parameter( name="Authorization", in="header", description="可选;有效 JWT 时用于 company_id / user_id", required=false, type="string"), + * @SWG\Parameter( + * name="body", + * in="body", + * required=true, + * @SWG\Schema( + * type="object", + * required={"behavior_type","activity_id","enterprise_id"}, + * @SWG\Property( property="behavior_type", type="string", enum={"scan","passphrase_verify"}, description="scan=扫码流水;passphrase_verify=口令校验" ), + * @SWG\Property( property="company_id", type="integer", description="未登录必填;已登录忽略,以 token 为准" ), + * @SWG\Property( property="activity_id", type="integer" ), + * @SWG\Property( property="enterprise_id", type="integer" ), + * @SWG\Property( property="visitor_key", type="string", description="未登录建议传,便于 UV" ), + * @SWG\Property( property="passphrase_code", type="string", description="behavior_type=passphrase_verify 时必填(或与 code 二选一)" ), + * @SWG\Property( property="code", type="string" ), + * ) + * ), + * @SWG\Response( + * response=200, + * description="scan 返回 status+log_id;passphrase_verify 返回 verified+log_id,且含 behavior_type", + * @SWG\Schema( + * @SWG\Property( property="data", type="object", + * @SWG\Property( property="behavior_type", type="string" ), + * @SWG\Property( property="log_id", type="integer" ), + * @SWG\Property( property="status", type="boolean", description="仅 scan" ), + * @SWG\Property( property="verified", type="boolean", description="仅 passphrase_verify" ), + * ), + * ), + * ), + * @SWG\Response( response="default", description="错误返回结构", @SWG\Schema( type="array", @SWG\Items(ref="#/definitions/EmployeePurchaseErrorRespones") ) ) + * ) + */ + public function reportActivityBehavior(Request $request) + { + $params = $request->all( + 'behavior_type', + 'company_id', + 'activity_id', + 'enterprise_id', + 'visitor_key', + 'passphrase_code', + 'code' + ); + $rules = [ + 'behavior_type' => ['required', '行为类型必填'], + 'activity_id' => ['required|integer', '活动ID必填'], + 'enterprise_id' => ['required|integer', '企业ID必填'], + ]; + $error = validator_params($params, $rules); + if ($error) { + throw new ResourceException($error); + } + + $auth = $request->get('auth'); + $userId = isset($auth['user_id']) ? (int) $auth['user_id'] : 0; + if ($userId > 0) { + $companyId = (int) ($auth['company_id'] ?? 0); + } else { + $companyId = isset($params['company_id']) ? (int) $params['company_id'] : 0; + } + if ($companyId <= 0) { + throw new ResourceException('公司ID必填'); + } + + $activityId = (int) $params['activity_id']; + $enterpriseId = (int) $params['enterprise_id']; + $visitorKey = isset($params['visitor_key']) && $params['visitor_key'] !== '' ? (string) $params['visitor_key'] : null; + + $behaviorType = trim((string) $params['behavior_type']); + if ($behaviorType === ActivityEnterpriseBehaviorLogService::BEHAVIOR_SCAN) { + $logId = $this->writeActivityScanLog($companyId, $activityId, $enterpriseId, $userId > 0 ? $userId : null, $visitorKey); + + return $this->response->array([ + 'behavior_type' => ActivityEnterpriseBehaviorLogService::BEHAVIOR_SCAN, + 'status' => true, + 'log_id' => $logId, + ]); + } + + if ($behaviorType === ActivityEnterpriseBehaviorLogService::BEHAVIOR_PASSPHRASE_VERIFY) { + $passphrase = ''; + if (isset($params['passphrase_code']) && $params['passphrase_code'] !== '') { + $passphrase = (string) $params['passphrase_code']; + } elseif (isset($params['code']) && $params['code'] !== '') { + $passphrase = (string) $params['code']; + } + if ($passphrase === '') { + throw new ResourceException('口令必填'); + } + + $payload = $this->verifyActivityPassphraseCore( + $companyId, + $activityId, + $enterpriseId, + $passphrase, + $userId > 0 ? $userId : null, + $visitorKey + ); + $payload['behavior_type'] = ActivityEnterpriseBehaviorLogService::BEHAVIOR_PASSPHRASE_VERIFY; + + return $this->response->array($payload); + } + + throw new ResourceException('behavior_type 须为 scan 或 passphrase_verify'); + } + + /** + * @return array{verified:bool,log_id:int} + */ + private function verifyActivityPassphraseCore($companyId, $activityId, $enterpriseId, $passphrase, $userId, $visitorKey) + { + $activitiesService = new ActivitiesService(); + $activity = $activitiesService->getInfo(['company_id' => $companyId, 'id' => $activityId]); + if (empty($activity)) { + throw new ResourceException('活动不存在'); + } + + $verified = $activitiesService->isActivityEnterprisePassphraseMatch($activity, $enterpriseId, $passphrase); + $resultStatus = $verified + ? ActivityEnterpriseBehaviorLogService::RESULT_SUCCESS + : ActivityEnterpriseBehaviorLogService::RESULT_FAIL; + + $logService = new ActivityEnterpriseBehaviorLogService(); + $logId = $logService->writeBehaviorLog( + $companyId, + $activityId, + $enterpriseId, + ActivityEnterpriseBehaviorLogService::BEHAVIOR_PASSPHRASE_VERIFY, + $userId !== null && $userId > 0 ? $userId : null, + $visitorKey, + null, + null, + $resultStatus + ); + + if ($verified && $userId !== null && $userId > 0) { + (new PassphraseVerifiedRedisService())->markVerified($companyId, $activityId, $enterpriseId, (int) $userId, $activity); + } + + return ['verified' => $verified, 'log_id' => $logId]; + } + + /** + * @param int|null $userId + * @param string|null $visitorKey + * @return int log id + */ + private function writeActivityScanLog($companyId, $activityId, $enterpriseId, $userId, $visitorKey) + { + $activitiesService = new ActivitiesService(); + $activity = $activitiesService->getInfo(['company_id' => $companyId, 'id' => $activityId]); + if (empty($activity)) { + throw new ResourceException('活动不存在'); + } + + $allowed = $activitiesService->normalizeActivityEnterpriseIds($activity['enterprise_id'] ?? []); + if (!in_array($enterpriseId, $allowed, true)) { + throw new ResourceException('企业未参与该活动'); + } + + $logService = new ActivityEnterpriseBehaviorLogService(); + + return $logService->writeBehaviorLog( + $companyId, + $activityId, + $enterpriseId, + ActivityEnterpriseBehaviorLogService::BEHAVIOR_SCAN, + $userId > 0 ? $userId : null, + $visitorKey, + null, + null + ); + } } diff --git a/src/EmployeePurchaseBundle/Http/FrontApi/V1/Action/Cart.php b/src/EmployeePurchaseBundle/Http/FrontApi/V1/Action/Cart.php index 9d8c364..6d7057f 100644 --- a/src/EmployeePurchaseBundle/Http/FrontApi/V1/Action/Cart.php +++ b/src/EmployeePurchaseBundle/Http/FrontApi/V1/Action/Cart.php @@ -235,7 +235,7 @@ class Cart extends BaseController * path="/wxapp/employeepurchase/cart", * summary="获取内购购物车", * tags={"内购"}, - * description="获取内购购物车", + * description="获取内购购物车;data.valid_cart[0].list 每行含 limit_num、limit_fee(分,0 表示不限额)、aggregate_num、aggregate_fee(分,无聚合记录为 0)", * operationId="getCartDataList", * @SWG\Parameter( name="Authorization", in="header", description="JWT验证token", required=true, type="string"), * @SWG\Parameter( name="enterprise_id", in="query", description="企业ID", required=true, type="integer"), diff --git a/src/EmployeePurchaseBundle/Http/FrontApi/V1/Action/Employee.php b/src/EmployeePurchaseBundle/Http/FrontApi/V1/Action/Employee.php index 09cd466..affc135 100644 --- a/src/EmployeePurchaseBundle/Http/FrontApi/V1/Action/Employee.php +++ b/src/EmployeePurchaseBundle/Http/FrontApi/V1/Action/Employee.php @@ -24,6 +24,7 @@ use Dingo\Api\Exception\ResourceException; use EmployeePurchaseBundle\Services\EmployeesService; use EmployeePurchaseBundle\Services\MemberActivityAggregateService; use EmployeePurchaseBundle\Services\ActivitiesService; +use EmployeePurchaseBundle\Services\PassphraseVerifiedRedisService; use EmployeePurchaseBundle\Services\RelativesService; use MembersBundle\Services\MemberService; @@ -37,7 +38,8 @@ class Employee extends BaseController * description="获取邮箱验证码", * operationId="sendEmailVcode", * @SWG\Parameter( name="Authorization", in="header", description="JWT验证token", required=true, type="string"), - * @SWG\Parameter( name="enterprise_id", in="query", description="enterprise_id", required=true, type="integer"), + * @SWG\Parameter( name="enterprise_id", in="query", description="当前认证的内购企业 ID(推荐);与收件邮箱后缀须一致。未传时须传 distributor_id 且该店铺下后缀唯一命中一家企业", required=false, type="integer"), + * @SWG\Parameter( name="distributor_id", in="query", description="店铺 ID;无 enterprise_id 时用于解析企业(非扫码进店场景)", required=false, type="integer"), * @SWG\Parameter( name="email", in="query", description="邮箱地址", required=true, type="string"), * @SWG\Response( response=200, description="成功返回结构", @SWG\Schema( * @SWG\Property( property="data", type="object", @@ -53,7 +55,6 @@ class Employee extends BaseController $params = $request->all('email', 'distributor_id', 'enterprise_id'); $rules = [ - // 'enterprise_id' => ['required', '企业ID不能为空'], 'email' => ['required|email', '收件邮箱格式不正确'], ]; $error = validator_params($params, $rules); @@ -122,6 +123,7 @@ class Employee extends BaseController * operationId="authentication", * @SWG\Parameter( name="Authorization", in="header", description="JWT验证token", required=true, type="string"), * @SWG\Parameter( name="enterprise_id", in="formData", description="enterprise_id", required=true, type="integer"), + * @SWG\Parameter( name="activity_id", in="formData", description="当前内购活动ID;传入则绑定成功后写行为流水 bind,供管理端 bind_user_count 统计", required=false, type="integer"), * @SWG\Parameter( name="employee_id", in="formData", description="员工ID", required=true, type="string"), * @SWG\Response( response=200, description="成功返回结构", @SWG\Schema( * @SWG\Property( property="data", type="object", @@ -136,7 +138,7 @@ class Employee extends BaseController // ID: 53686f704578 $authInfo = $request->get('auth'); - $params = $request->all('enterprise_id', 'employee_id', 'auth_type', 'email', 'mobile'); + $params = $request->all('enterprise_id', 'activity_id', 'employee_id', 'auth_type', 'email', 'mobile'); $rules = [ 'enterprise_id' => ['required', '企业ID必填'], 'employee_id' => ['required_if:auth_type,mobile,account', '员工ID必填'], @@ -180,9 +182,13 @@ class Employee extends BaseController * @SWG\Property( property="invited_num", type="integer", example="1"), * @SWG\Property( property="is_employee", type="integer", example="1"), * @SWG\Property( property="is_relative", type="integer", example="0"), - * @SWG\Property( property="limit_fee", type="integer", example="100"), - * @SWG\Property( property="aggregate_fee", type="integer", example="10"), - * @SWG\Property( property="left_fee", type="integer", example="90"), + * @SWG\Property( property="limit_fee", type="integer", example="100", description="分。未开口令=员工/亲友额度上限;开口令=口令表 passphrase_limitfee(每人上限),不要求员工/亲友身份" ), + * @SWG\Property( property="aggregate_fee", type="integer", example="10", description="分。未开口令=用户(或共享池)已用;开口令=当前用户在活动+企业下已用" ), + * @SWG\Property( property="left_fee", type="integer", example="90", description="分。开口令=当前用户剩余可买额度" ), + * @SWG\Property( property="is_passphrase_enabled", type="integer", example="0", description="是否开启口令通道 0/1"), + * @SWG\Property( property="passphrase_user_verified", type="integer", example="0", description="当前登录用户是否已在该活动+企业下口令校验成功(Redis 标记);未开口令或未登录恒为 0" ), + * @SWG\Property( property="passphrase_participate_quota", type="integer", description="口令通道可参与名额,无绑定时为 null" ), + * @SWG\Property( property="passphrase_limitfee", type="integer", description="与 limit_fee 一致(分);开口令时与口令表配置一致" ), * ), * )), * @SWG\Response( response="default", description="错误返回结构", @SWG\Schema( type="array", @SWG\Items(ref="#/definitions/EmployeePurchaseErrorRespones") ) ) @@ -222,12 +228,45 @@ class Employee extends BaseController $result['name'] = $activity['name']; $result['title'] = $activity['title']; $result['share_pic'] = $activity['share_pic']; + $result['pic'] = $activity['pic']; $result['if_relative_join'] = $activity['if_relative_join']; $result['invite_limit'] = $activity['invite_limit']; $result['relative_begin_time'] = $activity['relative_begin_time']; $result['relative_end_time'] = $activity['relative_end_time']; + $ppSummary = $activityService->getPassphraseClientSummary( + $companyId, + (int) $params['activity_id'], + (int) $params['enterprise_id'], + $activity + ); + $result['is_passphrase_enabled'] = $ppSummary['is_passphrase_enabled']; + $result['passphrase_participate_quota'] = $ppSummary['passphrase_participate_quota']; + if (!empty($ppSummary['is_passphrase_enabled'])) { + $result['passphrase_limitfee'] = $ppSummary['passphrase_limitfee'] !== null + ? (int) $ppSummary['passphrase_limitfee'] + : (int) ($result['limit_fee'] ?? 0); + } else { + $result['passphrase_limitfee'] = $ppSummary['passphrase_limitfee']; + } + + $result['passphrase_user_verified'] = !empty($ppSummary['is_passphrase_enabled']) && $userId > 0 + ? ((new PassphraseVerifiedRedisService())->isVerified( + $companyId, + (int) $params['activity_id'], + (int) $params['enterprise_id'], + $userId + ) ? 1 : 0) + : 0; + $employeesService = new EmployeesService(); + $employeesService->ensurePassphraseEmployeeFromVerifiedActivity( + $companyId, + (int) $params['enterprise_id'], + (int) $params['activity_id'], + $userId + ); + $employee = $employeesService->check($companyId, $params['enterprise_id'], $userId); $result['is_employee'] = 0; $result['is_relative'] = 0; @@ -321,6 +360,7 @@ class Employee extends BaseController $relativesService = new RelativesService(); $memberService = new MemberService(); $memberActivityAggregateService = new MemberActivityAggregateService(); + // 管理端开启口令时隐藏亲友配置,口令活动下通常无邀请亲友数据;每行仍统一走 getAggregateFee(与非口令活动一致) // 包含已失效的用户 $result = $relativesService->lists(['company_id' => $companyId, 'enterprise_id' => $params['enterprise_id'], 'employee_user_id' => $userId, 'activity_id' => $params['activity_id']], '*', $page, $pageSize, ['created' => 'DESC']); if ($result['list']) { @@ -336,7 +376,7 @@ class Employee extends BaseController $result['list'][$key]['limit_fee'] = $rowAggregate['limit_fee'] ?? 0; $result['list'][$key]['used_limitfee'] = $rowAggregate['aggregate_fee'] ?? 0; $result['list'][$key]['left_fee'] = $rowAggregate['left_fee'] ?? 0; - }catch (\Exception $e) { + } catch (\Exception $e) { $result['list'][$key]['limit_fee'] = 0; $result['list'][$key]['used_limitfee'] = 0; $result['list'][$key]['left_fee'] = 0; diff --git a/src/EmployeePurchaseBundle/Http/FrontApi/V1/Action/Enterprise.php b/src/EmployeePurchaseBundle/Http/FrontApi/V1/Action/Enterprise.php index 9716662..00c1d81 100644 --- a/src/EmployeePurchaseBundle/Http/FrontApi/V1/Action/Enterprise.php +++ b/src/EmployeePurchaseBundle/Http/FrontApi/V1/Action/Enterprise.php @@ -77,7 +77,10 @@ class Enterprise extends BaseController if ($request->get('enterprise_sn')) { $filter['enterprise_sn'] = $request->get('enterprise_sn'); } - + $enterprise_id = intval($request->get('enterprise_id', 0)); + if ( $enterprise_id > 0) { + $filter['id'] = $enterprise_id; + } $orderBy = ['sort' => 'ASC', 'created' => 'DESC']; $enterprisesService = new EnterprisesService(); $result = $enterprisesService->getEnterprisesList($filter, $page, $pageSize, $orderBy); @@ -92,13 +95,15 @@ class Enterprise extends BaseController * description="获取用户所在企业列表", * operationId="getUserEnterprisesList", * @SWG\Parameter( name="Authorization", in="header", description="JWT验证token", required=true, type="string"), - * @SWG\Parameter( name="disabled", in="query", description="是否有效身份", required=false, type="integer"), + * @SWG\Parameter( name="disabled", in="query", description="按员工/家属身份行的失效状态筛选(非企业禁用);与响应字段 disabled 含义不同", required=false, type="integer"), * @SWG\Response( response=200, description="成功返回结构", @SWG\Schema( * @SWG\Property( property="data", type="object", * @SWG\Property( property="company_id", type="string", example="1", description="公司id"), * @SWG\Property( property="name", type="string", example="test", description="企业名称"), * @SWG\Property( property="enterprise_sn", type="string", example="xxx", description="企业编码"), * @SWG\Property( property="login_account", type="string", example="111", description="登录账号"), + * @SWG\Property( property="disabled", type="integer", example="0", description="企业是否禁用(employee_purchase_enterprises.disabled,0 否 1 是)"), + * @SWG\Property( property="identity_disabled", type="integer", example="0", description="当前条员工/家属身份行的失效状态(employee 行或 relatives 行的 disabled;同企业多身份时以后写入的一条为准)"), * @SWG\Property( property="is_employee", type="integer", example="1", description="是否员工"), * @SWG\Property( property="is_relative", type="integer", example="0", description="是否家属"), * ), @@ -148,23 +153,26 @@ class Enterprise extends BaseController $enterprisesService = new EnterprisesService(); $enterprises = $enterprisesService->getLists(['company_id' => $authInfo['company_id'], 'id' => $enterpriseIds]); $enterprises = array_column($enterprises, null, 'id'); + $result = []; foreach ($employees as $row) { if (!isset($enterprises[$row['enterprise_id']])) { continue; } - $authType = $enterprises[$row['enterprise_id']]['auth_type']; + $ent = $enterprises[$row['enterprise_id']]; + $authType = $ent['auth_type']; if ($authType == 'qr_code') { $authType = 'mobile'; } $result[] = [ 'company_id' => $row['company_id'], - 'name' => $enterprises[$row['enterprise_id']]['name'], + 'name' => $ent['name'], 'enterprise_id' => $row['enterprise_id'], - 'enterprise_sn' => $enterprises[$row['enterprise_id']]['enterprise_sn'], - 'logo' => $enterprises[$row['enterprise_id']]['logo'], + 'enterprise_sn' => $ent['enterprise_sn'], + 'logo' => $ent['logo'], 'login_account' => $row[$authType], - 'disabled' => $row['disabled'], + 'disabled' => (int) ($ent['disabled'] ?? 0), + 'identity_disabled' => (int) ($row['disabled'] ?? 0), 'is_employee' => 1, 'is_relative' => 0, ]; @@ -174,26 +182,26 @@ class Enterprise extends BaseController if (!isset($enterprises[$row['enterprise_id']])) { continue; } + $ent = $enterprises[$row['enterprise_id']]; $result[] = [ 'company_id' => $row['company_id'], - 'name' => $enterprises[$row['enterprise_id']]['name'], + 'name' => $ent['name'], 'enterprise_id' => $row['enterprise_id'], - 'enterprise_sn' => $enterprises[$row['enterprise_id']]['enterprise_sn'], - 'logo' => $enterprises[$row['enterprise_id']]['logo'], + 'enterprise_sn' => $ent['enterprise_sn'], + 'logo' => $ent['logo'], 'login_account' => $row['member_mobile'], - 'disabled' => $row['disabled'], + 'disabled' => (int) ($ent['disabled'] ?? 0), + 'identity_disabled' => (int) ($row['disabled'] ?? 0), 'is_employee' => 0, 'is_relative' => 1, ]; } - //去重 $unique = []; foreach ($result as $item) { $unique[$item['enterprise_id']] = $item; } -// 如果你希望最后的结果是索引数组: $result = array_values($unique); return $this->response->array($result); diff --git a/src/EmployeePurchaseBundle/Http/FrontApi/V1/Action/StoreHomePage.php b/src/EmployeePurchaseBundle/Http/FrontApi/V1/Action/StoreHomePage.php new file mode 100644 index 0000000..3267039 --- /dev/null +++ b/src/EmployeePurchaseBundle/Http/FrontApi/V1/Action/StoreHomePage.php @@ -0,0 +1,66 @@ +get('auth'); + $companyId = (int) ($auth['company_id'] ?? 0); + $authDistributorId = (int) $request->get('distributor_id', 0); + $sid = (int) $id; + if ($sid <= 0) { + throw new ResourceException('id 无效'); + } + + $userId = (int) ($auth['user_id'] ?? 0); + $eActivityId = (int) $request->input('e_activity_id', 0); + + $service = new StoreHomePageService(); + $data = $service->getDetailForFront($companyId, $authDistributorId, $sid, $userId, $eActivityId); + + return $this->response->array($data); + } +} diff --git a/src/EmployeePurchaseBundle/Listeners/EmployeePurchaseOrderPaySuccessListener.php b/src/EmployeePurchaseBundle/Listeners/EmployeePurchaseOrderPaySuccessListener.php new file mode 100644 index 0000000..6baca27 --- /dev/null +++ b/src/EmployeePurchaseBundle/Listeners/EmployeePurchaseOrderPaySuccessListener.php @@ -0,0 +1,35 @@ +entities ?? []; + $companyId = (int) ($data['company_id'] ?? 0); + $orderId = $data['order_id'] ?? null; + if ($companyId <= 0 || $orderId === null || $orderId === '') { + return; + } + + try { + $service = new ActivityEnterpriseBehaviorLogService(); + $service->recordEmployeePurchaseOrderPaid($companyId, $orderId); + } catch (\Throwable $e) { + app('log')->warning('employee purchase order behavior log failed: '.$e->getMessage(), ['exception' => $e]); + } + } +} diff --git a/src/EmployeePurchaseBundle/Repositories/ActivitiesRepository.php b/src/EmployeePurchaseBundle/Repositories/ActivitiesRepository.php index 55fc6e9..bc3587e 100644 --- a/src/EmployeePurchaseBundle/Repositories/ActivitiesRepository.php +++ b/src/EmployeePurchaseBundle/Repositories/ActivitiesRepository.php @@ -25,7 +25,7 @@ use MembersBundle\Services\MemberService; class ActivitiesRepository extends EntityRepository { public $table = 'employee_purchase_activities'; - public $cols = ['id', 'company_id', 'distributor_id', 'operator_id', 'name', 'title', 'pages_template_id', 'pic', 'share_pic', 'enterprise_id', 'display_time', 'employee_begin_time', 'employee_end_time', 'employee_limitfee', 'if_relative_join', 'invite_limit', 'relative_begin_time', 'relative_end_time', 'if_share_limitfee', 'relative_limitfee', 'minimum_amount', 'close_modify_hours_after_activity', 'status', 'if_share_store', 'price_display_config', 'is_discount_description_enabled', 'discount_description', 'created', 'updated']; + public $cols = ['id', 'company_id', 'distributor_id', 'operator_id', 'name', 'title', 'pages_template_id', 'pic', 'share_pic', 'enterprise_id', 'display_time', 'employee_begin_time', 'employee_end_time', 'employee_limitfee', 'if_relative_join', 'invite_limit', 'relative_begin_time', 'relative_end_time', 'if_share_limitfee', 'relative_limitfee', 'minimum_amount', 'close_modify_hours_after_activity', 'status', 'if_share_store', 'price_display_config', 'is_discount_description_enabled', 'discount_description', 'is_passphrase_enabled', 'created', 'updated']; /** * 新增 @@ -150,27 +150,35 @@ class ActivitiesRepository extends EntityRepository } /** - * 筛选条件格式化 + * 内购活动列表「展示态」单条 SQL 条件(与 Api Activity::getActivityList 语义一致). * - * @param $filter - * @param $qb + * @param \Doctrine\DBAL\Query\QueryBuilder $qb + * @param int $now + * @param string $slug not_started|warm_up|ongoing|pending|cancel|over + * + * @return \Doctrine\DBAL\Query\Expression\CompositeExpression|null null 表示非展示态,走库字段 status 等值 */ - private function _filter($filter, $qb) + private function buildActivityListStatusCondition($qb, $now, $slug) { - foreach ($filter as $field => $value) { - $now = time(); - if ($field == 'status' && $value == 'warm_up') { - $qb = $qb->andWhere( + switch ($slug) { + case 'not_started': + return $qb->expr()->andX( + $qb->expr()->gt('display_time', $now), + $qb->expr()->eq('status', $qb->expr()->literal('active')) + ); + case 'warm_up': + return $qb->expr()->andX( $qb->expr()->lt('display_time', $now), $qb->expr()->gt('employee_begin_time', $now), $qb->expr()->orX( + $qb->expr()->isNull('relative_begin_time'), $qb->expr()->eq('relative_begin_time', 0), $qb->expr()->gt('relative_begin_time', $now) ), $qb->expr()->eq('status', $qb->expr()->literal('active')) ); - } elseif ($field == 'status' && $value == 'ongoing') { - $qb = $qb->andWhere( + case 'ongoing': + return $qb->expr()->andX( $qb->expr()->orX( $qb->expr()->lt('employee_begin_time', $now), $qb->expr()->andX( @@ -184,8 +192,8 @@ class ActivitiesRepository extends EntityRepository ), $qb->expr()->eq('status', $qb->expr()->literal('active')) ); - } elseif ($field == 'status' && $value == 'pending') { - $qb = $qb->andWhere( + case 'pending': + return $qb->expr()->andX( $qb->expr()->orX( $qb->expr()->lt('employee_begin_time', $now), $qb->expr()->lt('relative_begin_time', $now), @@ -196,17 +204,59 @@ class ActivitiesRepository extends EntityRepository ), $qb->expr()->eq('status', $qb->expr()->literal('pending')) ); - } elseif ($field == 'status' && $value == 'over') { - $qb = $qb->andWhere( - $qb->expr()->orX( - $qb->expr()->andX( - $qb->expr()->lt('employee_end_time', $now), - $qb->expr()->lt('relative_end_time', $now), - ), - $qb->expr()->eq('status', $qb->expr()->literal('over')) - ) + case 'over': + return $qb->expr()->orX( + $qb->expr()->andX( + $qb->expr()->lt('employee_end_time', $now), + $qb->expr()->lt('relative_end_time', $now), + ), + $qb->expr()->eq('status', $qb->expr()->literal('over')) ); - } elseif ($field == 'buy_time') { + case 'cancel': + return $qb->expr()->eq('status', $qb->expr()->literal('cancel')); + default: + return null; + } + } + + /** + * 筛选条件格式化 + * + * @param $filter + * @param $qb + */ + private function _filter($filter, $qb) + { + foreach ($filter as $field => $value) { + $now = time(); + if ($field === 'status|or' && is_array($value) && $value !== []) { + $parts = []; + foreach (array_unique($value) as $slug) { + $slug = is_string($slug) ? trim($slug) : ''; + if ($slug === '') { + continue; + } + $expr = $this->buildActivityListStatusCondition($qb, $now, $slug); + if ($expr === null) { + throw new ResourceException('status 参数不合法'); + } + $parts[] = $expr; + } + if ($parts !== []) { + $qb = $qb->andWhere(count($parts) === 1 ? $parts[0] : $qb->expr()->orX(...$parts)); + } + continue; + } + if ($field == 'status' && is_string($value)) { + $expr = $this->buildActivityListStatusCondition($qb, $now, $value); + if ($expr !== null) { + $qb = $qb->andWhere($expr); + continue; + } + $qb = $qb->andWhere($qb->expr()->eq('status', $qb->expr()->literal($value))); + continue; + } + if ($field == 'buy_time') { if (isset($value['begin']) && isset($value['end'])) { $qb = $qb->andWhere( $qb->expr()->orX( @@ -259,26 +309,27 @@ class ActivitiesRepository extends EntityRepository ) ); } - } else { - $list = explode('|', $field); - if (count($list) > 1) { - list($v, $k) = $list; - if ($k == 'contains') { - $k = 'like'; - } - if ($k == 'like') { - $value = '%'.$value.'%'; - } - $qb = $qb->andWhere($qb->expr()->$k($v, $qb->expr()->literal($value))); - continue; - } elseif (is_array($value)) { - array_walk($value, function (&$colVal) use ($qb) { - $colVal = $qb->expr()->literal($colVal); - }); - $qb = $qb->andWhere($qb->expr()->in($field, $value)); - } else { - $qb = $qb->andWhere($qb->expr()->eq($field, $qb->expr()->literal($value))); + continue; + } + $list = explode('|', $field); + if (count($list) > 1) { + list($v, $k) = $list; + if ($k == 'contains') { + $k = 'like'; } + if ($k == 'like') { + $value = '%'.$value.'%'; + } + $qb = $qb->andWhere($qb->expr()->$k($v, $qb->expr()->literal($value))); + continue; + } + if (is_array($value)) { + array_walk($value, function (&$colVal) use ($qb) { + $colVal = $qb->expr()->literal($colVal); + }); + $qb = $qb->andWhere($qb->expr()->in($field, $value)); + } else { + $qb = $qb->andWhere($qb->expr()->eq($field, $qb->expr()->literal($value))); } } return $qb; @@ -387,7 +438,7 @@ class ActivitiesRepository extends EntityRepository $companyId = $filter['company_id']; $userId = $filter['user_id']; $now = time(); - $subQuery = "SELECT ac.id,ac.company_id,ac.pic,ac.name,ac.title,ac.pages_template_id,ac.display_time,ac.employee_begin_time,ac.employee_end_time,ac.employee_limitfee,ac.if_relative_join,ac.invite_limit,ac.relative_begin_time,ac.relative_end_time,ac.if_share_limitfee,ac.relative_limitfee,ac.minimum_amount,ac.status,ac.if_share_store,ac.created,ac.updated,ac.price_display_config,ac.is_discount_description_enabled,ac.discount_description,ep.enterprise_id,ep.user_id,1 AS is_employee,0 AS is_relative,et.name AS rel_enterprise FROM employee_purchase_activities ac LEFT JOIN employee_purchase_activity_enterprises ae ON ac.id=ae.activity_id LEFT JOIN employee_purchase_employees ep ON ae.enterprise_id=ep.enterprise_id LEFT JOIN employee_purchase_enterprises et ON ae.enterprise_id=et.id WHERE ac.company_id={$companyId} AND ep.user_id={$userId} AND ac.status IN ('active', 'pending') AND et.disabled=0 AND ac.display_time<{$now} AND (ac.employee_end_time>{$now} OR ac.relative_end_time>{$now}) UNION SELECT ac.id,ac.company_id,ac.pic,ac.name,ac.title,ac.pages_template_id,ac.display_time,ac.employee_begin_time,ac.employee_end_time,ac.employee_limitfee,ac.if_relative_join,ac.invite_limit,ac.relative_begin_time,ac.relative_end_time,ac.if_share_limitfee,ac.relative_limitfee,ac.minimum_amount,ac.status,ac.if_share_store,ac.created,ac.updated,ac.price_display_config,ac.is_discount_description_enabled,ac.discount_description,re.enterprise_id,re.user_id,0 AS is_employee,1 AS is_relative,et.name AS rel_enterprise FROM employee_purchase_activities ac LEFT JOIN employee_purchase_relatives re ON ac.id=re.activity_id LEFT JOIN employee_purchase_enterprises et ON re.enterprise_id=et.id WHERE ac.company_id={$companyId} AND re.user_id={$userId} AND ac.status IN ('active', 'pending') AND et.disabled=0 AND ac.display_time<{$now} AND ac.relative_end_time>{$now}"; + $subQuery = "SELECT ac.id,ac.company_id,ac.pic,ac.name,ac.title,ac.pages_template_id,ac.display_time,ac.employee_begin_time,ac.employee_end_time,ac.employee_limitfee,ac.if_relative_join,ac.invite_limit,ac.relative_begin_time,ac.relative_end_time,ac.if_share_limitfee,ac.relative_limitfee,ac.minimum_amount,ac.status,ac.if_share_store,ac.created,ac.updated,ac.price_display_config,ac.is_discount_description_enabled,ac.discount_description,ac.is_passphrase_enabled,et.auth_type AS auth_type,ep.enterprise_id,ep.user_id,1 AS is_employee,0 AS is_relative,et.name AS rel_enterprise FROM employee_purchase_activities ac LEFT JOIN employee_purchase_activity_enterprises ae ON ac.id=ae.activity_id LEFT JOIN employee_purchase_employees ep ON ae.enterprise_id=ep.enterprise_id LEFT JOIN employee_purchase_enterprises et ON ae.enterprise_id=et.id WHERE ac.company_id={$companyId} AND ep.user_id={$userId} AND ac.status IN ('active', 'pending') AND et.disabled=0 AND ac.display_time<{$now} AND (ac.employee_end_time>{$now} OR ac.relative_end_time>{$now}) UNION SELECT ac.id,ac.company_id,ac.pic,ac.name,ac.title,ac.pages_template_id,ac.display_time,ac.employee_begin_time,ac.employee_end_time,ac.employee_limitfee,ac.if_relative_join,ac.invite_limit,ac.relative_begin_time,ac.relative_end_time,ac.if_share_limitfee,ac.relative_limitfee,ac.minimum_amount,ac.status,ac.if_share_store,ac.created,ac.updated,ac.price_display_config,ac.is_discount_description_enabled,ac.discount_description,ac.is_passphrase_enabled,et.auth_type AS auth_type,re.enterprise_id,re.user_id,0 AS is_employee,1 AS is_relative,et.name AS rel_enterprise FROM employee_purchase_activities ac LEFT JOIN employee_purchase_relatives re ON ac.id=re.activity_id LEFT JOIN employee_purchase_enterprises et ON re.enterprise_id=et.id WHERE ac.company_id={$companyId} AND re.user_id={$userId} AND ac.status IN ('active', 'pending') AND et.disabled=0 AND ac.display_time<{$now} AND ac.relative_end_time>{$now}"; $conn = app('registry')->getConnection('default'); $qb = $conn->createQueryBuilder(); $qb = $qb->select('count(*) as _count')->from('('.$subQuery.')', 't'); diff --git a/src/EmployeePurchaseBundle/Repositories/ActivityEnterpriseBehaviorLogRepository.php b/src/EmployeePurchaseBundle/Repositories/ActivityEnterpriseBehaviorLogRepository.php new file mode 100644 index 0000000..fde7d3b --- /dev/null +++ b/src/EmployeePurchaseBundle/Repositories/ActivityEnterpriseBehaviorLogRepository.php @@ -0,0 +1,103 @@ + $row + */ + public function insertRow(array $row) + { + if (!isset($row['created'])) { + $row['created'] = time(); + } + if (isset($row['extra']) && is_array($row['extra'])) { + $row['extra'] = json_encode($row['extra'], JSON_UNESCAPED_UNICODE); + } + $conn = app('registry')->getConnection('default'); + $conn->insert($this->table, $row); + + return (int) $conn->lastInsertId(); + } + + private function _filter($filter, $qb) + { + foreach ($filter as $field => $value) { + if (is_array($value)) { + array_walk($value, function (&$colVal) use ($qb) { + $colVal = $qb->expr()->literal($colVal); + }); + $qb = $qb->andWhere($qb->expr()->in($field, $value)); + } else { + $qb = $qb->andWhere($qb->expr()->eq($field, $qb->expr()->literal($value))); + } + } + + return $qb; + } + + /** + * @param array $filter + * @param string $cols + * @param array $orderBy + * @return array> + */ + public function getLists($filter, $cols = '*', $orderBy = ['id' => 'ASC']) + { + $conn = app('registry')->getConnection('default'); + $qb = $conn->createQueryBuilder()->select($cols)->from($this->table); + $qb = $this->_filter($filter, $qb); + foreach ($orderBy as $field => $val) { + $qb->addOrderBy($field, $val); + } + + return $qb->execute()->fetchAll(); + } + + /** + * 是否存在指定绑定方式的 bind 流水(MySQL JSON:`extra.bind_channel`)。 + * + * @param string $bindChannel 与员工认证 `auth_type` 一致,扫码为 `qr_code` + */ + public function existsBindLogWithBindChannel(int $companyId, int $activityId, int $enterpriseId, int $userId, string $bindChannel): bool + { + $conn = app('registry')->getConnection('default'); + if ($conn->getDatabasePlatform()->getName() !== 'mysql') { + return false; + } + $sql = 'SELECT 1 AS ok FROM '.$this->table + .' WHERE company_id = ? AND activity_id = ? AND enterprise_id = ? AND user_id = ? AND behavior_type = ?' + ." AND JSON_UNQUOTE(JSON_EXTRACT(extra, '$.bind_channel')) = ? LIMIT 1"; + $row = $conn->fetchAssoc($sql, [ + $companyId, + $activityId, + $enterpriseId, + $userId, + 'bind', + $bindChannel, + ]); + + return !empty($row); + } + + /** + * 活动+企业维度 bind 流水条数(与一次成功绑定一一对应,用于名额校准)。 + */ + public function countBindEventsForActivityEnterprise(int $companyId, int $activityId, int $enterpriseId): int + { + $conn = app('registry')->getConnection('default'); + $sql = 'SELECT COUNT(*) AS c FROM '.$this->table + .' WHERE company_id = ? AND activity_id = ? AND enterprise_id = ? AND behavior_type = ?'; + $row = $conn->fetchAssoc($sql, [$companyId, $activityId, $enterpriseId, 'bind']); + + return (int) ($row['c'] ?? 0); + } +} diff --git a/src/EmployeePurchaseBundle/Repositories/ActivityEnterpriseParticipateUserRepository.php b/src/EmployeePurchaseBundle/Repositories/ActivityEnterpriseParticipateUserRepository.php new file mode 100644 index 0000000..a0d1ad4 --- /dev/null +++ b/src/EmployeePurchaseBundle/Repositories/ActivityEnterpriseParticipateUserRepository.php @@ -0,0 +1,41 @@ +getConnection('default'); + $sql = 'SELECT 1 AS ok FROM '.$this->table + .' WHERE company_id = ? AND activity_id = ? AND enterprise_id = ? AND user_id = ? LIMIT 1'; + $row = $conn->fetchAssoc($sql, [$companyId, $activityId, $enterpriseId, $userId]); + + return !empty($row); + } + + /** + * 插入已占用名额用户;已存在则忽略(幂等)。 + */ + public function insertIgnore(int $companyId, int $activityId, int $enterpriseId, int $userId): void + { + if ($companyId <= 0 || $activityId <= 0 || $enterpriseId <= 0 || $userId <= 0) { + return; + } + $conn = app('registry')->getConnection('default'); + $conn->executeUpdate( + 'INSERT IGNORE INTO '.$this->table.' (company_id, activity_id, enterprise_id, user_id, created) VALUES (?,?,?,?,?)', + [$companyId, $activityId, $enterpriseId, $userId, time()] + ); + } +} diff --git a/src/EmployeePurchaseBundle/Repositories/ActivityPassphraseEnterprisesRepository.php b/src/EmployeePurchaseBundle/Repositories/ActivityPassphraseEnterprisesRepository.php new file mode 100644 index 0000000..8135039 --- /dev/null +++ b/src/EmployeePurchaseBundle/Repositories/ActivityPassphraseEnterprisesRepository.php @@ -0,0 +1,96 @@ +findBy($filter); + if (!$entityList) { + return true; + } + $em = $this->getEntityManager(); + foreach ($entityList as $entityProp) { + $em->remove($entityProp); + $em->flush(); + } + + return true; + } + + private function _filter($filter, $qb) + { + foreach ($filter as $field => $value) { + if (is_array($value)) { + array_walk($value, function (&$colVal) use ($qb) { + $colVal = $qb->expr()->literal($colVal); + }); + $qb = $qb->andWhere($qb->expr()->in($field, $value)); + } else { + $qb = $qb->andWhere($qb->expr()->eq($field, $qb->expr()->literal($value))); + } + } + + return $qb; + } + + /** + * @param array $filter + * @param string $cols + */ + public function getLists($filter, $cols = '*', $orderBy = ['id' => 'ASC']) + { + $conn = app('registry')->getConnection('default'); + $qb = $conn->createQueryBuilder()->select($cols)->from($this->table); + $qb = $this->_filter($filter, $qb); + foreach ($orderBy as $field => $val) { + $qb->addOrderBy($field, $val); + } + + return $qb->execute()->fetchAll(); + } + + /** + * @param array> $data + */ + public function batchInsert(array $data) + { + if (empty($data)) { + return false; + } + + $conn = app('registry')->getConnection('default'); + $qb = $conn->createQueryBuilder(); + + $columns = []; + foreach ($data[0] as $columnName => $value) { + $columns[] = $columnName; + } + + $sql = 'INSERT INTO '.$this->table.' ('.implode(', ', $columns).') VALUES '; + + $insertValue = []; + foreach ($data as $value) { + foreach ($value as &$v) { + $v = $qb->expr()->literal($v); + } + $insertValue[] = '('.implode(', ', $value).')'; + } + + $sql .= implode(',', $insertValue); + + return $conn->executeUpdate($sql); + } +} diff --git a/src/EmployeePurchaseBundle/Repositories/OrdersRelActivityRepository.php b/src/EmployeePurchaseBundle/Repositories/OrdersRelActivityRepository.php index 0d0a728..1bd61cb 100644 --- a/src/EmployeePurchaseBundle/Repositories/OrdersRelActivityRepository.php +++ b/src/EmployeePurchaseBundle/Repositories/OrdersRelActivityRepository.php @@ -25,7 +25,7 @@ use Dingo\Api\Exception\ResourceException; class OrdersRelActivityRepository extends EntityRepository { public $table = "employee_purchase_orders_rel_activity"; - public $cols = ['order_id', 'company_id', 'enterprise_id', 'activity_id', 'user_id', 'if_share_store', 'close_modify_time']; + public $cols = ['order_id', 'company_id', 'enterprise_id', 'activity_id', 'user_id', 'if_share_store', 'close_modify_time', 'participate_quota_order_consumed']; /** * 新增 * diff --git a/src/EmployeePurchaseBundle/Repositories/StoreHomePageRepository.php b/src/EmployeePurchaseBundle/Repositories/StoreHomePageRepository.php new file mode 100644 index 0000000..373bd75 --- /dev/null +++ b/src/EmployeePurchaseBundle/Repositories/StoreHomePageRepository.php @@ -0,0 +1,179 @@ +setColumnNamesData($entity, $data); + + $em = $this->getEntityManager(); + $em->persist($entity); + $em->flush(); + + return $this->getColumnNamesData($entity); + } + + public function updateOneBy(array $filter, array $data) + { + $entity = $this->findOneBy($filter); + if (!$entity) { + throw new ResourceException('未查询到更新数据'); + } + + $entity = $this->setColumnNamesData($entity, $data); + + $em = $this->getEntityManager(); + $em->persist($entity); + $em->flush(); + + return $this->getColumnNamesData($entity); + } + + public function deleteById($id) + { + $entity = $this->find($id); + if (!$entity) { + return true; + } + $em = $this->getEntityManager(); + $em->remove($entity); + $em->flush(); + + return true; + } + + private function setColumnNamesData($entity, $params) + { + foreach ($this->cols as $col) { + if (array_key_exists($col, $params)) { + $fun = 'set'.str_replace(' ', '', ucwords(str_replace('_', ' ', $col))); + $entity->$fun($params[$col]); + } + } + + return $entity; + } + + private function getColumnNamesData($entity, $cols = [], $ignore = []) + { + if (!$cols) { + $cols = $this->cols; + } + + $values = []; + foreach ($cols as $col) { + if ($ignore && in_array($col, $ignore)) { + continue; + } + $fun = 'get'.str_replace(' ', '', ucwords(str_replace('_', ' ', $col))); + $values[$col] = $entity->$fun(); + } + + return $values; + } + + private function _filter($filter, $qb) + { + foreach ($filter as $field => $value) { + $list = explode('|', $field); + if (count($list) > 1) { + list($v, $k) = $list; + if ($k == 'contains') { + $k = 'like'; + } + if ($k == 'like') { + $value = '%'.$value.'%'; + } + $qb = $qb->andWhere($qb->expr()->$k($v, $qb->expr()->literal($value))); + continue; + } + if (is_array($value)) { + array_walk($value, function (&$colVal) use ($qb) { + $colVal = $qb->expr()->literal($colVal); + }); + $qb = $qb->andWhere($qb->expr()->in($field, $value)); + } else { + $qb = $qb->andWhere($qb->expr()->eq($field, $qb->expr()->literal($value))); + } + } + + return $qb; + } + + public function lists($filter, $cols = '*', $page = 1, $pageSize = -1, $orderBy = []) + { + $result['total_count'] = $this->count($filter); + if ($result['total_count'] > 0) { + $conn = app('registry')->getConnection('default'); + $qb = $conn->createQueryBuilder()->select($cols)->from($this->table); + $qb = $this->_filter($filter, $qb); + if ($orderBy) { + foreach ($orderBy as $filed => $val) { + $qb->addOrderBy($filed, $val); + } + } + if ($pageSize > 0) { + $qb->setFirstResult(($page - 1) * $pageSize) + ->setMaxResults($pageSize); + } + $lists = $qb->execute()->fetchAll(); + } + $result['list'] = $lists ?? []; + + return $result; + } + + public function getInfoById($id) + { + $entity = $this->find($id); + if (!$entity) { + return []; + } + + return $this->getColumnNamesData($entity); + } + + public function getInfo(array $filter) + { + $entity = $this->findOneBy($filter); + if (!$entity) { + return []; + } + + return $this->getColumnNamesData($entity); + } + + public function count($filter) + { + $conn = app('registry')->getConnection('default'); + $qb = $conn->createQueryBuilder(); + $qb->select('count(*)') + ->from($this->table); + if ($filter) { + $this->_filter($filter, $qb); + } + $count = $qb->execute()->fetchColumn(); + + return intval($count); + } +} diff --git a/src/EmployeePurchaseBundle/Services/ActivitiesService.php b/src/EmployeePurchaseBundle/Services/ActivitiesService.php index fefaded..c84a0a3 100644 --- a/src/EmployeePurchaseBundle/Services/ActivitiesService.php +++ b/src/EmployeePurchaseBundle/Services/ActivitiesService.php @@ -24,19 +24,21 @@ use EmployeePurchaseBundle\Entities\ActivityGoods; use EmployeePurchaseBundle\Entities\ActivityEnterprises; use GoodsBundle\Entities\Items; use GoodsBundle\Repositories\ItemsRepository; +use EmployeePurchaseBundle\Entities\ActivityPassphraseEnterprises; use GoodsBundle\Services\ItemsService; use GoodsBundle\Services\ItemsCategoryService; use GoodsBundle\Services\ItemsRelCatsService; use GoodsBundle\Services\MultiLang\MultiLangService; use DistributionBundle\Services\DistributorService; use DistributionBundle\Services\DistributorItemsService; -use EmployeePurchaseBundle\Services\ActivityItemsService; +use WechatBundle\Services\WeappService; class ActivitiesService { public $entityRepository; public $itemsEntityRepository; public $enterpriseEntityRepository; + public $passphraseEnterpriseRepository; /** * MemberService 构造函数. @@ -47,10 +49,530 @@ class ActivitiesService $this->itemsEntityRepository = app('registry')->getManager('default')->getRepository(ActivityItems::class); $this->goodsEntityRepository = app('registry')->getManager('default')->getRepository(ActivityGoods::class); $this->enterpriseEntityRepository = app('registry')->getManager('default')->getRepository(ActivityEnterprises::class); + $this->passphraseEnterpriseRepository = app('registry')->getManager('default')->getRepository(ActivityPassphraseEnterprises::class); + } + + /** + * @param mixed $rows + * @return array + */ + public function normalizePassphraseRows($rows) + { + if ($rows === null || $rows === '') { + return []; + } + if (is_string($rows)) { + $decoded = json_decode($rows, true); + + return is_array($decoded) ? $decoded : []; + } + if (is_array($rows)) { + return array_values($rows); + } + + return []; + } + + /** + * @param mixed $enterpriseId + * @return int[] + */ + public function normalizeActivityEnterpriseIds($enterpriseId) + { + if (is_array($enterpriseId)) { + return array_values(array_unique(array_map('intval', $enterpriseId))); + } + if ($enterpriseId === null || $enterpriseId === '') { + return []; + } + if (is_string($enterpriseId)) { + return array_values(array_unique(array_map('intval', array_filter(explode(',', $enterpriseId))))); + } + + return [(int) $enterpriseId]; + } + + /** + * 开启口令时:每行须含参与名额(>0)、口令码、**每企业口令通道额度(分)且须 ≥1**;未开启口令时返回空数组。 + * + * @param int[] $allowedEnterpriseIds + * @return array + */ + public function validateAndBuildPassphraseRows($enabled, array $rawRows, array $allowedEnterpriseIds) + { + if (!$enabled) { + return []; + } + if (empty($rawRows)) { + throw new ResourceException('开启口令通道时请配置口令企业信息'); + } + $built = []; + $codes = []; + $eids = []; + foreach ($rawRows as $row) { + if (is_object($row)) { + $row = json_decode(json_encode($row), true); + } + if (!is_array($row)) { + throw new ResourceException('口令企业配置格式错误'); + } + $eid = (int) ($row['enterprise_id'] ?? 0); + $quota = (int) ($row['participate_quota'] ?? $row['quota'] ?? 0); + $rowLimit = $row['passphrase_limitfee'] ?? $row['limit_fee'] ?? null; + if ($rowLimit === null || $rowLimit === '' || !is_numeric($rowLimit) || (int) $rowLimit < 1) { + throw new ResourceException('口令通道额度须大于0(单位:分)'); + } + $code = isset($row['passphrase_code']) ? trim((string) $row['passphrase_code']) : ''; + if ($code === '' && isset($row['code'])) { + $code = trim((string) $row['code']); + } + if ($eid <= 0 || !in_array($eid, $allowedEnterpriseIds, true)) { + throw new ResourceException('口令企业须为活动参与企业'); + } + if ($quota <= 0) { + throw new ResourceException('可参与名额须大于0'); + } + if ($code === '' || strlen($code) > 64) { + throw new ResourceException('口令编码为1-64个字符'); + } + if (isset($codes[$code])) { + throw new ResourceException('同一活动下口令编码不能重复'); + } + $codes[$code] = true; + if (isset($eids[$eid])) { + throw new ResourceException('同一活动下企业不能重复配置口令'); + } + $eids[$eid] = true; + $built[] = [ + 'enterprise_id' => $eid, + 'participate_quota' => $quota, + 'passphrase_code' => $code, + 'passphrase_limitfee' => (int) $rowLimit, + ]; + } + + return $built; + } + + /** + * 口令编码在公司维度不可与其它活动已占用冲突(与生成接口去重策略一致);更新时可排除本活动旧数据 + * + * @param array $builtRows + * @param int|null $excludeActivityId 更新活动时传入当前活动 ID + */ + public function assertPassphraseCodesAvailableForCompany($companyId, array $builtRows, $excludeActivityId = null) + { + if (empty($builtRows)) { + return; + } + $wanted = []; + foreach ($builtRows as $r) { + $c = isset($r['passphrase_code']) ? trim((string) $r['passphrase_code']) : ''; + if ($c !== '') { + $wanted[$c] = true; + } + } + if (empty($wanted)) { + return; + } + + $excludeActivityId = $excludeActivityId !== null ? (int) $excludeActivityId : null; + $existing = $this->passphraseEnterpriseRepository->getLists(['company_id' => (int) $companyId], 'activity_id,passphrase_code'); + foreach ($existing as $row) { + $code = isset($row['passphrase_code']) ? trim((string) $row['passphrase_code']) : ''; + if ($code === '' || !isset($wanted[$code])) { + continue; + } + $aid = (int) ($row['activity_id'] ?? 0); + if ($excludeActivityId !== null && $aid === $excludeActivityId) { + continue; + } + throw new ResourceException('口令编码已被其它活动占用:'.$code); + } + } + + /** + * @param array $rows + */ + public function syncPassphraseEnterprises($companyId, $activityId, $enabled, array $rows) + { + $oldRows = $this->passphraseEnterpriseRepository->getLists([ + 'company_id' => $companyId, + 'activity_id' => $activityId, + ], 'enterprise_id,participate_quota'); + $oldEids = []; + $oldQuotaByEnterprise = []; + foreach ($oldRows as $or) { + $e = (int) ($or['enterprise_id'] ?? 0); + if ($e > 0) { + $oldEids[$e] = true; + $oldQuotaByEnterprise[$e] = (int) ($or['participate_quota'] ?? 0); + } + } + + if ($enabled && !empty($rows)) { + foreach ($rows as $r) { + $eid = (int) ($r['enterprise_id'] ?? 0); + if ($eid <= 0) { + continue; + } + $newQuota = (int) ($r['participate_quota'] ?? 0); + if (isset($oldQuotaByEnterprise[$eid]) && $newQuota < $oldQuotaByEnterprise[$eid]) { + $savedQuota = $oldQuotaByEnterprise[$eid]; + $ent = (new EnterprisesService())->getEnterpriseInfo([ + 'company_id' => $companyId, + 'id' => $eid, + ]); + $label = !empty($ent['name']) + ? ($ent['name'].'(企业 ID '.$eid.')') + : ('企业 ID '.$eid); + throw new ResourceException( + $label.':本活动可参与名额不得低于已保存值(已保存:'.$savedQuota.',本次提交:'.$newQuota.')' + ); + } + } + } + + $this->passphraseEnterpriseRepository->deleteBy(['company_id' => $companyId, 'activity_id' => $activityId]); + + $quotaRedis = new PassphraseParticipateQuotaRedisService(); + if (!$enabled || empty($rows)) { + foreach (array_keys($oldEids) as $eid) { + $quotaRedis->deleteKey((int) $companyId, (int) $activityId, (int) $eid); + } + + return; + } + $now = time(); + $batch = []; + $newEids = []; + foreach ($rows as $r) { + $batch[] = [ + 'company_id' => $companyId, + 'activity_id' => $activityId, + 'enterprise_id' => $r['enterprise_id'], + 'participate_quota' => $r['participate_quota'], + 'passphrase_code' => $r['passphrase_code'], + 'passphrase_limitfee' => (int) ($r['passphrase_limitfee'] ?? 0), + 'created' => $now, + 'updated' => $now, + ]; + $newEids[(int) $r['enterprise_id']] = true; + } + $this->passphraseEnterpriseRepository->batchInsert($batch); + + foreach ($rows as $r) { + $quotaRedis->warmEnterprise((int) $companyId, (int) $activityId, (int) $r['enterprise_id'], (int) $r['participate_quota']); + } + foreach (array_keys($oldEids) as $eid) { + if (!isset($newEids[(int) $eid])) { + $quotaRedis->deleteKey((int) $companyId, (int) $activityId, (int) $eid); + } + } + } + + public function getPassphraseEnterpriseList($companyId, $activityId) + { + $rows = $this->passphraseEnterpriseRepository->getLists([ + 'company_id' => $companyId, + 'activity_id' => $activityId, + ]); + $eids = []; + foreach ($rows as $r) { + $e = (int) ($r['enterprise_id'] ?? 0); + if ($e > 0) { + $eids[] = $e; + } + } + + $enterprisesService = new EnterprisesService(); + $entMap = $enterprisesService->getEnterpriseInfoBatchMap((int) $companyId, $eids); + + $out = []; + foreach ($rows as $r) { + $eid = (int) ($r['enterprise_id'] ?? 0); + $item = [ + 'id' => $r['id'] ?? null, + 'company_id' => $r['company_id'] ?? null, + 'activity_id' => $r['activity_id'] ?? null, + 'enterprise_id' => $r['enterprise_id'] ?? null, + 'participate_quota' => $r['participate_quota'] ?? null, + 'passphrase_code' => $r['passphrase_code'] ?? null, + 'passphrase_limitfee' => isset($r['passphrase_limitfee']) ? (int) $r['passphrase_limitfee'] : null, + 'created' => $r['created'] ?? null, + 'updated' => $r['updated'] ?? null, + ]; + if ($eid > 0 && isset($entMap[$eid])) { + $item['enterprise'] = $entMap[$eid]; + } elseif ($eid > 0) { + $item['enterprise'] = ['id' => $eid]; + } else { + $item['enterprise'] = null; + } + $out[] = $item; + } + + return $out; + } + + /** + * 小程序等 C 端:当前活动+企业在口令表中的**可展示**配置(不含 passphrase_code,避免泄露)。 + * + * @param array $activity ActivitiesRepository::getInfo 单行 + * @return array{is_passphrase_enabled:0|1,passphrase_participate_quota:int|null,passphrase_limitfee:int|null} 后者两项为分/名额,未开口令或该企业无绑定时为 null + */ + public function getPassphraseClientSummary(int $companyId, int $activityId, int $enterpriseId, array $activity) + { + $on = !empty($activity['is_passphrase_enabled']) ? 1 : 0; + if ($on === 0) { + return [ + 'is_passphrase_enabled' => 0, + 'passphrase_participate_quota' => null, + 'passphrase_limitfee' => null, + ]; + } + $rows = $this->passphraseEnterpriseRepository->getLists([ + 'company_id' => $companyId, + 'activity_id' => $activityId, + 'enterprise_id' => $enterpriseId, + ], 'participate_quota,passphrase_limitfee'); + if (empty($rows)) { + return [ + 'is_passphrase_enabled' => 1, + 'passphrase_participate_quota' => null, + 'passphrase_limitfee' => null, + ]; + } + $r = $rows[0]; + + return [ + 'is_passphrase_enabled' => 1, + 'passphrase_participate_quota' => isset($r['participate_quota']) ? (int) $r['participate_quota'] : null, + 'passphrase_limitfee' => isset($r['passphrase_limitfee']) ? (int) $r['passphrase_limitfee'] : null, + ]; + } + + /** + * 活动下某企业在口令表中的口令(无绑定行返回 null) + */ + public function getPassphraseCodeForActivityEnterprise(int $companyId, int $activityId, int $enterpriseId): ?string + { + $rows = $this->passphraseEnterpriseRepository->getLists([ + 'company_id' => $companyId, + 'activity_id' => $activityId, + 'enterprise_id' => $enterpriseId, + ], 'passphrase_code'); + if (empty($rows)) { + return null; + } + $code = $rows[0]['passphrase_code'] ?? null; + if ($code === null || $code === '') { + return null; + } + + return (string) $code; + } + + /** + * 用户输入的口令是否与库中该活动-企业绑定口令一致(不含活动存在性校验,请先 getInfo) + * + * @param array $activity ActivitiesRepository::getInfo 单行 + */ + public function isActivityEnterprisePassphraseMatch(array $activity, int $enterpriseId, string $inputCode): bool + { + $companyId = (int) ($activity['company_id'] ?? 0); + $activityId = (int) ($activity['id'] ?? 0); + if ($companyId <= 0 || $activityId <= 0 || $enterpriseId <= 0) { + return false; + } + $allowed = $this->normalizeActivityEnterpriseIds($activity['enterprise_id'] ?? []); + if (!in_array($enterpriseId, $allowed, true)) { + return false; + } + if (empty($activity['is_passphrase_enabled'])) { + return false; + } + $stored = $this->getPassphraseCodeForActivityEnterprise($companyId, $activityId, $enterpriseId); + if ($stored === null || $stored === '') { + return false; + } + $in = trim($inputCode); + if ($in === '') { + return false; + } + + return hash_equals($stored, $in); + } + + /** + * 活动已开口令、企业在参与范围内且口令表中有有效口令码(用于加车前自动建档判断)。 + */ + public function supportsPassphraseBypassWhitelist(int $companyId, int $activityId, int $enterpriseId): bool + { + $companyId = (int) $companyId; + $activityId = (int) $activityId; + $enterpriseId = (int) $enterpriseId; + if ($companyId < 1 || $activityId < 1 || $enterpriseId < 1) { + return false; + } + $activity = $this->getInfo(['company_id' => $companyId, 'id' => $activityId]); + if (empty($activity) || empty($activity['is_passphrase_enabled'])) { + return false; + } + $allowed = $this->normalizeActivityEnterpriseIds($activity['enterprise_id'] ?? []); + if (!in_array($enterpriseId, $allowed, true)) { + return false; + } + $code = $this->getPassphraseCodeForActivityEnterprise($companyId, $activityId, $enterpriseId); + + return $code !== null && $code !== ''; + } + + /** + * 批量生成口令编码:8 位数字+大小写字母,与本批结果互不重复。 + * - 有 activity_id:与「该活动」已保存口令去重;企业须为该活动参与企业。 + * - 无 activity_id(新建活动):与「本公司」下所有活动已占用口令去重;企业须为本公司(及店铺可见范围)有效内购企业。 + * + * @param int $companyId + * @param int[] $enterpriseIds + * @param int $countPerEnterprise + * @param int $activityId 0 表示未建活动 + * @param int|null $distributorScopeId 店铺操作员时为其 distributor_id;平台为 null + * @return array{list: array} + */ + public function generatePassphraseCodesForEnterprises($companyId, array $enterpriseIds, $countPerEnterprise = 1, $activityId = 0, $distributorScopeId = null) + { + $companyId = (int) $companyId; + $activityId = (int) $activityId; + $countPerEnterprise = (int) $countPerEnterprise; + if ($companyId <= 0) { + throw new ResourceException('公司参数无效'); + } + if ($countPerEnterprise < 1 || $countPerEnterprise > 50) { + throw new ResourceException('每企业生成条数须在 1~50 之间'); + } + $enterpriseIds = array_values(array_unique(array_filter(array_map('intval', $enterpriseIds)))); + if (empty($enterpriseIds)) { + throw new ResourceException('请传入企业ID'); + } + if (count($enterpriseIds) > 100) { + throw new ResourceException('单次最多选择 100 个企业'); + } + $totalSlots = count($enterpriseIds) * $countPerEnterprise; + if ($totalSlots > 500) { + throw new ResourceException('单次生成口令总数不能超过 500,请减少企业数量或每企业条数'); + } + + $used = []; + + if ($activityId > 0) { + $activityFilter = ['company_id' => $companyId, 'id' => $activityId]; + if ($distributorScopeId !== null) { + $activityFilter['distributor_id'] = (int) $distributorScopeId; + } + $activity = $this->entityRepository->getInfo($activityFilter); + if (empty($activity)) { + throw new ResourceException('活动不存在'); + } + + $allowedEnterpriseIds = $this->normalizeActivityEnterpriseIds($activity['enterprise_id'] ?? []); + foreach ($enterpriseIds as $eid) { + if ($eid <= 0 || !in_array($eid, $allowedEnterpriseIds, true)) { + throw new ResourceException('企业须为本活动参与企业'); + } + } + + $existingRows = $this->passphraseEnterpriseRepository->getLists(['activity_id' => $activityId], 'passphrase_code'); + } else { + $this->assertEnterprisesBelongToCompanyScope($companyId, $enterpriseIds, $distributorScopeId); + $existingRows = $this->passphraseEnterpriseRepository->getLists(['company_id' => $companyId], 'passphrase_code'); + } + + foreach ($existingRows as $row) { + $c = isset($row['passphrase_code']) ? trim((string) $row['passphrase_code']) : ''; + if ($c !== '') { + $used[$c] = true; + } + } + + $charset = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; + $charsetLen = strlen($charset); + $list = []; + foreach ($enterpriseIds as $eid) { + $codes = []; + for ($n = 0; $n < $countPerEnterprise; $n++) { + $codes[] = $this->rollUniquePassphraseCode8($charset, $charsetLen, $used); + } + $list[] = [ + 'enterprise_id' => $eid, + 'passphrase_codes' => $codes, + ]; + } + + return ['list' => $list]; + } + + /** + * @param int[] $enterpriseIds + * @param int|null $distributorScopeId + */ + private function assertEnterprisesBelongToCompanyScope($companyId, array $enterpriseIds, $distributorScopeId) + { + $enterprisesService = new EnterprisesService(); + $filter = [ + 'company_id' => $companyId, + 'id' => $enterpriseIds, + ]; + if ($distributorScopeId !== null) { + $filter['distributor_id'] = (int) $distributorScopeId; + } + $found = $enterprisesService->enterprisesRepository->getLists($filter, 'id'); + $foundIds = []; + foreach ($found as $row) { + if (isset($row['id'])) { + $foundIds[(int) $row['id']] = true; + } + } + foreach ($enterpriseIds as $eid) { + if ($eid <= 0 || !isset($foundIds[$eid])) { + throw new ResourceException('企业不存在或无权操作'); + } + } + } + + /** + * @param array $used + */ + private function rollUniquePassphraseCode8($charset, $charsetLen, array &$used) + { + $maxAttempts = 300; + for ($attempt = 0; $attempt < $maxAttempts; $attempt++) { + $code = ''; + for ($i = 0; $i < 8; $i++) { + $code .= $charset[random_int(0, $charsetLen - 1)]; + } + if (!isset($used[$code])) { + $used[$code] = true; + + return $code; + } + } + throw new ResourceException('生成唯一口令失败,请重试'); } public function create($data) { + $rawPassphraseRows = $this->normalizePassphraseRows($data['passphrase_enterprises'] ?? []); + unset($data['passphrase_enterprises']); + unset($data['passphrase_limitfee']); + + $enabled = !empty($data['is_passphrase_enabled']); + $data['is_passphrase_enabled'] = $enabled; + + $allowedIds = $this->normalizeActivityEnterpriseIds($data['enterprise_id'] ?? []); + $passphraseRows = $this->validateAndBuildPassphraseRows($enabled, $rawPassphraseRows, $allowedIds); + $this->assertPassphraseCodesAvailableForCompany((int) $data['company_id'], $passphraseRows, null); + $result = $this->entityRepository->create($data); $enterpriseData = []; foreach ($data['enterprise_id'] as $enterpriseId) { @@ -61,11 +583,19 @@ class ActivitiesService ]; } $this->enterpriseEntityRepository->batchInsert($enterpriseData); + $this->syncPassphraseEnterprises($result['company_id'], $result['id'], $enabled, $passphraseRows); + return $result; } public function updateActivity($filter, $data) { + $passphraseSync = $data['__passphrase_sync'] ?? 'none'; + unset($data['__passphrase_sync']); + $rawPassphrasePayload = $data['passphrase_enterprises'] ?? null; + unset($data['passphrase_enterprises']); + unset($data['passphrase_limitfee']); + $result = $this->entityRepository->updateOneBy($filter, $data); if (isset($data['enterprise_id']) && $data['enterprise_id']) { $this->enterpriseEntityRepository->deleteBy(['company_id' => $result['company_id'], 'activity_id' => $result['id']]); @@ -79,6 +609,18 @@ class ActivitiesService } $this->enterpriseEntityRepository->batchInsert($enterpriseData); } + + if ($passphraseSync === 'clear') { + $this->syncPassphraseEnterprises($result['company_id'], $result['id'], false, []); + } elseif ($passphraseSync === 'replace') { + $enabled = !empty($result['is_passphrase_enabled']); + $allowedIds = $this->normalizeActivityEnterpriseIds($result['enterprise_id']); + $rawRows = $this->normalizePassphraseRows($rawPassphrasePayload); + $rows = $this->validateAndBuildPassphraseRows($enabled, $rawRows, $allowedIds); + $this->assertPassphraseCodesAvailableForCompany((int) $result['company_id'], $rows, (int) $result['id']); + $this->syncPassphraseEnterprises($result['company_id'], $result['id'], $enabled, $rows); + } + return $result; } @@ -175,7 +717,7 @@ class ActivitiesService $result = $this->entityRepository->lists($filter, $cols, $page, $pageSize, $orderBy); if (!$result['total_count']) { - return $result; + return $result; } $distributorService = new DistributorService(); $storeIds = array_filter(array_unique(array_column($result['list'], 'distributor_id')), function ($distributorId) { @@ -231,6 +773,93 @@ class ActivitiesService return $list; } + /** + * 活动参与企业导出:企业名称、编码、口令码(未开启口令或该企业无绑定则为空)、扫码落地页小程序码下载 URL + * + * @param int $companyId + * @param int $activityId + * @param int|null $distributorScopeId 店铺账号时为其 distributor_id;平台为 null + * @return array 每行四列,供 Excel 导出 + */ + public function buildActivityEnterpriseQrcodeExportRows(int $companyId, int $activityId, $distributorScopeId = null): array + { + $filter = ['company_id' => $companyId, 'id' => $activityId]; + if ($distributorScopeId !== null) { + $filter['distributor_id'] = (int) $distributorScopeId; + } + $activity = $this->entityRepository->getInfo($filter); + if (empty($activity)) { + throw new ResourceException('活动不存在'); + } + + $participations = $this->enterpriseEntityRepository->getLists([ + 'company_id' => $companyId, + 'activity_id' => $activityId, + ], 'enterprise_id', 1, -1, ['enterprise_id' => 'ASC']); + + $eids = []; + foreach ($participations as $p) { + $e = (int) ($p['enterprise_id'] ?? 0); + if ($e > 0) { + $eids[] = $e; + } + } + + $enterprisesService = new EnterprisesService(); + $entMap = $enterprisesService->getEnterpriseInfoBatchMap($companyId, $eids); + + $weappService = new WeappService(); + $wxaAppid = $weappService->getWxappidByTemplateName($companyId, 'yykweishop'); + if (!$wxaAppid) { + throw new ResourceException('没有绑定小程序'); + } + + $passphraseEnabled = !empty($activity['is_passphrase_enabled']); + $baseUrl = rtrim((string) (config('app.url') ?: env('APP_URL', '')), '/'); + if ($baseUrl === '') { + $baseUrl = 'http://localhost'; + } + + $rows = []; + foreach ($participations as $p) { + $eid = (int) ($p['enterprise_id'] ?? 0); + if ($eid <= 0) { + continue; + } + $ent = $entMap[$eid] ?? []; + $name = (string) ($ent['name'] ?? ''); + $sn = (string) ($ent['enterprise_sn'] ?? ''); + $pass = ''; + if ($passphraseEnabled) { + $pass = $this->getPassphraseCodeForActivityEnterprise($companyId, $activityId, $eid) ?? ''; + } + // 与 WechatBundle Qrcode::getQrcode 一致:服务端用 company_id 取小程序;其余参数经 getShareId 落 Redis,scene 仅 share_id。 + // cid 会随 getShareId 写入 Redis(company_id 在 getQrcode 里会从入参剔除),落地页 getByShareId 可带回商户与活动上下文。 + $query = [ + 'company_id' => $companyId, + 'cid' => $companyId, + 'temp_name' => 'yykweishop', + 'page' => 'pages/share-land', + 'id' => $activityId, + 'enterprise_id' => $eid, + 'appid' => $wxaAppid, + 'from_scene' => 'poster_purchase_auth', + ]; + if ($passphraseEnabled) { + $query['ppe'] = '1'; + } + $qrcodeUrl = $baseUrl.'/wechatAuth/wxapp/qrcode.png?'.http_build_query( + $query, + '', + '&', + PHP_QUERY_RFC3986 + ); + $rows[] = [$name, $sn, $pass, $qrcodeUrl]; + } + + return $rows; + } + public function getActivityItemList($filter, $page, $pageSize, $itemSpec = false, $isDefault = false, $orderBy = ['item_id' => 'desc']) { $distributorId = $filter['distributor_id'] ?? 0; @@ -382,7 +1011,7 @@ class ActivitiesService $filter['item_id'] = array_column($distributorItemsList['list'], 'item_id'); unset($filter['item_category']); } - + $itemsService = new ItemsService(); $page = 1; $pageSize = 500; diff --git a/src/EmployeePurchaseBundle/Services/ActivityEnterpriseBehaviorLogService.php b/src/EmployeePurchaseBundle/Services/ActivityEnterpriseBehaviorLogService.php new file mode 100644 index 0000000..9954912 --- /dev/null +++ b/src/EmployeePurchaseBundle/Services/ActivityEnterpriseBehaviorLogService.php @@ -0,0 +1,472 @@ +getManager('default'); + $this->behaviorLogRepository = $em->getRepository(ActivityEnterpriseBehaviorLog::class); + $this->activitiesRepository = $em->getRepository(Activities::class); + $this->activityEnterprisesRepository = $em->getRepository(ActivityEnterprises::class); + } + + /** + * @return string[] + */ + public static function allowedBehaviorTypes() + { + return [ + self::BEHAVIOR_SCAN, + self::BEHAVIOR_PASSPHRASE_VERIFY, + self::BEHAVIOR_BIND, + self::BEHAVIOR_ORDER, + ]; + } + + /** + * @return string[] + */ + public static function allowedResultStatuses() + { + return [self::RESULT_SUCCESS, self::RESULT_FAIL]; + } + + /** + * 写入一条活动-企业行为流水(推荐在业务代码中统一调用本方法) + * + * @param int $companyId + * @param int $activityId + * @param int $enterpriseId + * @param string $behaviorType {@see self::BEHAVIOR_SCAN} 等 + * @param int|null $userId 已登录传会员 user_id + * @param string|null $visitorKey 未登录扫码等场景 UV 去重,最长 64,建议 openid 摘要 + * @param int|null $refId 如订单 ID + * @param array|null $extra 扩展字段,会存为 JSON + * @param string|null $resultStatus {@see self::RESULT_SUCCESS} / {@see self::RESULT_FAIL},当前仅 {@see self::BEHAVIOR_PASSPHRASE_VERIFY} 必填 + * @return int 新插入记录主键 id + */ + public function writeBehaviorLog($companyId, $activityId, $enterpriseId, $behaviorType, $userId = null, $visitorKey = null, $refId = null, array $extra = null, $resultStatus = null) + { + $behaviorType = (string) $behaviorType; + if (!in_array($behaviorType, self::allowedBehaviorTypes(), true)) { + throw new ResourceException('无效的行为类型'); + } + $companyId = (int) $companyId; + $activityId = (int) $activityId; + $enterpriseId = (int) $enterpriseId; + if ($companyId <= 0 || $activityId <= 0 || $enterpriseId <= 0) { + throw new ResourceException('参数错误'); + } + $resultStatus = $resultStatus !== null && $resultStatus !== '' ? (string) $resultStatus : null; + if ($behaviorType === self::BEHAVIOR_PASSPHRASE_VERIFY) { + if ($resultStatus === null || !in_array($resultStatus, self::allowedResultStatuses(), true)) { + throw new ResourceException('口令验证流水须指定 result_status 为 success 或 fail'); + } + } elseif ($resultStatus !== null) { + throw new ResourceException('当前仅口令验证行为可写 result_status'); + } + $row = [ + 'company_id' => $companyId, + 'activity_id' => $activityId, + 'enterprise_id' => $enterpriseId, + 'behavior_type' => $behaviorType, + 'created' => time(), + ]; + if ($resultStatus !== null) { + $row['result_status'] = $resultStatus; + } else { + $row['result_status'] = null; + } + if ($userId !== null && $userId !== '') { + $row['user_id'] = (int) $userId; + } else { + $row['user_id'] = null; + } + if ($visitorKey !== null && $visitorKey !== '') { + $row['visitor_key'] = substr((string) $visitorKey, 0, 64); + } else { + $row['visitor_key'] = null; + } + if ($refId !== null && $refId !== '') { + $row['ref_id'] = (int) $refId; + } else { + $row['ref_id'] = null; + } + if ($extra !== null && $extra !== []) { + $row['extra'] = json_encode($extra, JSON_UNESCAPED_UNICODE); + } else { + $row['extra'] = null; + } + + return $this->behaviorLogRepository->insertRow($row); + } + + /** + * 同 {@see writeBehaviorLog},保留别名便于旧文档或调用处兼容 + */ + public function record($companyId, $activityId, $enterpriseId, $behaviorType, $userId = null, $visitorKey = null, $refId = null, array $extra = null, $resultStatus = null) + { + return $this->writeBehaviorLog($companyId, $activityId, $enterpriseId, $behaviorType, $userId, $visitorKey, $refId, $extra, $resultStatus); + } + + /** + * 内购订单支付成功后记录 behavior_type=order(ref_id=订单号)。 + * + * **如何认定内购订单**:`OrderAssociationService::getOrder` 返回的 **`orders_associations`** 行为 **`order_type=normal` 且 `order_class=employee_purchase`** + *(与 {@see GetOrderServiceTrait::getOrderServiceByOrderInfo()} 拼出的 `normal_employee_purchase` 一致;库表不存拼接字符串)。 + * 再读 **`employee_purchase_orders_rel_activity`**(下单时 `NormalOrderService::createExtend` 已写入 `activity_id`/`enterprise_id`/`user_id`)。 + * 若无关联行或非内购订单类型,则不写流水。 + * + * **下单统计口径**:内购订单支付成功且通过上述校验后写入 `order` 流水;管理端「下单人数」为该流水按 `user_id` 去重聚合,**不依赖**绑定渠道(扫码/口令等)。 + * + * 幂等:同一订单已成功写入过 order 流水则跳过。取消/退款等不删除、不冲正流水。 + * + * @param int|string $orderId 订单号 + * @param OrderAssociationService|null $orderAssociationService 测试注入;默认 `new OrderAssociationService()` + */ + public function recordEmployeePurchaseOrderPaid($companyId, $orderId, OrderAssociationService $orderAssociationService = null): void + { + $companyId = (int) $companyId; + if ($companyId <= 0 || $orderId === null || $orderId === '') { + return; + } + + if ($orderAssociationService === null) { + $orderAssociationService = new OrderAssociationService(); + } + $order = $orderAssociationService->getOrder($companyId, $orderId); + if (empty($order) || !$this->isEmployeePurchaseAssociationOrder($order)) { + return; + } + + $em = app('registry')->getManager('default'); + /** @var \EmployeePurchaseBundle\Repositories\OrdersRelActivityRepository $ordersRelRepo */ + $ordersRelRepo = $em->getRepository(OrdersRelActivity::class); + $rel = $ordersRelRepo->getInfo(['company_id' => $companyId, 'order_id' => $orderId]); + if (empty($rel)) { + return; + } + + $activityId = (int) ($rel['activity_id'] ?? 0); + $enterpriseId = (int) ($rel['enterprise_id'] ?? 0); + $userId = (int) ($rel['user_id'] ?? 0); + if ($activityId <= 0 || $enterpriseId <= 0 || $userId <= 0) { + return; + } + + $refId = (int) $orderId; + if ($refId <= 0 && (string) $orderId !== '0') { + return; + } + + $dup = $this->behaviorLogRepository->getLists([ + 'company_id' => $companyId, + 'activity_id' => $activityId, + 'enterprise_id' => $enterpriseId, + 'behavior_type' => self::BEHAVIOR_ORDER, + 'ref_id' => $refId, + ], 'id'); + if (!empty($dup)) { + return; + } + + $this->writeBehaviorLog( + $companyId, + $activityId, + $enterpriseId, + self::BEHAVIOR_ORDER, + $userId, + null, + $refId, + null + ); + } + + /** + * 是否与 {@see GetOrderServiceTrait::getOrderService('normal_employee_purchase')} 为同一类订单(库表拆开存,非拼接字段)。 + * + * @param array $order OrderAssociationService::getOrder 单行 + */ + private function isEmployeePurchaseAssociationOrder(array $order): bool + { + return ($order['order_type'] ?? '') === 'normal' && ($order['order_class'] ?? '') === 'employee_purchase'; + } + + /** + * 管理端:活动下各参与企业的行为聚合(实时查流水表) + * + * @param int $companyId + * @param int $activityId + * @param int|null $distributorScopeId 店铺账号时传入 distributor_id + * @return array{list: array>} + */ + public function getAggregatedStatsForAdmin($companyId, $activityId, $distributorScopeId = null) + { + $companyId = (int) $companyId; + $activityId = (int) $activityId; + $filter = ['company_id' => $companyId, 'id' => $activityId]; + if ($distributorScopeId !== null) { + $filter['distributor_id'] = (int) $distributorScopeId; + } + $activity = $this->activitiesRepository->getInfo($filter); + if (empty($activity)) { + throw new ResourceException('活动不存在'); + } + + $participations = $this->activityEnterprisesRepository->getLists([ + 'company_id' => $companyId, + 'activity_id' => $activityId, + ], 'enterprise_id', 1, -1, ['enterprise_id' => 'ASC']); + + $logRows = $this->behaviorLogRepository->getLists([ + 'company_id' => $companyId, + 'activity_id' => $activityId, + ], 'id, enterprise_id, behavior_type, result_status, user_id, visitor_key'); + + $aggMap = $this->aggregateBehaviorStatsByEnterprise($logRows); + + $eids = []; + foreach ($participations as $p) { + $eids[] = (int) ($p['enterprise_id'] ?? 0); + } + $enterprisesService = new EnterprisesService(); + $entMap = $enterprisesService->getEnterpriseInfoBatchMap($companyId, $eids); + + $list = []; + foreach ($participations as $p) { + $eid = (int) ($p['enterprise_id'] ?? 0); + if ($eid <= 0) { + continue; + } + $row = $aggMap[$eid] ?? [ + 'scan_pv' => 0, + 'scan_uv' => 0, + 'passphrase_verify_uv' => 0, + 'bind_uv' => 0, + 'order_uv' => 0, + ]; + $ent = $entMap[$eid] ?? []; + $list[] = [ + 'enterprise_id' => $eid, + 'enterprise_name' => $ent['name'] ?? '', + 'enterprise_sn' => $ent['enterprise_sn'] ?? '', + 'logo' => $ent['logo'] ?? '', + 'scan_count' => (int) $row['scan_pv'], + 'scan_user_count' => (int) $row['scan_uv'], + 'passphrase_verify_user_count' => (int) $row['passphrase_verify_uv'], + 'bind_user_count' => (int) $row['bind_uv'], + 'order_user_count' => (int) $row['order_uv'], + ]; + } + + return ['list' => $list]; + } + + /** + * 管理端活动列表:按活动 ID 聚合整活动维度统计(与单活动内各企业之和的口径不同,UV 为活动内去重) + * + * @param int $companyId + * @param int[] $activityIds + * @return array + */ + public function getAggregatedStatsTotalsByActivityIds($companyId, array $activityIds) + { + $companyId = (int) $companyId; + $activityIds = array_values(array_unique(array_filter(array_map('intval', $activityIds)))); + if ($companyId <= 0 || $activityIds === []) { + return []; + } + + $logRows = $this->behaviorLogRepository->getLists([ + 'company_id' => $companyId, + 'activity_id' => $activityIds, + ], 'id, activity_id, enterprise_id, behavior_type, result_status, user_id, visitor_key'); + + $byActivity = []; + foreach ($logRows as $r) { + $aid = (int) ($r['activity_id'] ?? 0); + if ($aid <= 0) { + continue; + } + if (!isset($byActivity[$aid])) { + $byActivity[$aid] = []; + } + $byActivity[$aid][] = $r; + } + + $out = []; + foreach ($activityIds as $aid) { + $subset = $byActivity[$aid] ?? []; + $stats = $this->computeEnterpriseBehaviorStats($subset); + $out[$aid] = [ + 'scan_count' => (int) $stats['scan_pv'], + 'scan_user_count' => (int) $stats['scan_uv'], + 'passphrase_verify_user_count' => (int) $stats['passphrase_verify_uv'], + 'bind_user_count' => (int) $stats['bind_uv'], + 'order_user_count' => (int) $stats['order_uv'], + ]; + } + + return $out; + } + + /** + * @param array> $logRows + * @return array + */ + private function aggregateBehaviorStatsByEnterprise(array $logRows) + { + $byEnterprise = []; + foreach ($logRows as $r) { + $eid = (int) ($r['enterprise_id'] ?? 0); + if ($eid <= 0) { + continue; + } + if (!isset($byEnterprise[$eid])) { + $byEnterprise[$eid] = []; + } + $byEnterprise[$eid][] = $r; + } + + $map = []; + foreach ($byEnterprise as $eid => $rows) { + $map[$eid] = $this->computeEnterpriseBehaviorStats($rows); + } + + return $map; + } + + /** + * @param array> $rows 同一 enterprise_id 下的流水 + * @return array{scan_pv:int,scan_uv:int,passphrase_verify_uv:int,bind_uv:int,order_uv:int} + */ + private function computeEnterpriseBehaviorStats(array $rows) + { + return [ + 'scan_pv' => $this->countBehaviorEvents($rows, self::BEHAVIOR_SCAN), + 'scan_uv' => $this->countDistinctVisitorsForBehavior($rows, self::BEHAVIOR_SCAN), + 'passphrase_verify_uv' => $this->countDistinctVisitorsForPassphraseVerifySuccess($rows), + 'bind_uv' => $this->countDistinctVisitorsForBehavior($rows, self::BEHAVIOR_BIND), + 'order_uv' => $this->countDistinctVisitorsForBehavior($rows, self::BEHAVIOR_ORDER), + ]; + } + + /** + * @param array> $rows + */ + private function countBehaviorEvents(array $rows, $behaviorType) + { + $n = 0; + foreach ($rows as $r) { + if (($r['behavior_type'] ?? '') === $behaviorType) { + $n++; + } + } + + return $n; + } + + /** + * @param array> $rows + */ + private function countDistinctVisitorsForBehavior(array $rows, $behaviorType) + { + $keys = []; + foreach ($rows as $r) { + if (($r['behavior_type'] ?? '') !== $behaviorType) { + continue; + } + $keys[$this->visitorDistinctKey($r)] = true; + } + + return count($keys); + } + + /** + * 口令验证成功人数(UV);result_status 为 success,历史 NULL 视为成功(兼容迁移前数据) + * + * @param array> $rows + */ + private function countDistinctVisitorsForPassphraseVerifySuccess(array $rows) + { + $keys = []; + foreach ($rows as $r) { + if (($r['behavior_type'] ?? '') !== self::BEHAVIOR_PASSPHRASE_VERIFY) { + continue; + } + $rs = $r['result_status'] ?? null; + if ($rs === self::RESULT_FAIL) { + continue; + } + if ($rs !== null && $rs !== '' && $rs !== self::RESULT_SUCCESS) { + continue; + } + $keys[$this->visitorDistinctKey($r)] = true; + } + + return count($keys); + } + + /** + * 与原先 SQL 中 UV 规则一致:优先 user_id,其次 visitor_key,否则用行 id + * + * @param array $row + */ + private function visitorDistinctKey(array $row) + { + $uid = (int) ($row['user_id'] ?? 0); + if ($uid > 0) { + return 'u'.$uid; + } + $vk = trim((string) ($row['visitor_key'] ?? '')); + if ($vk !== '') { + return 'v'.$vk; + } + + return 'i'.(int) ($row['id'] ?? 0); + } +} diff --git a/src/EmployeePurchaseBundle/Services/CartService.php b/src/EmployeePurchaseBundle/Services/CartService.php index d04314f..32231ff 100644 --- a/src/EmployeePurchaseBundle/Services/CartService.php +++ b/src/EmployeePurchaseBundle/Services/CartService.php @@ -60,6 +60,8 @@ class CartService if ($cartType == 'fastbuy') { $params['is_checked'] = true; $params = array_merge($filter, $params); + $this->assertFastBuyIntentWithinItemLimits($filter, $params); + return $this->setFastBuyCart($filter['company_id'], $filter['enterprise_id'], $filter['activity_id'], $filter['user_id'], $params); } @@ -73,10 +75,17 @@ class CartService } if ($cartInfo) { //$isAccumulate=true 累增; =false 覆盖 - $params['num'] = (!$isAccumulate || $isAccumulate === 'false') ? $params['num'] : ($params['num'] + $cartInfo['num']) ; + $finalNum = (!$isAccumulate || $isAccumulate === 'false') ? (int) $params['num'] : ((int) $params['num'] + (int) $cartInfo['num']); + $willCheck = array_key_exists('is_checked', $params) ? (bool) $params['is_checked'] : (bool) $cartInfo['is_checked']; + $this->assertDbCartIntentWithinItemLimits($filter, (string) $filter['item_id'], $finalNum, $willCheck); + $params['num'] = $finalNum; + return $this->entityRepository->updateOneBy($filter, $params); } $params = array_merge($filter, $params); + $willCheckNew = array_key_exists('is_checked', $params) ? (bool) $params['is_checked'] : true; + $this->assertDbCartIntentWithinItemLimits($filter, (string) $filter['item_id'], (int) ($params['num'] ?? 0), $willCheckNew); + return $this->entityRepository->create($params); } @@ -94,6 +103,9 @@ class CartService $this->entityRepository->deleteBy($filter); return []; } + $willCheck = array_key_exists('is_checked', $params) ? (bool) $params['is_checked'] : (bool) $cartInfo['is_checked']; + $this->assertDbCartIntentWithinItemLimits($filter, (string) $cartInfo['item_id'], (int) ($params['num'] ?? 0), $willCheck); + return $this->entityRepository->updateOneBy($filter, $params); } @@ -149,6 +161,13 @@ class CartService } $employeesService = new EmployeesService(); + $employeesService->ensurePassphraseEmployeeFromVerifiedActivity( + (int) $filter['company_id'], + (int) $filter['enterprise_id'], + (int) $filter['activity_id'], + (int) $filter['user_id'] + ); + $employee = $employeesService->check($filter['company_id'], $filter['enterprise_id'], $filter['user_id']); if ($employee) { if ($activity['employee_begin_time'] > time() || $activity['employee_end_time'] < time()) { @@ -285,6 +304,19 @@ class CartService $activityItemList = $activityItemsService->getLists(['company_id' => $filter['company_id'], 'activity_id' => $filter['activity_id'], 'item_id' => $itemIds]); $activityItemList = array_column($activityItemList, null, 'item_id'); + $aggregateByItemId = []; + if ($itemIds !== []) { + $memberActivityItemsAggregateService = new MemberActivityItemsAggregateService(); + $aggregateList = $memberActivityItemsAggregateService->getLists([ + 'company_id' => $filter['company_id'], + 'enterprise_id' => $filter['enterprise_id'], + 'user_id' => $filter['user_id'], + 'activity_id' => $filter['activity_id'], + 'item_id' => $itemIds, + ]) ?: []; + $aggregateByItemId = array_column($aggregateList, null, 'item_id'); + } + foreach ($itemList as $key => $item) { if (isset($activityItemList[$item['item_id']])) { $itemList[$key]['sale_price'] = $item['price']; @@ -339,6 +371,18 @@ class CartService $validCart['cart_total_count'] = $cartTotalCount; $validCart['total_fee'] = $cartTotalPrice; $validCart['list'] = $cartData['valid_cart']; + foreach ($validCart['list'] as $idx => $cartRow) { + $iid = $cartRow['item_id'] ?? null; + if ($iid === null || $iid === '') { + continue; + } + $actRow = $activityItemList[$iid] ?? null; + $aggRow = $aggregateByItemId[$iid] ?? null; + $validCart['list'][$idx]['limit_num'] = $actRow ? (int) ($actRow['limit_num'] ?? 0) : 0; + $validCart['list'][$idx]['limit_fee'] = $actRow ? (int) ($actRow['limit_fee'] ?? 0) : 0; + $validCart['list'][$idx]['aggregate_num'] = $aggRow ? (int) ($aggRow['aggregate_num'] ?? 0) : 0; + $validCart['list'][$idx]['aggregate_fee'] = $aggRow ? (int) ($aggRow['aggregate_fee'] ?? 0) : 0; + } if ($cartData['invalid_cart']) { $cartIds = array_column($cartData['invalid_cart'], 'cart_id'); @@ -348,6 +392,99 @@ class CartService return ['invalid_cart' => $cartData['invalid_cart'], 'valid_cart' => [$validCart]]; } + private function assertFastBuyIntentWithinItemLimits(array $filter, array $mergedParams): void + { + $activityItemsService = new ActivityItemsService(); + $activityItem = $activityItemsService->getInfo([ + 'company_id' => $filter['company_id'], + 'activity_id' => $filter['activity_id'], + 'item_id' => $mergedParams['item_id'], + ]); + if (!$activityItem) { + throw new ResourceException('商品未参加内购活动'); + } + $num = (int) ($mergedParams['num'] ?? 0); + $fee = (int) $activityItem['activity_price'] * $num; + $lines = [['item_id' => $mergedParams['item_id'], 'num' => $num, 'item_fee' => $fee]]; + EmployeePurchaseItemLimitValidator::assertFromContextAndLines([ + 'company_id' => $filter['company_id'], + 'enterprise_id' => $filter['enterprise_id'], + 'activity_id' => $filter['activity_id'], + 'user_id' => $filter['user_id'], + ], $lines); + } + + private function assertDbCartIntentWithinItemLimits(array $filter, string $targetItemId, int $targetFinalNum, bool $targetWillBeChecked): void + { + if ($targetFinalNum <= 0 || !$targetWillBeChecked) { + return; + } + $listFilter = [ + 'company_id' => $filter['company_id'], + 'enterprise_id' => $filter['enterprise_id'], + 'activity_id' => $filter['activity_id'], + 'user_id' => $filter['user_id'], + ]; + $rows = $this->entityRepository->getLists($listFilter) ?: []; + usort($rows, static function ($a, $b) { + return ((int) ($a['cart_id'] ?? 0)) <=> ((int) ($b['cart_id'] ?? 0)); + }); + $rawLines = []; + $hasCheckedTarget = false; + foreach ($rows as $row) { + if (empty($row['is_checked'])) { + continue; + } + $iid = (string) $row['item_id']; + $num = ($iid === $targetItemId) ? $targetFinalNum : (int) $row['num']; + if ($iid === $targetItemId) { + $hasCheckedTarget = true; + } + if ($num <= 0) { + continue; + } + $rawLines[] = ['item_id' => $iid, 'num' => $num, '_cart_id' => (int) ($row['cart_id'] ?? 0)]; + } + if (!$hasCheckedTarget) { + $rawLines[] = ['item_id' => $targetItemId, 'num' => $targetFinalNum, '_cart_id' => PHP_INT_MAX]; + } + usort($rawLines, static function ($a, $b) { + return $a['_cart_id'] <=> $b['_cart_id']; + }); + $itemIds = array_values(array_unique(array_column($rawLines, 'item_id'))); + $activityItemsService = new ActivityItemsService(); + $activityItemList = $activityItemsService->getLists([ + 'company_id' => $filter['company_id'], + 'activity_id' => $filter['activity_id'], + 'item_id' => $itemIds, + ]); + $activityItemByItemId = array_column($activityItemList, null, 'item_id'); + $lines = []; + foreach ($rawLines as $row) { + $iid = $row['item_id']; + if (!isset($activityItemByItemId[$iid])) { + throw new ResourceException('商品未参加内购活动'); + } + $price = (int) $activityItemByItemId[$iid]['activity_price']; + $lines[] = [ + 'item_id' => $iid, + 'num' => (int) $row['num'], + 'item_fee' => $price * (int) $row['num'], + ]; + } + $memberActivityItemsAggregateService = new MemberActivityItemsAggregateService(); + $aggregateList = $memberActivityItemsAggregateService->getLists([ + 'company_id' => $filter['company_id'], + 'enterprise_id' => $filter['enterprise_id'], + 'user_id' => $filter['user_id'], + 'activity_id' => $filter['activity_id'], + 'item_id' => $itemIds, + ]); + $aggregateByItemId = array_column($aggregateList, null, 'item_id'); + + EmployeePurchaseItemLimitValidator::assertWithPreloadedData($activityItemByItemId, $aggregateByItemId, $lines); + } + public function setFastBuyCart($companyId, $enterpriseId, $activityId, $userId, $params) { $key = "employee_purchase_fastbuy:" . sha1($companyId . $enterpriseId . $activityId . $userId); diff --git a/src/EmployeePurchaseBundle/Services/EmployeePurchaseItemLimitValidator.php b/src/EmployeePurchaseBundle/Services/EmployeePurchaseItemLimitValidator.php new file mode 100644 index 0000000..fb2745b --- /dev/null +++ b/src/EmployeePurchaseBundle/Services/EmployeePurchaseItemLimitValidator.php @@ -0,0 +1,109 @@ + row(含 limit_num、limit_fee) + * @param array $aggregateByItemId item_id => row(含 aggregate_num、aggregate_fee,可缺省) + * @param array $linesOrdered [['item_id'=>,'num'=>int,'item_fee'=>int 分], ...] 展示/请求顺序 + */ + public static function assertWithPreloadedData(array $activityItemByItemId, array $aggregateByItemId, array $linesOrdered): void + { + $merged = self::mergeLinesByFirstAppearance($linesOrdered); + foreach ($merged as $line) { + $itemId = $line['item_id']; + if (!isset($activityItemByItemId[$itemId])) { + throw new ResourceException('商品未参加内购活动'); + } + $limitNum = (int) ($activityItemByItemId[$itemId]['limit_num'] ?? 0); + $limitFee = (int) ($activityItemByItemId[$itemId]['limit_fee'] ?? 0); + $aggNum = isset($aggregateByItemId[$itemId]) ? (int) $aggregateByItemId[$itemId]['aggregate_num'] : 0; + $aggFee = isset($aggregateByItemId[$itemId]) ? (int) $aggregateByItemId[$itemId]['aggregate_fee'] : 0; + $newNum = $aggNum + (int) $line['num']; + $newFee = $aggFee + (int) $line['item_fee']; + $qtyExceed = $limitNum > 0 && $newNum > $limitNum; + $feeExceed = $limitFee > 0 && $newFee > $limitFee; + if ($qtyExceed && $feeExceed) { + throw new ResourceException(self::MSG_FEE); + } + if ($feeExceed) { + throw new ResourceException(self::MSG_FEE); + } + if ($qtyExceed) { + throw new ResourceException(self::MSG_NUM); + } + } + } + + /** + * @param array $context company_id, enterprise_id, activity_id, user_id + * @param array $linesOrdered 每行含 item_id、num、item_fee(分) + */ + public static function assertFromContextAndLines(array $context, array $linesOrdered): void + { + $itemIds = array_values(array_unique(array_column($linesOrdered, 'item_id'))); + if ($itemIds === []) { + return; + } + $activityItemsService = new ActivityItemsService(); + $activityItemList = $activityItemsService->getLists([ + 'company_id' => $context['company_id'], + 'activity_id' => $context['activity_id'], + 'item_id' => $itemIds, + ]); + $activityItemByItemId = array_column($activityItemList, null, 'item_id'); + + $memberActivityItemsAggregateService = new MemberActivityItemsAggregateService(); + $aggregateList = $memberActivityItemsAggregateService->getLists([ + 'company_id' => $context['company_id'], + 'enterprise_id' => $context['enterprise_id'], + 'user_id' => $context['user_id'], + 'activity_id' => $context['activity_id'], + 'item_id' => $itemIds, + ]); + $aggregateByItemId = array_column($aggregateList, null, 'item_id'); + + self::assertWithPreloadedData($activityItemByItemId, $aggregateByItemId, $linesOrdered); + } + + /** + * @return list + */ + private static function mergeLinesByFirstAppearance(array $linesOrdered): array + { + $bucket = []; + $order = []; + foreach ($linesOrdered as $line) { + $id = $line['item_id']; + if (!isset($bucket[$id])) { + $bucket[$id] = ['item_id' => $id, 'num' => 0, 'item_fee' => 0]; + $order[] = $id; + } + $bucket[$id]['num'] += (int) $line['num']; + $bucket[$id]['item_fee'] += (int) $line['item_fee']; + } + $merged = []; + foreach ($order as $id) { + $merged[] = $bucket[$id]; + } + + return $merged; + } +} diff --git a/src/EmployeePurchaseBundle/Services/EmployeesService.php b/src/EmployeePurchaseBundle/Services/EmployeesService.php index f92a57c..df1bde7 100644 --- a/src/EmployeePurchaseBundle/Services/EmployeesService.php +++ b/src/EmployeePurchaseBundle/Services/EmployeesService.php @@ -21,10 +21,14 @@ use Dingo\Api\Exception\ResourceException; use CompanysBundle\Services\EmailService; use DistributionBundle\Services\DistributorService; use EmployeePurchaseBundle\Entities\Employees; +use EmployeePurchaseBundle\Entities\ActivityEnterpriseParticipateUser; +use EmployeePurchaseBundle\Entities\MemberActivityAggregate; +use EmployeePurchaseBundle\Entities\MemberActivityItemsAggregate; use EmployeePurchaseBundle\Services\EnterprisesService; use EmployeePurchaseBundle\Services\RelativesService; use EmployeePurchaseBundle\Services\ActivitiesService; use Hashids\Hashids; +use MembersBundle\Services\MemberService; class EmployeesService { @@ -47,6 +51,11 @@ class EmployeesService if (!$enterpriseInfo) { throw new ResourceException('企业不存在'); } + // 无需验证:用户侧选企业后直接建档,后台不允许维护白名单员工 + if (($enterpriseInfo['auth_type'] ?? '') === 'no_verify') { + throw new ResourceException('该企业不需要添加员工'); + } + // 未开启白名单校验的企业(如邮箱验证类)同样不允许后台加人 if ($enterpriseInfo['is_employee_check_enabled'] == false) { throw new ResourceException('该企业不需要添加员工'); } @@ -130,60 +139,64 @@ class EmployeesService } /** - * 发送邮箱验证码 - * @param array $params + * 发送邮箱验证码:须指定当前认证企业(传 enterprise_id;无则须传 distributor_id 且该公司+店铺下邮箱后缀仅命中一家 email 认证企业) + * 收件邮箱后缀须与该企业发件箱 suffix 一致。 + * + * @param array $params company_id, email, enterprise_id|distributor_id */ public function sendEmailVcode($params) { - // 验证邮箱格式 if (!isemail($params['email'])) { throw new ResourceException('收件邮箱格式不正确'); } - $suffix = substr($params['email'], strpos($params['email'], "@")); - // 查询企业设置的发件邮箱配置 + $enterpriseId = $this->resolveEmailAuthEnterpriseId($params, $params['email']); + if ($enterpriseId < 1) { + throw new ResourceException('企业ID不能为空'); + } + $enterprisesService = new EnterprisesService(); - $filter = [ + $enterprise = $enterprisesService->getInfo([ 'company_id' => $params['company_id'], - 'suffix' => $suffix, - ]; - $distributorId = intval($params['distributor_id'] ?? 0); - if ( $distributorId > 0) { - $filter['distributor_id'] = $distributorId; - } - $enterpriseId = intval($params['enterprise_id'] ?? 0); - if ( $enterpriseId > 0) { - $filter['enterprise_id'] = $enterpriseId; - } - $enterpriseInfo = $enterprisesService->getEnterpriseByEmailSuffix($filter); - if (!$enterpriseInfo) { + 'id' => $enterpriseId, + ]); + if (!$enterprise || !empty($enterprise['disabled'])) { throw new ResourceException('企业不存在'); } - if ($enterpriseInfo['auth_type'] != 'email') { + if (($enterprise['auth_type'] ?? '') !== 'email') { throw new ResourceException('请选择其他验证方式'); } - if (!isset($enterpriseInfo['relay_host'], $enterpriseInfo['smtp_port'], $enterpriseInfo['email_user'], $enterpriseInfo['email_password']) || !$enterpriseInfo['relay_host'] || !$enterpriseInfo['smtp_port'] || !$enterpriseInfo['email_user'] || !$enterpriseInfo['email_password']) { + + $distributorId = (int) ($params['distributor_id'] ?? 0); + if ($distributorId > 0 && (int) ($enterprise['distributor_id'] ?? 0) !== $distributorId) { + throw new ResourceException('企业不存在'); + } + + $box = $enterprisesService->enterpriseEmailBoxRepository->getInfo([ + 'company_id' => $params['company_id'], + 'enterprise_id' => $enterpriseId, + ]); + if (!$box) { + throw new ResourceException('企业未配置发件邮箱'); + } + $recvSuffix = $this->normalizeMailboxSuffixFromEmail($params['email']); + $cfgSuffix = $this->normalizeMailboxSuffixFromConfig((string) ($box['suffix'] ?? '')); + if ($cfgSuffix === '' || $recvSuffix === '' || $recvSuffix !== $cfgSuffix) { + throw new ResourceException('邮箱后缀与当前企业要求不一致'); + } + + if (empty($box['relay_host']) || empty($box['smtp_port']) || empty($box['user']) || $box['password'] === null || $box['password'] === '') { throw new ResourceException('企业发件箱配置错误'); } - // 检查邮件是否在白名单中 - // $filter = [ - // 'company_id' => $params['company_id'], - // 'enterprise_id' => $params['enterprise_id'], - // 'email' => $params['email'], - // ]; - // $employeeInfo = $this->entityRepository->getInfo($filter); - // if (!$employeeInfo) { - // throw new ResourceException('非企业员工邮箱'); - // } $from = [ - 'email_smtp_port' => $enterpriseInfo['smtp_port'], - 'email_relay_host' => $enterpriseInfo['relay_host'], - 'email_user' => $enterpriseInfo['email_user'], - 'email_password' => $enterpriseInfo['email_password'], + 'email_smtp_port' => $box['smtp_port'], + 'email_relay_host' => $box['relay_host'], + 'email_user' => $box['user'], + 'email_password' => $box['password'], ]; $emailService = new EmailService($from); $to = $params['email']; - $key = $this->generateReidsKey($to, 'email'); + $key = $this->generateReidsKey($to, 'email', $enterpriseId); $vcode = (string)mt_rand(100000, 999999); //保存验证码 $this->redisStore($key, $vcode, 1800); @@ -230,7 +243,7 @@ EOF; throw new ResourceException('请输入验证码'); } - if (!$this->checkEmailVcode($params['email'], $params['vcode'])) { + if (!$this->checkEmailVcode($params['email'], $params['vcode'], (int) $params['enterprise_id'])) { throw new ResourceException('验证码错误'); } @@ -310,6 +323,23 @@ EOF; throw new ResourceException('已经是该企业员工,不需要重复绑定'); } + $quotaConsumed = false; + $bindCompanyId = (int) $params['company_id']; + $bindEnterpriseId = (int) $params['enterprise_id']; + $bindActivityId = isset($params['activity_id']) ? (int) $params['activity_id'] : 0; + $participateQuotaSvc = new PassphraseParticipateQuotaRedisService(); + if ($bindActivityId > 0 && $participateQuotaSvc->isApplicable($bindCompanyId, $bindActivityId, $bindEnterpriseId)) { + $emExempt = app('registry')->getManager('default'); + /** @var \EmployeePurchaseBundle\Repositories\ActivityEnterpriseParticipateUserRepository $exemptRepo */ + $exemptRepo = $emExempt->getRepository(ActivityEnterpriseParticipateUser::class); + if (!$exemptRepo->existsForUser($bindCompanyId, $bindActivityId, $bindEnterpriseId, (int) $params['user_id'])) { + if (!$participateQuotaSvc->tryConsumeSlot($bindCompanyId, $bindActivityId, $bindEnterpriseId)) { + throw new ResourceException('该企业在本活动下的参与名额已满'); + } + $quotaConsumed = true; + } + } + $conn = app('registry')->getConnection('default'); $conn->beginTransaction(); try { @@ -327,6 +357,10 @@ EOF; $data['name'] = urldecode($params['email']); $data['email'] = urldecode($params['email']); break; + case 'no_verify': + $data['name'] = '用户-'.substr((string) $params['member_mobile'], -4); + $data['mobile'] = $params['member_mobile']; + break; default: $data['name'] = $params['member_mobile']; $data['mobile'] = $params['member_mobile']; @@ -361,19 +395,84 @@ EOF; $conn->commit(); } catch (\Exception $e) { $conn->rollback(); + if ($quotaConsumed) { + $participateQuotaSvc->releaseOneSlot($bindCompanyId, $bindActivityId, $bindEnterpriseId); + } throw new ResourceException($e->getMessage()); } + $this->tryWriteEmployeeBindBehaviorLog($params); + + if ($quotaConsumed && $bindActivityId > 0) { + $emExempt = app('registry')->getManager('default'); + /** @var \EmployeePurchaseBundle\Repositories\ActivityEnterpriseParticipateUserRepository $exemptRepo */ + $exemptRepo = $emExempt->getRepository(ActivityEnterpriseParticipateUser::class); + $exemptRepo->insertIgnore($bindCompanyId, $bindActivityId, $bindEnterpriseId, (int) $params['user_id']); + } + return true; } - //验证邮件验证码 - public function checkEmailVcode($email, $vcode) + /** + * 活动内员工账号绑定成功后写入行为流水 behavior_type=bind(管理端按活动+企业聚合 bind_user_count)。 + * 仅当入参带有效 activity_id 且该企业参与该活动时写入;失败不影响绑定结果。 + * `extra.bind_channel` 与 `auth_type` 一致,供支付成功写 `order` 流水时识别是否扫码绑定。 + * + * @param array $params {@see authentication()} 入参,须含 company_id、enterprise_id、user_id;可选 activity_id、auth_type + */ + private function tryWriteEmployeeBindBehaviorLog(array $params): void + { + $activityId = isset($params['activity_id']) ? (int) $params['activity_id'] : 0; + if ($activityId <= 0) { + return; + } + $companyId = (int) ($params['company_id'] ?? 0); + $enterpriseId = (int) ($params['enterprise_id'] ?? 0); + $userId = (int) ($params['user_id'] ?? 0); + if ($companyId <= 0 || $enterpriseId <= 0 || $userId <= 0) { + return; + } + try { + $activitiesService = new ActivitiesService(); + $activity = $activitiesService->getInfo(['company_id' => $companyId, 'id' => $activityId]); + if (empty($activity)) { + return; + } + $allowed = $activitiesService->normalizeActivityEnterpriseIds($activity['enterprise_id'] ?? []); + if (!in_array($enterpriseId, $allowed, true)) { + return; + } + $bindChannel = isset($params['auth_type']) ? (string) $params['auth_type'] : ''; + $bindExtra = [ActivityEnterpriseBehaviorLogService::EXTRA_KEY_BIND_CHANNEL => $bindChannel]; + $logService = new ActivityEnterpriseBehaviorLogService(); + $logService->writeBehaviorLog( + $companyId, + $activityId, + $enterpriseId, + ActivityEnterpriseBehaviorLogService::BEHAVIOR_BIND, + $userId, + null, + null, + $bindExtra + ); + } catch (\Throwable $e) { + app('log')->warning('employee bind behavior log failed: '.$e->getMessage(), ['exception' => $e]); + } + } + + /** + * 验证邮件验证码(须与发送时同一 enterprise_id) + */ + public function checkEmailVcode($email, $vcode, $enterpriseId = 0) { if (empty($email)) { throw new ResourceException('请输入邮箱'); } - $key = $this->generateReidsKey($email, 'email'); + $enterpriseId = (int) $enterpriseId; + if ($enterpriseId < 1) { + throw new ResourceException('企业ID不能为空'); + } + $key = $this->generateReidsKey($email, 'email', $enterpriseId); $storeVcode = $this->redisFetch($key); if ($storeVcode == $vcode) { app('redis')->del($key); @@ -382,12 +481,72 @@ EOF; return false; } - //生成验证码的redis key - private function generateReidsKey($token, $type = 'email') + /** @see sendEmailVcode / checkEmailVcode */ + private function generateReidsKey($token, $type = 'email', $enterpriseId = 0) { + $enterpriseId = (int) $enterpriseId; + if ($type === 'email' && $enterpriseId > 0) { + return 'employee-purchase-'.$type.':'.$enterpriseId.':'.$token; + } + return 'employee-purchase-'.$type.':'.$token; } + /** 收件邮箱 @ 起的小写后缀,如 @qq.com */ + private function normalizeMailboxSuffixFromEmail(string $email): string + { + $at = strpos($email, '@'); + if ($at === false) { + return ''; + } + + return strtolower(substr($email, $at)); + } + + /** 库表 suffix 与收件后缀对齐:统一小写并保证带 @ */ + private function normalizeMailboxSuffixFromConfig(string $stored): string + { + $s = strtolower(trim($stored)); + if ($s === '') { + return ''; + } + if (strpos($s, '@') !== 0) { + $s = '@'.$s; + } + + return $s; + } + + /** + * 解析「当前邮箱认证」对应的企业 ID:优先 enterprise_id;否则 company_id + distributor_id + 收件后缀在库中唯一命中。 + */ + private function resolveEmailAuthEnterpriseId(array $params, string $email): int + { + $eid = (int) ($params['enterprise_id'] ?? 0); + if ($eid > 0) { + return $eid; + } + $dist = (int) ($params['distributor_id'] ?? 0); + if ($dist < 1) { + return 0; + } + $suffix = $this->normalizeMailboxSuffixFromEmail($email); + if ($suffix === '') { + return 0; + } + $es = new EnterprisesService(); + $row = $es->getEnterpriseByEmailSuffix([ + 'company_id' => $params['company_id'], + 'suffix' => $suffix, + 'e.distributor_id' => $dist, + ]); + if (empty($row['id'])) { + return 0; + } + + return (int) $row['id']; + } + //redis存储 private function redisStore($key, $value, $expire = 300) { @@ -520,10 +679,11 @@ EOF; public function doEmployeeCheck($params) { // 验证参数 - if ($params['auth_type'] == 'email') { + if ($params['auth_type'] == 'email' || $params['auth_type'] == 'no_verify') { $info = $this->__checkEmployee($params); + $eid = $info['enterprise_id'] ?? $info['id'] ?? 0; $data = [ - 'enterprise_id' => $info['enterprise_id'], + 'enterprise_id' => $eid, 'enterprise_name' => $info['name'], 'enterprise_sn' => $info['enterprise_sn'], 'auth_type' => $info['auth_type'], @@ -552,7 +712,7 @@ EOF; { $filter = [ 'company_id' => $params['company_id'], - 'user_id' => false, + 'user_id' => 0, 'auth_type' => $params['auth_type'], ]; @@ -567,7 +727,9 @@ EOF; return $this->response->array([]); } $filter['enterprise_id'] = array_column($enterprisesList, 'enterprise_id'); - } else { + // 与仅传 enterprise_id 分支一致:按账号/手机查白名单,不限定 user_id=0;否则已绑定会员的员工行无法命中 + unset($filter['user_id']); + } else { $distributorId = intval($params['distributor_id'] ?? 0); if ( $distributorId > 0) { $filter['distributor_id'] = $distributorId; @@ -584,6 +746,7 @@ EOF; case 'qr_code': case 'mobile': if (!$params['mobile']) throw new ResourceException('手机号必填'); + unset($filter['user_id']); $filter['mobile'] = $params['mobile']; break; case 'account': @@ -593,16 +756,70 @@ EOF; $filter['auth_code'] = $params['auth_code']; break; case 'email': - if (!$params['email']) throw new ResourceException('邮箱必填'); - if (!$params['vcode']) throw new ResourceException('验证码必填'); - // 去验证邮箱验证码正确 - if (!$this->checkEmailVcode($params['email'], $params['vcode'])) { + if (!$params['email']) { + throw new ResourceException('邮箱必填'); + } + if (!$params['vcode']) { + throw new ResourceException('验证码必填'); + } + $eidEmail = $this->resolveEmailAuthEnterpriseId($params, $params['email']); + if ($eidEmail < 1) { + throw new ResourceException('企业ID不能为空'); + } + if ($activity_id > 0) { + $allowedE = isset($filter['enterprise_id']) ? (array) $filter['enterprise_id'] : []; + $allowedE = array_map('intval', $allowedE); + if (!in_array($eidEmail, $allowedE, true)) { + throw new ResourceException('企业不参与该活动'); + } + } + if (!$this->checkEmailVcode($params['email'], $params['vcode'], $eidEmail)) { throw new ResourceException('验证码错误'); } - $filter['suffix'] = substr($params['email'], strpos($params['email'], "@")); - unset($filter['user_id']); $enterprisesService = new EnterprisesService(); - $enterpriseInfo = $enterprisesService->getEnterpriseByEmailSuffix($filter); + $enterpriseInfo = $enterprisesService->getInfo([ + 'company_id' => $params['company_id'], + 'id' => $eidEmail, + 'auth_type' => 'email', + ]); + if (!$enterpriseInfo || !empty($enterpriseInfo['disabled'])) { + throw new ResourceException('未关联企业信息,请确认后再操作'); + } + $box = $enterprisesService->enterpriseEmailBoxRepository->getInfo([ + 'company_id' => $params['company_id'], + 'enterprise_id' => $eidEmail, + ]); + $recvSuffix = $this->normalizeMailboxSuffixFromEmail($params['email']); + $cfgSuffix = $this->normalizeMailboxSuffixFromConfig((string) ($box['suffix'] ?? '')); + if ($cfgSuffix === '' || $recvSuffix === '' || $recvSuffix !== $cfgSuffix) { + throw new ResourceException('邮箱后缀与当前企业要求不一致'); + } + + return $enterpriseInfo; + break; + case 'no_verify': + // 无需验证:仅校验企业存在且为 no_verify 类型(及活动/店铺范围) + $eid = intval($params['enterprise_id'] ?? 0); + if ($eid <= 0) { + throw new ResourceException('企业ID必填'); + } + if ($activity_id > 0) { + $allowed = isset($filter['enterprise_id']) ? (array) $filter['enterprise_id'] : []; + $allowed = array_map('intval', $allowed); + if (!in_array($eid, $allowed, true)) { + throw new ResourceException('企业不参与该活动'); + } + } elseif (isset($filter['enterprise_id']) && !is_array($filter['enterprise_id'])) { + if ((int) $filter['enterprise_id'] !== $eid) { + throw new ResourceException('企业信息不匹配'); + } + } + $enterprisesService = new EnterprisesService(); + $enterpriseInfo = $enterprisesService->getInfo([ + 'company_id' => $params['company_id'], + 'id' => $eid, + 'auth_type' => 'no_verify', + ]); if (!$enterpriseInfo) { throw new ResourceException('未关联企业信息,请确认后再操作'); } @@ -615,6 +832,177 @@ EOF; return $filter; } + /** + * 口令活动:用户已在 behavior-report 验口令(Redis)且未预录白名单时,自动写入 employee_purchase_employees(加车/下单/活动数据前调用)。 + * 已是员工或已是亲友则跳过;名额逻辑与 {@see authentication()} 一致。 + */ + public function ensurePassphraseEmployeeFromVerifiedActivity(int $companyId, int $enterpriseId, int $activityId, int $userId): void + { + if ($companyId < 1 || $enterpriseId < 1 || $activityId < 1 || $userId < 1) { + return; + } + if ($this->check($companyId, $enterpriseId, $userId)) { + return; + } + $relativesService = new RelativesService(); + if ($relativesService->check($companyId, $enterpriseId, $activityId, $userId)) { + return; + } + + $activitiesSvc = new ActivitiesService(); + if (!$activitiesSvc->supportsPassphraseBypassWhitelist($companyId, $activityId, $enterpriseId)) { + return; + } + if (!(new PassphraseVerifiedRedisService())->isVerified($companyId, $activityId, $enterpriseId, $userId)) { + return; + } + + $memberService = new MemberService(); + $member = $memberService->getMemberInfo(['company_id' => $companyId, 'user_id' => $userId]); + if (empty($member) || empty($member['user_id'])) { + throw new ResourceException('会员信息不存在'); + } + $memberMobile = trim((string) ($member['mobile'] ?? '')); + $memberEmail = trim((string) ($member['email'] ?? '')); + + $enterprisesService = new EnterprisesService(); + $enterpriseInfo = $enterprisesService->getInfo(['company_id' => $companyId, 'id' => $enterpriseId]); + if (!$enterpriseInfo) { + return; + } + + $quotaConsumed = false; + $participateQuotaSvc = new PassphraseParticipateQuotaRedisService(); + if ($participateQuotaSvc->isApplicable($companyId, $activityId, $enterpriseId)) { + $emExempt = app('registry')->getManager('default'); + /** @var \EmployeePurchaseBundle\Repositories\ActivityEnterpriseParticipateUserRepository $exemptRepo */ + $exemptRepo = $emExempt->getRepository(ActivityEnterpriseParticipateUser::class); + if (!$exemptRepo->existsForUser($companyId, $activityId, $enterpriseId, $userId)) { + if (!$participateQuotaSvc->tryConsumeSlot($companyId, $activityId, $enterpriseId)) { + throw new ResourceException('该企业在本活动下的参与名额已满'); + } + $quotaConsumed = true; + } + } + + $conn = app('registry')->getConnection('default'); + $conn->beginTransaction(); + try { + if ($this->check($companyId, $enterpriseId, $userId)) { + $conn->rollback(); + if ($quotaConsumed) { + $participateQuotaSvc->releaseOneSlot($companyId, $activityId, $enterpriseId); + } + + return; + } + + $data = [ + 'company_id' => $companyId, + 'enterprise_id' => $enterpriseId, + 'user_id' => $userId, + 'member_mobile' => $memberMobile !== '' ? $memberMobile : ($memberEmail !== '' ? $memberEmail : (string) $userId), + 'distributor_id' => $enterpriseInfo['distributor_id'], + ]; + switch ($enterpriseInfo['auth_type']) { + case 'email': + if ($memberEmail === '') { + throw new ResourceException('请先绑定邮箱'); + } + $data['name'] = $memberEmail; + $data['email'] = $memberEmail; + break; + case 'no_verify': + $mob = $memberMobile !== '' ? $memberMobile : (string) $userId; + $data['name'] = '用户-'.substr($mob, -4); + $data['mobile'] = $mob; + break; + default: + if ($memberMobile === '') { + throw new ResourceException('请先绑定手机号'); + } + $data['name'] = $memberMobile; + $data['mobile'] = $memberMobile; + break; + } + + $this->entityRepository->create($data); + $relativesService->updateBy(['company_id' => $companyId, 'user_id' => $userId, 'enterprise_id' => $enterpriseId], ['disabled' => 1]); + $conn->commit(); + } catch (\Exception $e) { + $conn->rollback(); + if ($quotaConsumed) { + $participateQuotaSvc->releaseOneSlot($companyId, $activityId, $enterpriseId); + } + if ($this->check($companyId, $enterpriseId, $userId)) { + return; + } + throw new ResourceException($e->getMessage()); + } + + if ($quotaConsumed) { + $emExempt = app('registry')->getManager('default'); + /** @var \EmployeePurchaseBundle\Repositories\ActivityEnterpriseParticipateUserRepository $exemptRepo */ + $exemptRepo = $emExempt->getRepository(ActivityEnterpriseParticipateUser::class); + $exemptRepo->insertIgnore($companyId, $activityId, $enterpriseId, $userId); + } + + $this->tryWriteEmployeeBindBehaviorLog([ + 'company_id' => $companyId, + 'enterprise_id' => $enterpriseId, + 'user_id' => $userId, + 'activity_id' => $activityId, + 'auth_type' => ActivityEnterpriseBehaviorLogService::BIND_CHANNEL_PASSPHRASE, + ]); + } + + /** + * 删除企业员工;若已绑定会员,同步清空该会员在本企业下的活动额度累计(活动总维度 + 商品维度),便于删后重新自动建档时额度重算。 + * + * @param array $filter 须能唯一定位一行,如 company_id + id + */ + public function deleteBy(array $filter) + { + $row = $this->entityRepository->getInfo($filter); + if (!$row) { + return true; + } + + $companyId = (int) ($row['company_id'] ?? 0); + $enterpriseId = (int) ($row['enterprise_id'] ?? 0); + $userId = (int) ($row['user_id'] ?? 0); + + $conn = app('registry')->getConnection('default'); + $conn->beginTransaction(); + try { + if ($companyId > 0 && $enterpriseId > 0 && $userId > 0) { + $aggFilter = [ + 'company_id' => $companyId, + 'enterprise_id' => $enterpriseId, + 'user_id' => $userId, + ]; + $em = app('registry')->getManager('default'); + $em->getRepository(MemberActivityAggregate::class)->deleteBy($aggFilter); + $em->getRepository(MemberActivityItemsAggregate::class)->deleteBy($aggFilter); + } + $this->entityRepository->deleteBy($filter); + $conn->commit(); + } catch (\Throwable $e) { + $conn->rollback(); + throw new ResourceException($e->getMessage()); + } + + if ($companyId > 0 && $enterpriseId > 0 && $userId > 0) { + try { + (new PassphraseVerifiedRedisService())->forgetVerifiedForUserEnterprise($companyId, $enterpriseId, $userId); + } catch (\Throwable $e) { + app('log')->warning('forget passphrase verified redis failed: '.$e->getMessage(), ['exception' => $e]); + } + } + + return true; + } + // 如果可以直接调取Repositories中的方法,则直接调用 public function __call($method, $parameters) { diff --git a/src/EmployeePurchaseBundle/Services/EmployeesUploadService.php b/src/EmployeePurchaseBundle/Services/EmployeesUploadService.php index 9f1aab3..e2e17bc 100644 --- a/src/EmployeePurchaseBundle/Services/EmployeesUploadService.php +++ b/src/EmployeePurchaseBundle/Services/EmployeesUploadService.php @@ -24,6 +24,9 @@ use EmployeePurchaseBundle\Services\EnterprisesService; class EmployeesUploadService { + /** 单次导入最大数据行数(不含表头) */ + public const MAX_IMPORT_DATA_ROWS = 5000; + public $header = [ '企业编码' => 'enterprise_sn', '姓名' => 'name', @@ -90,6 +93,19 @@ class EmployeesUploadService return app('filesystem')->disk('import-file'); } + /** + * 去表头后的数据行数上限校验(与商品批量导入一致:先 unset 表头再 count,超限抛 BadRequestHttpException,文案「每次最多上传…...请减少后再提交」) + */ + public static function assertImportDataRowsWithinLimit(int $dataRowCount): void + { + $maxRows = self::MAX_IMPORT_DATA_ROWS; + if ($dataRowCount > $maxRows) { + throw new BadRequestHttpException( + "每次最多上传{$maxRows}条员工数据(不含表头)...请减少后再提交" + ); + } + } + /** * 获取头部标题 */ diff --git a/src/EmployeePurchaseBundle/Services/EnterprisesService.php b/src/EmployeePurchaseBundle/Services/EnterprisesService.php index 957003a..f1cf041 100644 --- a/src/EmployeePurchaseBundle/Services/EnterprisesService.php +++ b/src/EmployeePurchaseBundle/Services/EnterprisesService.php @@ -210,7 +210,81 @@ class EnterprisesService $data['is_employee_check_enabled'] = $data['is_employee_check_enabled'] == true ? 'true' : 'false'; return $data; } - + + /** + * 批量获取与 {@see getEnterpriseInfo} 一致的企业详情结构,并补充列表中的 distributor_name(活动详情口令行等场景) + * + * @param int $companyId + * @param int[] $enterpriseIds + * @return array> enterprise_id => 企业数据 + */ + public function getEnterpriseInfoBatchMap($companyId, array $enterpriseIds) + { + $companyId = (int) $companyId; + $enterpriseIds = array_values(array_unique(array_filter(array_map('intval', $enterpriseIds)))); + if ($companyId <= 0 || empty($enterpriseIds)) { + return []; + } + + $list = $this->enterprisesRepository->getLists([ + 'company_id' => $companyId, + 'id' => $enterpriseIds, + ], '*', 1, -1, ['id' => 'ASC']); + if (!$list) { + return []; + } + + $relList = $this->enterpriseEmailBoxRepository->getLists([ + 'company_id' => $companyId, + 'enterprise_id' => $enterpriseIds, + ], '*', 1, -1, []); + $relByEid = []; + foreach ($relList as $rel) { + if (isset($rel['enterprise_id'])) { + $relByEid[(int) $rel['enterprise_id']] = $rel; + } + } + + $storeIds = array_filter(array_unique(array_column($list, 'distributor_id')), function ($distributorId) { + return is_numeric($distributorId) && $distributorId >= 0; + }); + $storeData = []; + if ($storeIds) { + $distributorService = new DistributorService(); + $pageSize = max(count($storeIds), 1); + $storeList = $distributorService->getDistributorOriginalList([ + 'company_id' => $companyId, + 'distributor_id' => $storeIds, + ], 1, $pageSize); + $rows = $storeList['list'] ?? []; + $storeData = array_column($rows, null, 'distributor_id'); + $storeData[0] = $distributorService->getDistributorSelfSimpleInfo($companyId); + } + + $map = []; + foreach ($list as $data) { + $id = (int) ($data['id'] ?? 0); + if ($id <= 0) { + continue; + } + if (($data['auth_type'] ?? '') == 'email') { + $relData = $relByEid[$id] ?? []; + if ($relData) { + $data['relay_host'] = $relData['relay_host']; + $data['smtp_port'] = $relData['smtp_port']; + $data['email_user'] = $relData['user']; + $data['email_password'] = $relData['password']; + $data['email_suffix'] = $relData['suffix']; + } + } + $data['distributor_name'] = isset($data['distributor_id']) ? ($storeData[$data['distributor_id']]['name'] ?? '') : ''; + $data['is_employee_check_enabled'] = ($data['is_employee_check_enabled'] ?? false) == true ? 'true' : 'false'; + $map[$id] = $data; + } + + return $map; + } + public function delete($filter) { $data = $this->enterprisesRepository->getInfo($filter); diff --git a/src/EmployeePurchaseBundle/Services/MemberActivityAggregateService.php b/src/EmployeePurchaseBundle/Services/MemberActivityAggregateService.php index abafcaa..a75b214 100644 --- a/src/EmployeePurchaseBundle/Services/MemberActivityAggregateService.php +++ b/src/EmployeePurchaseBundle/Services/MemberActivityAggregateService.php @@ -45,6 +45,31 @@ class MemberActivityAggregateService throw new ResourceException('企业不参与该活动'); } + // 口令活动:额度来自口令企业表,按当前用户累加;参与者可为任意已登录会员,不要求员工/亲友身份 + if (!empty($activity['is_passphrase_enabled'])) { + $pp = $activityService->getPassphraseClientSummary( + (int) $companyId, + (int) $activityId, + (int) $enterpriseId, + $activity + ); + $limitFen = $pp['passphrase_limitfee']; + $limitFen = $limitFen === null ? 0 : (int) $limitFen; + $aggregateFee = (int) $this->entityRepository->sum([ + 'company_id' => $companyId, + 'enterprise_id' => $enterpriseId, + 'activity_id' => $activityId, + 'user_id' => $userId, + ]); + $left = (int) bcsub((string) $limitFen, (string) $aggregateFee); + + return [ + 'limit_fee' => $limitFen, + 'aggregate_fee' => $aggregateFee, + 'left_fee' => $left < 0 ? 0 : $left, + ]; + } + $employeesService = new EmployeesService(); $relativesService = new RelativesService(); $employee = $employeesService->check($companyId, $enterpriseId, $userId); @@ -87,6 +112,67 @@ class MemberActivityAggregateService throw new ResourceException('企业不参与该活动'); } + if (!empty($activity['is_passphrase_enabled'])) { + try { + $key = 'addAggregateFee_passphrase_'.$companyId.'_'.$enterpriseId.'_'.$activityId.'_'.$userId; + + $succ = app('redis')->setnx($key, 1); + while (!$succ) { + usleep(rand(1000, 1000000)); + $succ = app('redis')->setnx($key, 1); + } + + $pp = $activityService->getPassphraseClientSummary( + (int) $companyId, + (int) $activityId, + (int) $enterpriseId, + $activity + ); + $limitFen = $pp['passphrase_limitfee']; + $limitFen = $limitFen === null ? 0 : (int) $limitFen; + $aggregateFee = (int) $this->entityRepository->sum([ + 'company_id' => $companyId, + 'enterprise_id' => $enterpriseId, + 'activity_id' => $activityId, + 'user_id' => $userId, + ]); + if ($aggregateFee + $fee > $limitFen) { + throw new ResourceException('超过个人口令通道额度'); + } + + $aggregateInfo = $this->entityRepository->getInfo(['company_id' => $companyId, 'enterprise_id' => $enterpriseId, 'activity_id' => $activityId, 'user_id' => $userId]); + if (!$aggregateInfo) { + $data = [ + 'company_id' => $companyId, + 'enterprise_id' => $enterpriseId, + 'activity_id' => $activityId, + 'user_id' => $userId, + 'aggregate_fee' => $fee, + ]; + $this->entityRepository->create($data); + } else { + $data = [ + 'aggregate_fee' => $aggregateInfo['aggregate_fee'] + $fee, + ]; + $filter = [ + 'company_id' => $companyId, + 'enterprise_id' => $enterpriseId, + 'activity_id' => $activityId, + 'user_id' => $userId, + ]; + $this->entityRepository->updateBy($filter, $data); + } + app('redis')->del($key); + } catch (\Exception $e) { + if (isset($key)) { + app('redis')->del($key); + } + throw new ResourceException($e->getMessage()); + } + + return true; + } + $employeesService = new EmployeesService(); $relativesService = new RelativesService(); $employee = $employeesService->check($companyId, $enterpriseId, $userId); diff --git a/src/EmployeePurchaseBundle/Services/MemberActivityItemsAggregateService.php b/src/EmployeePurchaseBundle/Services/MemberActivityItemsAggregateService.php index e326b3f..ccbed1b 100644 --- a/src/EmployeePurchaseBundle/Services/MemberActivityItemsAggregateService.php +++ b/src/EmployeePurchaseBundle/Services/MemberActivityItemsAggregateService.php @@ -50,12 +50,16 @@ class MemberActivityItemsAggregateService $aggregateInfo = $this->entityRepository->getInfo(['company_id' => $companyId, 'enterprise_id' => $enterpriseId, 'activity_id' => $activityId, 'user_id' => $userId, 'item_id' => $itemId]); if (!$aggregateInfo) { - if ($activityItem['limit_fee'] > 0 && $activityItem['limit_fee'] < $fee ) { - throw new ResourceException('商品超过限额'); + $qtyExceed = $activityItem['limit_num'] > 0 && $num > $activityItem['limit_num']; + $feeExceed = $activityItem['limit_fee'] > 0 && $fee > $activityItem['limit_fee']; + if ($qtyExceed && $feeExceed) { + throw new ResourceException(EmployeePurchaseItemLimitValidator::MSG_FEE); } - - if ($activityItem['limit_num'] > 0 && $activityItem['limit_num'] < $num ) { - throw new ResourceException('商品超过限购数量'); + if ($feeExceed) { + throw new ResourceException(EmployeePurchaseItemLimitValidator::MSG_FEE); + } + if ($qtyExceed) { + throw new ResourceException(EmployeePurchaseItemLimitValidator::MSG_NUM); } $data = [ @@ -74,12 +78,16 @@ class MemberActivityItemsAggregateService 'aggregate_num' => $aggregateInfo['aggregate_num'] + $num, ]; - if ($activityItem['limit_fee'] > 0 && $activityItem['limit_fee'] < $data['aggregate_fee']) { - throw new ResourceException('商品超过限额'); + $qtyExceed = $activityItem['limit_num'] > 0 && $data['aggregate_num'] > $activityItem['limit_num']; + $feeExceed = $activityItem['limit_fee'] > 0 && $data['aggregate_fee'] > $activityItem['limit_fee']; + if ($qtyExceed && $feeExceed) { + throw new ResourceException(EmployeePurchaseItemLimitValidator::MSG_FEE); } - - if ($activityItem['limit_num'] > 0 && $activityItem['limit_num'] < $data['aggregate_num']) { - throw new ResourceException('商品超过限购数量'); + if ($feeExceed) { + throw new ResourceException(EmployeePurchaseItemLimitValidator::MSG_FEE); + } + if ($qtyExceed) { + throw new ResourceException(EmployeePurchaseItemLimitValidator::MSG_NUM); } $filter = [ diff --git a/src/EmployeePurchaseBundle/Services/NormalOrderService.php b/src/EmployeePurchaseBundle/Services/NormalOrderService.php index d22a7e7..e94f688 100644 --- a/src/EmployeePurchaseBundle/Services/NormalOrderService.php +++ b/src/EmployeePurchaseBundle/Services/NormalOrderService.php @@ -32,6 +32,8 @@ use OrdersBundle\Services\ShippingTemplatesService; use OrdersBundle\Services\TradeSetting\CancelService; use OrdersBundle\Services\TradeSettingService; use DistributionBundle\Services\DistributorService; +use EmployeePurchaseBundle\Entities\OrdersRelActivity; +use EmployeePurchaseBundle\Entities\ActivityEnterpriseParticipateUser; class NormalOrderService extends AbstractNormalOrder { @@ -62,6 +64,9 @@ class NormalOrderService extends AbstractNormalOrder private $activityInfo = []; private $orderRelData = []; + /** @var bool 本单在 commit 前扣减过口令参与名额时,在 afterCreateOrder 写入豁免用户表 */ + private $participateQuotaRecordUserAfterOrderCommit = false; + public function __construct() { parent::__construct(); @@ -140,6 +145,13 @@ class NormalOrderService extends AbstractNormalOrder } $employeesService = new EmployeesService(); + $employeesService->ensurePassphraseEmployeeFromVerifiedActivity( + (int) $params['company_id'], + (int) $params['enterprise_id'], + (int) $params['activity_id'], + (int) $params['user_id'] + ); + $employee = $employeesService->check($params['company_id'], $params['enterprise_id'], $params['user_id']); if ($employee) { if ($activity['employee_begin_time'] > time() || $activity['employee_end_time'] < time()) { @@ -177,34 +189,22 @@ class NormalOrderService extends AbstractNormalOrder $memberActivityItemsAggregateService = new MemberActivityItemsAggregateService(); $memberActivityItemsAggregateList = $memberActivityItemsAggregateService->getLists(['company_id' => $params['company_id'], 'enterprise_id' => $params['enterprise_id'], 'user_id' => $params['user_id'], 'activity_id' => $params['activity_id'], 'item_id' => $itemIds]); $memberActivityItemsAggregateList = array_column($memberActivityItemsAggregateList, null, 'item_id'); - foreach ($orderData['items'] as $item) { - if ($activityItemList[$item['item_id']]['limit_num'] > 0) { - $itemAggregateNum = 0; - if (isset($memberActivityItemsAggregateList[$item['item_id']])) { - $itemAggregateNum = $memberActivityItemsAggregateList[$item['item_id']]['aggregate_num']; - } - if ($itemAggregateNum + $item['num'] > $activityItemList[$item['item_id']]['limit_num']) { - if ($isCheck) { - throw new ResourceException($item['item_name'].'超过限购数量'); - } else { - $orderData['extraTips'] = $item['item_name'].'超过限购数量'; - } - } - } - if ($activityItemList[$item['item_id']]['limit_fee'] > 0) { - $itemAggregateFee = 0; - if (isset($memberActivityItemsAggregateList[$item['item_id']])) { - $itemAggregateFee = $memberActivityItemsAggregateList[$item['item_id']]['aggregate_fee']; - } - if ($itemAggregateFee + $item['item_fee'] > $activityItemList[$item['item_id']]['limit_fee']) { - if ($isCheck) { - throw new ResourceException($item['item_name'].'超过限额'); - } else { - $orderData['extraTips'] = $item['item_name'].'超过限额'; - } - } + $limitLines = []; + foreach ($orderData['items'] as $item) { + $limitLines[] = [ + 'item_id' => $item['item_id'], + 'num' => (int) $item['num'], + 'item_fee' => (int) $item['item_fee'], + ]; + } + try { + EmployeePurchaseItemLimitValidator::assertWithPreloadedData($activityItemList, $memberActivityItemsAggregateList, $limitLines); + } catch (ResourceException $e) { + if ($isCheck) { + throw $e; } + $orderData['extraTips'] = $e->getMessage(); } $memberActivityAggregateService = new MemberActivityAggregateService(); @@ -219,9 +219,9 @@ class NormalOrderService extends AbstractNormalOrder if ($this->activityInfo['minimum_amount'] > 0 && $orderData['item_fee'] < $this->activityInfo['minimum_amount']) { if ($isCheck) { - throw new ResourceException('订单商品定额不能少于'.bcdiv($this->activityInfo['minimum_amount'], 100, 2).'元'); + throw new ResourceException('订单商品金额不能少于'.bcdiv($this->activityInfo['minimum_amount'], 100, 2).'元'); } else { - $orderData['extraTips'] = '订单商品定额不能少于'.bcdiv($this->activityInfo['minimum_amount'], 100, 2).'元'; + $orderData['extraTips'] = '订单商品金额不能少于'.bcdiv($this->activityInfo['minimum_amount'], 100, 2).'元'; } } @@ -274,6 +274,68 @@ class NormalOrderService extends AbstractNormalOrder } } + /** + * 订单事务提交前扣减口令企业参与名额(与 {@see PassphraseParticipateQuotaRedisService} 一致;失败则整单回滚)。 + * + * @param array $orderData + * @param array $params + */ + public function beforeOrderCreateCommit($orderData, $params) + { + $this->participateQuotaRecordUserAfterOrderCommit = false; + $companyId = (int) ($orderData['company_id'] ?? 0); + $activityId = (int) ($this->activityInfo['id'] ?? $params['activity_id'] ?? 0); + $enterpriseId = (int) ($this->orderRelData['enterprise_id'] ?? $params['enterprise_id'] ?? 0); + $userId = (int) ($orderData['user_id'] ?? 0); + if ($companyId <= 0 || $activityId <= 0 || $enterpriseId <= 0 || $userId <= 0) { + return; + } + $quotaSvc = new PassphraseParticipateQuotaRedisService(); + if (!$quotaSvc->isApplicable($companyId, $activityId, $enterpriseId)) { + return; + } + $em = app('registry')->getManager('default'); + /** @var \EmployeePurchaseBundle\Repositories\ActivityEnterpriseParticipateUserRepository $exemptRepo */ + $exemptRepo = $em->getRepository(ActivityEnterpriseParticipateUser::class); + if ($exemptRepo->existsForUser($companyId, $activityId, $enterpriseId, $userId)) { + return; + } + if (!$quotaSvc->tryConsumeSlot($companyId, $activityId, $enterpriseId)) { + throw new ResourceException('该企业在本活动下的参与名额已满'); + } + /** @var \EmployeePurchaseBundle\Repositories\OrdersRelActivityRepository $relRepo */ + $relRepo = $em->getRepository(OrdersRelActivity::class); + $relRepo->updateOneBy( + ['order_id' => $orderData['order_id'], 'company_id' => $companyId], + ['participate_quota_order_consumed' => 1] + ); + $this->participateQuotaRecordUserAfterOrderCommit = true; + } + + /** + * 订单已提交后写入「已占用名额用户」,后续同活动+企业不再校验/扣减名额。 + * + * @param array $orderData + */ + public function afterCreateOrder(array $orderData): void + { + if (!$this->participateQuotaRecordUserAfterOrderCommit) { + return; + } + $this->participateQuotaRecordUserAfterOrderCommit = false; + $companyId = (int) ($orderData['company_id'] ?? 0); + $activityId = (int) ($this->activityInfo['id'] ?? $orderData['act_id'] ?? 0); + $enterpriseId = (int) ($this->orderRelData['enterprise_id'] ?? 0); + $userId = (int) ($orderData['user_id'] ?? 0); + if ($companyId <= 0 || $activityId <= 0 || $enterpriseId <= 0 || $userId <= 0) { + return; + } + $em = app('registry')->getManager('default'); + /** @var \EmployeePurchaseBundle\Repositories\ActivityEnterpriseParticipateUserRepository $exemptRepo */ + $exemptRepo = $em->getRepository(ActivityEnterpriseParticipateUser::class); + $exemptRepo->insertIgnore($companyId, $activityId, $enterpriseId, $userId); + } + public function emptyCart($params) { $cartService = new CartService(); @@ -486,6 +548,16 @@ class NormalOrderService extends AbstractNormalOrder foreach ($orderData['items'] as $item) { $memberActivityItemsAggregateService->minusItemAggregate($orderData['company_id'], $orderData['enterprise_id'], $orderData['act_id'], $orderData['user_id'], $item['item_id'], $item['item_fee'], $item['num']); } + + if (!empty($orderData['participate_quota_order_consumed'])) { + $quotaSvc = new PassphraseParticipateQuotaRedisService(); + $relActivityId = (int) ($orderData['act_id'] ?? $orderData['activity_id'] ?? 0); + $quotaSvc->releaseOneSlot( + (int) $orderData['company_id'], + $relActivityId, + (int) ($orderData['enterprise_id'] ?? 0) + ); + } } /** diff --git a/src/EmployeePurchaseBundle/Services/PassphraseParticipateQuotaRedisService.php b/src/EmployeePurchaseBundle/Services/PassphraseParticipateQuotaRedisService.php new file mode 100644 index 0000000..f2900b8 --- /dev/null +++ b/src/EmployeePurchaseBundle/Services/PassphraseParticipateQuotaRedisService.php @@ -0,0 +1,165 @@ +getManager('default'); + /** @var \EmployeePurchaseBundle\Repositories\ActivitiesRepository $actRepo */ + $actRepo = $em->getRepository(Activities::class); + $activity = $actRepo->getInfo(['company_id' => $companyId, 'id' => $activityId]); + if (empty($activity) || empty($activity['is_passphrase_enabled'])) { + return false; + } + /** @var \EmployeePurchaseBundle\Repositories\ActivityPassphraseEnterprisesRepository $pRepo */ + $pRepo = $em->getRepository(ActivityPassphraseEnterprises::class); + $rows = $pRepo->getLists([ + 'company_id' => $companyId, + 'activity_id' => $activityId, + 'enterprise_id' => $enterpriseId, + ], 'participate_quota'); + if (empty($rows)) { + return false; + } + + return (int) ($rows[0]['participate_quota'] ?? 0) > 0; + } + + /** + * 从 DB 重算并写入剩余名额(不参与 Lua,仅 SET)。 + */ + public function warmEnterprise(int $companyId, int $activityId, int $enterpriseId, int $participateQuota): void + { + if ($participateQuota <= 0) { + app('redis')->del([self::redisKey($companyId, $activityId, $enterpriseId)]); + + return; + } + $em = app('registry')->getManager('default'); + /** @var \EmployeePurchaseBundle\Repositories\ActivityEnterpriseBehaviorLogRepository $logRepo */ + $logRepo = $em->getRepository(ActivityEnterpriseBehaviorLog::class); + $bindUsed = $logRepo->countBindEventsForActivityEnterprise($companyId, $activityId, $enterpriseId); + $orderUsed = $this->countNonCancelledEmployeePurchaseOrders($companyId, $activityId, $enterpriseId); + $remain = $participateQuota - $bindUsed - $orderUsed; + if ($remain < 0) { + $remain = 0; + } + app('redis')->set(self::redisKey($companyId, $activityId, $enterpriseId), (string) $remain); + } + + public function deleteKey(int $companyId, int $activityId, int $enterpriseId): void + { + app('redis')->del([self::redisKey($companyId, $activityId, $enterpriseId)]); + } + + /** + * 确保 key 存在后再扣减;返回是否扣减成功。 + */ + public function tryConsumeSlot(int $companyId, int $activityId, int $enterpriseId): bool + { + if (!$this->isApplicable($companyId, $activityId, $enterpriseId)) { + return true; + } + $key = self::redisKey($companyId, $activityId, $enterpriseId); + $redis = app('redis'); + $cur = $redis->get($key); + if ($cur === false || $cur === null) { + $this->warmFromPassphraseRow($companyId, $activityId, $enterpriseId); + } + $r = (int) $redis->eval(self::luaConsume(), 1, $key); + if ($r === -1) { + $this->warmFromPassphraseRow($companyId, $activityId, $enterpriseId); + $r = (int) $redis->eval(self::luaConsume(), 1, $key); + } + + return $r === 1; + } + + /** + * 取消订单等场景释放一笔订单占用的名额(与 tryConsumeSlot 对称)。 + */ + public function releaseOneSlot(int $companyId, int $activityId, int $enterpriseId): void + { + if (!$this->isApplicable($companyId, $activityId, $enterpriseId)) { + return; + } + $key = self::redisKey($companyId, $activityId, $enterpriseId); + $redis = app('redis'); + $cur = $redis->get($key); + if ($cur === false || $cur === null) { + $this->warmFromPassphraseRow($companyId, $activityId, $enterpriseId); + } + $redis->incr($key); + } + + private function warmFromPassphraseRow(int $companyId, int $activityId, int $enterpriseId): void + { + $em = app('registry')->getManager('default'); + /** @var \EmployeePurchaseBundle\Repositories\ActivityPassphraseEnterprisesRepository $pRepo */ + $pRepo = $em->getRepository(ActivityPassphraseEnterprises::class); + $rows = $pRepo->getLists([ + 'company_id' => $companyId, + 'activity_id' => $activityId, + 'enterprise_id' => $enterpriseId, + ], 'participate_quota'); + if (empty($rows)) { + throw new ResourceException('口令企业配置不存在'); + } + $quota = (int) ($rows[0]['participate_quota'] ?? 0); + $this->warmEnterprise($companyId, $activityId, $enterpriseId, $quota); + } + + private function countNonCancelledEmployeePurchaseOrders(int $companyId, int $activityId, int $enterpriseId): int + { + $conn = app('registry')->getConnection('default'); + if ($conn->getDatabasePlatform()->getName() !== 'mysql') { + return 0; + } + $sql = 'SELECT COUNT(*) AS c FROM employee_purchase_orders_rel_activity r ' + .'INNER JOIN orders_normal_orders o ON o.order_id = r.order_id AND o.company_id = r.company_id ' + .'WHERE r.company_id = ? AND r.activity_id = ? AND r.enterprise_id = ? AND o.order_class = ? AND o.order_status <> ?'; + $row = $conn->fetchAssoc($sql, [$companyId, $activityId, $enterpriseId, 'employee_purchase', 'CANCEL']); + + return (int) ($row['c'] ?? 0); + } +} diff --git a/src/EmployeePurchaseBundle/Services/PassphraseVerifiedRedisService.php b/src/EmployeePurchaseBundle/Services/PassphraseVerifiedRedisService.php new file mode 100644 index 0000000..7d292df --- /dev/null +++ b/src/EmployeePurchaseBundle/Services/PassphraseVerifiedRedisService.php @@ -0,0 +1,75 @@ +0 有效)。 + * + * @param array $activity {@see ActivitiesService::getInfo} 单行 + */ + public function markVerified(int $companyId, int $activityId, int $enterpriseId, int $userId, array $activity): void + { + if ($userId < 1 || $companyId < 1 || $activityId < 1 || $enterpriseId < 1) { + return; + } + $ttl = $this->ttlSecondsUntilActivityEnds($activity); + app('redis')->setex(self::redisKey($companyId, $activityId, $enterpriseId, $userId), $ttl, '1'); + } + + public function isVerified(int $companyId, int $activityId, int $enterpriseId, int $userId): bool + { + if ($userId < 1 || $companyId < 1 || $activityId < 1 || $enterpriseId < 1) { + return false; + } + $v = app('redis')->get(self::redisKey($companyId, $activityId, $enterpriseId, $userId)); + + return $v !== null && $v !== false && $v !== ''; + } + + /** + * 清除某会员在某企业下、所有活动维度的口令已验 Redis 键;删员工后须重新 behavior-report 验口令。 + */ + public function forgetVerifiedForUserEnterprise(int $companyId, int $enterpriseId, int $userId): void + { + if ($userId < 1 || $companyId < 1 || $enterpriseId < 1) { + return; + } + $pattern = self::KEY_PREFIX.$companyId.':*:'.$enterpriseId.':'.$userId; + $redis = app('redis'); + $keys = $redis->keys($pattern); + if (!empty($keys)) { + $redis->del($keys); + } + } + + /** + * @param array $activity + */ + private function ttlSecondsUntilActivityEnds(array $activity): int + { + $end = (int) ($activity['employee_end_time'] ?? 0); + if (!empty($activity['if_relative_join'])) { + $re = (int) ($activity['relative_end_time'] ?? 0); + $end = max($end, $re); + } + $now = time(); + $sec = $end > $now ? ($end - $now + 86400) : 86400; + + return min(max($sec, 3600), 90 * 86400); + } +} diff --git a/src/EmployeePurchaseBundle/Services/StoreHomePageAccess.php b/src/EmployeePurchaseBundle/Services/StoreHomePageAccess.php new file mode 100644 index 0000000..bf76035 --- /dev/null +++ b/src/EmployeePurchaseBundle/Services/StoreHomePageAccess.php @@ -0,0 +1,24 @@ + $row + */ + public static function assertRowMatchesDealer(array $row, int $authDistributorId): void + { + if ($authDistributorId > 0 && (int) ($row['distributor_id'] ?? 0) !== $authDistributorId) { + throw new ResourceException('未查询到数据'); + } + } +} diff --git a/src/EmployeePurchaseBundle/Services/StoreHomePageService.php b/src/EmployeePurchaseBundle/Services/StoreHomePageService.php new file mode 100644 index 0000000..bdb4ab9 --- /dev/null +++ b/src/EmployeePurchaseBundle/Services/StoreHomePageService.php @@ -0,0 +1,440 @@ +repository = app('registry')->getManager('default')->getRepository(StoreHomePage::class); + $this->customizePageService = new CustomizePageService(); + $this->weappSettingRepository = getRepositoryLangue(WeappSetting::class); + } + + public static function internalCustomizePageName(int $companyId, int $distributorId): string + { + return 'ep_store_home_'.$companyId.'_'.$distributorId; + } + + /** 新建自定义页 row 的 page_name,带随机后缀以免同门店多条重名 */ + public static function uniqueInternalCustomizePageName(int $companyId, int $distributorId): string + { + return self::internalCustomizePageName($companyId, $distributorId).'_'.bin2hex(random_bytes(4)); + } + + /** + * wechat_weapp_setting.page_name:内购 enterprise 自定义页装修与 shopDecoration 一致为 custom_{id}。 + */ + public static function weappSettingPageNameForCustomizePage(int $weappCustomizePageId): ?string + { + if ($weappCustomizePageId <= 0) { + return null; + } + + return 'custom_'.$weappCustomizePageId; + } + + /** + * @param array|null $detail PagesTemplateServices::content 返回值 + */ + public static function pageTemplateDetailHasNonEmptyList(?array $detail): bool + { + return is_array($detail) + && isset($detail['list']) + && is_array($detail['list']) + && $detail['list'] !== []; + } + + /** + * @return array{list: array>, total_count: int} + */ + public function getList(int $companyId, int $authDistributorId, int $page, int $pageSize, ?int $filterDistributorId = null): array + { + $filter = ['company_id' => $companyId]; + if ($authDistributorId > 0) { + $filter['distributor_id'] = $authDistributorId; + } elseif ($filterDistributorId !== null && $filterDistributorId > 0) { + $filter['distributor_id'] = $filterDistributorId; + } + $result = $this->repository->lists($filter, '*', $page, $pageSize, ['id' => 'DESC']); + + return [ + 'list' => $result['list'], + 'total_count' => $result['total_count'], + ]; + } + + /** + * @param array $params + * @return array + */ + public function createRow(int $companyId, int $authDistributorId, array $params): array + { + $distributorId = (int) $authDistributorId; + $templateName = $params['template_name'] ?? ''; + if ($templateName === '') { + throw new ResourceException('模版名称不能为空'); + } + $pageName = $params['page_name'] ?? ''; + $pageDescription = $params['page_description'] ?? ''; + if ($pageName === '' || $pageDescription === '') { + throw new ResourceException('页面名称与描述不能为空'); + } + + $isOpen = self::normalizeIsOpen($params['is_open'] ?? true); + + $internalName = self::uniqueInternalCustomizePageName($companyId, $distributorId); + $cpParams = [ + 'template_name' => $templateName, + 'page_name' => $internalName, + 'page_description' => $pageDescription, + 'page_share_title' => $params['page_share_title'] ?? '', + 'page_share_desc' => $params['page_share_desc'] ?? '', + 'page_share_imageUrl' => $params['page_share_imageUrl'] ?? '', + 'is_open' => $isOpen, + 'page_type' => self::CUSTOMIZE_PAGE_TYPE, + 'regionauth_id' => 0, + 'company_id' => $companyId, + ]; + $cp = $this->customizePageService->create($cpParams); + $customizeId = (int) ($cp['id'] ?? 0); + if ($customizeId <= 0) { + throw new ResourceException('创建装修页失败'); + } + + $row = [ + 'company_id' => $companyId, + 'distributor_id' => $distributorId, + 'template_name' => $templateName, + 'page_name' => $pageName, + 'page_description' => $pageDescription, + 'page_share_title' => $params['page_share_title'] ?? null, + 'page_share_desc' => $params['page_share_desc'] ?? null, + 'page_share_imageUrl' => $params['page_share_imageUrl'] ?? null, + 'is_open' => $isOpen ? 1 : 0, + 'weapp_customize_page_id' => $customizeId, + ]; + + return $this->repository->create($row); + } + + /** + * @param array $params + * @return array + */ + public function updateRow(int $companyId, int $authDistributorId, int $id, array $params): array + { + $row = $this->repository->getInfo(['id' => $id, 'company_id' => $companyId]); + if (!$row) { + throw new ResourceException('未查询到数据'); + } + StoreHomePageAccess::assertRowMatchesDealer($row, $authDistributorId); + + $data = []; + foreach (['page_name', 'page_description', 'page_share_title', 'page_share_desc', 'page_share_imageUrl', 'template_name'] as $k) { + if (array_key_exists($k, $params)) { + $data[$k] = $params[$k]; + } + } + if (array_key_exists('is_open', $params)) { + $data['is_open'] = self::normalizeIsOpen($params['is_open']) ? 1 : 0; + } + if (isset($data['page_name']) && $data['page_name'] === '') { + throw new ResourceException('页面名称不能为空'); + } + if (isset($data['page_description']) && $data['page_description'] === '') { + throw new ResourceException('页面描述不能为空'); + } + + $updated = $this->repository->updateOneBy(['id' => $id, 'company_id' => $companyId], $data); + + $cid = (int) ($row['weapp_customize_page_id'] ?? 0); + if ($cid > 0) { + $cpUpdate = []; + if (isset($data['template_name'])) { + $cpUpdate['template_name'] = $data['template_name']; + } + if (isset($data['page_description'])) { + $cpUpdate['page_description'] = $data['page_description']; + } + if (isset($data['page_share_title'])) { + $cpUpdate['page_share_title'] = $data['page_share_title']; + } + if (isset($data['page_share_desc'])) { + $cpUpdate['page_share_desc'] = $data['page_share_desc']; + } + if (isset($data['page_share_imageUrl'])) { + $cpUpdate['page_share_imageUrl'] = $data['page_share_imageUrl']; + } + if (array_key_exists('is_open', $params)) { + $cpUpdate['is_open'] = self::normalizeIsOpen($params['is_open']); + } + if ($cpUpdate) { + $this->customizePageService->updateOneBy( + ['id' => $cid, 'company_id' => $companyId], + $cpUpdate + ); + } + } + + return $updated; + } + + public function getById(int $companyId, int $authDistributorId, int $id): array + { + $row = $this->repository->getInfo(['id' => $id, 'company_id' => $companyId]); + if (!$row) { + throw new ResourceException('未查询到数据'); + } + StoreHomePageAccess::assertRowMatchesDealer($row, $authDistributorId); + + return $row; + } + + /** + * ToC:按内购模版主键读取详情,并尽力解析装修侧 pages_template_id(与活动表 pages_template_id 字段语义不同)。 + * + * @return array + */ + public function getDetailForFront(int $companyId, int $authDistributorId, int $id, int $userId = 0, int $eActivityId = 0): array + { + $row = $this->repository->getInfo(['id' => $id, 'company_id' => $companyId]); + if (!$row) { + throw new ResourceException('未查询到数据'); + } + StoreHomePageAccess::assertRowMatchesDealer($row, $authDistributorId); + + $resolved = $this->resolvePagesTemplateForStoreHomeRow($companyId, $row); + + $base = [ + 'store_home_page_id' => $id, + 'company_id' => (int) ($row['company_id'] ?? 0), + 'distributor_id' => (int) ($row['distributor_id'] ?? 0), + 'page_name' => $row['page_name'] ?? '', + 'page_description' => $row['page_description'] ?? '', + 'page_share_title' => $row['page_share_title'] ?? '', + 'page_share_desc' => $row['page_share_desc'] ?? '', + 'page_share_imageUrl' => $row['page_share_imageUrl'] ?? '', + 'template_name' => (string) ($row['template_name'] ?? ''), + 'is_open' => $row['is_open'] ?? 0, + 'weapp_customize_page_id' => isset($row['weapp_customize_page_id']) ? (int) $row['weapp_customize_page_id'] : null, + 'resolved_pages_template_id' => $resolved['resolved_pages_template_id'], + 'template_meta' => $resolved['template_meta'], + 'pages_template_record' => $resolved['pages_template_record'], + 'page_template_detail' => null, + ]; + + $pid = (int) ($resolved['resolved_pages_template_id'] ?? 0); + if ($pid > 0) { + $distId = (int) ($row['distributor_id'] ?? 0); + $customizeId = isset($row['weapp_customize_page_id']) ? (int) $row['weapp_customize_page_id'] : 0; + $pagesTemplateServices = new PagesTemplateServices(); + + $indexParams = [ + 'company_id' => $companyId, + 'regionauth_id' => 0, + 'user_id' => $userId, + 'distributor_id' => $distId, + 'weapp_pages' => 'index', + 'template_name' => (string) ($row['template_name'] ?? ''), + 'version' => self::INDEX_DECORATION_SETTING_VERSION, + 'page' => '1', + 'page_size' => '50', + 'weapp_setting_id' => null, + 'goods_grid_tab_id' => null, + 'pages_template_id' => $pid, + 'e_activity_id' => $eActivityId, + ]; + + $customPageName = self::weappSettingPageNameForCustomizePage($customizeId); + if ($customPageName !== null) { + $customParams = array_merge($indexParams, [ + 'version' => self::CUSTOM_DECORATION_SETTING_VERSION, + 'weapp_setting_page_name' => $customPageName, + ]); + $detail = $pagesTemplateServices->content($customParams); + if (!self::pageTemplateDetailHasNonEmptyList($detail)) { + $customParams['weapp_setting_pages_template_id'] = 0; + $detail = $pagesTemplateServices->content($customParams); + } + if (!self::pageTemplateDetailHasNonEmptyList($detail)) { + app('log')->warning('[StoreHomePageService] enterprise_store_home 自定义页装修无匹配 wechat_weapp_setting,回退 index', [ + 'company_id' => $companyId, + 'store_home_page_id' => $id, + 'weapp_customize_page_id' => $customizeId, + 'weapp_setting_page_name' => $customPageName, + ]); + $detail = $pagesTemplateServices->content($indexParams); + } + $base['page_template_detail'] = $detail; + } else { + $base['page_template_detail'] = $pagesTemplateServices->content($indexParams); + } + } + + return $base; + } + + /** + * @param array $storeHomeRow + * + * @return array{ + * resolved_pages_template_id: int|null, + * template_meta: array|null, + * pages_template_record: array|null + * } + */ + private function resolvePagesTemplateForStoreHomeRow(int $companyId, array $storeHomeRow): array + { + $templateName = (string) ($storeHomeRow['template_name'] ?? ''); + if ($templateName === '') { + return [ + 'resolved_pages_template_id' => null, + 'template_meta' => null, + 'pages_template_record' => null, + ]; + } + + $distributorId = (int) ($storeHomeRow['distributor_id'] ?? 0); + $weappPages = $distributorId > 0 ? 'distributor_index' : 'index'; + + $pagesTemplateServices = new PagesTemplateServices(); + $listResult = $pagesTemplateServices->lists([ + 'company_id' => $companyId, + 'distributor_id' => $distributorId, + 'weapp_pages' => $weappPages, + 'page_no' => 1, + 'page_size' => 100, + ]); + + $rows = $listResult['list'] ?? []; + $picked = self::pickResolvedPagesTemplateRow(is_array($rows) ? $rows : [], $templateName); + + if ($picked === null) { + return [ + 'resolved_pages_template_id' => null, + 'template_meta' => null, + 'pages_template_record' => null, + ]; + } + + $pid = isset($picked['pages_template_id']) ? (int) $picked['pages_template_id'] : 0; + + return [ + 'resolved_pages_template_id' => $pid > 0 ? $pid : null, + 'template_meta' => [ + 'template_title' => $picked['template_title'] ?? '', + 'template_pic' => $picked['template_pic'] ?? '', + 'weapp_pages' => $picked['weapp_pages'] ?? '', + 'status' => $picked['status'] ?? null, + ], + 'pages_template_record' => $picked, + ]; + } + + /** + * 从 pages_template 列表中选出与内购模版 template_name 一致且启用的记录;多条时取列表顺序第一条。 + * + * @param array> $pagesTemplateListRows + * + * @return array|null + */ + public static function pickResolvedPagesTemplateRow(array $pagesTemplateListRows, string $templateName): ?array + { + if ($templateName === '') { + return null; + } + + $enabledFirst = []; + $anyMatch = []; + + foreach ($pagesTemplateListRows as $row) { + if (!is_array($row)) { + continue; + } + if (($row['template_name'] ?? '') !== $templateName) { + continue; + } + $anyMatch[] = $row; + if ((int) ($row['status'] ?? 0) === 1) { + $enabledFirst[] = $row; + } + } + + if ($enabledFirst !== []) { + return $enabledFirst[0]; + } + + if ($anyMatch !== []) { + return $anyMatch[0]; + } + + return null; + } + + public function deleteRow(int $companyId, int $authDistributorId, int $id): bool + { + $row = $this->repository->getInfo(['id' => $id, 'company_id' => $companyId]); + if (!$row) { + throw new ResourceException('未查询到数据'); + } + StoreHomePageAccess::assertRowMatchesDealer($row, $authDistributorId); + + $cid = (int) ($row['weapp_customize_page_id'] ?? 0); + if ($cid > 0) { + $pageInfo = $this->customizePageService->getInfoById($cid); + if ($pageInfo) { + $delParams = [ + 'template_name' => $pageInfo['template_name'], + 'company_id' => $companyId, + 'page_name' => 'custom_'.$cid, + ]; + $this->weappSettingRepository->deleteBy($delParams); + $this->customizePageService->deleteBy(['id' => $cid, 'company_id' => $companyId]); + } + } + + return $this->repository->deleteById($id); + } + + /** + * @param mixed $raw + */ + private static function normalizeIsOpen($raw): bool + { + if ($raw === 'false' || $raw === false || $raw === 0 || $raw === '0') { + return false; + } + + return true; + } +} diff --git a/src/EmployeePurchaseBundle/Support/ActivityListDisplayStatusQuery.php b/src/EmployeePurchaseBundle/Support/ActivityListDisplayStatusQuery.php new file mode 100644 index 0000000..0a70adc --- /dev/null +++ b/src/EmployeePurchaseBundle/Support/ActivityListDisplayStatusQuery.php @@ -0,0 +1,75 @@ + $filter + */ + public function exportData($filter) + { + $companyId = (int) ($filter['company_id'] ?? 0); + $activityId = (int) ($filter['activity_id'] ?? 0); + $distributorScopeId = isset($filter['distributor_id']) ? (int) $filter['distributor_id'] : null; + if ($companyId <= 0 || $activityId <= 0) { + throw new ResourceException('导出参数错误'); + } + + $activitiesService = new ActivitiesService(); + $sourceRows = $activitiesService->buildActivityEnterpriseQrcodeExportRows( + $companyId, + $activityId, + $distributorScopeId + ); + if (empty($sourceRows)) { + throw new ResourceException('导出有误,暂无数据导出'); + } + + $title = [ + 'enterprise_name' => '企业名称', + 'enterprise_sn' => '企业编码', + 'passphrase_code' => '企业口令码', + 'qrcode_url' => '企业小程序码下载地址', + ]; + $rows = []; + foreach ($sourceRows as $r) { + $rows[] = [ + 'enterprise_name' => (string) ($r[0] ?? ''), + 'enterprise_sn' => (string) ($r[1] ?? ''), + 'passphrase_code' => (string) ($r[2] ?? ''), + 'qrcode_url' => (string) ($r[3] ?? ''), + ]; + } + + $dataGenerator = (function () use ($rows) { + yield $rows; + })(); + $fileName = date('YmdHis').'_activity_'.$activityId.'_qrcode'; + $exportService = new ExportFileService(); + + return $exportService->exportCsv($fileName, $title, $dataGenerator, ['enterprise_sn']); + } +} diff --git a/src/EspierBundle/Services/Export/EmployeePurchaseActivityScanStatsExportService.php b/src/EspierBundle/Services/Export/EmployeePurchaseActivityScanStatsExportService.php new file mode 100644 index 0000000..04ac0ae --- /dev/null +++ b/src/EspierBundle/Services/Export/EmployeePurchaseActivityScanStatsExportService.php @@ -0,0 +1,108 @@ + $filter + */ + public function exportData($filter) + { + $companyId = (int) ($filter['company_id'] ?? 0); + $activityId = (int) ($filter['activity_id'] ?? 0); + $distributorScopeId = isset($filter['distributor_id']) ? (int) $filter['distributor_id'] : null; + if ($companyId <= 0 || $activityId <= 0) { + throw new ResourceException('导出参数错误'); + } + + $activitiesService = new ActivitiesService(); + $activityFilter = [ + 'company_id' => $companyId, + 'id' => $activityId, + ]; + if ($distributorScopeId !== null) { + $activityFilter['distributor_id'] = $distributorScopeId; + } + $activity = $activitiesService->getInfo($activityFilter); + $passphraseEnabled = !empty($activity['is_passphrase_enabled']); + + $service = new ActivityEnterpriseBehaviorLogService(); + $result = $service->getAggregatedStatsForAdmin($companyId, $activityId, $distributorScopeId); + $list = $result['list'] ?? []; + if (empty($list)) { + throw new ResourceException('导出有误,暂无数据导出'); + } + + $title = [ + 'enterprise_name' => '企业名称', + 'enterprise_sn' => '企业编码', + 'scan_count' => '扫码次数', + 'scan_user_count' => '扫码人数', + ]; + if ($passphraseEnabled) { + $title['passphrase_verify_user_count'] = '验证口令人数'; + } + $title['bind_user_count'] = '绑定人数'; + $title['order_user_count'] = '下单人数'; + + $rows = []; + $totals = [ + 'scan_count' => 0, + 'scan_user_count' => 0, + 'passphrase_verify_user_count' => 0, + 'bind_user_count' => 0, + 'order_user_count' => 0, + ]; + foreach ($list as $row) { + $totals['scan_count'] += (int) ($row['scan_count'] ?? 0); + $totals['scan_user_count'] += (int) ($row['scan_user_count'] ?? 0); + $totals['passphrase_verify_user_count'] += (int) ($row['passphrase_verify_user_count'] ?? 0); + $totals['bind_user_count'] += (int) ($row['bind_user_count'] ?? 0); + $totals['order_user_count'] += (int) ($row['order_user_count'] ?? 0); + + $line = [ + 'enterprise_name' => (string) ($row['enterprise_name'] ?? ''), + 'enterprise_sn' => (string) ($row['enterprise_sn'] ?? ''), + 'scan_count' => (int) ($row['scan_count'] ?? 0), + 'scan_user_count' => (int) ($row['scan_user_count'] ?? 0), + ]; + if ($passphraseEnabled) { + $line['passphrase_verify_user_count'] = (int) ($row['passphrase_verify_user_count'] ?? 0); + } + $line['bind_user_count'] = (int) ($row['bind_user_count'] ?? 0); + $line['order_user_count'] = (int) ($row['order_user_count'] ?? 0); + $rows[] = $line; + } + + $totalLine = [ + 'enterprise_name' => '合计', + 'enterprise_sn' => '', + 'scan_count' => $totals['scan_count'], + 'scan_user_count' => $totals['scan_user_count'], + ]; + if ($passphraseEnabled) { + $totalLine['passphrase_verify_user_count'] = $totals['passphrase_verify_user_count']; + } + $totalLine['bind_user_count'] = $totals['bind_user_count']; + $totalLine['order_user_count'] = $totals['order_user_count']; + $rows[] = $totalLine; + + $dataGenerator = (function () use ($rows) { + yield $rows; + })(); + $fileName = date('YmdHis').'_activity_'.$activityId.'_scan_stats'; + $exportService = new ExportFileService(); + + return $exportService->exportCsv($fileName, $title, $dataGenerator, ['enterprise_sn']); + } +} diff --git a/src/EspierBundle/Services/UploadFileService.php b/src/EspierBundle/Services/UploadFileService.php index 445c1a7..2bc8d55 100644 --- a/src/EspierBundle/Services/UploadFileService.php +++ b/src/EspierBundle/Services/UploadFileService.php @@ -221,6 +221,19 @@ class UploadFileService app('log')->error('handleUploadFile => ' . json_encode($errorData, 256)); } + if ($headerSuccess && isset($data['file_type']) && $data['file_type'] === 'employee_purchase_employees') { + try { + \EmployeePurchaseBundle\Services\EmployeesUploadService::assertImportDataRowsWithinLimit(count($results)); + } catch (BadRequestHttpException $e) { + $headerSuccess = false; + $errorLine++; + $headerTitle = $this->uploadFile->getHeaderTitle($companyId); + $columnNum = count($headerTitle['all']); + $errorData[] = array_merge(array_fill(0, $columnNum, ''), ['数据行数', $e->getMessage()]); + app('log')->error('handleUploadFile => ' . json_encode($errorData, 256)); + } + } + // 如果头部是正确的,才会处理到下一步 if ($headerSuccess) { $newAarray = array_chunk($results, 500, true); diff --git a/src/EspierBundle/Traits/GetExportServiceTraits.php b/src/EspierBundle/Traits/GetExportServiceTraits.php index f211177..0aef6fe 100644 --- a/src/EspierBundle/Traits/GetExportServiceTraits.php +++ b/src/EspierBundle/Traits/GetExportServiceTraits.php @@ -61,6 +61,8 @@ use EspierBundle\Services\Export\StatementDetailsExportService; use EspierBundle\Services\Export\DivisionExportService; use EspierBundle\Services\Export\DivisionDetailExportService; use EspierBundle\Services\Export\PurchaseEmployeesExportService; +use EspierBundle\Services\Export\EmployeePurchaseActivityQrcodeExportService; +use EspierBundle\Services\Export\EmployeePurchaseActivityScanStatsExportService; use BsPayBundle\Services\Export\BspayTradeExportService; use SupplierBundle\Services\Export\SupplierGoodsExportService; use SupplierBundle\Services\Export\SupplierOrderExportService; @@ -207,6 +209,12 @@ trait GetExportServiceTraits case 'employee_purchase_employees': $exportService = new PurchaseEmployeesExportService(); break; + case 'employee_purchase_activity_qrcode': + $exportService = new EmployeePurchaseActivityQrcodeExportService(); + break; + case 'employee_purchase_activity_scan_stats': + $exportService = new EmployeePurchaseActivityScanStatsExportService(); + break; case 'member_point_logs': $exportService = new PointMemberLogExportService(); break; diff --git a/src/OrdersBundle/Http/FrontApi/V1/Action/UserInvoice.php b/src/OrdersBundle/Http/FrontApi/V1/Action/UserInvoice.php index b2947fa..8fc8663 100644 --- a/src/OrdersBundle/Http/FrontApi/V1/Action/UserInvoice.php +++ b/src/OrdersBundle/Http/FrontApi/V1/Action/UserInvoice.php @@ -145,7 +145,7 @@ class UserInvoice extends Controller $data = $request->all(); if(isset($data['invoice_id']) && isset($data['invoice_status']) && $data['invoice_status'] == "cancel"){ $data['invoice_status'] = "cancel"; - $invoiceDetail = $this->invoiceService->getInvoiceDetail($data['invoice_id'],$authInfo); + $invoiceDetail = $this->invoiceService->getInvoiceDetail($data['invoice_id'],$authInfo['company_id']); if($invoiceDetail['invoice_status'] == "cancel" ){ throw new ResourceException('发票已取消'); } diff --git a/src/OrdersBundle/Http/FrontApi/V1/Action/WxappOrder.php b/src/OrdersBundle/Http/FrontApi/V1/Action/WxappOrder.php index e31cc8e..39365c2 100644 --- a/src/OrdersBundle/Http/FrontApi/V1/Action/WxappOrder.php +++ b/src/OrdersBundle/Http/FrontApi/V1/Action/WxappOrder.php @@ -1677,7 +1677,7 @@ class WxappOrder extends Controller if (isset($params['order_class']) && $params['order_class'] == 'employee_purchase') { $filter['order_class'] = $params['order_class']; } elseif (!in_array($filter['order_type'], ['bargain'])) { - $filter['order_class|in'] = ['normal', 'groups', 'seckill', 'shopguide', 'shopadmin', 'bargain', 'pointsmall', ExcardNormalOrderService::CLASS_NAME, 'drug']; + $filter['order_class|in'] = ['normal', 'groups', 'seckill', 'shopguide', 'shopadmin', 'bargain', 'pointsmall', ExcardNormalOrderService::CLASS_NAME, 'drug', 'employee_purchase']; } if (isset($params['order_status']) && $filter['order_type'] != 'service') { diff --git a/src/OrdersBundle/Providers/EventServiceProvider.php b/src/OrdersBundle/Providers/EventServiceProvider.php index 79e5c0a..53ffd18 100644 --- a/src/OrdersBundle/Providers/EventServiceProvider.php +++ b/src/OrdersBundle/Providers/EventServiceProvider.php @@ -70,6 +70,10 @@ class EventServiceProvider extends ServiceProvider 'OrdersBundle\Listeners\OrderFinishInvoiceListener', // 订单完成时更新发票结束时间 ], + 'OrdersBundle\Events\NormalOrderPaySuccessEvent' => [ + 'EmployeePurchaseBundle\Listeners\EmployeePurchaseOrderPaySuccessListener', + ], + // 'OrdersBundle\Events\TestEvent' => [ // 'SystemLinkBundle\Listeners\TradeFinishSendOme', // 订单发送到ome // 'SystemLinkBundle\Listeners\TradeRefundSendOme', // 退款申请发送到ome diff --git a/src/OrdersBundle/Services/OrderService.php b/src/OrdersBundle/Services/OrderService.php index ae9d8fd..dc7b17d 100644 --- a/src/OrdersBundle/Services/OrderService.php +++ b/src/OrdersBundle/Services/OrderService.php @@ -291,6 +291,9 @@ class OrderService 'params' => $params, ]; event(new OrderProcessLogEvent($orderProcessLog)); + if (method_exists($this->orderInterface, 'beforeOrderCreateCommit')) { + $this->orderInterface->beforeOrderCreateCommit($orderData, $params); + } $conn->commit(); } catch (\Exception $e) { $conn->rollback(); diff --git a/src/ThemeBundle/Services/PagesTemplateServices.php b/src/ThemeBundle/Services/PagesTemplateServices.php index 60622e4..94abfe9 100644 --- a/src/ThemeBundle/Services/PagesTemplateServices.php +++ b/src/ThemeBundle/Services/PagesTemplateServices.php @@ -712,6 +712,12 @@ class PagesTemplateServices */ public function content($params) { + $incomingWeappSettingPageName = $params['weapp_setting_page_name'] ?? null; + $incomingHasWeappSettingPagesTemplateId = array_key_exists('weapp_setting_pages_template_id', $params); + $incomingWeappSettingPagesTemplateId = $incomingHasWeappSettingPagesTemplateId + ? $params['weapp_setting_pages_template_id'] + : null; + $company_id = $params['company_id']; $user_id = $params['user_id']; $distributor_id = $params['distributor_id']; @@ -779,7 +785,7 @@ class PagesTemplateServices } //模板内容数据 - $params = [ + $contentParams = [ 'company_id' => $company_id, 'distributor_id' => $distributor_id, 'pages_template_id' => $result['pages_template_id'], @@ -792,8 +798,14 @@ class PagesTemplateServices 'goods_grid_tab_id' => $params['goods_grid_tab_id'], 'e_activity_id' => $params['e_activity_id'], ]; + if ($incomingWeappSettingPageName !== null && $incomingWeappSettingPageName !== '') { + $contentParams['weapp_setting_page_name'] = $incomingWeappSettingPageName; + } + if ($incomingHasWeappSettingPagesTemplateId) { + $contentParams['weapp_setting_pages_template_id'] = (int) $incomingWeappSettingPagesTemplateId; + } - $list = $this->templateContentByPage($params); + $list = $this->templateContentByPage($contentParams); // 按会员标签过滤组件 $list = $this->filterTemplateContentByMemberTags($company_id, $user_id, $list); @@ -1209,11 +1221,15 @@ class PagesTemplateServices $page_size = $params['page_size']; $weapp_setting_id = $params['weapp_setting_id']; $goods_grid_tab_id = $params['goods_grid_tab_id']; + $pageNameForWeappSetting = $params['weapp_setting_page_name'] ?? 'index'; + $settingPagesTemplateId = array_key_exists('weapp_setting_pages_template_id', $params) + ? (int) $params['weapp_setting_pages_template_id'] + : (int) ($params['pages_template_id'] ?? 0); // 获取小程序模板装修表中的指定的那条数据 $data = app('registry') ->getManager('default') ->getRepository(WeappSetting::class) - ->getParamByTempName($companyId, $params['template_name'], "index", "", $params['version'], $params['pages_template_id'], $weapp_setting_id); + ->getParamByTempName($companyId, $params['template_name'], $pageNameForWeappSetting, "", $params['version'], $settingPagesTemplateId, $weapp_setting_id); $list = []; if (!$data || !is_array($data)) { return $list; diff --git a/src/ThirdPartyBundle/Services/SaasCertCentre/CertService.php b/src/ThirdPartyBundle/Services/SaasCertCentre/CertService.php index b6a2786..8e7ae4d 100644 --- a/src/ThirdPartyBundle/Services/SaasCertCentre/CertService.php +++ b/src/ThirdPartyBundle/Services/SaasCertCentre/CertService.php @@ -240,9 +240,10 @@ class CertService } //保存绑定关系 $this->saveShopNode($bindData); - if ($node_type == 'ecos.ome') { + // 不限制node_type + // if ($node_type == 'ecos.ome') { app('redis')->set($this->genErpBindReidsId(), $bindData['node_id']); - } + // } $msg = 'succ'; return true; } elseif ($data['status'] == 'unbind') { diff --git a/src/ThirdPartyBundle/Services/SaasErpCentre/OrderService.php b/src/ThirdPartyBundle/Services/SaasErpCentre/OrderService.php index bccdfb7..fa076f3 100644 --- a/src/ThirdPartyBundle/Services/SaasErpCentre/OrderService.php +++ b/src/ThirdPartyBundle/Services/SaasErpCentre/OrderService.php @@ -34,6 +34,62 @@ class OrderService { } + /** + * 不推送 Saas OMS 的支付方式(黑名单)。 + * + * @var string[] + */ + private const OMS_PUSH_SKIP_PAY_TYPES = [ + 'localPay', + ]; + + /** + * @param string $payType 支付编码或空字符串 + */ + private function shouldSkipOmsPushForPayType(string $payType): bool + { + return $payType !== '' && in_array($payType, self::OMS_PUSH_SKIP_PAY_TYPES, true); + } + + /** + * OMS 展示用支付方式文案;映射表未收录时使用原始 payType。 + */ + private function resolveOmsPaymentDisplayLabel(string $payType): string + { + if ($payType === '') { + return ''; + } + $labels = $this->getOmsPaymentDisplayLabels(); + + return $labels[$payType] ?? $payType; + } + + /** + * @return array + */ + private function getOmsPaymentDisplayLabels(): array + { + return [ + 'amorepay' => '微信支付(amorepay)', + 'wxpay' => '微信支付', + 'wxpayh5' => '微信支付', + 'wxpayjs' => '微信支付', + 'wxpayapp' => '微信支付', + 'wxpaypos' => '微信条码支付', + 'wxpaypc' => '微信PC支付', + 'pos' => '刷卡', + 'point' => '积分', + 'dhpoint' => '积分', + 'deposit' => '预存款支付', + 'alipay' => '支付宝支付', + 'alipayh5' => '支付宝支付', + 'alipayapp' => '支付宝支付', + 'alipaypos' => '支付宝条码支付', + 'alipaymini' => '支付宝小程序', + 'hfpay' => '汇付支付', + ]; + } + /** * 订单结构体 * @@ -69,6 +125,12 @@ class OrderService return false; } + $effectivePayType = $tradeInfo['payType'] ?? $orderInfo['pay_type'] ?? ''; + if ($this->shouldSkipOmsPushForPayType($effectivePayType)) { + app('log')->debug("saaserp ".__FUNCTION__.",".__LINE__.", skip OMS for pay_type===>: ".$effectivePayType."\n"); + return false; + } + // 获取买家信息 $member_info = $this->__formatMemberInfo($orderInfo); @@ -115,25 +177,7 @@ class OrderService $ship_status = 'SHIP_FINISH'; } - $payment_type = [ - 'amorepay' => '微信支付(amorepay)', - 'wxpay' => '微信支付', - 'wxpayh5' => '微信支付', - 'wxpayjs' => '微信支付', - 'wxpayapp' => '微信支付', - 'wxpaypos' => '微信条码支付', - 'wxpaypc' => '微信PC支付', - 'pos' => '刷卡', - 'point' => '积分', - 'dhpoint' => '积分', - 'deposit' => '预存款支付', - 'alipay' => '支付宝支付', - 'alipayh5' => '支付宝支付', - 'alipayapp' => '支付宝支付', - 'alipaypos' => '支付宝条码支付', - 'alipaymini' => '支付宝小程序', - 'hfpay' => '汇付支付', - ]; + $paymentDisplayLabel = $this->resolveOmsPaymentDisplayLabel($tradeInfo['payType'] ?? $orderInfo['pay_type'] ?? ''); // 优惠信息 $promotion_details = $this->__getPmtDetail($orderInfo); @@ -175,7 +219,7 @@ class OrderService 'discount_fee' => 0, 'is_cod' => 'false', 'payment_tid' => $tradeInfo['payType'] ?? $orderInfo['pay_type'], - 'payment_type' => $payment_type[$tradeInfo['payType'] ?? $orderInfo['pay_type']] ?? '', + 'payment_type' => $paymentDisplayLabel, 'orders_number' => 1,// 子单商品总数量 暂定 'buyer_uname' => $member_info['uname'], 'buyer_name' => isset($member_info['name']) ? trim($member_info['name']) : $member_info['uname'], @@ -334,36 +378,18 @@ class OrderService return []; } - $pay_type = [ - 'amorepay' => '微信支付(amorepay)', - 'wxpay' => '微信支付', - 'wxpayh5' => '微信支付', - 'wxpayjs' => '微信支付', - 'wxpayapp' => '微信支付', - 'wxpaypos' => '微信条码支付', - 'wxpaypc' => '微信PC支付', - 'pos' => '刷卡', - 'point' => '积分', - 'dhpoint' => '积分', - 'deposit' => '预存款支付', - 'alipay' => '支付宝支付', - 'alipayh5' => '支付宝支付', - 'alipayapp' => '支付宝支付', - 'alipaypos' => '支付宝条码支付', - 'alipaymini' => '支付宝小程序', - 'hfpay' => '汇付支付', - ]; + $payLabel = $this->resolveOmsPaymentDisplayLabel($tradeInfo['payType'] ?? ''); $payment_lists['payment_list'][] = [ 'tid' => $tradeInfo['orderId'], 'payment_id' => $tradeInfo['tradeId'], - 'seller_bank' => $pay_type[$tradeInfo['payType']], + 'seller_bank' => $payLabel, 'seller_account' => $tradeInfo['openId'], 'buyer_account' => $tradeInfo['mchId'], 'currency' => 'CNY', 'paycost' => '0.000', 'pay_type' => 'online', - 'payment_name' => $pay_type[$tradeInfo['payType']], + 'payment_name' => $payLabel, 'payment_code' => $tradeInfo['payType'], 't_begin' => date('Y-m-d H:i:s', $tradeInfo['timeStart']), 't_end' => date('Y-m-d H:i:s', $tradeInfo['timeExpire']), @@ -386,20 +412,11 @@ class OrderService return false; } - $pay_type = [ - 'amorepay' => '微信支付(amorepay)', - 'wxpay' => '微信支付', - 'deposit' => '预存款支付', - 'pos' => '刷卡', - 'point' => '积分', - 'dhpoint' => '积分', - ]; - $order_payments[] = [ 'trade_no' => $payments['tradeId'], 'account' => trim($payments['body']), 'pay_account' => $payments['mchId'], - 'paymethod' => $pay_type[$payments['payType']], + 'paymethod' => $this->resolveOmsPaymentDisplayLabel($payments['payType'] ?? ''), 'money' => bcdiv($payments['payFee'], 100, 2), 'memo' => trim($payments['detail']), 'paycost' => '0.00', @@ -511,6 +528,12 @@ class OrderService return false; } + $effectivePayType = $tradeInfo['payType'] ?? $orderInfo['pay_type'] ?? ''; + if ($this->shouldSkipOmsPushForPayType($effectivePayType)) { + app('log')->debug("saaserp ".__FUNCTION__.",".__LINE__.", skip OMS for pay_type===>: ".$effectivePayType."\n"); + return false; + } + // 获取买家信息 $member_info = $this->__formatMemberInfo($orderInfo); @@ -556,25 +579,7 @@ class OrderService $ship_status = 'SHIP_FINISH'; } - $payment_type = [ - 'amorepay' => '微信支付(amorepay)', - 'wxpay' => '微信支付', - 'wxpayh5' => '微信支付', - 'wxpayjs' => '微信支付', - 'wxpayapp' => '微信支付', - 'wxpaypos' => '微信条码支付', - 'wxpaypc' => '微信PC支付', - 'pos' => '刷卡', - 'point' => '积分', - 'dhpoint' => '积分', - 'deposit' => '预存款支付', - 'alipay' => '支付宝支付', - 'alipayh5' => '支付宝支付', - 'alipayapp' => '支付宝支付', - 'alipaypos' => '支付宝条码支付', - 'alipaymini' => '支付宝小程序', - 'hfpay' => '汇付支付', - ]; + $paymentDisplayLabel = $this->resolveOmsPaymentDisplayLabel($tradeInfo['payType'] ?? $orderInfo['pay_type'] ?? ''); // 优惠信息 $promotion_details = $this->__getPmtDetail($orderInfo); @@ -616,7 +621,7 @@ class OrderService 'discount_fee' => 0, 'is_cod' => 'false', 'payment_tid' => $tradeInfo['payType'] ?? $orderInfo['pay_type'], - 'payment_type' => $payment_type[$tradeInfo['payType'] ?? $orderInfo['pay_type']] ?? '', + 'payment_type' => $paymentDisplayLabel, 'orders_number' => 1,// 子单商品总数量 暂定 'buyer_uname' => $member_info['uname'], 'buyer_name' => isset($member_info['name']) ? trim($member_info['name']) : $member_info['uname'], diff --git a/src/WechatBundle/Http/Api/V1/Action/CustomizePage.php b/src/WechatBundle/Http/Api/V1/Action/CustomizePage.php index 63b2e6e..4f8ab13 100644 --- a/src/WechatBundle/Http/Api/V1/Action/CustomizePage.php +++ b/src/WechatBundle/Http/Api/V1/Action/CustomizePage.php @@ -77,7 +77,7 @@ class CustomizePage extends Controller 'template_name' => ['required', '模版名称不能为空'], 'page_name' => ['required', '自定义页面名称不能为空'], 'page_description' => ['required', '页面描述不能为空'], - 'page_type' => ['in:normal,salesperson,category,my,task_share', '页面类型不能为空'], + 'page_type' => ['in:normal,salesperson,category,my,task_share,enterprise_store_home', '页面类型不能为空'], ]; $error = validator_params($params, $rules); if ($error) { diff --git a/tests/ActivityEnterpriseBehaviorLogServiceRecordEmployeePurchaseOrderPaidTest.php b/tests/ActivityEnterpriseBehaviorLogServiceRecordEmployeePurchaseOrderPaidTest.php new file mode 100644 index 0000000..3aab855 --- /dev/null +++ b/tests/ActivityEnterpriseBehaviorLogServiceRecordEmployeePurchaseOrderPaidTest.php @@ -0,0 +1,169 @@ +app)) { + $this->app->forgetInstance('registry'); + } + parent::tearDown(); + } + + /** + * @param array $extraMap + * + * @return ActivityEnterpriseBehaviorLogRepository&\PHPUnit\Framework\MockObject\MockObject + */ + private function bindRegistry( + EntityManagerInterface $em, + ObjectRepository $dummyRepo, + ActivityEnterpriseBehaviorLogRepository $logRepo, + array $extraMap = [] + ): ActivityEnterpriseBehaviorLogRepository { + $registry = new class($em) { + public function __construct(private EntityManagerInterface $em) + { + } + + public function getManager(string $name): EntityManagerInterface + { + return $this->em; + } + }; + $this->app->instance('registry', $registry); + + $map = [ + [Activities::class, $dummyRepo], + [ActivityEnterprises::class, $dummyRepo], + [ActivityEnterpriseBehaviorLog::class, $logRepo], + ]; + foreach ($extraMap as $pair) { + $map[] = $pair; + } + $em->method('getRepository')->willReturnMap($map); + + return $logRepo; + } + + public function testInsertsOrderLogWithoutQrBindWhenPaymentSuccessContextOk(): void + { + $em = $this->createMock(EntityManagerInterface::class); + $dummyRepo = $this->createMock(ObjectRepository::class); + $ordersRelRepo = $this->createMock(OrdersRelActivityRepository::class); + $ordersRelRepo->method('getInfo')->willReturn([ + 'activity_id' => 10, + 'enterprise_id' => 20, + 'user_id' => 30, + ]); + + $logRepo = $this->createMock(ActivityEnterpriseBehaviorLogRepository::class); + $this->bindRegistry($em, $dummyRepo, $logRepo, [[OrdersRelActivity::class, $ordersRelRepo]]); + + $logRepo->expects($this->once())->method('getLists')->willReturn([]); + $logRepo->expects($this->never())->method('existsBindLogWithBindChannel'); + $logRepo->expects($this->once())->method('insertRow')->with($this->callback(function (array $row) { + return $row['behavior_type'] === ActivityEnterpriseBehaviorLogService::BEHAVIOR_ORDER + && (int) $row['company_id'] === 1 + && (int) $row['activity_id'] === 10 + && (int) $row['enterprise_id'] === 20 + && (int) $row['user_id'] === 30 + && (int) $row['ref_id'] === 2001; + }))->willReturn(1); + + $orderSvc = $this->createMock(OrderAssociationService::class); + $orderSvc->method('getOrder')->with(1, '2001')->willReturn([ + 'order_type' => 'normal', + 'order_class' => 'employee_purchase', + ]); + + $svc = new ActivityEnterpriseBehaviorLogService(); + $svc->recordEmployeePurchaseOrderPaid(1, '2001', $orderSvc); + } + + public function testSkipsInsertWhenDuplicateOrderLogExists(): void + { + $em = $this->createMock(EntityManagerInterface::class); + $dummyRepo = $this->createMock(ObjectRepository::class); + $ordersRelRepo = $this->createMock(OrdersRelActivityRepository::class); + $ordersRelRepo->method('getInfo')->willReturn([ + 'activity_id' => 10, + 'enterprise_id' => 20, + 'user_id' => 30, + ]); + + $logRepo = $this->createMock(ActivityEnterpriseBehaviorLogRepository::class); + $this->bindRegistry($em, $dummyRepo, $logRepo, [[OrdersRelActivity::class, $ordersRelRepo]]); + + $logRepo->expects($this->once())->method('getLists')->willReturn([['id' => 99]]); + $logRepo->expects($this->never())->method('insertRow'); + + $orderSvc = $this->createMock(OrderAssociationService::class); + $orderSvc->method('getOrder')->willReturn([ + 'order_type' => 'normal', + 'order_class' => 'employee_purchase', + ]); + + $svc = new ActivityEnterpriseBehaviorLogService(); + $svc->recordEmployeePurchaseOrderPaid(1, '2001', $orderSvc); + } + + public function testNoInsertWhenNotEmployeePurchaseOrder(): void + { + $em = $this->createMock(EntityManagerInterface::class); + $dummyRepo = $this->createMock(ObjectRepository::class); + $logRepo = $this->createMock(ActivityEnterpriseBehaviorLogRepository::class); + $this->bindRegistry($em, $dummyRepo, $logRepo); + + $logRepo->expects($this->never())->method('getLists'); + $logRepo->expects($this->never())->method('insertRow'); + + $orderSvc = $this->createMock(OrderAssociationService::class); + $orderSvc->method('getOrder')->willReturn([ + 'order_type' => 'normal', + 'order_class' => 'normal', + ]); + + $svc = new ActivityEnterpriseBehaviorLogService(); + $svc->recordEmployeePurchaseOrderPaid(1, '2001', $orderSvc); + } + + public function testNoInsertWhenOrdersRelMissing(): void + { + $em = $this->createMock(EntityManagerInterface::class); + $dummyRepo = $this->createMock(ObjectRepository::class); + $ordersRelRepo = $this->createMock(OrdersRelActivityRepository::class); + $ordersRelRepo->method('getInfo')->willReturn([]); + + $logRepo = $this->createMock(ActivityEnterpriseBehaviorLogRepository::class); + $this->bindRegistry($em, $dummyRepo, $logRepo, [[OrdersRelActivity::class, $ordersRelRepo]]); + + $logRepo->expects($this->never())->method('getLists'); + $logRepo->expects($this->never())->method('insertRow'); + + $orderSvc = $this->createMock(OrderAssociationService::class); + $orderSvc->method('getOrder')->willReturn([ + 'order_type' => 'normal', + 'order_class' => 'employee_purchase', + ]); + + $svc = new ActivityEnterpriseBehaviorLogService(); + $svc->recordEmployeePurchaseOrderPaid(1, '2001', $orderSvc); + } +} diff --git a/tests/ActivityListDisplayStatusQueryTest.php b/tests/ActivityListDisplayStatusQueryTest.php new file mode 100644 index 0000000..8666ccd --- /dev/null +++ b/tests/ActivityListDisplayStatusQueryTest.php @@ -0,0 +1,41 @@ +assertNull(ActivityListDisplayStatusQuery::statusSlugsForFilterOrNull(null)); + $this->assertNull(ActivityListDisplayStatusQuery::statusSlugsForFilterOrNull('')); + } + + public function testCommaSeparated(): void + { + $this->assertSame( + ['warm_up', 'ongoing'], + ActivityListDisplayStatusQuery::statusSlugsForFilterOrNull('warm_up,ongoing') + ); + } + + public function testRepeatedArrayChunks(): void + { + $this->assertSame( + ['warm_up', 'ongoing'], + ActivityListDisplayStatusQuery::statusSlugsForFilterOrNull(['warm_up', 'ongoing']) + ); + $this->assertSame( + ['warm_up', 'ongoing'], + ActivityListDisplayStatusQuery::statusSlugsForFilterOrNull(['warm_up,ongoing', 'ongoing']) + ); + } + + public function testInvalidSlugThrows(): void + { + $this->expectException(ResourceException::class); + $this->expectExceptionMessage('status 参数不合法'); + ActivityListDisplayStatusQuery::statusSlugsForFilterOrNull('warm_up,invalid'); + } +} diff --git a/tests/EmployeePurchaseCartLimitFieldsStructureTest.php b/tests/EmployeePurchaseCartLimitFieldsStructureTest.php new file mode 100644 index 0000000..bb52eaf --- /dev/null +++ b/tests/EmployeePurchaseCartLimitFieldsStructureTest.php @@ -0,0 +1,31 @@ +getFileName(); + $this->assertNotFalse($file); + $lines = file($file, FILE_IGNORE_NEW_LINES); + $this->assertIsArray($lines); + $slice = array_slice($lines, $ref->getStartLine() - 1, $ref->getEndLine() - $ref->getStartLine() + 1); + + return implode("\n", $slice); + } + + public function testGetCartdataListBatchLoadsMemberAggregate(): void + { + $body = $this->methodBody(CartService::class, 'getCartdataList'); + $this->assertStringContainsString('MemberActivityItemsAggregateService', $body); + $this->assertStringContainsString('getLists', $body); + $this->assertStringContainsString("'aggregate_num'", $body); + $this->assertStringContainsString("'limit_num'", $body); + } +} diff --git a/tests/EmployeePurchaseItemLimitValidatorTest.php b/tests/EmployeePurchaseItemLimitValidatorTest.php new file mode 100644 index 0000000..4a0f937 --- /dev/null +++ b/tests/EmployeePurchaseItemLimitValidatorTest.php @@ -0,0 +1,92 @@ + $limitNum, 'limit_fee' => $limitFee, 'activity_price' => 100]; + } + + public function testAllowsWithinLimits(): void + { + $this->expectNotToPerformAssertions(); + EmployeePurchaseItemLimitValidator::assertWithPreloadedData( + ['1' => $this->activityRow(10, 100000)], + [], + [['item_id' => '1', 'num' => 1, 'item_fee' => 100]] + ); + } + + public function testQuantityOnlyUsesNumMessage(): void + { + $this->expectException(ResourceException::class); + $this->expectExceptionMessage(EmployeePurchaseItemLimitValidator::MSG_NUM); + EmployeePurchaseItemLimitValidator::assertWithPreloadedData( + ['1' => $this->activityRow(1, 100000)], + [], + [['item_id' => '1', 'num' => 2, 'item_fee' => 200]] + ); + } + + public function testFeeOnlyUsesFeeMessage(): void + { + $this->expectException(ResourceException::class); + $this->expectExceptionMessage(EmployeePurchaseItemLimitValidator::MSG_FEE); + EmployeePurchaseItemLimitValidator::assertWithPreloadedData( + ['1' => $this->activityRow(100, 100)], + [], + [['item_id' => '1', 'num' => 1, 'item_fee' => 200]] + ); + } + + public function testBothViolationsPreferFeeMessage(): void + { + $this->expectException(ResourceException::class); + $this->expectExceptionMessage(EmployeePurchaseItemLimitValidator::MSG_FEE); + EmployeePurchaseItemLimitValidator::assertWithPreloadedData( + ['1' => $this->activityRow(1, 100)], + [], + [['item_id' => '1', 'num' => 2, 'item_fee' => 500]] + ); + } + + public function testMultiSkuSecondLineFailsQuantity(): void + { + $this->expectException(ResourceException::class); + $this->expectExceptionMessage(EmployeePurchaseItemLimitValidator::MSG_NUM); + EmployeePurchaseItemLimitValidator::assertWithPreloadedData( + [ + '1' => $this->activityRow(10, 100000), + '2' => $this->activityRow(1, 100000), + ], + [], + [ + ['item_id' => '1', 'num' => 1, 'item_fee' => 100], + ['item_id' => '2', 'num' => 2, 'item_fee' => 200], + ] + ); + } + + public function testFixedMessagesDoNotContainItemName(): void + { + try { + EmployeePurchaseItemLimitValidator::assertWithPreloadedData( + ['9' => $this->activityRow(1, 100000)], + [], + [['item_id' => '9', 'num' => 5, 'item_fee' => 500]] + ); + $this->fail('expected exception'); + } catch (ResourceException $e) { + $msg = $e->getMessage(); + $this->assertTrue( + $msg === EmployeePurchaseItemLimitValidator::MSG_NUM + || $msg === EmployeePurchaseItemLimitValidator::MSG_FEE + ); + $this->assertStringNotContainsString('某某商品', $msg); + } + } +} diff --git a/tests/EmployeesUploadServiceRowLimitTest.php b/tests/EmployeesUploadServiceRowLimitTest.php new file mode 100644 index 0000000..ec289b8 --- /dev/null +++ b/tests/EmployeesUploadServiceRowLimitTest.php @@ -0,0 +1,27 @@ +expectNotToPerformAssertions(); + EmployeesUploadService::assertImportDataRowsWithinLimit(EmployeesUploadService::MAX_IMPORT_DATA_ROWS); + EmployeesUploadService::assertImportDataRowsWithinLimit(0); + } + + public function testAssertImportDataRowsWithinLimitThrowsWhenOverLimit(): void + { + $expected = sprintf( + '每次最多上传%d条员工数据(不含表头)...请减少后再提交', + EmployeesUploadService::MAX_IMPORT_DATA_ROWS + ); + $this->expectException(BadRequestHttpException::class); + $this->expectExceptionMessage($expected); + + EmployeesUploadService::assertImportDataRowsWithinLimit(EmployeesUploadService::MAX_IMPORT_DATA_ROWS + 1); + } +} diff --git a/tests/StoreHomePageAccessTest.php b/tests/StoreHomePageAccessTest.php new file mode 100644 index 0000000..8c0e6df --- /dev/null +++ b/tests/StoreHomePageAccessTest.php @@ -0,0 +1,26 @@ +expectException(ResourceException::class); + StoreHomePageAccess::assertRowMatchesDealer(['distributor_id' => 10], 99); + } + + public function testDealerMatchOk(): void + { + $this->expectNotToPerformAssertions(); + StoreHomePageAccess::assertRowMatchesDealer(['distributor_id' => 10], 10); + } + + public function testZeroAuthDistributorSkipsDealerCheck(): void + { + $this->expectNotToPerformAssertions(); + StoreHomePageAccess::assertRowMatchesDealer(['distributor_id' => 10], 0); + } +} diff --git a/tests/StoreHomePageServiceInternalNameTest.php b/tests/StoreHomePageServiceInternalNameTest.php new file mode 100644 index 0000000..38dfdde --- /dev/null +++ b/tests/StoreHomePageServiceInternalNameTest.php @@ -0,0 +1,46 @@ +assertSame( + 'ep_store_home_10_5', + StoreHomePageService::internalCustomizePageName(10, 5) + ); + } + + public function testUniqueInternalCustomizePageNameHasSuffix(): void + { + $name = StoreHomePageService::uniqueInternalCustomizePageName(10, 5); + $this->assertMatchesRegularExpression( + '/^ep_store_home_10_5_[0-9a-f]{8}$/', + $name + ); + } + + public function testUniqueInternalCustomizePageNameUsuallyDiffersBetweenCalls(): void + { + $a = StoreHomePageService::uniqueInternalCustomizePageName(10, 5); + $b = StoreHomePageService::uniqueInternalCustomizePageName(10, 5); + $this->assertNotSame($a, $b); + } + + public function testWeappSettingPageNameForCustomizePage(): void + { + $this->assertNull(StoreHomePageService::weappSettingPageNameForCustomizePage(0)); + $this->assertNull(StoreHomePageService::weappSettingPageNameForCustomizePage(-1)); + $this->assertSame('custom_103', StoreHomePageService::weappSettingPageNameForCustomizePage(103)); + } + + public function testPageTemplateDetailHasNonEmptyList(): void + { + $this->assertFalse(StoreHomePageService::pageTemplateDetailHasNonEmptyList(null)); + $this->assertFalse(StoreHomePageService::pageTemplateDetailHasNonEmptyList([])); + $this->assertFalse(StoreHomePageService::pageTemplateDetailHasNonEmptyList(['list' => []])); + $this->assertTrue(StoreHomePageService::pageTemplateDetailHasNonEmptyList(['list' => [['x' => 1]]])); + } +} diff --git a/tests/StoreHomePageTemplatePickTest.php b/tests/StoreHomePageTemplatePickTest.php new file mode 100644 index 0000000..e4d6693 --- /dev/null +++ b/tests/StoreHomePageTemplatePickTest.php @@ -0,0 +1,46 @@ + 'other', 'pages_template_id' => 1, 'status' => 1], + ['template_name' => 'yyk', 'pages_template_id' => 99, 'status' => 1], + ['template_name' => 'yyk', 'pages_template_id' => 100, 'status' => 0], + ]; + $row = StoreHomePageService::pickResolvedPagesTemplateRow($list, 'yyk'); + $this->assertNotNull($row); + $this->assertSame(99, (int) $row['pages_template_id']); + } + + public function testFallsBackToFirstMatchWhenNoneEnabled(): void + { + $list = [ + ['template_name' => 'yyk', 'pages_template_id' => 7, 'status' => 0], + ]; + $row = StoreHomePageService::pickResolvedPagesTemplateRow($list, 'yyk'); + $this->assertNotNull($row); + $this->assertSame(7, (int) $row['pages_template_id']); + } + + public function testReturnsNullWhenNoMatch(): void + { + $this->assertNull(StoreHomePageService::pickResolvedPagesTemplateRow([], 'yyk')); + $this->assertNull(StoreHomePageService::pickResolvedPagesTemplateRow( + [['template_name' => 'x', 'pages_template_id' => 1, 'status' => 1]], + 'yyk' + )); + } + + public function testEmptyTemplateNameReturnsNull(): void + { + $this->assertNull(StoreHomePageService::pickResolvedPagesTemplateRow( + [['template_name' => 'yyk', 'pages_template_id' => 1, 'status' => 1]], + '' + )); + } +} diff --git a/workwechat_cn.png b/workwechat_cn.png deleted file mode 100644 index c952efa..0000000 Binary files a/workwechat_cn.png and /dev/null differ diff --git a/workwechat_en.png b/workwechat_en.png deleted file mode 100644 index 2f5b689..0000000 Binary files a/workwechat_en.png and /dev/null differ