Status: 🟡 Approved design, not yet implemented. Spec of record for the
"one gated wizard + one service" refactor. See Roadmap item #21.
Audience: engineers. House terms: Job / Client.
There is no single create_job() service. A Job(...) row is constructed in
7 places, each repeating boilerplate and doing a different subset of the
peripheral work (schedule seeding, budget/cost-codes, lead linking, client resolve,
kickoff.notify, draft flag). The logic drifts every time one path changes.
| # | Constructor site | Role |
|---|---|---|
| 1 | app/routers/jobs.py:36 |
Inline — REST POST /api/jobs |
| 2 | app/services/ai_assistant.py:574 (_materialize_project) |
Shared — wizard + AI bar |
| 3 | app/routers/jobextras.py:930 (job_from_photo) |
Inline — express photo lane |
| 4 | app/services/jobs_admin.py:41 (create_draft_job_with_plan) |
Shared — plan lanes |
| 5 | app/services/conversion.py:67 (convert_proposal) |
Shared — proposal accept |
| 6 | app/services/resources/specs.py:252 (_job_from_row) |
Inline — CSV import (live; docstring wrongly says "export-only") |
| 7 | sample_data.py / demo_data.py |
Seed/demo only — out of scope |
Draft fast-capture is real and load-bearing: Job.is_draft (models/job.py:32)
purge_abandoned_drafts() (scheduler.py:95) that hard-deletesjobextras is_draft=(cid is None), documents.py:1281, conversion.py:56).Two layers:
create_job(db, org_id, ctx) → Job (the real cleanup)New app/services/job_create.py. Every path calls it. It owns name truncation,
resolve_or_create_client (people.py:113), schedule/budget seeding
(materialize_tasks, budgeting.seed_*), lead-won stitching + contact copy,
kickoff.notify_new_project, and the draft flag. Returns the Job — entry points
keep their own redirects (so view=classic vs command is never baked into the service).
JobCreateContext:
name: str | None # None ⇒ service derives a default per source
address: str | None
status: JobStatus = active
is_draft: bool = False
contract_value_cents: int | None
client: ClientRef | None # existing id OR {name,email,phone} to create
lead_id: UUID | None # → mark won, copy contacts, activity
proposal_id: UUID | None # → estimate/schedule/payments (peripheral stays in convert_proposal)
template_id: UUID | None
tasks: list[TaskDict] | None
seed_budget: bool = True
staged_doc_ids: list[UUID] # plan/photo already uploaded
source: Literal["api","wizard","ai_bar","photo","plan","proposal","import"]
actor_id: UUID | None
notify_office: bool = True
GET /portal/jobs/new, pre-seeded via ?lead= / ?template= / ?photo= / ?plan= /
?proposal=. Interactive entry points redirect into it; it calls create_job() at the end.
POST /api/jobs, proposal-accept + e-sign webhook, and CSV import call create_job()
directly — they have their context and can't/shouldn't show a wizard.
Draft creation stays ungated (express photo/plan upload-and-go stays fast; the 7-day
sweep cleans abandoned ones). The four checks fire only when a draft is finished /
promoted into a real Job via the wizard:
create_job(); repoint the 4 normaljobs.py:36, ai_assistant._materialize_project:574,jobextras.job_from_photo:930, jobs_admin:41) + /api/jobs + import at it. Verifyportal/router.py:609) → redirect into wizard" with AI pre-fill. Gate on finish.from-template route (planning.py:225), duplicate modals. Centralize the 4-spotconvert_proposal calls create_job(source="proposal")status != accepted guard,proposal_portal.py:132); the promote-existing-draft branch (conversion.py:55-66)convert_proposal — don't fold it into a god-function.create_job() never owns redirects; each entry point decides next.Scope = both layers · gate enforces all 4 checks · gate at promotion · keep drafts for
fast capture · AI pre-fills the wizard · form factor = full-page wizard · remove the
redundant interactive surfaces · direct callers = API + proposal + import · rollout = incremental.
Source investigation + decisions: _meta/capture-log. Fixes the
four "start a new job" pains from How-to: Start a new Job.