Nhóm chiến dịch và chuỗi kế thừa - Thiết kế BE

1. Mô hình và luồng chung

Một group có nhiều campaign độc lập hoặc nhiều chuỗi. Chuỗi là cây: mỗi campaign con có một nguồn trực tiếp, một nguồn có nhiều con. Group không điều khiển lịch gửi.

Group
  A: campaign gốc
    B: người chưa đạt mục tiêu sau A
      D: người chưa đạt mục tiêu sau B
    C: người gửi WhatsApp lỗi ở A
  E: campaign độc lập

Mỗi lần chạy A mở một kỳ. B/C/D có tối đa một lượt tự động trong kỳ đó. Tệp kế thừa lấy theo run nguồn, không lấy segment version mới nhất hoặc cộng dồn mọi lần chạy. Trích xuất segment ở bước 5 vẫn độc lập.

[Diagram]

Run hoàn thành khi xử lý gửi xong. Campaign hoàn thành khi hết lịch và không còn run chưa kết thúc. Conversion ghi nhận theo thời hạn riêng, không chặn hai trạng thái này. Tệp con đã chốt không đổi theo conversion phát sinh sau đó.

2. Thiết kế database

ID lưu String theo model hiện tại. Thời gian lưu theo convention hiện có; tính lịch dùng múi giờ ứng dụng thống nhất, không phụ thuộc timezone máy chạy scheduler.

2.1. Collection campaign_group - LAV-439

Field Kiểu Quy tắc
_id String ID nhóm
companyId String Lấy từ company đang thao tác, không tin companyId FE
name String Trim, 1–255 ký tự
nameKey String Trim, lowercase; dùng kiểm tra tên duy nhất
nameSearch String Lowercase, bỏ dấu; dùng tìm kiếm chứa chuỗi
description String Không bắt buộc, tối đa 500 ký tự
status Enum ACTIVE / DELETED
revision Long Bắt đầu 1; kiểm soát cập nhật và thay đổi thành viên
createdAt/By, updatedAt/By BaseModel Dùng audit hiện có

Quy tắc tên duy nhất: không phân biệt hoa thường; tìm kiếm bỏ dấu không đồng nghĩa tên duy nhất cũng bỏ dấu. Regex tìm kiếm phải escape input, không nhận regex từ FE.

Index:

Campaign lưu groupId, không lưu mảng campaign trong group hoặc bảng mapping. Các thao tác thêm/gỡ/chuyển/xóa thành viên phải cập nhật revision group trong cùng Mongo transaction để không có race "đang xóa nhóm nhưng người khác thêm campaign".

2.2. Bổ sung campaign - LAV-440/441/442

Field Kiểu Ý nghĩa
campaignGroupId String/null Nhóm hiện tại
parentCampaignId String/null Nguồn trực tiếp; null nếu gốc/độc lập
rootCampaignId String/null Gốc chuỗi; gốc trỏ chính nó, độc lập null
inheritance Object/null Chỉ campaign con có
chainRevision Long Chỉ có ý nghĩa ở gốc; kiểm soát thao tác toàn chuỗi
chainControl Enum NORMAL / PAUSED / STOPPED, lưu ở gốc
approvalPackageId String/null Gói duyệt hiện hành
needsReview Boolean Cấu hình con không còn hợp lệ
needsReviewReasons List Mã lỗi, sourceCampaignId và field cần sửa

inheritance:

{
  "type": "NOT_CONVERTED",
  "schedule": {
    "type": "AFTER_INTERVAL",
    "offsetDays": 2,
    "runTime": "09:00"
  }
}

type: NOT_CONVERTED / CONVERTED / FAILED_DELIVERY. Lịch AFTER_INTERVAL dùng offsetDays >= 1; FIXED_DAY_IN_PERIOD dùng: {mode: DAY_OF_MONTH, day: 1..31 hoặc LAST}, hoặc {mode: WEEKDAY, week: 1..4 hoặc LAST, weekday: MON..SUN}. Gốc tuần chỉ cho chọn weekday, không có week/day-of-month.

Ví dụ lịch cố định ngày cuối tháng:

{
  "type": "FIXED_DAY_IN_PERIOD",
  "fixedDay": { "mode": "DAY_OF_MONTH", "day": "LAST" },
  "runTime": "09:00"
}

Trong request, day/week dùng chuỗi "1".."31"/"1".."4" hoặc "LAST" để không trộn String và số trong cùng field. BE validate rồi chuyển sang giá trị lịch.

Giữ when cho lịch gốc. Con không đăng ký một lịch độc lập qua when. Con kế thừa định nghĩa goal từ gốc; giữ attributionWindowDays riêng. Nội dung, action, giới hạn và seed A/B của con độc lập.

Index:

Mỗi sửa cấu hình/quan hệ chuỗi phải so expectedChainRevision và tăng revision gốc trong transaction. Đổi nhánh giữa hai chuỗi phải cập nhật cả hai gốc. Đây là điểm ghi chung để tránh hai người đổi quan hệ đồng thời tạo vòng lặp.

2.3. Collection campaign_chain_approval - LAV-442

Code hiện duyệt trực tiếp campaign, chưa có bản ghi gói chứa snapshot toàn chuỗi. Thêm một collection gói duyệt; campaign độc lập vẫn dùng duyệt hiện tại.

Field Kiểu Ý nghĩa
_id String Mã gói
companyId, rootCampaignId String Phạm vi gói
submittedRevision Long Revision chuỗi sau khi gửi duyệt
status Enum SUBMITTED / APPROVED / REJECTED / WITHDRAWN
items List Snapshot từng campaign được gửi duyệt
submittedAt/By Audit Người gửi duyệt
decidedAt/By, reason Audit Người duyệt/từ chối
createdAt/By, updatedAt/By BaseModel Audit

Mỗi item chứa campaignId, parent/root, nội dung/action, goal, lịch, seed A/B, giới hạn và các cấu hình cần thực thi. Không chứa member hoặc lịch sử run. Snapshot một lần khi submit, không ghi đè khi reject/withdraw; gửi lại tạo gói mới. Validate kích thước document trước khi ghi; quá giới hạn Mongo thì trả lỗi, không duyệt một phần chuỗi.

Index (companyId, rootCampaignId, createdAt DESC) và (companyId, status, createdAt). Campaign approvalPackageId xác định gói hiện hành; request cũ không được duyệt chỉ vì gói đó vẫn tồn tại.

2.4. Bổ sung campaign_run - LAV-446

Field Kiểu Ý nghĩa
runType Enum Thêm INHERITED; giữ SCHEDULED/MANUAL/RERUN
periodKey String ID run gốc mở kỳ; run gốc thuộc chuỗi cũng lưu chính ID nó
sourceRunId String/null ID run nguồn trực tiếp của con
plannedAt DateTime/null Lịch đầu tiên tính được; không đổi khi dời
nextCheckAt DateTime Lượt kiểm tra tiếp của scheduler
waitDeadlineAt DateTime/null Hạn chờ, tính một lần
waitReason String/null SOURCE_PROCESSING / DELIVERY_DATA_PENDING / OCS_NOT_READY
skipReason String/null Lý do bỏ qua
expectedExecutionCount Long Số execution phải có sau dispatch, không phải số tin
dispatchedAt DateTime/null Đã phát hết phần dispatch

Giữ scheduledAt làm giờ thực thi hiện tại, startedAt là lần đầu bắt đầu dispatch, completedAt là kết thúc xử lý gửi. Reclaim lease không ghi đè startedAt. Giữ sourceTargetSnapshotId, targetSnapshotId, cursor và lease hiện có. Không đổi ý nghĩa rootRunId/parentCampaignRunId đang phục vụ CAM2.

Index:

Run gốc/độc lập không bị unique kế thừa tác động. Duplicate key khi tạo cùng một run con là đọc lại run đã có, không sinh ID mới để vượt index.

2.5. Target và execution

Target dùng campaign_target_snapshot hiện có, không thêm collection. Lưu vào selectionRuleSnapshot:

{
  "sourceRunId": "run-A-10",
  "sourceTargetSnapshotId": "target-A-10",
  "inheritType": "NOT_CONVERTED",
  "deliveryCutoffAt": "2026-10-03T02:00:00+07:00",
  "ocsSnapshotId": "ocs-20261003",
  "ocsDataAsOf": "2026-10-03T02:00:00+07:00"
}

Mốc OCS là thời điểm dữ liệu thực tế, không phải giờ BE bắt đầu call. WhatsApp lỗi không có metadata OCS. Target lưu attemptNo và buildId hiện có; materialized membership dùng đúng buildId của attempt đang công bố.

Execution cần index (campaignRunId, status); pending WhatsApp cần index campaignRunId. Giữ khóa command/worker hiện có nhưng phải bảo đảm replay không tạo execution trùng. Có thể dùng ID execution xác định từ run + recipient + chainCode cho run mới; không dựa vào non-unique index hoặc find-then-insert. Chuỗi/action thiếu chainCode phải được cấp mã ổn định khi validate.

3. LAV-439 - Tạo, sửa, xóa nhóm

3.1. API

Method/path Request chính Kết quả
GET /api/v1/campaign-groups query, page, size Nhóm và số campaign theo status
POST /api/v1/campaign-groups name, description Nhóm ACTIVE
PUT /api/v1/campaign-groups/{id} name, description, expectedRevision Nhóm đã cập nhật
DELETE /api/v1/campaign-groups/{id} expectedRevision Xóa mềm
POST /api/v1/campaign-groups/{id}/campaigns campaignIds addedIds, rejectedItems
DELETE /api/v1/campaign-groups/{id}/campaigns/{campaignId} expectedRevision Gỡ campaign độc lập

Dùng ResponseDto, phân trang và quyền hiện có. Group luôn lọc companyId ở BE. Danh sách campaign mở rộng group filter: ALL, NONE hoặc một groupId cụ thể.

3.2. Xử lý

Tạo/sửa: kiểm tra quyền, trim/validate, tính nameKey/nameSearch, ghi DB. Unique index xử lý race trùng tên; cập nhật sai revision trả 409, không ghi đè.

Thêm: tối đa 100 ID, bỏ ID lặp trong request. Mỗi campaign phải cùng company, chưa bị xóa, chưa thuộc nhóm và không thuộc chuỗi. Cập nhật có điều kiện groupId vẫn null; group phải ACTIVE trong transaction. Trả kết quả từng ID, không làm mất các mục đã thêm chỉ vì một campaign bị người khác chuyển nhóm.

Gỡ/chuyển nhóm: chỉ campaign độc lập; giữ trạng thái/lịch/segment/run. Chuyển nhóm thực hiện một transaction với kiểm tra hai group còn ACTIVE.

Xóa: trong transaction khóa cập nhật group, kiểm tra không còn chuỗi chưa kết thúc, đặt group DELETED và gỡ groupId khỏi campaign. Giữ parent/root của chuỗi đã kết thúc để lưu lịch sử; các campaign đó không trở thành độc lập.

Danh sách: lấy trang group rồi một aggregate campaign cho các group trên trang. Không đọc bảng member để đếm campaign.

3.3. Case

Case Xử lý
Trùng tên ACTIVE khác hoa thường 409; nhóm DELETED không chặn tên
Group khác company hoặc không tồn tại 404
Group bị xóa khi đang thêm campaign Không thêm vào nhóm đã xóa; trả lỗi
Một số campaign đã chuyển nhóm Trả rejectedItems, giữ các mục thành công
Gỡ campaign thuộc chuỗi 409, không thay dữ liệu
Xóa group còn chuỗi nháp/chờ/chạy/pause 409 và danh sách chuỗi chặn
Xóa group của chuỗi đã kết thúc Giữ campaign/run và quan hệ lịch sử
Người sửa gửi revision cũ 409, FE tải lại

4. LAV-440 - Tạo và cấu hình chuỗi kế thừa

4.1. Request và API

Mở rộng POST/PUT /api/v1/campaign hiện có:

{
  "campaignGroupId": "group-1",
  "parentCampaignId": "campaign-A",
  "expectedChainRevision": 3,
  "inheritance": {
    "type": "NOT_CONVERTED",
    "schedule": {
      "type": "AFTER_INTERVAL",
      "offsetDays": 2,
      "runTime": "09:00"
    }
  }
}

Các field campaign khác giữ contract hiện có. FE không được đặt rootCampaignId, chainControl hoặc goal rules kế thừa.

GET /campaign-groups/{id}/inheritance-sources trả nguồn hợp lệ. POST /campaign/inheritance/preview nhận nguồn/lịch và trả tối đa bốn kỳ: periodStart, nextRootAt, plannedSourceAt, plannedChildAt và lỗi theo field. Preview không dựng tệp và không trả 0 như một audience đã biết.

4.2. Lưu campaign con

  1. Kiểm tra quyền, group ACTIVE, nguồn cùng company/group.
  2. Nguồn là campaign theo danh sách, goal AUTO_OCS; gốc một lần/tuần/tháng. Không nối thêm con vào chuỗi đã submit/duyệt.
  3. Truy ngược parent tới gốc để kiểm tra vòng lặp và tính rootCampaignId.
  4. Validate lịch, action, param và cấu hình goal; lấy goal rules từ gốc, attributionWindowDays lấy từ cấu hình con.
  5. Transaction cập nhật revision group và gốc, insert/update con. Nguồn độc lập trở thành gốc: rootCampaignId = chính nó.
  6. Trả campaign và chainRevision mới; không tạo run/member khi lưu nháp.

Mục tiêu sửa trên gốc được truyền sang các con khi chuỗi còn sửa được. Tệp kế thừa giữ payload của nguồn; param con phải tồn tại trong dữ liệu có thể lấy từ nguồn hoặc là param campaign đã khai báo. Không tự call lại OCS để bổ sung mọi thuộc tính thiếu trong task này.

4.3. Lịch

4.4. Case

Case Xử lý
Nguồn chính nó, con/cháu của nó 400 field error, không lưu
Nguồn khác group/company 404/400 theo quyền truy cập, không tiết lộ dữ liệu
Nguồn không AUTO_OCS hoặc theo sự kiện 400 nguồn không hợp lệ
Gốc ngày/giờ hoặc kiểu chu kỳ không hỗ trợ 400 lịch gốc không hỗ trợ
AFTER_INTERVAL quá kỳ ngắn nhất 400, trả kỳ và ngày vi phạm
Child lịch ngày trong tháng nhưng gốc tuần 400 field error
Nguồn bị duyệt trong lúc lưu con Transaction thất bại, 409
Chưa biết audience khi tính dự toán Trả count/cost chưa xác định, không tính chính xác bằng 0

5. LAV-441 - Quản lý cây và sửa chuỗi

5.1. API cây

GET /campaign-groups/{id}/campaign-tree?query=&page=&size=. Response gồm totalRootItems và roots; mỗi node có campaignId, parent/root, status, needsReview/reasons, children và allowedActions.

Lấy trang node cấp cao nhất rồi lấy các con theo rootCampaignId của trang đó; dựng cây trong bộ nhớ từ metadata, không đệ quy query từng node. Tìm tên một con khớp thì trả cả chuỗi của con, đánh dấu matched. allowedActions chỉ phục vụ FE; BE vẫn validate lại lúc thao tác.

5.2. Sửa, đổi nguồn, xóa, nhân bản

Sửa: request expectedChainRevision. Lấy cả cây, áp thay đổi dự kiến và validate hậu duệ. Thay đổi làm chính nguồn mất điều kiện nguồn: chặn 400. Thay đổi làm con không hợp lệ: trả affectedCampaigns; FE xác nhận bằng acceptAffected=true kèm revision. Request xác nhận phải validate lại, không dùng danh sách FE làm kết quả.

Đổi nguồn: chỉ cây chưa duyệt. Không chọn node trong nhánh đang di chuyển. Transaction tăng revision cả gốc cũ/mới, đổi parent của đầu nhánh và root của toàn nhánh; truyền goal và validate lại. Gốc cũ hết con trở về độc lập. Cả hai root phải được cập nhật để tránh write skew tạo vòng lặp.

Xóa: chỉ leaf; transaction kiểm tra lại chưa có con và trạng thái cho xóa. Xóa con cuối cùng làm gốc nháp trở về độc lập. GroupId không tự mất.

Nhân bản: cấp ID cho mọi bản sao trước, lập mapping ID cũ -> mới rồi ghi parent/root trỏ vào bản sao. Gốc clone cả cây; con nháp clone một node với cùng nguồn. Bỏ package/run/target/status vận hành, đặt DRAFT và seed mới. Nếu group đã DELETED thì yêu cầu chọn group ACTIVE cho bản sao trước khi ghi.

5.3. Case

Case Xử lý
Bỏ WhatsApp ở nguồn có con FAILED_DELIVERY Cảnh báo; lưu xác nhận làm con needsReview
Gốc đổi goal nhưng vẫn AUTO_OCS Truyền rules mới, validate toàn cây
Đổi tháng -> tuần làm lịch con sai needsReview; không submit được đến khi sửa
Sửa nguồn làm con hợp lệ trở lại Tự gỡ needsReview
Hai người cùng sửa/đổi nguồn Một transaction thắng, request còn lại 409
Xóa node còn con 409
Sửa chuỗi SUBMITTED Withdraw gói trong transaction trước khi lưu nháp
Thêm con/đổi nguồn khi SUBMITTED Yêu cầu withdraw trước; không mở rộng gói đang chờ
Clone chuỗi Không mang ID quan hệ/package/run của chuỗi cũ

6. LAV-442 - Gửi duyệt, duyệt và khóa chuỗi

6.1. API

Endpoint Request
POST /campaign/{id}/submit expectedChainRevision; thuộc chuỗi thì submit cả cây
GET /campaign-chain-approvals/{id} Đọc gói và snapshot
POST /campaign-chain-approvals/{id}/approve expectedSubmittedRevision
POST /campaign-chain-approvals/{id}/reject expectedSubmittedRevision, reason
POST /campaign-chain-approvals/{id}/withdraw expectedSubmittedRevision

API duyệt lẻ hiện tại trả 409 nếu campaign thuộc gói chuỗi. Quyền duyệt theo company và quyền hệ thống hiện có, không chỉ kiểm tra người biết packageId.

6.2. Transaction duyệt

Submit: đọc cây cùng revision, validate toàn bộ, transaction cập nhật root revision, tạo package SUBMITTED và đặt mọi node SUBMITTED + packageId. Có lỗi một node thì không đổi trạng thái node nào.

Approve: kiểm tra package SUBMITTED, vẫn là package hiện hành, root revision khớp; validate snapshot và tính lại lịch theo thời điểm duyệt. Transaction đặt package APPROVED và toàn cây WAITING_SCHEDULE; chainControl NORMAL, khóa cấu hình. Tái sử dụng snapshot gói để tạo snapshot run; không dùng nội dung FE gửi khi duyệt.

Sau commit chỉ scheduler gốc đăng ký/tạo lịch. Nếu process chết ngay sau commit, job reconcile phát hiện gốc đã duyệt chưa có run occurrence và tạo bù có điều kiện. Con không đi qua generateCronJobs độc lập.

Reject/withdraw: transaction cập nhật toàn cây REJECTED/DRAFT và package REJECTED/WITHDRAWN. Gửi lại tạo package mới. Giữ package cũ để tra cứu audit.

Thêm WAITING_SCHEDULE vào enum campaign với mã mới, giữ mã 0–6 cũ. Run bắt đầu lần đầu chuyển campaign sang IN_PROGRESS; giữa các kỳ giữ IN_PROGRESS. Campaign độc lập không thay workflow duyệt hiện tại ngoài sửa điều kiện hoàn thành.

6.3. Case

Case Xử lý
Có node needsReview hoặc thiếu action 400, trả danh sách node/field lỗi
Người duyệt mở bản cũ, người tạo đã sửa 409, không duyệt snapshot cũ
Bấm approve hai lần Trả kết quả đã duyệt cùng gói, không tạo run lần hai
Duyệt package cũ sau submit lại 409
Duyệt muộn không còn lịch con khả thi Trả cảnh báo khi xem gói; approve vẫn được, ghi SKIPPED tương ứng
Gốc không còn occurrence nào FINISHED, con không chạy; trả lý do NO_FEASIBLE_RUN
Lỗi giữa cập nhật các campaign Rollback toàn transaction
Sửa hoặc duyệt lẻ qua API cũ Guard chung chặn, không phụ thuộc FE

7. LAV-446 - Chạy tự động và vận hành

7.1. Tạo kỳ và run con

Khi tạo run gốc đã duyệt: cấp ID trước, ghi periodKey = ID đó. Scheduler lấy cây đã khóa và bảo đảm mỗi con có run INHERITED cùng periodKey.

Run cháu liên kết run cha được tạo cho cùng kỳ, kể cả cha chưa chạy. Với AFTER_INTERVAL, khi nguồn chưa startedAt thì scheduledAt/plannedAt chưa xác định; scheduler dùng nextCheckAt để chờ mốc, không chạy ngay vì scheduledAt null. Khi nguồn có startedAt, tính plannedAt một lần, scheduledAt = plannedAt, waitDeadlineAt = plannedAt + 48 giờ.

Run gốc chỉ mở kỳ thực thi khi bắt đầu gửi. Run gốc SKIPPED/FAILED trước gửi không cung cấp audience cho con; các run con đã chuẩn bị được SKIPPED theo nguồn. MANUAL/RERUN không tự kích hoạt con trong phạm vi này; API phải thể hiện rõ và không cho chạy riêng một con đã khóa bằng luồng manual độc lập.

Ví dụ:

Run periodKey sourceRunId Lịch
A-10 A-10 null 01/10 09:00
B-10 A-10 A-10 Sau startedAt của A-10 2 ngày
C-10 A-10 B-10 Sau startedAt của B-10 1 ngày
A-11 A-11 null 01/11 09:00
B-11 A-11 A-11 Không đọc A-10

7.2. Scheduler và xử lý chờ

Scheduler lấy batch nhỏ run INHERITED ở SCHEDULED/WAITING_SOURCE/READY_TO_DISPATCH đến nextCheckAt. Claim có điều kiện lease rỗng/hết hạn, heartbeat khi build. Không gọi OCS hoặc dựng target trong thread scheduler chung.

Thứ tự kiểm tra:

  1. Đã terminal: bỏ qua.
  2. Chain STOPPED/PAUSED và chưa bắt đầu gửi: SKIPPED.
  3. Kỳ mới của gốc đã bắt đầu: SKIPPED NEW_PERIOD nếu chưa gửi.
  4. Nguồn FAILED/CANCELLED/SKIPPED: SKIPPED SOURCE_FAILED/SOURCE_SKIPPED.
  5. Chưa đến lịch: giữ SCHEDULED, nextCheckAt bằng giờ cần kiểm tra.
  6. Đến giờ nhưng nguồn/dữ liệu thiếu: WAITING_SOURCE, ghi waitReason.
  7. Hết waitDeadlineAt: SKIPPED SOURCE_TIMEOUT.
  8. Đủ điều kiện: tính giờ chạy tiếp theo cùng runTime nếu lượt đã phải dời. Nếu giờ đó vượt hạn chờ/ngày kết thúc hoặc kỳ đã hết thì SKIPPED.
  9. Đến giờ thực thi, kiểm tra lại và dựng target.

Kiểm tra chờ mỗi 5 phút; thông báo dời chỉ khi lần đầu vào WAITING_SOURCE hoặc scheduledAt thật sự thay đổi, không thông báo mỗi poll. Nguồn chưa tồn tại do tạo run chưa xong: repair/reconcile, không SKIPPED ngay.

Đề xuất biên lịch: kỳ lặp dùng khoảng mở ở cuối, con phải chạy trước kỳ kế tiếp. Ngày kết thúc campaign cũng là mốc không nhận lượt mới; một lượt đã bắt đầu trước mốc đó được xử lý nốt. Thời điểm deadline được kiểm tra theo giờ thực thi thực tế, không theo ngày dự kiến của FE.

7.3. Build target kế thừa

  1. Kiểm tra run nguồn đã xử lý gửi xong; lấy target nguồn FROZEN.
  2. Xác nhận dữ liệu delivery đã tới ClickHouse. Đối chiếu khóa kết quả từng action đã thực thi của run nguồn theo requestId/groupIndex/actionIndex, loại event lặp; không chỉ so count thô hoặc thấy một event là đủ. Kiểm tra theo batch, dừng khi gặp phần chưa đủ dữ liệu; không scan tất cả campaign mỗi poll.
  3. Đạt/Chưa đạt cần bản chụp OCS READY sau nguồn gửi xong và đủ mới cho ngày chạy. Ghim cùng snapshot cho mọi batch. WhatsApp lỗi không cần bước này.
  4. Tạo target BUILDING, lưu selectionRuleSnapshot/cursor, đọc nguồn theo trang.
  5. Lọc theo loại kế thừa, suppression, giới hạn và A/B. Ghi membership/payload bằng helper hiện có; không ghi segment_version_member/campaign_target_member cũ.
  6. Kiểm chứng member/payload và số lượng rồi công bố FROZEN, gắn target vào run.
  7. Run READY_TO_DISPATCH; scheduler gửi trigger hiện có, ghi nhận ack/log. Đến lúc dispatcher claim phải kiểm tra điều khiển gốc lần nữa.

Nguồn trạng thái SUBMITTED/IN_PROGRESS của campaign không thay thế ID run. Run nguồn COMPLETED không bảo đảm delivery đã tới ClickHouse. Kết quả OCS dùng cho kế thừa ngoài hạn báo cáo không được ghi ngược vào campaign_conversion_event.

7.4. Phân loại khách

Đối tượng Tập đầu vào
CONVERTED Người có kết quả gửi thành công của run nguồn và đạt goal
NOT_CONVERTED Người có kết quả gửi thành công của run nguồn và không đạt goal
FAILED_DELIVERY Người có tin WhatsApp kết quả cuối FAILED/BOUNCED
CONTROL nguồn Không vào tập kế thừa

Giữ SENT/UNRESOLVED là thành công theo semantics hiện có, không đổi thành xác nhận đến thiết bị. Xác định kết quả cuối theo một lệnh/action, không gộp mọi tin của người thành một event cuối bất kỳ.

Quy tắc cần nghiệp vụ chọn trước implement: nếu cùng người thành công Email nhưng lỗi WhatsApp, cho phép vào hai loại hay ưu tiên nhóm WhatsApp lỗi. Khi yêu cầu ba nhóm không trùng, phải chọn quy tắc ưu tiên; không tự suy ra từ tên nhóm. Hai campaign con cùng chọn NOT_CONVERTED vẫn có thể gửi cho cùng người: giới hạn một run mỗi kỳ không phải quy tắc loại trùng người giữa các nhánh.

7.5. Hoàn thành run/campaign

Dispatcher lưu expectedExecutionCount dựa trên số chain thực sự cần chạy của từng variant; CONTROL không tạo execution. Có lỗi đọc target/tạo execution/phát lệnh thì không bỏ người đó rồi tăng cursor như xử lý thành công.

Sau checkpoint cuối: DISPATCHED + dispatchedAt, không lập tức COMPLETED. Job completion kiểm tra execution theo run:

Đủ điều kiện: CAS DISPATCHED -> COMPLETED và completedAt. Run 90 thành công, 10 lỗi đã xác định vẫn COMPLETED. CONTROL toàn bộ hoặc tệp rỗng phải có đường terminal rõ ràng; con tệp rỗng SKIPPED EMPTY_AUDIENCE.

Campaign FINISHED khi không còn occurrence tương lai và không còn run đang xử lý. Gốc FINISHED không ghi FINISHED cho con còn lượt hợp lệ. Run FAILED lưu phần đã gửi; campaign không giữ IN_PROGRESS vô hạn khi không còn lượt tương lai.

Rà tất cả điểm gán status: CampaignRunTriggerConsumer, DispatchLeaseService, CronjobService, CampaignRunSchedulingService và CAM2. Conversion job chọn lượt còn trong thời hạn, kể cả campaign FINISHED hoặc CANCELLED đã gửi một phần. Luồng trích xuất WhatsApp lỗi được completion/reconcile kiểm tra lại; không chỉ phụ thuộc callback xóa pending vì callback có thể xảy ra trước run hoàn thành.

7.6. Pause/resume/stop và ranh giới gửi

POST /campaign/{rootId}/chain/pause|resume|stop nhận expectedChainRevision. API pause/stop hiện có điều hướng gốc sang cùng xử lý; thao tác trên con trả 409.

Transaction pause/stop cập nhật chainControl, tăng revision gốc, trạng thái các campaign chưa terminal và SKIPPED run chưa bắt đầu. Không đặt CANCELLED cho run đã gửi nếu quy tắc là gửi nốt.

Dispatcher bắt đầu run kế thừa bằng transaction kiểm tra root NORMAL, tăng chainRevision và claim run READY_TO_DISPATCH -> DISPATCHING, đặt startedAt một lần. Pause cũng ghi cùng root nên hai quyết định được tuần tự hóa:

Ở đây "bắt đầu" là bước claim được ghi bền vững trước lệnh đầu tiên, không là thời điểm HTTP provider trả về. Worker dùng quyết định của run đối với chuỗi; không dừng nhầm vì campaign PAUSED sau khi run đã được phép bắt đầu. Campaign độc lập giữ semantics pause/stop riêng.

Resume đặt NORMAL, khôi phục WAITING_SCHEDULE/IN_PROGRESS cho campaign còn hiệu lực; không mở lại SKIPPED. Stop không hoàn tác; vẫn ghi nhận conversion đến hạn. Gốc FINISHED được giữ nguyên khi điều khiển con còn chạy.

7.7. Restart, timeout và lỗi

Case Xử lý
Hai node tạo cùng run con Unique index thắng một insert, node còn lại đọc run có sẵn
Process chết giữa tạo các con Scheduler tạo nốt node thiếu theo kỳ, không tạo lại node đã có
Nguồn chưa gửi xong WAITING_SOURCE, không giữ thread chờ
ClickHouse/OCS timeout Giữ chờ hoặc fail attempt, không coi NOT_MATCHED; kiểm tra lại đến hạn
Provider timeout chưa biết đã gửi chưa Không tự gửi lại mù; dùng idempotency/check status hiện có
Không có lịch sử chuyển đổi sau gửi ở OCS Chặn loại goal cần lịch sử; không thay bằng giờ BE phát hiện
Pending WhatsApp bị TTL xóa nhưng thiếu kết quả cuối Chưa coi dữ liệu nguồn sẵn sàng; reconcile hoặc báo lỗi dữ liệu
Build bị mất lease Attempt cũ không được công bố; reclaim tạo attempt mới nếu không phục hồi an toàn
Query attempt cũ vẫn chạy ở ClickHouse ID attempt riêng giữ dữ liệu đó ngoài manifest đang dùng
Build xong nhưng crash trước FROZEN Reconcile kiểm chứng build trước khi attach; không gửi target chưa công bố
FROZEN xong, Kafka chưa nhận trigger Scheduler phát lại READY_TO_DISPATCH
Kafka nhận nhưng process chết trước cập nhật Replay claim/idempotency không tạo execution mới
Worker result lặp CAS command đang active; không tăng action hai lần
Run chờ vượt 48 giờ SKIPPED SOURCE_TIMEOUT
Kỳ mới mở, run cũ chưa gửi SKIPPED NEW_PERIOD kể cả đang build; attempt không được phát lệnh
Nguồn SKIPPED/FAILED Hậu duệ cùng kỳ SKIPPED, không lấy nguồn kỳ khác
Đến giờ dời nhưng quá ngày kết thúc SKIPPED PAST_END_DATE
Cả tệp bị loại SKIPPED EMPTY_AUDIENCE, thông báo một lần
Nguồn đổi conversion sau target con FROZEN Target con không đổi
Chỉ còn UNKNOWN/pending không giải quyết được Ghi lỗi vận hành rõ, không đánh COMPLETED giả

8. Báo cáo, migration và kiểm tra

8.1. Response và log

Run response bổ sung periodKey, sourceRunId, plannedAt, scheduledAt, waitReason, skipReason. Chi tiết campaign con hiển thị "Kế thừa từ nguồn - loại khách" và số người của run gần nhất; chưa chạy thì chưa có số, không lấy count cộng dồn.

Báo cáo/export đọc đúng target run đã chốt. Thống kê group chỉ từ campaign metadata. Log sự kiện đổi status, claim, build, timeout và gửi có companyId, campaignId, rootCampaignId, periodKey, runId, sourceRunId, targetSnapshotId/attemptNo, số người và duration; không log danh sách phone, token hoặc nội dung cá nhân hóa.

8.2. Migration

8.3. Kiểm tra theo story

Story Kiểm tra bắt buộc
439 Tenant, trùng tên, batch partial, race thêm/xóa group
440 Nguồn hợp lệ, vòng lặp, lịch tuần/tháng, tháng thiếu ngày, nhiều kỳ ngắn
441 Đổi nhánh hai gốc, concurrent edit, leaf delete, clone mapping, needsReview
442 Submit/approve nguyên tử, stale package, double approve, duyệt muộn
446 Mixed success/failure, nhiều action, CONTROL, WhatsApp pending, ClickHouse trễ
446 A định kỳ -> B -> C đúng kỳ; kỳ mới trong lúc B chờ/build/gửi
446 Pause tại ranh giới claim, restart trước/sau publish, event/result replay
446 OCS batch thiếu, snapshot thay đổi, limit ổn định và tệp rỗng
Báo cáo/export Tệp và số người khớp target, lịch sử không đổi sau nguồn cập nhật

9. Phân công code

Thành phần Thay đổi
common Model/enum snapshot, helper membership và điều kiện dùng chung
laoads API nhóm/cây, cấu hình, gói duyệt, guard và migration
cronjob Scheduler kỳ/kế thừa, completion/reconcile và conversion
campaign-dispatcher Claim bắt đầu, execution idempotency, checkpoint và DISPATCHED
workers Result/guard theo run; không điều phối chuỗi
segment-import-worker Giữ trích xuất hiện có, tương thích điều kiện completion mới

Làm phần completion tại mục 7.5 trước, sau đó 439/440/441/442, cuối cùng scheduler/build và vận hành của 446. Chỉ có một hàm kiểm tra điều kiện hoàn thành; không nhân bản giữa dispatcher và cronjob.

Contract OCS cần xác định snapshot READY, mốc dữ liệu và khả năng tra lịch sử chuyển đổi. Quy tắc phân loại nhiều action cần chốt như mục 7.4; hai điểm này là điều kiện triển khai đúng, không mặc định dùng dữ liệu cũ hoặc phân loại sai.