0%

EspoCRM定制第5篇:自定义 Job 开发——到期提醒通用模板(批量 + 去重 + 发信)

适用版本: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;

/**
* 用 ORM 查询,避免手写 SQL 绑死表结构/字段名。
* 这里假设你的自定义实体类型是 CExpiringItem,字段 endDate 是 date 类型。
*
* @return \Espo\ORM\Entity[]
*/
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;

// 在构造函数注入 EmailSender
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 界面配置

  1. 进入 Admin → Scheduled Jobs
  2. 创建新任务,选择 Job:Expiration Reminder
  3. 设置 Cron 表达式:0 9,15 * * *(每天 9:00 和 15:00 执行)
  4. 设置为 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 天过滤
  • 用日志定位“开始/处理/发送成功/发送失败”的链路是否完整