SAV Reminder Workflow
Overviewβ
This document describes the new SAV reminder workflow used by Logidav and the chatbot integration.
The goal of this workflow is to attach reminder events to the real customer SAV claim, keep a proper reminder history, enforce business delays, and automatically surface critical cases to the SAV team.
Business Goalsβ
- Create reminders only on an existing non-reminder SAV claim.
- Record every reminder in
SaleSavReminder, starting at reminder#1. - Enforce reminder delays before allowing a new reminder.
- Stop reminder creation when the type-specific limit is reached.
- Mark critical reminder cases as priority for the SAV team.
- Keep chatbot and back-office behavior aligned.
Supported Reminder Typesβ
| Reminder type | Code | First reminder delay | Next reminder delay | Max reminders |
|---|---|---|---|---|
| Refund reminder | refund | 14 business days | 2 business days | 3 |
| Reshipment reminder | reminder-reshipment | 5 business days | 2 business days | 3 |
| Pickup recovery reminder | pickup-recovery | 5 business days | 2 business days | 3 |
| Spare parts reminder | reminder-files | 20 business days | 3 business days | 3 |
All delays are handled in business days.
Functional Behaviorβ
First reminderβ
When a customer creates a first valid reminder:
- no new
mz_sale_savrow is created - the request must target an existing non-reminder SAV id
- one
SaleSavReminderrow is created for reminder#1 - reminder counters and priority state are updated on the targeted
SaleSav
Subsequent remindersβ
When the same customer creates another reminder on the same SAV claim and reminder type:
- no new
mz_sale_savrow is created - a new
SaleSavReminderrow is created reminder_countis incrementedlast_reminder_dateis updated- the SAV priority may be raised depending on the configured threshold
Delay not reachedβ
If the required delay is not reached:
- no new
SaleSavReminderrow is created - no new
mz_sale_savrow is created - the API returns a business error message with the next allowed date
Maximum reachedβ
If the maximum number of reminders is reached for the type:
- no new reminder is created
- the SAV is flagged as priority
- the user-facing message indicates that the SAV team will contact the customer within 48 hours
Data Modelβ
Main SAV rowβ
The main SAV claim remains stored in mz_sale_sav. Reminder rows are attached directly to this real, non-reminder SAV claim.
Relevant fields used by the reminder workflow:
communication_typesale_idpriorityreminder_countlast_reminder_datepriority_levelmax_reminders_reachedarchived_at
Reminder historyβ
Reminder history is stored in SaleSavReminder.
Each row represents one reminder event and keeps:
- the reminded
SaleSav - the canonical reminder type
- the communication type
- the user who created the reminder
- the note or customer observation payload
- the creation date
- the legacy SAV id when migrated from old duplicated SAV rows
Matching Ruleβ
The workflow considers a reminder case unique by:
- the targeted non-reminder SAV id
- reminder type
When the chatbot or back office submits a reminder request:
- the reminder type is normalized to a canonical type
- the service verifies the target SAV exists, is active, and is not itself a reminder type
- the reminder is appended to that SAV history
- no reminder-parent SAV is created
Legacy aliases such as Relance remboursement are normalized before matching.
Reference Datesβ
The first reminder delay is computed from the creation date of the targeted non-reminder SAV claim. Later reminders use the last reminder date for the same reminder type.
API Flowβ
Chatbot creation endpointβ
The chatbot must resolve the existing SAV claim first, usually through GET /api/customer/sav-status, then create reminders through:
POST /api/sale-sav/{id}/reminder
Minimal payload:
{
"type": "refund"
}
Reminder types sent to POST /api/sav are rejected to avoid creating reminder-parent SAV rows.
Back-office reminder endpointsβ
These endpoints apply only to existing non-reminder SAV claim rows:
POST /sav/{id}/reminderGET /sav/{id}/reminder-statusGET /sav/{id}/reminders
They are used to:
- create a manual reminder on an existing SAV claim;
typeis required - check whether a new reminder is allowed;
typeis required - read reminder history;
typeis optional and filters the history when present
Scheduler and Dispatcher Entry Pointsβ
Reminder creation is currently event-driven, not cron-driven.
Active entry points:
- chatbot/API creation resolves an existing SAV claim and calls the reminder endpoints listed above
- back-office manual reminders call the same reminder endpoints
- legacy duplicate cleanup is handled by the migration command documented below
There is no active recurring reminder dispatcher registered in docs/system-crontab.md at the time of this document update. If a recurring dispatcher is added later, it must be documented in both:
docs/system-crontab.md- the generated command page under
docs/cronjobs/
The expected command documentation should include the cron expression, command name, log file, idempotency rule, and whether the command creates reminders, only sends notifications, or only refreshes existing reminder state.
SAV Claim State Email Flowβ
The route send_sav_claim_state_email sends the current SAV claim state to the customer.
Route and UI entry points:
- route:
POST /sav/send-sav-claim-state-email/{id} - controller:
SaleSavController::sendSavClaimStateEmailAction() - front-end trigger:
.send-sav-claim-state-emailincreateSav.html.twig - email template:
AppBundle:SaleSav:_emails/claimStateEmail.html.twig
Flow:
- the user clicks the send email button from the SAV detail page
- the browser asks for confirmation
- the controller checks SAV menu access and the dedicated email ACL
- the controller loads the SAV and its linked sale
- the customer email is resolved from the SAV, then from the sale as fallback
- the email locale is resolved from the sale store
- the mailer sends the claim state email
- a SAV message notification is posted to the conversation history
Blocking cases:
- the user lacks the ACL
- the SAV id does not exist
- the SAV has no linked sale
- no valid customer email can be resolved
- the mailer throws an exception or returns
false
ACL: CAN_SEND_SAV_CLAIM_STATE_EMAILβ
The claim state email action is protected by:
ROLE_ADMIN, or- user access
CAN_SEND_SAV_CLAIM_STATE_EMAIL
The same rule controls button visibility through canShowSavEmailButton(). The button is not shown when the current user cannot send the email, and it is only shown for SAV cases that match the refund/action conditions checked by the controller.
Back-Office Relaunch Tableβ
The relaunch table is available through:
- route:
GET /sav/sav-relaunch-request/{source} - template:
savRelaunchRequests.html.twig - data endpoint:
GET /sav/paginate-sav-relaunch-request
The table exposes reminder-specific visibility columns:
| Column | Data key | Purpose |
|---|---|---|
| Reminder count | reminder_count | Number of real reminder rows for the SAV |
| Priority level | priority_level | Human-readable priority label derived from reminder count |
| Last reminder date | last_reminder_date | Most recent reminder date resolved from history |
| Max reminders reached | max_reminders_reached / critical_reminder | Indicates critical reminder cases |
Filtering and sorting:
priority_levelfilters onSaleSav::priorityLevelreminder_countfilters onSIZE(s.reminders)max_reminders_reachedandcritical_reminderfilter by whether the reminder count has reached the configured maximumlast_reminder_dateis displayed and orderable in the table
Visual escalation:
- medium priority displays warning styling
- high priority displays danger styling
- reminder count badges use warning/danger labels when thresholds are approached or reached
Migration of Legacy Duplicatesβ
Historical duplicate reminder SAV rows are migrated with:
php bin/console menzzo:sale-sav:migrate-sav-reminders
Supported options:
--since="YYYY-MM-DD HH:MM:SS"--dry-run
If --since is omitted, the command starts from:
2026-01-01 00:00:00
Migration behavior:
- groups duplicated reminder SAV rows by order, canonical type, customer identity, and product scope
- skips ambiguous groups instead of merging them silently
- chooses a primary SAV row
- creates one
SaleSavReminderper migrated duplicate - archives duplicated legacy SAV rows
- marks migrated duplicates so the process stays idempotent
Migration edge behaviorβ
is_migrated_duplicaterows are treated as already converted legacy duplicates and should not be selected as active reminder cases.- migrated duplicate rows are archived with
archived_atduring migration. - rollback restores archived legacy rows by clearing
archived_atandis_migrated_duplicate. - if a migrated reminder already exists for a duplicate SAV, the migration refreshes state without creating a second reminder row.
- ambiguous groups are skipped instead of merged silently.
Priority Rulesβ
Priority is derived from reminder progression:
- normal level before approaching the threshold
- medium level when approaching the threshold
- high level when the threshold is reached
At maximum reached:
max_reminders_reachedis enabled- priority is raised on the main
SaleSav - the case becomes visible and sortable in the SAV reminder table
Edge Casesβ
Concurrent reminder requestsβ
Concurrent reminder submissions for the same SAV id and reminder type must append reminder rows to the same existing SAV claim. The service path normalizes the type and refuses reminder-parent SAV rows.
The write path uses one Doctrine flush for reminder creation. Callers should not open a separate manual transaction around SaleSavReminderService::addReminder() unless they own the complete outer workflow and can guarantee no duplicate concurrent creation path.
Archived SAV rowsβ
Archived SAV rows are not considered active reminder targets and should not receive new reminder rows.
Migrated duplicate rowsβ
Rows marked is_migrated_duplicate are legacy source rows. New reminder activity should be attached only to the real non-reminder SAV claim id returned by customer SAV lookup.
Missing reference datesβ
If the targeted SAV claim has no creation date, reminder creation is blocked because the first-delay reference cannot be resolved.
Delay and limit failuresβ
Business blocking failures are represented by specific exception classes:
ReminderTypeExceptionReminderDelayNotReachedExceptionReminderLimitReachedException
These extend InvalidArgumentException for backward compatibility, but callers can branch on the specific class when they need tailored UI or API responses.
Testing Checklistβ
First reminder creationβ
- choose an existing SAV claim eligible for one reminder type
- resolve it with
GET /api/customer/sav-status - call
POST /api/sale-sav/{id}/reminderwithtype - verify no new
mz_sale_savrow is created - verify exactly one
SaleSavReminderrow exists - verify
reminder_count = 1
Second reminder on same caseβ
- repeat the request after the required business delay
- verify no second
mz_sale_savrow is created - verify a second
SaleSavReminderrow exists - verify
reminder_count = 2
Delay blockingβ
- repeat too early
- verify the request is rejected
- verify no new reminder row is created
Maximum reachedβ
- repeat until the maximum threshold is reached
- verify the request is rejected after the limit
- verify the SAV is marked as priority
- verify the customer message switches to the 48-hour SAV follow-up message
Legacy migrationβ
- run the migration in
--dry-run - verify skipped ambiguous groups are listed
- run the real migration
- verify duplicates are archived and converted into
SaleSavReminder
Relaunch table visibilityβ
- open
/sav/sav-relaunch-request/{source} - verify the reminder count, priority level, last reminder date, and max reminders reached columns are present
- filter by priority level
- filter by reminder count
- filter by critical reminder status
- verify medium/high priority rows receive the expected warning/danger styling
Claim state emailβ
- use a user with
CAN_SEND_SAV_CLAIM_STATE_EMAIL - open an eligible SAV detail page
- send the claim state email
- verify the customer receives the email
- verify a SAV conversation notification is created
- repeat with a user without the ACL and verify the action is blocked
Relevant Filesβ
src/AppBundle/Services/SaleSavReminderService.phpsrc/AppBundle/Services/SaleSavService.phpsrc/AppBundle/Repository/SaleSavRepository.phpsrc/AppBundle/Controller/Sale/Sav/SaleSavController.phpsrc/AppBundle/Resources/views/SaleSav/savRelaunchRequests.html.twigsrc/AppBundle/Resources/views/SaleSav/createSav.html.twigsrc/AppBundle/Api/MenzzoChatbotWrapperApiController.phpsrc/AppBundle/Command/SaleSav/MigrateSavRemindersCommand.phpdocs/system-crontab.md
Summaryβ
This workflow changes reminder handling from "one SAV row per reminder attempt" to "one real SAV claim plus a structured reminder history".
That gives the SAV team:
- less duplicate noise
- better visibility on repeated customer reminders
- consistent business blocking rules
- automatic escalation of critical reminder cases
- create a manual reminder on an existing reminder SAV
- check whether a new reminder is allowed
- read reminder history