Tính năng này trả lời được bốn câu hỏi:
Hệ thống hỗ trợ đúng hai cách ghi nhận:
| Chế độ | Cách hoạt động |
|---|---|
AUTO_OCS |
Hệ thống định kỳ đối chiếu target của Campaign với dữ liệu OCS. |
MANUAL_IMPORT |
Người dùng import danh sách khách đã phản hồi tại màn chi tiết Campaign. |
Một Campaign chỉ được chọn một trong hai chế độ. Không cho phép vừa tự động vừa import thủ công để tránh một khách hàng được ghi nhận từ hai nguồn khác nhau.
Phạm vi hiện tại chỉ tính một khách hàng chuyển đổi một lần trong một Campaign. Không phát triển attribution nhiều chạm, doanh thu, nhiều Goal trên một Campaign hoặc tự động chọn biến thể A/B thắng.
Luồng hiện tại đã có:
Campaign, CampaignRun và bản chụp cấu hình của từng Run.campaign_target_member trong ClickHouse chứa đúng tập khách đã chốt cho từng Run.campaign_delivery_event ghi SENT, FAILED và các kết quả gửi.campaign_click_event, campaign_open_event ghi tương tác.Phần còn thiếu:
Với target được gửi thông điệp:
campaign_delivery_event.event_type = SENT.SENT gần nhất nhưng không sau thời điểm conversion được phát hiện.campaignRunId, targetSnapshotId, variantCode, channel và actionIndex lấy từ lần gửi được gắn conversion.Với target thuộc nhóm A/B CONTROL:
SENT.campaign_target_snapshot.frozenAt.CONTROL vẫn được ghi nhận để so sánh tỷ lệ chuyển đổi tự nhiên với các nhóm được gửi.Target gửi thất bại hoàn toàn và không thuộc CONTROL không được tính conversion.
Các trường như login_superapp, data_package, vas_service, g_volume phản ánh trạng thái hiện tại. Chúng không cung cấp chính xác thời điểm trạng thái thay đổi.
Vì vậy chế độ AUTO_OCS áp dụng quy tắc:
SENT hoặc target CONTROL.detectedAt là thời điểm phát hiện.Như vậy hệ thống chứng minh được khách chưa đạt Goal ở thời điểm chốt target và đã đạt Goal ở lần kiểm tra sau khi gửi. Muốn có chính xác thời điểm nghiệp vụ, OCS phải bổ sung cột thời gian phát sinh sự kiện.
conversionId được tạo cố định:
SHA-256(companyId + ":" + campaignId + ":" + recipientKey)
Cùng một khách trong cùng Campaign luôn có cùng conversionId, kể cả khi:
Báo cáo luôn dùng uniqExact(conversion_id). Vì vậy bản ghi Kafka/ClickHouse bị phát lại không làm tăng số conversion.
Để cấu hình dễ hiểu và không cần biểu thức lồng phức tạp:
AND.OR.Quy tắc này đủ cho cả năm Campaign và tránh phải lưu thêm ruleMatchPolicy.
Tại bước thiết lập Campaign, FE thêm phần Ghi nhận chuyển đổi.
FE hiển thị:
Tự động từ OCS.OR.Payload tối thiểu:
{
"conversionGoal": {
"mode": "AUTO_OCS",
"rules": [
{
"datasetCode": "ALL_REPORT_STL_NEW",
"conditions": [
{
"fieldCode": "LOGIN_SUPERAPP",
"operator": "GREATER_EQUAL_THAN",
"valueSource": "FIXED",
"value": 1
}
]
}
]
}
}
Không yêu cầu FE gửi tên bảng, tên cột vật lý hoặc selectedFields. Backend tự resolve từ ocs_dataset_config và ocs_dataset_field_config.
Khi tạo Campaign, FE chỉ gửi:
{
"conversionGoal": {
"mode": "MANUAL_IMPORT"
}
}
Không upload file tại bước tạo Campaign.
Nút Import kết quả chuyển đổi chỉ xuất hiện tại màn chi tiết Campaign khi:
MANUAL_IMPORT.IN_PROGRESS, PAUSED hoặc FINISHED.Không cho import khi Campaign là DRAFT, SUBMITTED, REJECTED hoặc CANCELLED.
Dataset: ALL_REPORT_STL_NEW
LOGIN_SUPERAPP >= 1
Dataset: ALL_REPORT_STL_NEW
LOGIN_SUPERAPP >= 1
Dataset: ALL_REPORT_STL_NEW
LOGIN_SUPERAPP >= 1
Chỉ cần một trong hai rule khớp:
Rule 1
Dataset: ALL_REPORT_STL_NEW
DATA_PACKAGE = "50K"
OR
Rule 2
Dataset: F013_VAS_MONTH_STREAM
VAS_SERVICE = "50K"
Nếu nghiệp vụ cung cấp nhiều mã gói 50K, danh sách giá trị phải được quản lý trong cấu hình field/rule, không hard-code rải rác trong scheduler.
g_volume và num_usage_day_4g là số không âm, nên > 0 đồng thời loại null và 0.
Rule 1
Dataset: ALL_REPORT_STL_NEW
DATA_PACKAGE = "10K6S"
AND G_VOLUME > 0
OR
Rule 2
Dataset: ALL_REPORT_STL_NEW
DATA_PACKAGE = "10K6S"
AND NUM_USAGE_DAY_4G > 0
Các field sau cần được bổ sung vào cấu hình dataset ALL_REPORT_STL_NEW nếu chưa có:
LOGIN_SUPERAPP STRING
G_VOLUME NUMBER
NUM_USAGE_DAY_4G NUMBER
campaignKhông tạo collection Goal riêng vì mỗi Campaign hiện chỉ có một Goal.
Bổ sung object nhúng:
conversionGoal.mode
conversionGoal.rules[]
Model đề xuất:
CampaignConversionGoalModel
mode: AUTO_OCS | MANUAL_IMPORT
rules: List<SegmentQueryDefinitionModel>
Backend nhận datasetCode + conditions, sau đó dùng validator OCS hiện có để resolve thành SegmentQueryDefinitionModel. Bản lưu phải chứa schema, table, column và kiểu dữ liệu đã resolve; không phụ thuộc vào label FE.
Không bổ sung các field sau trong giai đoạn này:
goalCode
countingMode
attributionWindowDays
ruleMatchPolicy
Các giá trị đó đã có quy tắc cố định trong phạm vi năm Campaign.
campaign_runBổ sung conversionGoal vào CampaignRunDefinitionSnapshotModel.
Khi tạo Run, MapStruct copy Goal hiện tại của Campaign vào snapshot. Job conversion luôn đọc snapshot, không đọc cấu hình Campaign có thể đã bị thay đổi sau đó.
Sau khi Campaign đã có Run đầu tiên, không cho sửa conversionGoal. Muốn đổi Goal phải tạo Campaign mới để không làm báo cáo cũ thay đổi ý nghĩa.
campaign_conversion_scan_stateCollection này chỉ lưu trạng thái kỹ thuật của job tự động, không lưu từng khách hàng.
| Trường | Ý nghĩa |
|---|---|
companyId, campaignId |
Campaign đang được quét. |
cycleDate |
Ngày chạy hiện tại. |
status |
IDLE, RUNNING, RETRY_WAIT. |
cursorRecipientKey |
Điểm đã xử lý thành công gần nhất. |
scannedCount, matchedCount |
Số liệu theo dõi tiến độ. |
leaseOwner, leaseUntil |
Chống hai node xử lý cùng Campaign. |
nextScanAt |
Thời điểm được phép quét tiếp. |
lastSuccessAt, lastErrorCode |
Theo dõi vận hành. |
Index:
UNIQUE(companyId, campaignId)
INDEX(status, nextScanAt)
INDEX(leaseUntil)
Cursor chỉ được cập nhật sau khi batch conversion đã ghi thành công xuống ClickHouse.
campaign_conversion_import_jobCollection này phục vụ giao diện theo dõi import thủ công.
| Trường | Ý nghĩa |
|---|---|
companyId, campaignId |
Phạm vi import. |
fileAssetId |
File nguồn đã upload lên MinIO. |
status |
QUEUED, RUNNING, COMPLETED, FAILED. |
totalRows, successRows, invalidRows, duplicateRows |
Tiến độ xử lý. |
errorFileAssetId |
File lỗi để FE tải lại. |
createdBy, startedAt, completedAt |
Audit. |
lastErrorCode |
Mã lỗi tổng của job. |
Index:
INDEX(companyId, campaignId, createdAt DESC)
INDEX(status, createdAt)
Không lưu từng dòng import trong MongoDB.
campaign_conversion_eventĐây là bảng kết quả chuyển đổi theo khách hàng và là nguồn chính cho báo cáo.
CREATE TABLE campaign_conversion_event
(
event_date Date,
converted_at DateTime64(3),
detected_at DateTime64(3),
conversion_id String,
company_id String,
campaign_id String,
campaign_run_id String,
target_snapshot_id String,
recipient_key String,
variant_code String DEFAULT '',
channel LowCardinality(String) DEFAULT '',
action_index Int32 DEFAULT -1,
attributed_sent_at Nullable(DateTime64(3)),
source LowCardinality(String),
time_source LowCardinality(String),
matched_rule_no UInt16,
source_dataset LowCardinality(String) DEFAULT '',
source_reference String DEFAULT '',
import_job_id String DEFAULT '',
evidence String DEFAULT '{}',
created_at DateTime64(3)
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_date)
ORDER BY (
company_id,
campaign_id,
campaign_run_id,
event_date,
recipient_key
);
Giải thích các trường dễ nhầm:
| Trường | Ý nghĩa |
|---|---|
converted_at |
Thời điểm conversion từ file hoặc thời điểm OCS được phát hiện. |
detected_at |
Thời điểm LaoAds ghi nhận được conversion. |
time_source |
SOURCE_TIME, DETECTED_TIME hoặc IMPORT_TIME. |
attributed_sent_at |
Lần gửi được gắn conversion; null với CONTROL. |
matched_rule_no |
Rule OCS đã khớp, bắt đầu từ 1. |
evidence |
JSON nhỏ chứa giá trị chứng minh rule khớp; không chứa số điện thoại gốc. |
Không dùng MongoDB để lưu conversion theo từng người vì dữ liệu có thể rất lớn.
Không tạo bảng tổng hợp mới trong giai đoạn đầu. ClickHouse query trực tiếp bằng uniqExact(conversion_id) trên một Campaign. Chỉ bổ sung aggregate/Flink khi đo thực tế cho thấy API báo cáo không đạt ngưỡng yêu cầu.
Không được coi UNKNOWN, NOT_FOUND hoặc lỗi OCS là “chưa đạt Goal”. Nếu không xác định được đầy đủ thì fail batch và retry để tránh gửi nhầm.
Nếu process chết sau khi insert ClickHouse nhưng trước khi cập nhật cursor, batch được chạy lại. conversionId cố định và báo cáo dùng uniqExact, nên không tăng sai số liệu.
Không gọi từng ISDN. Một request tối đa 1.000 ISDN:
POST /api/v1/internal/subscribers/match-goal
X-API-Key: <internal-api-key>
Request chứa:
{
"requestId": "scan-id",
"isdns": ["85620xxxxxxx"],
"rules": [
{
"tableName": "all_report_stl_new",
"identityColumn": "isdn",
"conditions": [
{
"columnName": "login_superapp",
"dataType": "STRING",
"operator": "GREATER_EQUAL_THAN",
"value": 1
}
]
}
]
}
API này chỉ nhận cấu hình đã resolve từ backend. FE không gọi trực tiếp. OCS service phải validate table/column/operator theo allow-list hiện có và bind giá trị bằng prepared statement.
Response phải trả kết quả cho toàn bộ ISDN đầu vào, gồm MATCHED, NOT_MATCHED, NOT_FOUND, UNKNOWN, INVALID. LaoAds chỉ ghi conversion cho MATCHED.
| Cột | Bắt buộc | Ý nghĩa |
|---|---|---|
phone |
Có | Số điện thoại khách hàng. |
converted_at |
Không | Thời điểm phản hồi; nếu trống dùng thời điểm import. |
reference |
Không | Mã giao dịch hoặc mã đối soát bên ngoài. |
INVALID_PHONE
DUPLICATE_IN_FILE
NOT_CAMPAIGN_TARGET
NO_SUCCESSFUL_SEND
CONVERTED_BEFORE_SEND
ALREADY_CONVERTED
INVALID_CONVERTED_AT
Với target CONTROL, thay NO_SUCCESSFUL_SEND bằng kiểm tra converted_at >= targetSnapshot.frozenAt.
Một file có dòng lỗi vẫn hoàn thành job với các dòng hợp lệ. Chỉ đánh FAILED khi không đọc được file, ClickHouse không ghi được hoặc lỗi hệ thống khiến job không thể tiếp tục.
Nguồn dữ liệu:
| Chỉ số | Bảng |
|---|---|
| Target | campaign_target_member |
| Sent/Failed/Delivered | campaign_delivery_event hoặc campaign_metric_1m |
| Open/Read/Click | Bảng tracking hiện có hoặc campaign_metric_1m |
| Conversion | campaign_conversion_event |
Quy tắc tính:
conversionCount = uniqExact(conversion_id)
conversionRate = conversionCount / distinctTargetCount * 100
Dùng target làm mẫu số để nhóm CONTROL vẫn so sánh được. Không dùng số action hoàn thành trong MongoDB làm conversion.
Các API cần bổ sung hoặc sửa:
conversionCount và conversionRate.conversions theo converted_at.variant_code.channel đã được gắn từ lần SENT.CampaignActionExecutionModel.Query báo cáo bắt buộc scope bằng company_id + campaign_id; khi xem một Run thì thêm campaign_run_id.
| Trường hợp | Xử lý |
|---|---|
| Khách đạt Goal trước khi chốt target | Loại khỏi Target Snapshot, không ghi conversion cho Campaign. |
| OCS lỗi khi chốt target | Snapshot fail và retry; không mặc định cho khách đi tiếp. |
| OCS lỗi khi quét conversion | Không cập nhật cursor; retry cùng batch. |
| OCS trả thiếu một ISDN | Batch lỗi; không đánh ISDN thiếu là NOT_MATCHED. |
| Khách có nhiều dòng OCS | OCS tổng hợp theo ISDN; chỉ trả một kết quả cuối. |
| Khách khớp nhiều rule | Chỉ một conversion; lưu rule đầu tiên theo thứ tự cấu hình và evidence các rule khớp nếu cần. |
| Khách thuộc nhiều Run | Gắn lần SENT gần nhất trước khi phát hiện; chỉ tính một lần trong Campaign. |
| Khách chỉ có event FAILED | Không conversion, trừ khi thuộc CONTROL. |
| Job chết giữa batch | Chạy lại; conversionId chống tăng số liệu. |
| Hai node cùng chạy | Mongo lease chỉ cho một node giữ Campaign. |
| Import lặp cùng file | Dòng đã conversion được tính ALREADY_CONVERTED, không tăng báo cáo. |
| Import khi Campaign AUTO | Từ chối toàn bộ request. |
| Import khi Campaign FINISHED | Cho phép. |
| Import khi Campaign CANCELLED | Từ chối. |
| Campaign đã có Run rồi sửa Goal | Từ chối; tạo Campaign mới. |
converted_at trống trong file |
Dùng thời điểm import và time_source=IMPORT_TIME. |
| Target CONTROL chuyển đổi | Ghi conversion, attributed_sent_at=null, channel=''. |
| Campaign không có ngày kết thúc | AUTO tiếp tục quét khi Campaign còn hoạt động; khi FINISHED thực hiện một lượt quét cuối. |
Log theo batch, không log từng người:
requestId, scanId, importJobId
companyId, campaignId, campaignRunId
cursor, batchSize, matchedCount, invalidCount
durationMs, errorCode, retryCount
Không log số điện thoại gốc, nội dung file, credential OCS hoặc evidence đầy đủ.
recipient_key, không dùng OFFSET.company_id + campaign_id.Các exception từ API phải chỉ dùng error code và message resource hiện có:
CAMPAIGN_GOAL0001 Goal mode không hợp lệ
CAMPAIGN_GOAL0002 AUTO_OCS phải có ít nhất một rule
CAMPAIGN_GOAL0003 MANUAL_IMPORT không được chứa rule
CAMPAIGN_GOAL0004 Dataset/field/operator Goal không hợp lệ
CAMPAIGN_GOAL0005 Không được sửa Goal sau khi Campaign đã có Run
CAMPAIGN_GOAL0006 Campaign không cho phép import conversion
CAMPAIGN_GOAL0007 File import không hợp lệ
CAMPAIGN_GOAL0008 Không tìm thấy Goal snapshot của Run
CAMPAIGN_GOAL0009 Không thể kiểm tra Goal từ OCS
CAMPAIGN_GOAL0010 Kết quả OCS không đầy đủ
Bổ sung message tương ứng vào đầy đủ file ngôn ngữ đang được LaoAds sử dụng. Không hard-code message tiếng Anh trong service.
Phần việc được tách thành chín task. Mỗi task tạo thành một merge request độc lập, có đầu ra rõ ràng và không gom toàn bộ trách nhiệm của một nhánh vào một merge request lớn.
Tạo contract thống nhất cho Goal để Campaign API, Run snapshot, OCS và import cùng sử dụng.
CampaignConversionModeEnum: AUTO_OCS, MANUAL_IMPORT.CampaignConversionGoal.AUTO_OCS bắt buộc có ít nhất một rule; mỗi rule có dataset và ít nhất một condition.MANUAL_IMPORT không được chứa rule.datasetCode, fieldCode thành schema/table/column/data type ở Backend.Không phụ thuộc task khác. Đây là một trong ba task có thể bắt đầu ngay.
SegmentValidationService sang Campaign.Lưu Goal khi tạo Campaign và cố định Goal theo từng Run để cấu hình về sau không làm sai báo cáo cũ.
conversionGoal vào CampaignModel, Campaign request/response và API chi tiết Campaign.conversionGoal vào CampaignRunDefinitionSnapshotModel.conversionGoal vẫn đọc được.Phụ thuộc Task 01.
Tạo một nơi duy nhất ghi conversion cho cả AUTO_OCS và MANUAL_IMPORT, đồng thời cung cấp các query nền tảng cho scheduler và report.
campaign_conversion_event theo schema tại mục 6.5.conversionId = SHA-256(companyId + ":" + campaignId + ":" + recipientKey) tại một hàm dùng chung.SENT gần nhất trước thời điểm conversion.recipient_key; không dùng OFFSET.company_id và campaign_id.CONTROL không cần SENT, attributed_sent_at để null.Không phụ thuộc task khác. Đây là một trong ba task có thể bắt đầu ngay.
conversionId không làm tăng kết quả uniqExact.Cho phép LaoAds kiểm tra tối đa 1.000 thuê bao theo một hoặc nhiều rule mà không gọi OCS từng số điện thoại.
POST /api/v1/internal/subscribers/match-goal.AND; nhiều rule là OR.MATCHED, NOT_MATCHED, NOT_FOUND, UNKNOWN, INVALID.Không phụ thuộc task khác. Đây là một trong ba task có thể bắt đầu ngay.
requestId, batch size, dataset, duration và error code; không log ISDN.Định kỳ tìm khách đã đạt Goal, ghi conversion theo batch và tiếp tục đúng vị trí sau retry hoặc restart.
campaign_conversion_scan_state và các index tại mục 6.3.AUTO_OCS đến hạn.SENT hoặc CONTROL, chưa có conversion, theo keyset.MATCHED; không coi UNKNOWN, INVALID hoặc response thiếu là chưa đạt Goal.UNKNOWN.Phụ thuộc Task 01, Task 03 và Task 04.
Không đưa vào Run mới những khách đã đạt Goal trước khi gửi hoặc đã được ghi conversion trong Campaign.
AUTO_OCS.campaign_conversion_event để loại khách đã conversion ở Run trước.MANUAL_IMPORT không gọi OCS ở bước này; vẫn loại conversion đã import trước đó nếu Campaign tạo Run tiếp.MANUAL_IMPORT không có rules OCS.Phụ thuộc Task 02, Task 03 và Task 04.
Cho phép FE tạo và theo dõi một lần import mà chưa xử lý dữ liệu file trong request HTTP.
campaign_conversion_import_job và index tại mục 6.4.fileAssetId đã upload bằng File Asset/MinIO hiện có.MANUAL_IMPORT khi Campaign IN_PROGRESS, PAUSED hoặc FINISHED.importJobId ngay.fileAssetId.Phụ thuộc Task 01. Có thể phát triển song song với Task 02 sau khi contract Task 01 được chốt.
Đọc file lớn an toàn, ghi các dòng hợp lệ và trả file lỗi mà không làm mất toàn bộ job vì một dòng sai.
QUEUED thành RUNNING theo cách atomic.phone, converted_at, reference.SENT gần nhất; xử lý riêng CONTROL.COMPLETED; chỉ dùng FAILED cho lỗi hệ thống không thể tiếp tục.converted_at trước thời điểm gửi/freeze.Phụ thuộc Task 03 và Task 07.
Đưa conversion thật vào báo cáo Campaign, Run, A/B, channel và báo cáo tổng hợp company.
conversionCount và conversionRate vào Campaign summary.converted_at.campaign_run_id, variant_code và channel.CONTROL.campaign_conversion_event.CampaignActionExecutionModel làm nguồn conversion.uniqExact(conversion_id) và mẫu số distinct target.company_id; API Campaign phải thêm campaign_id.CONTROL hoặc channel rỗng.Phụ thuộc Task 03 để chốt schema và query contract. Có thể làm song song với Task 05, Task 06 và Task 08.
Không cần chờ hoàn thành toàn bộ một nhánh mới bắt đầu nhánh khác. Thứ tự đề xuất:
| Giai đoạn | Nhánh A | Nhánh B | Nhánh C |
|---|---|---|---|
| Bắt đầu | Task 01 - Goal contract | Task 03 - ClickHouse conversion store | Task 04 - OCS Goal API |
| Sau khi nền tảng ổn định | Task 02 - Campaign/Run snapshot | Task 07 - Import job API | Task 05 - AUTO scheduler |
| Ghép luồng | Task 06 - Target pre-check | Task 08 - Import processor | Task 09 - Report |
Sơ đồ phụ thuộc:
Ba task phải được chốt contract sớm nhất là Task 01, Task 03 và Task 04. Sau khi ba contract này được merge hoặc thống nhất trên cùng branch tích hợp, các task còn lại có thể phát triển song song mà không tự định nghĩa lại model, schema hoặc response.
Mốc 1: Task 01 + Task 03 + Task 04
Mốc 2: Task 02 + Task 05 + Task 07 + Task 09
Mốc 3: Task 06 + Task 08
Mốc 4: kiểm tra xuyên suốt AUTO_OCS và MANUAL_IMPORT
Hai luồng bắt buộc chạy xuyên suốt trước khi đóng nhóm task:
AUTO_OCS:
create -> approve -> snapshot pre-check -> send -> daily scan -> conversion -> report
MANUAL_IMPORT:
create -> approve -> send -> upload file -> import job -> conversion -> report
Task 01-09 đã hoàn thành nên phần dưới đây được triển khai bằng một task độc lập. Không mở lại hoặc thay đổi tiêu chí nghiệm thu của các task cũ.
Phần bổ sung này mở rộng Goal với hai nhóm cấu hình còn thiếu trên màn tạo Campaign:
Các quy tắc trong phần này thay thế hai giả định cũ đối với Campaign được tạo hoặc cập nhật sau khi Task 10 được triển khai:
attributionWindowDays không còn là giá trị cố định nằm ngoài Goal definition.Các field được lưu cùng conversionGoal và được snapshot vào CampaignRunDefinitionSnapshot. FE không tự suy diễn giá trị mặc định.
{
"conversionGoal": {
"mode": "AUTO_OCS",
"rules": [],
"attributionWindowDays": 7,
"stopPolicy": {
"stopOnGoalAchieved": true,
"noResponse": {
"enabled": true,
"consecutiveSentCount": 3
},
"reentry": {
"enabled": true,
"afterDays": 30
}
}
}
}
Quy tắc validate:
| Field | Quy tắc |
|---|---|
attributionWindowDays |
Bắt buộc khi có Goal, từ 1 đến 90 ngày. |
stopOnGoalAchieved |
Mặc định true. |
noResponse.enabled |
Mặc định false. |
noResponse.consecutiveSentCount |
Bắt buộc khi bật, từ 1 đến 100. |
reentry.enabled |
Mặc định false; chỉ được bật khi noResponse.enabled=true. |
reentry.afterDays |
Bắt buộc khi bật, từ 1 đến 365 ngày. |
Campaign cũ không có các field mới được đọc với giá trị tương thích:
attributionWindowDays = 7
stopOnGoalAchieved = true
noResponse.enabled = false
reentry.enabled = false
Sau khi Campaign đã có Run đầu tiên, không cho sửa Goal hoặc stop policy vì Run cũ phải giữ nguyên definition đã chạy.
Mỗi recipient có attribution window riêng, không lấy một mốc chung cho toàn Campaign.
attributionAnchor = thời điểm SENT gần nhất có thể quy conversion về recipient
attributionDeadline = attributionAnchor + attributionWindowDays
Conversion được ghi nhận khi:
attributionAnchor <= convertedAt <= attributionDeadline
Quy tắc theo nhóm:
event_time của delivery event SENT gần nhất trước conversion.CONTROL: anchor là frozenAt của TargetSnapshot vì nhóm này không có delivery event.FAILED, SUPPRESSED và event retry trùng không tạo anchor.SENT mới tạo deadline mới; conversion được quy về lần gửi gần nhất hợp lệ.MANUAL_IMPORT dùng convertedAt trong file để kiểm tra window.AUTO_OCS chỉ ghi nhận khi scheduler phát hiện Goal trong window. OCS là current state nên không được tự suy đoán rằng Goal đã đạt trước deadline nếu lần quét đầu tiên nhìn thấy nó sau deadline.Khi Campaign đã dừng gửi, scheduler vẫn scan các Run còn attribution window mở. Campaign chỉ hoàn tất phần conversion khi không còn recipient nào có attributionDeadline > now.
Nếu stopOnGoalAchieved=true, TargetSnapshot Builder loại recipient đã có conversion của Campaign trước khi chốt snapshot tiếp theo.
Nếu stopOnGoalAchieved=false, recipient vẫn có thể nhận bước hoặc Run tiếp theo. Conversion vẫn chỉ được đếm một lần cho mỗi recipient trong một Campaign theo idempotency contract hiện tại.
Snapshot đã chốt không bị sửa ngược. Worker gửi vẫn áp dụng final check hiện có nếu action yêu cầu chống gửi sau conversion.
Trong Task 10, “phản hồi” được định nghĩa là đạt Goal, không phải open hoặc click. Open/click không đồng nhất giữa SMS, Mail, WhatsApp, Push và nhóm CONTROL, nên không dùng làm tín hiệu reset bộ đếm.
Một lần thông báo liên tiếp được tính khi có một delivery event SENT duy nhất:
FAILED, SUPPRESSED hoặc provider callback lặp.CONTROL không tăng bộ đếm vì không thực sự nhận thông báo.Trước khi tạo TargetSnapshot mới, hệ thống đếm số lần SENT kể từ countingStartedAt. Nếu recipient chưa đạt Goal và số lần gửi đạt consecutiveSentCount, recipient bị loại khỏi snapshot đang build và các snapshot sau.
Để tránh hiểu sai trên FE, nội dung nên hiển thị là:
Khách hàng chưa đạt mục tiêu sau [N] lần gửi thành công liên tiếp
Trong phạm vi Task 10, re-entry chỉ áp dụng cho recipient bị dừng vì NO_RESPONSE.
eligibleAgainAt = suppressedAt + reentry.afterDays
Khi tới eligibleAgainAt, recipient chỉ được xem xét lại nếu vẫn thuộc Segment/SegmentVersion nguồn của Run mới. Khi quay lại:
countingStartedAt bằng thời điểm quay lại.Nếu không bật re-entry, suppression NO_RESPONSE có hiệu lực tới khi Campaign kết thúc.
Không hỗ trợ quay lại sau khi đã đạt Goal trong task này. Muốn một khách hàng đạt Goal nhiều lần trong cùng Campaign phải bổ sung participationId/entryId và thay đổi contract “một conversion cho mỗi recipient trong một Campaign”; đây là một phạm vi nghiệp vụ khác.
Không tạo Segment phụ và không sửa member của Segment nguồn. Chỉ lưu recipient thực sự đã bị dừng hoặc đã quay lại trong MongoDB collection campaign_recipient_suppression_state:
{
"companyId": "companyId",
"campaignId": "campaignId",
"recipientKey": "sha256-normalized-recipient",
"status": "SUPPRESSED_NO_RESPONSE",
"countingStartedAt": "...",
"suppressedAt": "...",
"eligibleAgainAt": "...",
"lastEvaluatedAt": "...",
"version": 3,
"updatedAt": "..."
}
Index bắt buộc:
db.campaign_recipient_suppression_state.createIndex(
{ companyId: 1, campaignId: 1, recipientKey: 1 },
{ unique: true, name: "campaign_recipient_suppression_unique_idx" }
)
db.campaign_recipient_suppression_state.createIndex(
{ companyId: 1, campaignId: 1, status: 1, eligibleAgainAt: 1 },
{ name: "campaign_recipient_suppression_reentry_idx" }
)
Không lưu raw phone/email. Recipient chưa từng bị suppress không cần tạo document; countingStartedAt mặc định là thời điểm Campaign bắt đầu gửi.
Số lần gửi được batch query từ campaign_delivery_event hiện có thay vì duy trì thêm một counter theo từng callback. Cách này giữ delivery event là nguồn đúng, chống retry và không tạo thêm một pipeline đồng bộ.
Mọi phép đọc/ghi dùng batch tối đa 1.000 recipient. Không query Mongo hoặc ClickHouse từng recipient. Upsert suppression phải dùng version hoặc điều kiện trạng thái để hai node không cùng reset/suppress sai.
Bổ sung phần cấu hình còn thiếu của Conversion Goal mà không thay đổi dữ liệu và luồng đã hoàn thành ở Task 01-09.
attributionWindowDays và stopPolicy vào request/response, model và Run definition snapshot.stopOnGoalAchieved là cấu hình thay vì luôn bật.SENT theo Campaign và recipient.campaign_recipient_suppression_state.A', Segment hiệu hoặc copy Segment member để biểu diễn trạng thái dừng.CAMPAIGN_GOAL0011 Thời gian ghi nhận chuyển đổi không hợp lệ
CAMPAIGN_GOAL0012 Chính sách dừng Campaign không hợp lệ
CAMPAIGN_GOAL0013 Số lần gửi liên tiếp không hợp lệ
CAMPAIGN_GOAL0014 Cấu hình cho phép quay lại Campaign không hợp lệ
CAMPAIGN_GOAL0015 Không thể sửa Goal hoặc chính sách dừng sau khi Campaign đã chạy
| Trường hợp | Kết quả |
|---|---|
| Conversion nằm đúng attribution deadline | Được ghi nhận. |
| Conversion sau deadline | Không ghi nhận cho Campaign. |
| Campaign ngừng gửi nhưng window vẫn mở | Scheduler tiếp tục scan. |
| Recipient đạt Goal và bật stop | Không vào snapshot sau. |
| Recipient đạt Goal nhưng tắt stop | Vẫn có thể vào snapshot sau; conversion không nhân đôi. |
Ba delivery event SENT khác nhau, chưa đạt Goal |
Suppress trước lần gửi kế tiếp khi ngưỡng là 3. |
| Kafka/provider retry cùng một delivery event | Chỉ tính một lần. |
Delivery FAILED hoặc SUPPRESSED |
Không tăng bộ đếm. |
Recipient thuộc nhóm CONTROL |
Không tăng bộ đếm no-response. |
Chưa tới eligibleAgainAt |
Không vào snapshot. |
Đã tới eligibleAgainAt nhưng không còn trong Segment nguồn |
Không quay lại. |
Đã tới eligibleAgainAt và còn trong Segment nguồn |
Quay lại với bộ đếm mới bằng 0. |
| Hai builder cùng xử lý recipient | Chỉ một state transition thành công. |
CONTROL.SENT, bỏ retry và status không hợp lệ.countingStartedAt.Task 10 bắt đầu sau khi Task 01-09 đã hoàn thành và tái sử dụng trực tiếp:
Mốc 5: Task 10 - Attribution Window, Stop Policy và Re-entry
Task 10 nên được merge bằng một thay đổi độc lập sau Task 01-09. Migration chỉ tạo collection/index mới; không backfill suppression state và không sửa các conversion event đã ghi.