The sales-man pipeline
Before a sales-man can close a sale, they run a discovery
pipeline: capture a prospect as a lead, open a pipeline on that lead, log meetings,
record feedback and unit-type interest, and finally push the lead toward a reservation —
or disqualify the lead, or mark the pipeline lost. The sales-man backend is a thin,
authenticated pass-through to crms-middleware, which writes each step to the CRM (Dynamics);
there is almost no local mirror. Everything here is verified against the current backend code.
The single most important structural fact: every route in this flow requires a country
request header, and that header does double duty — it both scopes the CRM identity of the write
and selects which per-country FormRequest validates the body.
The discovery journey, in one diagram
Section titled “The discovery journey, in one diagram”sequenceDiagram
actor SM as Sales-Man
participant SPA as Sales-Man SPA
participant API as Backend /api/sales-man
participant MW as crms-middleware → CRM (Dynamics)
Note over SM,API: every call carries a Sanctum token AND a valid country header
SM->>SPA: Capture a prospect
SPA->>API: POST /leads (SubmitLeadRequest — per country)
API->>MW: POST /salesman/{sid}/leads
Note over MW: lead state OPEN
SM->>SPA: Open a discovery pipeline on that lead
SPA->>API: POST /pipelines (CreatePipelineRequest — per country)
API->>MW: POST /salesman/{sid}/pipelines (is_update false)
loop Discovery work
SM->>SPA: Log a meeting
SPA->>API: POST /pipelines/{pid}/meetings
API->>MW: POST /salesman/{sid}/pipelines/{pid}/meetings
SM->>SPA: Record feedback (and unit-type interest)
SPA->>API: PATCH /pipelines/{pid} (UpdatePipelineRequest, is_update true)
SPA->>API: POST /pipelines/{pid}/unit-types (Oman and Montenegro only)
end
alt Qualify toward a sale
SPA->>API: POST /leads/check-lead-eligible
API-->>SPA: reservation_link (only if eligible)
Note over SPA: continues on the Sales-Man closes a sale flow
else Disqualify the lead
SPA->>API: POST /leads/{lid}/disqualify (status_reason)
Note over MW: lead state DISQUALIFIED
else Mark the pipeline lost
SPA->>API: PATCH /pipelines/{pid}/lost (justification_id)
Note over MW: Oman and Montenegro only
endThe route stack: three nested gates
Section titled “The route stack: three nested gates”Every endpoint on this page lives inside three concentric middleware groups in
routes/api/sales-man.php:
user-provider:sales_man— the persona guard for the whole file (routes/api/sales-man.php:8).auth:sanctum+ability:access-sales-man-api— the authenticated block; the Microsoft-SSO Sanctum token must carry theaccess-sales-man-apiability (:20-21).salesman-country-validation— the country-scoped block that wraps every lead, pipeline, and meeting route (:88). The alias resolves toValidateSalesManCountry(app/Http/Kernel.php:78).
ValidateSalesManCountry binds the resolved Country with App::scoped(Country::class, …)
(app/Http/Middleware/ValidateSalesManCountry.php:20-25), so when a controller type-hints
Country $country it receives the exact model the middleware chose. Each Action then forwards that
country->slug back to crms-middleware as a country header and resolves the agent’s per-country
CRM id via SalesManUtil::getSourceIdByCountryIdAndUserId($country->id, $salesMan->id). Only three
slugs exist: egypt, oman, montenegro (app/Enums/CountryEnums.php:11-13).
Walking the pipeline, endpoint by endpoint
Section titled “Walking the pipeline, endpoint by endpoint”-
Create the lead —
POST /leads→LeadController@store(app/Http/Controllers/Api/SalesMan/LeadController.php:60-106). Validated bySubmitLeadRequest, which merges country-neutral common rules (first_name,phone,destination_slug, …) withCountryRequestValidator::make('create_lead')(app/Http/Requests/SalesMan/Lead/SubmitLeadRequest.php:21-44). TheSubmitNewLeadAction posts to{api}/salesman/{sid}/leadswith thecountryheader (app/Actions/SalesMan/Lead/SubmitNewLead.php:14-31). On a200, a localUserRequestaudit row (request_type = LEAD) is saved (LeadController.php:89-95); a CRM “duplicate” response comes back non-200, triggers aDuplicateLeadnotification, and returns a400(:96-105). A new lead isOPEN. -
Open a pipeline on the lead (or an existing customer) —
POST /pipelines→PipelinesController@store(app/Http/Controllers/Api/SalesMan/PipelinesController.php:56-91).CreatePipelineRequestcallsCountryRequestValidator::make('create_pipeline')(app/Http/Requests/SalesMan/Pipeline/CreatePipelineRequest.php:17). The body must carry exactly one oflead_source_idorcustomer_source_id(required_withouteach other) and apipeline_typeof1SINGLE_UNITor2MULTIPLE_UNITS(app/Enums/SalesMan/LeadPipelineTypeEnums.php:11-12). TheCreatePipelineAction posts to{api}/salesman/{sid}/pipelinesand stampsis_update = false(app/Actions/SalesMan/Pipelines/CreatePipeline.php:14-47). -
Log meetings —
POST /pipelines/{pipelineSourceId}/meetings→MeetingsController@submitMeeting(app/Http/Controllers/Api/SalesMan/MeetingsController.php:67-99), validated byCountryRequestValidator::make('submit_meeting')(app/Http/Requests/SalesMan/Pipeline/SubmitMeetingRequest.php:17).SubmitNewMeetingposts to{api}/salesman/{sid}/pipelines/{pipelineSourceId}/meetings(app/Actions/SalesMan/Meetings/SubmitNewMeeting.php:13-34). The agent’s whole day is available atGET /meetings(MeetingsController@index, sorted bymeeting_datedescending,:22-40,:101-105), and a pipeline’s meetings atGET /pipelines/{pipelineSourceId}/meetings(:42-65). -
Record feedback — feedback is not its own
POST. It rides on the pipeline create/update body: both the EgyptCreatePipelineRequestandUpdatePipelineRequestextendPipelineFeedbackRequest, whosefeedback_idisrequiredand constrained toEgyptPipelineFeedbackEnums(app/Http/Requests/SalesMan/Country/Egypt/PipelineFeedbackRequest.php:23-27; enumapp/Enums/SalesMan/EgyptPipelineFeedbackEnums.php:11-26). To move a pipeline forward youPATCH /pipelines/{pipelineSourceId}→PipelinesController@update→UpdatePipeline, which stampsis_update = true(app/Actions/SalesMan/Pipelines/UpdatePipeline.php:38-50). The only feedback route is a read:GET /pipelines/{pipelineSourceId}/feedback(PipelinesController@listFeedback,:132-151). -
Capture unit-type interest —
POST /pipelines/{pipelineSourceId}/unit-types→PipelineUnitTypesController@store(app/Http/Controllers/Api/SalesMan/PipelineUnitTypesController.php:41-80), validated byCountryRequestValidator::make('create_unit_type')(app/Http/Requests/SalesMan/Pipeline/SubmitUnitTypeRequest.php:17). This sub-resource is Oman and Montenegro only — foregyptthe interested unit type is captured inline on the feedback body viaunit_type_source_id(PipelineFeedbackRequest.php:37), so an Egypt pipeline never calls this endpoint. -
Resolve the pipeline — three exits:
- Qualify →
POST /leads/check-lead-eligible(LeadController@checkLeadEligible,:211-237). If the CRM reports the lead has no active reservation, the controller returns a freshreservation_link(:228-231); otherwise a fixed400“not eligible” message (:232-235). The reservation itself is the Sales-Man closes a sale flow. - Disqualify the lead →
POST /leads/{leadSourceId}/disqualify(LeadController@disqualify,:133-162), validated byCountryRequestValidator::make('disqualify_lead')— astatus_reasonfrom that country’s disqualification enum (Egypt:EgyptLeadDisqualificationReasonEnums,app/Http/Requests/SalesMan/Country/Egypt/DisqualifyLeadRequest.php:18-21). - Mark the pipeline lost →
PATCH /pipelines/{pipelineSourceId}/lost(PipelinesController@markPipelineAsLost,:186-225), validated byCountryRequestValidator::make('mark_pipeline_as_lost')— ajustification_idfromLostPipelineJustificationEnumswith conditionalrequired_iffollow-ups (app/Http/Requests/SalesMan/Country/Oman/MarkPipelineAsLostRequest.php:21-119). Oman and Montenegro only.
- Qualify →
The per-country validators
Section titled “The per-country validators”CountryRequestValidator is a static dispatch table: each action maps to a {country → FormRequest}
lookup (CountryRequestValidator.php:33-72). The thin FormRequest classes under
app/Http/Requests/SalesMan/Pipeline/* and .../Lead/* do nothing but call
CountryRequestValidator::make('<action>')->rules(); the real rules live in the country folders under
app/Http/Requests/SalesMan/Country/{Egypt,Oman,Montenegro}/.
| Action key | egypt |
oman |
montenegro |
Notes |
|---|---|---|---|---|
create_lead |
yes | yes | yes | Egypt adds channel_id (required), feedback_id, lead_quality_code (Egypt/CreateLeadRequest.php:15-32). |
create_pipeline |
yes | yes | yes | Egypt also requires destination_slug (Egypt/CreatePipelineRequest.php:23) — Oman and Montenegro do not (Oman/CreatePipelineRequest.php:18-24). |
update_pipeline |
yes | yes | yes | Egypt adds proceed_to_closing, objection fields, unit_id, closure_date (Egypt/UpdatePipelineRequest.php:19-51). |
submit_meeting |
yes | yes | yes | Egypt is meeting_date (required int) plus comment and location (Egypt/SubmitMeetingRequest.php:11-16). Montenegro adds meeting_type (Montenegro/SubmitMeetingRequest.php:13-18). |
disqualify_lead |
yes | yes | yes | status_reason from that country’s disqualification enum. |
create_sale |
yes | yes | yes | Belongs to the close-a-sale flow. |
create_unit_type |
no | yes | yes | CountryRequestValidator.php:44-47 — no Egypt entry. |
mark_pipeline_as_lost |
no | yes | yes | CountryRequestValidator.php:58-61 — no Egypt entry. |
Lead status, as the portal reads it
Section titled “Lead status, as the portal reads it”The lead’s state and expiration status are CRM-owned — the portal never writes them
locally. It only decodes the CRM’s numeric codes into these enums when it maps a lead for display or
analytics (app/Actions/SalesMan/Analytics/SyncData/Leads/TransformLeadsCrm.php:57-58, using
state_code and expiration_status_code).
LeadStateEnums (int-backed, app/Enums/SalesMan/LeadStateEnums.php:11-13):
| Value | Case | Meaning |
|---|---|---|
0 |
OPEN |
Freshly captured lead, still being worked in discovery. |
1 |
QUALIFIED |
Lead qualified — a pipeline has progressed it toward a sale. |
2 |
DISQUALIFIED |
Lead disqualified via POST /leads/{leadSourceId}/disqualify, reflected back from the CRM. |
LeadExpirationStatusEnums (int-backed, app/Enums/SalesMan/LeadExpirationStatusEnums.php:11-15):
| Value | Case | Meaning |
|---|---|---|
1 |
ACTIVE |
Lead is live and inside its follow-up window. |
2 |
NOT_RESPONSIVE |
Contact attempts have not landed. |
3 |
LOST |
Lead lost. |
4 |
DISQUALIFIED |
Expired into the disqualified bucket. |
5 |
MET_UNREACHABLE |
Met once, now unreachable. |
These labels drive the analytics filters — for example meetings-with-no-feedback excludes leads in
NOT_RESPONSIVE / LOST / DISQUALIFIED / MET_UNREACHABLE
(app/Actions/SalesMan/Analytics/Meetings/GetMeetingsWithNoFeedbackList.php:47-50).
Pipeline phase and feedback, on the read side
Section titled “Pipeline phase and feedback, on the read side”- Oman pipeline phase is computed for display, not read verbatim.
ListPipelinesMappercallsOmanPipelineCurrentPhaseEnums::next($item->deal_progress_id)and shows that (app/ResponseMapper/SalesMan/ListPipelinesMapper.php:71-76). The phases are1DISCOVERY→2PROSPECTING→3SALESPITCH→4HANDLINGOBJECTIONS→5CLOSING(app/Enums/SalesMan/OmanPipelineCurrentPhaseEnums.php:11-27). - Egypt pipeline feedback is decoded the other direction: the mapper turns the CRM’s
feedback_valuestring back into an intfeedback_idviaEgyptPipelineFeedbackEnums::map()(ListPipelinesMapper.php:38-48;EgyptPipelineFeedbackEnums::map, enum:28-47). - Montenegro pipelines get no extra field mapping (
ListPipelinesMapper.php:16-20,default).
