适用版本:EspoCRM 9.2.2+
很多 CRM 需求需要“定时触发”:到期提醒、日报生成、数据同步、数据质量巡检。自定义 Job 是正确姿势。
TL;DR
- 实现
JobDataLess 接口用于无参数定时任务
- 用依赖注入获取 EntityManager、EmailSender、Log 等服务
- 批量查询(BATCH_SIZE)避免内存溢出
- 去重机制防止重复发送提醒
- Admin 界面配置 Cron 表达式
1. 场景:定时任务的业务价值
很多 CRM 需求需要”定时触发”:
- 记录到期前 N 天发送提醒
- 每日生成销售报表
- 定期同步外部数据
- 清理过期数据
这类需求不适合用 Hook,因为 Hook 是同步的、响应式的。正确的做法是自定义 Job。
2. JobDataLess 接口与依赖注入
EspoCRM 提供了 JobDataLess 接口用于无参数的定时任务:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| <?php namespace Espo\Modules\MyModule\Jobs;
use Espo\Core\Job\JobDataLess; use Espo\Core\ORM\EntityManager; use Espo\Core\Utils\Log;
class ExpirationReminder implements JobDataLess { public function __construct( private EntityManager $entityManager, private Log $log ) {}
public function run(): void { $this->log->info('ExpirationReminder started');
$this->log->info('ExpirationReminder completed'); } }
|
3. 通用案例:到期提醒(不绑定具体项目)
3.1 业务需求(抽象化)
- 记录到期前 90/60/30/10/3/1 天发送提醒
- 提醒发送给负责人 + 相关用户
- 避免重复发送(去重机制)
3.2 批量查询(避免内存溢出)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| private const BATCH_SIZE = 100;
private function findItemsExpiringOnDate(\DateTimeInterface $targetDate, int $offset = 0): array { return $this->entityManager ->getRDBRepository('CExpiringItem') ->where([ 'endDate' => $targetDate->format('Y-m-d'), 'deleted' => false, ]) ->order('id', 'ASC') ->limit($offset, self::BATCH_SIZE) ->find(); }
|
3.3 去重机制(防止重复提醒)
原则:不要直接手写 CREATE TABLE。用元数据定义一个“提醒发送日志实体”,让系统在 rebuild 时创建表结构与索引。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
|
private function isReminderAlreadySent( string $itemId, string $userId, string $reminderType, \DateTimeInterface $reminderDate ): bool { return $this->entityManager ->getRDBRepository('CExpirationReminderLog') ->where([ 'itemId' => $itemId, 'userId' => $userId, 'reminderType' => $reminderType, 'reminderDate' => $reminderDate->format('Y-m-d'), 'deleted' => false, ]) ->count() > 0; }
|
对应的 entityDefs(示意,公开版):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| { "fields": { "itemId": { "type": "varchar", "len": 36, "required": true }, "userId": { "type": "varchar", "len": 36, "required": true }, "reminderType": { "type": "varchar", "len": 20, "required": true }, "reminderDate": { "type": "date", "required": true } }, "indexes": { "checkUnique": { "unique": true, "columns": ["itemId", "userId", "reminderType", "reminderDate", "deleted"] }, "reminderDate": { "columns": ["reminderDate", "deleted"] } } }
|
写入发送日志(成功发送后立刻写):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| private function writeReminderLog( string $itemId, string $userId, string $reminderType, \DateTimeInterface $reminderDate ): void { $logEntity = $this->entityManager->getNewEntity('CExpirationReminderLog'); $logEntity->set([ 'itemId' => $itemId, 'userId' => $userId, 'reminderType' => $reminderType, 'reminderDate' => $reminderDate->format('Y-m-d'), ]);
$this->entityManager->saveEntity($logEntity); }
|
3.4 邮件发送
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| use Espo\Core\Mail\EmailSender; use Espo\Entities\Email;
public function __construct( private EntityManager $entityManager, private EmailSender $emailSender, private Log $log ) {}
$emailEntity = $this->entityManager->getNewEntity(Email::ENTITY_TYPE); $emailEntity->setSubject('Item Expiring Soon'); $emailEntity->setBody($body); $emailEntity->addToAddress($userEmail); $this->emailSender->create()->send($emailEntity);
|
4. Job 的注册与调度
方式一:ClassFinder 自动发现
将 Job 类放在 custom/Espo/Custom/Jobs/ 或 custom/Espo/Modules/{Module}/Jobs/ 目录下,系统会自动发现。
方式二:metadata 配置
1 2 3 4 5 6
| { "ExpirationReminder": { "isSystem": false, "scheduling": "0 9,15 * * *" } }
|
在 Admin 界面配置:
- 进入 Admin → Scheduled Jobs
- 创建新任务,选择 Job:Expiration Reminder
- 设置 Cron 表达式:
0 9,15 * * *(每天 9:00 和 15:00 执行)
- 设置为 Active
5. 监控与排障
关键日志:
1 2 3 4 5
| [INFO] ExpirationReminder started with reminder days: [90,60,30,10,3,1] [INFO] Processing item: ITEM-123 (ID: xxx) expiring on 2025-03-15 [INFO] Sending reminder to recipient@example.com [SUCCESS] Successfully sent reminder email [ERROR] Failed to send email - SMTP timeout
|
验证建议:
- 在后台给 Job 做一个列表视图,按 createdAt 最近 7 天过滤
- 用日志定位“开始/处理/发送成功/发送失败”的链路是否完整