Skip to content

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.

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
end
Sales-man pipeline — lead → pipeline → meetings → feedback → qualify / disqualify / lost

Every endpoint on this page lives inside three concentric middleware groups in routes/api/sales-man.php:

  1. user-provider:sales_man — the persona guard for the whole file (routes/api/sales-man.php:8).
  2. auth:sanctum + ability:access-sales-man-api — the authenticated block; the Microsoft-SSO Sanctum token must carry the access-sales-man-api ability (:20-21).
  3. salesman-country-validation — the country-scoped block that wraps every lead, pipeline, and meeting route (:88). The alias resolves to ValidateSalesManCountry (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”
  1. Create the leadPOST /leadsLeadController@store (app/Http/Controllers/Api/SalesMan/LeadController.php:60-106). Validated by SubmitLeadRequest, which merges country-neutral common rules (first_name, phone, destination_slug, …) with CountryRequestValidator::make('create_lead') (app/Http/Requests/SalesMan/Lead/SubmitLeadRequest.php:21-44). The SubmitNewLead Action posts to {api}/salesman/{sid}/leads with the country header (app/Actions/SalesMan/Lead/SubmitNewLead.php:14-31). On a 200, a local UserRequest audit row (request_type = LEAD) is saved (LeadController.php:89-95); a CRM “duplicate” response comes back non-200, triggers a DuplicateLead notification, and returns a 400 (:96-105). A new lead is OPEN.

  2. Open a pipeline on the lead (or an existing customer)POST /pipelinesPipelinesController@store (app/Http/Controllers/Api/SalesMan/PipelinesController.php:56-91). CreatePipelineRequest calls CountryRequestValidator::make('create_pipeline') (app/Http/Requests/SalesMan/Pipeline/CreatePipelineRequest.php:17). The body must carry exactly one of lead_source_id or customer_source_id (required_without each other) and a pipeline_type of 1 SINGLE_UNIT or 2 MULTIPLE_UNITS (app/Enums/SalesMan/LeadPipelineTypeEnums.php:11-12). The CreatePipeline Action posts to {api}/salesman/{sid}/pipelines and stamps is_update = false (app/Actions/SalesMan/Pipelines/CreatePipeline.php:14-47).

  3. Log meetingsPOST /pipelines/{pipelineSourceId}/meetingsMeetingsController@submitMeeting (app/Http/Controllers/Api/SalesMan/MeetingsController.php:67-99), validated by CountryRequestValidator::make('submit_meeting') (app/Http/Requests/SalesMan/Pipeline/SubmitMeetingRequest.php:17). SubmitNewMeeting posts to {api}/salesman/{sid}/pipelines/{pipelineSourceId}/meetings (app/Actions/SalesMan/Meetings/SubmitNewMeeting.php:13-34). The agent’s whole day is available at GET /meetings (MeetingsController@index, sorted by meeting_date descending, :22-40, :101-105), and a pipeline’s meetings at GET /pipelines/{pipelineSourceId}/meetings (:42-65).

  4. Record feedback — feedback is not its own POST. It rides on the pipeline create/update body: both the Egypt CreatePipelineRequest and UpdatePipelineRequest extend PipelineFeedbackRequest, whose feedback_id is required and constrained to EgyptPipelineFeedbackEnums (app/Http/Requests/SalesMan/Country/Egypt/PipelineFeedbackRequest.php:23-27; enum app/Enums/SalesMan/EgyptPipelineFeedbackEnums.php:11-26). To move a pipeline forward you PATCH /pipelines/{pipelineSourceId}PipelinesController@updateUpdatePipeline, which stamps is_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).

  5. Capture unit-type interestPOST /pipelines/{pipelineSourceId}/unit-typesPipelineUnitTypesController@store (app/Http/Controllers/Api/SalesMan/PipelineUnitTypesController.php:41-80), validated by CountryRequestValidator::make('create_unit_type') (app/Http/Requests/SalesMan/Pipeline/SubmitUnitTypeRequest.php:17). This sub-resource is Oman and Montenegro only — for egypt the interested unit type is captured inline on the feedback body via unit_type_source_id (PipelineFeedbackRequest.php:37), so an Egypt pipeline never calls this endpoint.

  6. Resolve the pipeline — three exits:

    • QualifyPOST /leads/check-lead-eligible (LeadController@checkLeadEligible, :211-237). If the CRM reports the lead has no active reservation, the controller returns a fresh reservation_link (:228-231); otherwise a fixed 400 “not eligible” message (:232-235). The reservation itself is the Sales-Man closes a sale flow.
    • Disqualify the leadPOST /leads/{leadSourceId}/disqualify (LeadController@disqualify, :133-162), validated by CountryRequestValidator::make('disqualify_lead') — a status_reason from that country’s disqualification enum (Egypt: EgyptLeadDisqualificationReasonEnums, app/Http/Requests/SalesMan/Country/Egypt/DisqualifyLeadRequest.php:18-21).
    • Mark the pipeline lostPATCH /pipelines/{pipelineSourceId}/lost (PipelinesController@markPipelineAsLost, :186-225), validated by CountryRequestValidator::make('mark_pipeline_as_lost') — a justification_id from LostPipelineJustificationEnums with conditional required_if follow-ups (app/Http/Requests/SalesMan/Country/Oman/MarkPipelineAsLostRequest.php:21-119). Oman and Montenegro only.

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-47no Egypt entry.
mark_pipeline_as_lost no yes yes CountryRequestValidator.php:58-61no Egypt entry.

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. ListPipelinesMapper calls OmanPipelineCurrentPhaseEnums::next($item->deal_progress_id) and shows that (app/ResponseMapper/SalesMan/ListPipelinesMapper.php:71-76). The phases are 1 DISCOVERY2 PROSPECTING3 SALESPITCH4 HANDLINGOBJECTIONS5 CLOSING (app/Enums/SalesMan/OmanPipelineCurrentPhaseEnums.php:11-27).
  • Egypt pipeline feedback is decoded the other direction: the mapper turns the CRM’s feedback_value string back into an int feedback_id via EgyptPipelineFeedbackEnums::map() (ListPipelinesMapper.php:38-48; EgyptPipelineFeedbackEnums::map, enum :28-47).
  • Montenegro pipelines get no extra field mapping (ListPipelinesMapper.php:16-20, default).