Spec: F27 — Relationships (the contractor’s customer/supplier/subcontractor graph)
Status: partial — repository backend proof is strong, but browser execution and deployed extraction-quality evidence remain unproven in this checkout. Contract Graph: F27.cue · implementation audit. The graph records repository proof separately from browser and deployed-quality evidence.
The Contract Graph assigns the generated company, contact, core deal, custom-field, pipeline, and
stage CRUD operations, unresolved company, signals, timeline, and resolution operations individually
to F27.
This closes inventory ownership only; it does not claim browser, production, or live CRM-provider
evidence.
Reference study: reference/Django-CRM/ — see §11 for what we adopt and what we deliberately reject.
1. Problem & CEO Job
See clearly — per relationship. A contractor’s khách hàng (customers), NCC (suppliers), and thầu phụ (subcontractors) live in the owner’s head, Zalo, and Excel — strings that never aggregate. Lumi captures those names on tasks, commitments, risks, and daily pulses, but the CEO cannot see a customer’s open quotes, slipping commitments, unresolved risks, and last mention together. F27 turns names into records so signals converge into a relationship graph (khách hàng · liên hệ · báo giá–đặt cọc–công trình): operating memory Excel and Zalo cannot provide. The competitor is the customer spreadsheet and lost-in-Zalo history, not Salesforce or HubSpot. Relationship 360 is primary; the deal pipeline is secondary. Unlike Excel, Lumi’s activity log writes itself from pulses.
Multi-team by design: a Company can be a customer and a supplier; pipelines belong to teams (sales runs a sales pipeline, purchasing runs a supplier-onboarding or order pipeline). Same primitives, no “sales module” smell.
2. User Stories & Acceptance Criteria
- As a CEO, I want one page per company showing everything Lumi knows, so that “tình hình khách X?” is one tap, not four questions.
- Company 360 shows: relationship type(s), key contacts, open deals (per pipeline), open commitments to them, open risks about them, open tasks tagged to them — each linking to its source page.
- A server-validated
httporhttpswebsite on shared native Company Detail is a 48dp+ accessible action that opens the system browser. Unsupported or malformed legacy schemes remain neutral text, and a failed handoff leaves the record visible with a retryable action. - Shared native Company Detail measures its Edit/Delete rail outside the scroll viewport while preserving the native-shell bottom allowance, so company context cannot settle underneath either action.
- As a field operator, I want to find a company by the name or context I remember, so that a growing directory stays useful on a phone.
- The shared company directory searches the loaded tenant snapshot by company name, industry, city, tax code, or website; no-match results use authored copy and a clear action restores the full list without a per-keystroke request.
- A failed directory refresh keeps the last committed companies and search context visible behind one compact localized warning with Retry; only an initial failure without a snapshot replaces the directory.
- As a salesperson, I want deals as stage cards I drag/advance, so that pipeline review takes minutes.
- Deal: company + pipeline + stage + amount + currency (defaults to org’s
default_currency, set from user language at org creation — vi→VND, en→USD, ar→SAR, ms→MYR, id→IDR, fil→PHP, lv→EUR) + expected close + owner. Create with company + title only; everything else progressive. - Won/lost are terminal with required
lost_reasonchoice on lost (the only mandatory friction — it’s the learning loop); won requiresamount, closed requiresclosed_on— validated server-side (Django-CRM pattern). - Web Won/Lost actions close by canonical deal status even when an organization-defined pipeline has no matching terminal-stage row; when a matching row exists it is retained in the update, and missing required close data is explained rather than flattened to a generic failure.
- Once a deal is Won or Lost, web table, CSV export, and detail summary present the localized terminal status even if the deal retains its former open-stage ID because no matching terminal stage exists.
- Every stage change stamps
stage_changed_at; “days in stage” renders on the card — staleness is visible where the work happens, not only in the Brief.
- Deal: company + pipeline + stage + amount + currency (defaults to org’s
- As a purchasing lead, I want my own pipeline with my own stages, so that supplier flows don’t wear sales vocabulary.
- Pipeline: name + ordered Stage rows (org-defined labels + per-stage
expected_daysfor staleness), optional team binding; deals are filterable per pipeline/team.
- Pipeline: name + ordered Stage rows (org-defined labels + per-stage
- As an admin, I want custom fields without a developer, so that our industry’s data fits.
-
FieldDefinition(entity: company/contact/deal; key, label vi+en, type: text/number/date/choice; choices; required) — values live incustom_fieldsJSONField, validated server-side against the definition. No free-floating JSON garbage: undefined keys are rejected.
-
- As Lumi (AI), I want to resolve pulse/extraction name-strings to companies, so that signals self-file.
- Extraction (F08) attempts exact/normalized match of
customer_name→Company; sets the FK on match, leaves the string untouched on miss (never guess-match — a wrong link is worse than none). - Task/Commitment/Risk gain nullable
companyFK; existingcustomer_namestrings remain as raw capture.
- Extraction (F08) attempts exact/normalized match of
- As a CEO, I want deal risk in my Brief, so that revenue exposure reaches me without opening the pipeline.
- Brief candidates: deal closing ≤7 days with open commitments to that company; deal stalled —
days_in_current_stageexceeds the stage’sexpected_days(default 14) — above an amount floor. Capped by F12’s noise rules.
- Brief candidates: deal closing ≤7 days with open commitments to that company; deal stalled —
- As a CEO, I want the Business lane to distinguish an empty CRM section from unavailable data, so that a failed CRM request never looks like a calm zero.
- Business keeps successful CRM summary sections (deals, companies, and contacts) visible when another section fails, labels failed CRM sections as unavailable, and offers a retry path.
- A valid successful empty CRM response remains an authored empty state rather than an error; a total Business outage preserves the existing last-known-good snapshot through the error path.
Note: custom-field “Thông tin thêm” form rendering on company/contact/deal create/edit forms (driven by
FieldDefinition) is deferred — the CompaniesPage create form is name + relationship flags only, custom_fields editing not yet exposed in UI (backend validation is enforced; no UI gap blocks any acceptance criterion above).
3. Scope Boundaries
- In: companies, contacts, pipelines (multi-team), deals, custom fields, name→company resolution, company-360, Brief/chat glue,
Task.deallink (tasks attachable to deals). - Cross-link (external confirmation → Loop 2): when an external party — a client duyệt báo giá (approves a quote), an NCC xác nhận giá/ngày giao (supplier confirms price/delivery date), or a thầu phụ (subcontractor) confirms a task — that confirmation routes via F71 (Loop 2). F27 owns the relationship record (the
Company/Contact/Dealrow and its 360); F71 owns the external no-login surface that mints the link, records the answer with evidence, and turns the confirming party into a future Lumi buyer. F27 does not build any external-facing page itself. - Out (hard — this is where CRMs go to bloat): email integration/sync, lead-capture forms, marketing automation, quotas/forecasting dashboards, activity logging UI (pulses are the activity stream), import/export (registry out-of-scope), duplicate-merge tooling (v1: prevent via unique normalized name warning at create).
4. Data Model (as built — app crm)
Company— org FK, name (+ normalized name for matching, unique per org case-insensitively — Django-CRM’sLower("name")constraint), relationship flags (is_customer, is_supplier, is_partner — flags, not enum: real companies are several at once), tax_code, website, industry, address_line, city, owner, team,custom_fieldsJSON. Indexes: (org, -created_at), name.Contact— org FK, company FK, name, phone, zalo, email, title,custom_fields.Pipeline— org FK, name, team nullable, is_active.Stage— pipeline FK, name,order,expected_days(staleness threshold, default 14), is_terminal_won/is_terminal_lost flags. Stage is a DB row, not JSON (Django-CRM pattern): deals FK it, renames are free, per-stage aging config has a home, and delete-while-occupied is blockable at the DB level.Deal— org FK, company FK, pipeline FK, stage FK, title, amount/currency, owner, team, expected_close_date, status:open → won | lost,lost_reason,closed_on,stage_changed_at(auto-stamped on stage change),custom_fields. Validation: won ⇒ amount required; won/lost ⇒ closed_on required.FieldDefinition— org FK, entity_type, key, label_vi/label_en, field_type,choices_json, required. Unique (org, entity_type, key).- Migrations on existing models: nullable
companyFK on Task, Commitment, Risk; nullabledealFK on Task.
ER + Deal state machine → backend/DIAGRAMS.md. GLOSSARY entries: Company, Deal, Pipeline, FieldDefinition. ADR required: custom-field validation strategy and name-resolution rules.
Reference study: Django-CRM patterns adopted: Stage as DB rows (not JSON); stage_changed_at + per-stage aging; close validation (won ⇒ amount, closed ⇒ close date); CustomFieldDefinition row + entity custom_fields JSONField; case-insensitive unique company name via Lower("name"). Deliberately rejected: separate Lead entity + conversion flow (SMEs don’t think in these); Cases app (helpdesk); email models (no email infra by philosophy); Activity/Comment/Notification models (pulses are the activity stream); invoices/orders/line items (F24’s lane); address as separate model, annual_revenue, probability auto-scoring (field farming).
5. API Contract (as built)
| Method | Path | Min role | Notes |
|---|---|---|---|
| CRUD | /api/companies/ | member | `?relationship=customer |
| GET | /api/companies/<id>/signals/ | viewer | The 360: deals/commitments/risks/tasks aggregated |
| CRUD | /api/contacts/ | member | |
| CRUD | /api/pipelines/ (+ nested stages) | admin | Stage rename is a row update (deals FK the row); stage delete blocked while occupied |
| CRUD | /api/deals/ | member | ?pipeline=&stage=&company= filters; stage change = PATCH |
| CRUD | /api/field-definitions/ | admin |
Tenant-scoped throughout; custom_fields validated against definitions on every write.
6. UI / UX
- New routes:
/companies(list + 360 detail),/deals(pipeline board — columns = stages; this is the one board view in Lumi and it must not metastasize into kanban-everything). - The 360 page reuses the same item cards as Today/Brief — Lumi has one card vocabulary; CRM doesn’t introduce a second design language.
- Custom fields render in a plain “Thông tin thêm” section — labeled inputs, no form-builder UI exposure to members.
- States: empty companies (“Lumi sẽ gợi ý công ty từ pulse khi bạn nhắc tên khách”) — and when extraction has unresolved names, surface them as one-tap “tạo công ty từ ‘Vincom’?” suggestions. The Business lane must distinguish valid empty data from a failed CRM summary section: successful zero counts keep their empty copy; failed CRM sections show an unavailable label and a retryable partial-data banner. Approval and Forecast row truth is owned by F26 and F50.
- The populated Contacts directory keeps search local to the already tenant-scoped response. It matches name, title, email, phone, or Zalo, shows a purposeful no-match state, and offers a clear action that restores the full directory without a per-keystroke request. Refresh failure keeps the last committed rows and current query visible behind a compact localized Retry; repeated Retry never discards that snapshot.
- The populated Companies directory follows the same local-search contract. It matches company name, industry, city, tax code, or website, shows a purposeful no-match state, and offers a clear action that restores the full directory without a per-keystroke request.
- Company Detail must not paint a dead website value as an action. Only a supported
httporhttpsvalue receives Lumi Blue, button semantics, a stable automation tag, and a 48dp+ target; URI schemes that can execute another capability are never dispatched as websites. - Company 360 signal rows render each source’s own facts truthfully: open deals show their amount through the shared money formatter with the endpoint-provided currency (CRM-G1 — never a raw serializer string, never a hidden amount), tasks and commitments show a humanized localized due label plus status (WRK-G2 — never raw ISO), and risks show localized severity. Parts the backend never sent stay omitted; unknown server vocabulary stays visible for recovery rather than being silently blanked.
- The compact pipeline aggregate is deliberately fail-closed when any CRM count needed for the combined sentence is unavailable; the individual directory rows still preserve each successful CRM section.
- i18n:
companies,dealsnamespaces, en+vi.
7. Test Plan
| # | Case | Type | Where |
|---|---|---|---|
| 1 | CRUD all entities; cross-tenant blocked on every one | backend | new classes in backend/tests/ |
| 3 | Name resolution: exact/normalized match sets FK; ambiguous/miss leaves string | backend | same |
| 4 | 360 aggregation correctness (only that company’s signals) | backend | same |
| 5 | Deal lifecycle incl. lost_reason requirement; stage-rename mapping | backend | same |
| 6 | Brief deal-risk candidates respect caps | backend | extend TestBriefDealItems |
| 7 | Company list/360, deal board, stage move | e2e | new describe("Companies"), describe("Deals") |
| 8 | Native contact CRUD: create → detail → edit → delete with the same tenant-scoped API record | platform UI | .maestro/critical/27_contacts_crud_flow.yaml; named-device execution required for PM scoring |
| 9 | Native populated Contacts search and recovery: match recognizable metadata → no-match → clear → bounded refresh failure with stale rows → Retry | platform UI | .maestro/critical/146_contacts_search.yaml; scripts/run-android-contact-search-proof.sh; named-device execution remains required for PM scoring |
| 10 | Native populated Companies search: match recognizable metadata → no-match state → clear back to full directory | platform UI | .maestro/critical/148_ios_company_search.yaml through scripts/run-ios-company-360-signals-proof.sh; Android source/JVM proof and named-device execution remain required for PM scoring |
| 11 | Native Company Detail website: accept only http/https, expose one accessible action, and hand off to the system browser | shared JVM + platform UI | CompanyDetail360PresentationTest; CompanyDetailNavigationSourceTest; .maestro/critical/98_company_360_signals.yaml through the lifecycle-owned Android/iOS Company 360 runners |
| 12 | For non-viewer roles, native Company Detail opens the existing Create Deal form with the exact company preselected; viewers do not receive that CTA and Edit remains secondary | shared JVM + platform UI | CompanyDetailNavigationSourceTest; WorkspaceTests; .maestro/critical/98_company_360_signals.yaml through the lifecycle-owned Android/iOS Company 360 runners |
8. Current Behavior
CRM v1 already includes companies, contacts, pipelines, stages, deals, 360 views, and extraction-linked resolution, with the deliberately narrow scope that F50 later deepens rather than replaces. The shared Business home loads five summary sections in parallel; F27 owns the CRM sections (deals, companies, and contacts) and preserves their endpoint-level failure metadata so a failed section cannot present as a successful zero. F26 and F50 own approval and Forecast row truth. Before the 2026-08-09 repair, shared Company Detail painted a website in Lumi Blue but supplied no click callback, so the value falsely advertised an action.
9. Error States
| # | Error | User message | HTTP | Logged | Notes |
|---|---|---|---|---|---|
| ES1 | Cross-tenant company access — member of org A tries to access org B’s company UUID. | Standard 404 | 404 | No | CompanyViewSet queryset filters by request.tenant. |
| ES2 | Duplicate company name (case-insensitive per org) — two companies with name “Vincom” and “vincom” in the same org. | {"name": ["Company with this Name already exists."]} | 400 | No | Enforced by UniqueConstraint(Lower("name"), "organization"). |
| ES3 | Cross-org contact company — a contact is created with a company_id from a different org. | {"company": ["Company does not exist."]} | 400 | No | ContactSerializer validates company.organization == request.tenant in validate_company(). |
| ES4 | Stage delete with active deals — admin tries to delete a stage that has deals in it. | stages.error_stage_has_deals | 409 | No | Checked in StageViewSet.perform_destroy() — counts deals linked to stage. |
| ES5 | Custom field undefined key — PATCH on a company with a custom_fields key that has no matching FieldDefinition. | {"custom_fields": {"<key>": ["Unknown field '<key>'."]}} | 400 | No | validate_custom_fields() in validators.py rejects any key not in the org+entity_type FieldDefinition set. |
| ES6 | Custom field wrong type — a number-type field gets a string value. | {"custom_fields": {"<key>": ["Expected number, got str."]}} | 400 | No | Type enforcement per field_type. |
| ES7 | Terminal deal reopen — a won/lost deal is PATCHed with status=open. | {"detail": "Won/lost deals cannot be reopened."} | 400 | No | Checked in DealViewSet serializer validation. |
| ES8 | Unresolved name with zero companies — GET /api/companies/unresolved/ called when the org has no companies. | Returns [] (empty list) — nothing to resolve to. | 200 [] | No | Queryset filters correctly; no explicit empty-org test. |
| ES9 | 360 endpoint for company with no signals — signals endpoint for a company with zero deals/commitments/risks/tasks. | Returns empty arrays for each category (no nulls). | 200 | No | Serializer handles empty querysets; no explicit empty-signals test. |
| ES10 | Business CRM summary section unavailable — one or more Business home deals, companies, or contacts requests fail while another succeeds. | Partial-data banner with Retry; failed CRM rows say unavailable; successful CRM rows and valid zero counts remain visible. | n/a | Yes | Endpoint-level failure metadata is kept in the shared summary; cancellation still propagates. F26 and F50 own approval and Forecast rows. |
| ES11 | Total Business summary outage — all five parallel summary requests fail. | Existing Business error state with Retry; on refresh, the last successful summary remains visible instead of being replaced by all-zero data. | n/a | Yes | The repository throws after all section results fail; BusinessHomeScreenModel retains LoadState.Error.last. |
| ES12 | Contacts search has no match — a local query does not match the loaded directory snapshot. | Purposeful no-match copy with a clear-search action; the server list is not mutated and no empty-directory copy is shown. | n/a | No | Filtering is local to the tenant-scoped response; clearing the query restores all rows. |
| ES13 | Companies search has no match — a local query does not match the loaded directory snapshot. | Purposeful no-match copy with a clear-search action; the server list is not mutated and no empty-directory copy is shown. | n/a | No | Filtering is local to the tenant-scoped response; clearing the query restores all rows. |
| ES14 | Company website cannot be dispatched — a legacy value has a non-web scheme, is malformed, or the platform cannot open it. | Unsupported values remain neutral text. A platform handoff failure keeps Company Detail visible and shows the existing localized CRM action-failed banner; tapping the website retries. | n/a | No | Shared code permits only http and https; it never dispatches javascript, file, mailto, or another capability as a company website. |
| ES15 | Populated Contacts refresh fails — the directory has committed rows and a later refresh cannot reach the endpoint. | Existing rows, search field, and query remain visible with a one-line localized warning and Retry; transport detail is not rendered. | n/a | Yes | LoadState.Error.last remains the visible directory source and both load and refresh recover stale data through dataOrStale(). |
| ES16 | Populated Contact Detail refresh fails — a loaded contact cannot be refreshed. | The contact, company, communication facts, and available actions stay visible behind one compact localized Retry; repeated failure cannot erase the snapshot. | n/a | Yes | Both load and refresh use dataOrStale(); only an initial failure without a contact is blocking. |
10. Diagrams & Docs Touched
backend/DIAGRAMS.md (ER + Deal states) · frontend/DIAGRAMS.md (routes) · DIAGRAMS.md (signal-flow update: extraction→company resolution) · GLOSSARY · registry Out-of-Scope edit (CRM line superseded) · new ADR.
2026-08-22 — Contact detail lists its open stakeholder deals
Type: Shared Android/iOS + backend capability (Pipedrive-grade contact 360)
Summary:
- Contact detail showed only identity/company facts; the deals a contact participates in were invisible on mobile, forcing a manual pipeline hunt.
GET /api/deals/gains a tenant-scoped?contact=filter (stakeholder roles, deduped); KMP client, repository, and screen model exposecontactDealswith an independent LoadState so a deals failure never hides the contact record and offers in-place Retry.- The detail renders one compact Open-deals section: title + formatted money
(CRM-G1), rows navigating to canonical Deal Detail. iOS bridge routes through
onRowSelected; Android through DealDetailNavKey.
Tests:
backend/tests/crm/test_crm_deals_core.py::test_deal_filter_by_contact_returns_stakeholder_deals_onlyContactDetailScreenModelTest.loadFetchesTheContactOpenDealsForTheSameContactId/.dealsFailureKeepsContactVisibleAndRetryDealsRecovers- F27.AC008 contract proofs CONTACT_DEALS / CONTACT_DEALS_MODEL / CONTACT_DEALS_ERROR
Proof boundary:
- JVM/backend structural proof only; runtime Maestro journey and named-device evidence remain open.
2026-08-21 — Company 360 signal rows show truthful money, due, and status facts
Type: Shared Android/iOS presentation correctness
Summary:
- The Company 360 sections already listed open deals, commitments, risks, and tasks, but the row
meta line was
dueDate ?: severity ?: priority: deal rows showed no amount at all (violating CRM-G1), task rows leaked raw ISO dates, and risk severity stayed untranslated. - A pure mapper (
companySignalMeta) now extracts each signal kind’s own facts from the existingCompanySignalsResponse; the composable layer renders them through the shared money formatter, the localized due vocabulary, and localized status/severity resources. - The backend signals payload now includes deal
currencyso formatting uses the real currency; unknown server vocabulary remains visible instead of being blanked.
Tests:
CompanySignalMetaTest(money, humanized due, status/severity extraction)scripts/tests/test_company_360_mobile_policy.py::test_company_360_signal_rows_render_truthful_localized_meta.maestro/critical/98_company_360_signals.yaml(asserts formatted15.000.000 VND, rejects raw15000000, asserts a localized due label, rejects raw ISO dates)scripts/run-company-360-signals-proof.sh
Proof boundary:
- The flow-98 guarded Android runtime re-run with the extended assertions is recorded in the audit evidence; named-device, iOS runtime, accessibility-service, store, deployed, and production proof remain open.
2026-08-10 — Preserve the populated Contacts directory through refresh failure
Type: Shared Android/iOS recovery correctness
Summary:
- Contacts already retained stale rows in
LoadState.Error.last, but the shared screen replaced them with a blocking error and Retry reloaded from an empty snapshot. - The directory now renders committed rows and the current local search context behind a compact localized warning. In-place Retry keeps that snapshot until the endpoint succeeds.
- Flow 146 now combines its existing search/no-match/clear journey with a bounded two-request outage and exact recovery proof.
Tests:
ContactsScreenModelTestscripts/tests/test_crm_contact_search_policy.py.maestro/critical/146_contacts_search.yamlscripts/run-android-contact-search-proof.sh
Proof boundary:
- Guarded flow 146 most recently passed on
Lumi_Pixel7_API35with identical built/installed prodDebug APK SHA-25671d75415869893e46e7ba423be6882e55e4df7d453848950bd3218de65e33fa1. Both contacts remained visible across two allowlisted503responses, Retry forwarded a successful request, transport text stayed hidden, fixture cleanup reached0:0:0:0, and the sole emulator shut down. - This is managed-emulator evidence. iOS runtime, named-device, screen-reader-service, store, deployed, production, and PM audit proof remain open.
2026-08-10 — Preserve populated Contact Detail through refresh failure
Type: Shared Android/iOS recovery correctness
Summary:
- Contact Detail retained a first failed-refresh snapshot in
Error.last, but the shared screen replaced it with a blocking error and a second Retry could reload from an empty snapshot. - Detail now stays visible behind one compact one-line localized warning. Initial load failure remains blocking, while repeated refresh and explicit Retry use the same current-or-stale contact.
- Flow 146 extends its owned directory journey into Ada’s detail, injects an exact bounded detail outage, and proves recovery without mutating either contact.
Proof boundary:
- Guarded flow 146 passed on
Lumi_Pixel7_API35with identical built/installed prodDebug APK SHA-25671d75415869893e46e7ba423be6882e55e4df7d453848950bd3218de65e33fa1. Two directory and two exact-detail503s each preserved their populated surface; both explicit retries reached forwarded200responses, transport copy stayed hidden, cleanup reached0:0:0:0, proof ports closed, and the sole emulator shut down. - This is managed-emulator evidence. iOS runtime, named-device, screen-reader-service, store, deployed, production, and PM scoring remain open.
2026-08-04 — Preserve Business summary failure truth
Type: Mobile reliability / data truth
Summary:
- The Business home records which of its five parallel summary requests failed instead of flattening failures into empty counts; F27 owns the deals, companies, and contacts portion of that contract.
- Partial responses keep successful CRM data visible, mark failed CRM rows unavailable, and expose a retry action. A valid empty CRM response remains distinct from a failed response; a total outage keeps the existing stale-data error path, and retry keeps that stale snapshot visible while loading without replaying an old partial warning as current request truth. F26 and F50 own approval and Forecast semantics.
Proof boundary:
- Shared repository/model and source-policy tests prove the distinction.
- Named Android/iOS runtime, browser, and deployed evidence remain open; no runtime or release claim is inferred from JVM proof.
11. Implementation Audit (2026-06-10)
| # | Claim | Status | Evidence |
|---|---|---|---|
| 1 | Company CRUD: case-insensitive unique name per org (Lower("name")), relationship flags (is_customer/supplier/partner) | ✅ match | TestCompany; test_name_unique_per_org_case_insensitive |
| 2 | Deal lifecycle: stage change stamps stage_changed_at; won requires amount; won/lost requires closed_on | ✅ match | TestDeal::test_stage_change_stamps_stage_changed_at; TestDeal::test_won_deal_requires_amount |
| 3 | Custom fields: defined-key validation, type enforcement, undefined-key rejection | ✅ match | TestCustomFields backend tests |
| 4 | Name→company resolution: exact/normalized match sets FK; miss leaves string untouched (never guess-match) | ✅ match | TestNameResolution backend tests (test_crm.py:1151) |
| 5 | Company 360: /api/companies/<id>/signals/ aggregates deals/commitments/risks/tasks | ✅ match | TestCompany360Full::test_signals_aggregates_company_data |
| 6 | Brief deal-risk candidates: closing ≤7d with open commitments, stalled > expected_days | ✅ match | TestBriefDealItems (test_crm.py:1392; test_closing_soon_deal_with_open_commitment_appears_in_brief, test_stalled_deal_above_threshold_appears_in_brief) |
| 7 | Stage delete blocked when deals exist; terminal deal cannot be reopened | ✅ match | TestStageViewSet::test_stage_delete_blocked_when_deal_exists; TestDealTerminalState::test_won_deal_cannot_be_reopened |
| 8 | Organization.default_currency set from owner language at org creation; Deal.currency defaults to it | ✅ match | Implemented 2026-06-15: field on Organization (migration 0009, CharField(max_length=3) — ISO 4217); apps/organizations/currency.py maps language→currency (vi→VND, en→USD, ar→SAR, ms→MYR, id→IDR, fil/tl→PHP, lv→EUR; fallback USD); set in create_organization. Frontend deal amounts fall back to it via formatCurrency. Tests: tests/test_organization_currency.py |
| 9 | Orphaned custom fields after FieldDefinition deletion — next write rejects undefined keys | ✅ match | E9; validate_custom_fields() loads live FieldDefinition rows at validation time; deleted definition becomes unknown key; TestCustomFields::test_field_definition_deletion_orphans_key_at_next_write (2026-06-26) |
| 10 | Backward stage move stamps stage_changed_at and resets days counter | ✅ match | E2; perform_update stamps on any stage change (new_stage.pk != old_stage_id), direction-agnostic; TestDeal::test_backward_stage_move_stamps_stage_changed_at (2026-06-26) verifies backward move sets stage_changed_at and days_in_current_stage == 0 |
Summary: 10/10 match; all gaps closed (2026-06-26: rows 9 and 10 closed by backend test additions).
Reference Study (Django-CRM, audited 2026-06-10; see §4 for what was adopted/rejected): Stage-as-DB-rows, stage_changed_at + expected_days aging, close validation, CustomFieldDefinition, case-insensitive unique name — all adopted. Separate Lead entity, Cases app, email models, Activity/Comment/Notification models, invoices/orders, address-as-model, probability auto-scoring — all deliberately rejected.
Edge Cases Catalog (formerly §12, 2026-06-12; E1–E9): Key items integrated above. Remaining: E1 ownerless company — company still visible to all org members, 360 works; E3 duplicate contact email — no uniqueness constraint, both coexist (manual deduplication); E4 contact merge — not in v1; E5 org-delete cascade → Company cascade (standard Django); E6 bulk import — out of scope (single-request only); E7 won-without-amount → 400 (✅ tested). Full catalog in git history.
12. Open Questions
- Q1 — Organization.default_currency (decided 2026-06-14): Default currency follows user language, not VND. Add
Organization.default_currency(CharField max_length=10); set from owner’slanguageat org creation:vi→VND,en→USD,ar→SAR,ms→MYR,id→IDR,fil→PHP,lv→EUR. ChangeDeal.currencyserializer default toorganization.default_currency. Backfill existing orgs: vi→VND, others→USD. Unblocks F50 pipeline analytics (F50 §13 Q6) and F69 currency display. - Zalo as a first-class contact field (done above) — any other VN-specific fields warranting defaults? Ask design partners.
- Amount-floor for “stalled deal” brief items — org-configurable or fixed? Start fixed (50M VND), revisit.
- Tags M2M (Django-CRM has them, cheap and team-agnostic) — deferred from v1; add only if custom fields prove insufficient for filtering.
13. Bug / Change Log
2026-08-09 — Make the native Company website affordance truthful
Type: Mobile CRM / interaction trust
Summary:
- Shared Company Detail previously painted the website value in action blue but rendered the row without any click behavior.
- The website row will dispatch only validated
http/httpsvalues through the platform URI handler and expose one 48dp+ accessible action. - Unsupported legacy schemes remain neutral text. A failed platform handoff stays on the record and uses the existing localized CRM action-failed banner; no new browser, field, or action panel is introduced.
Tests:
CompanyDetail360PresentationTestCompanyDetailNavigationSourceTestscripts/tests/test_company_360_mobile_policy.py.maestro/critical/98_company_360_signals.yaml
Proof boundary:
- Shared JVM/source checks can prove URI policy, target semantics, and platform-handler ownership.
- The guarded Android runner must prove system handoff for its exact artifact, then re-enter the seeded company before continuing its existing Contact/Deal journey. Shared source/JVM compilation protects the common iOS path, and current iOS Company 360/search regression runs prove that shared screen still builds and navigates. The iOS flow does not execute the Android-only browser branch, so iOS handoff remains an explicit runtime gap alongside named-device, store-binary, deployed website, and screen-reader-service evidence.
2026-08-05 — Keep the Companies search field above the keyboard
Type: Mobile CRM / keyboard layout reliability
Summary:
- The shared Companies directory now owns
lumiImePadding()at its root, so the populated search field and result list retain an explicit Android keyboard-inset boundary. - This closes the
MobileUxInvariantsTestPart2regression that previously flagged the directory as a scrollable search surface without an inset owner.
Tests:
MobileUxInvariantsTestPart2.searchAndDataListScreensWithTopLevelLumiFormFieldMustApplyImePaddingscripts/tests/test_crm_company_search_policy.py
Proof boundary:
- The source invariant covers all shared scrollable search/data-list screens; named-device and deployed keyboard evidence remain separate mobile proof gaps.
2026-08-05 — Make Company 360 partial data recoverable in place
Type: Mobile CRM / relationship clarity
Summary:
- Company 360 keeps each count on the section that owns the corresponding row destination. A count renders only when that endpoint has committed current or last-known-good data, so a failed Contacts or Signals request cannot masquerade as a successful zero.
- A fully loaded company with no related records uses one authored empty state instead of a duplicate aggregate summary followed by repeated empty destinations.
- A linked-data failure exposes an explicit localized Retry action beside the error instead of requiring the operator to discover pull-to-refresh. Retry reloads only the related Contacts and Signals while keeping the base company visible and preserving stale related rows. Full refresh and scoped Retry are mutually exclusive so late responses cannot overwrite newer recovery state.
Tests:
CompanyDetailScreenModelTest.retryRelatedReloadsOnlyLinkedDataCompanyDetailScreenModelTest.retryRelatedIsIgnoredWhileFullRefreshIsInProgressCompanyDetail360PresentationTestscripts/tests/test_company_360_mobile_policy.pyCompanyDetailNavigationSourceTest.maestro/critical/98_company_360_signals.yaml
Proof boundary:
- Source/JVM tests and the guarded iOS Company 360 + company-search lifecycle are registered for the shared behavior on the approved simulator. The last iOS run predates the endpoint-owned count repair. Empty/error rendering, current-source iOS runtime, named-device, accessibility, store, and deployed evidence remain separate gaps.
2026-08-12 — Make Company Detail operational without adding a CRM menu
Type: Shared Android/iOS workflow repair
Summary:
- Company Detail now exposes one primary
Create dealaction that opens the existing shared deal form with the exact company selected. The company record remains the route-owned context; no new API, form, or duplicate CRM workflow was added. - Viewer sessions do not receive the Create Deal callback, matching the member-minimum deal CRUD contract; Edit is retained as a quiet secondary action and Delete remains confirmed. The action hierarchy therefore advances relationship work without crowding the measured bottom rail.
- Android carries the company through
CreateDealNavKey(companyId). iOS replaces separate generic and contact-origin destination cases with one typedcreateDeal(companyId, contactId)route so generic, company-origin, and contact-origin capture share one destination implementation.
Tests:
CompanyDetailNavigationSourceTestWorkspaceTests.maestro/critical/98_company_360_signals.yaml
Proof boundary:
- Shared JVM/source proof covers callback custody and exact typed shell routes. Guarded Android flow
98 passed on identical built/installed prodDebug APK SHA-256
b5e602f6280d95d8ad41dc0102b167e18f231b323c2773dcc31a2fb2fba96877: Company Detail opened the existing Create Deal screen, Maestro matched its company-ID-specific automation signal and the retained frame visibly shows360 Proof Companyselected, Back returned to Company Detail, the prior Contact/Deal/Task journey passed, fixture cleanup was0:0:0:0:0:0, and the emulator shut down. The current iOS XCFramework compiled for arm64 device and simulator; focusedCompanyBreadthTests(2) andCompanyDetailBridgeTests(2) passed on the one approved simulator before shutdown. This is emulator/simulator evidence, not named-device, accessibility-service, store, deployed, or production proof.
2026-08-10 — Company Detail actions no longer cover relationship context
Type: Shared Android/iOS layout correctness fix
Summary:
- Company Detail drew Edit/Delete over a full-height scroller and relied on a fixed
176.dpbottom pad to protect the final content. That pad could not stop intermediate relationship sections from occupying the action rail’s coordinates. - The same permission-gated actions now form a measured sibling below a weighted scroll viewport. The existing native-shell bottom allowance remains on the rail, and no edit/delete callback, confirmation, route, or related-data behavior changes.
Tests:
scripts/tests/test_company_360_mobile_policy.py.maestro/critical/98_company_360_signals.yaml
Proof boundary:
- Guarded Android flow 98 passed on identical built/installed prodDebug APK SHA-256
990d7c82cfb8c68c4649b0e381c8ed75944af192bf6011b074d9b2a5a3526e1b; it completed website handoff and linked Contact/Deal routes, required the final Tasks section plus Edit/Delete in one retained frame, cleaned fixtures to0:0:0:0:0, and shut the sole emulator down. Visual inspection confirmed the Tasks header ends above the measured rail. Named-device, iOS runtime, screen-reader-service, store, deployed, and production evidence remain separate obligations.
2026-08-10 — Remove duplicate Company 360 summary and false-zero ambiguity
Type: Mobile CRM / relationship truth and density
Summary:
- Removed the aggregate relationship-summary card because it repeated the five destination counts immediately below it and pushed linked contacts and deals down the phone viewport.
- Preserved Contacts and Signals availability separately before defaulting their response values. Section counts now render only for a source that returned committed data; a partial failure keeps the existing compact Retry warning and any stale rows without inventing a zero for the failed source.
- Populated contact and deal rows, website handoff, Edit/Delete actions, and retry serialization are unchanged.
Tests:
CompanyDetail360PresentationTestscripts/tests/test_company_360_mobile_policy.py.maestro/critical/98_company_360_signals.yaml
Proof boundary:
- Shared JVM and source-policy proof cover availability and presentation branching. Guarded Android
flow 98 passed the populated Company Detail journey on
Lumi_Pixel7_API35with identical built/installed prodDebug APK SHA-256446c46a2b877c39bc34bc89c7306e1a74e0141b1e6372c12c09f57c31ddae474; the runner retained the destination-owned count frame, completed Contact/Deal navigation and website handoff, verified fixture cleanup at0:0:0:0:0, and shut the emulator down. Current-source iOS, named-device, accessibility-service, store-binary, deployed, and production proof remain separate gaps.
2026-08-05 — Make the populated Companies directory searchable
Type: Mobile CRM / field usability
Summary:
- The shared Companies directory now exposes a compact local search field once companies are available, matching name, industry, city, tax code, or website without issuing a request for every keystroke.
- No-match results use authored English/Vietnamese and translated locale copy plus a stable Clear search action; clearing restores the complete tenant-scoped directory.
- The existing iOS Company 360 runner now owns both the relationship-detail flow and the populated company-search flow in one Maestro process with one disposable tenant.
Tests:
CrmCompanySearchTestscripts/tests/test_crm_company_search_policy.py.maestro/critical/148_ios_company_search.yamlscripts/run-ios-company-360-signals-proof.sh
Proof boundary:
- Shared JVM/source proof and the guarded approved-simulator lifecycle cover the search mechanics.
- On 2026-08-05, flows 98 and 148 passed sequentially in one Maestro process on the approved
QC iPhone 17 Pro v2simulator (0A8C774B-C1D3-4A43-816C-81D3D3D849D8) with installed build 185 SHA-256a26c4402af21fdfef08591df255be07c2a9b944483f43c788b8febb7a33c6330; the disposable tenant cleanup verified org:user:contact:pipeline:deal at0:0:0:0:0. - Android runtime, named-device, accessibility-service, browser, store, and deployed CRM evidence remain separate gaps.
2026-08-05 — Make Company 360 relationship rows actionable
Type: Mobile CRM / navigation clarity
Summary:
- Company 360 contact and open-deal rows now expose a compact trailing-arrow affordance and a 48dp+ button target instead of dead text rows.
- Android and iOS route those rows through the existing tenant-scoped Contact Detail and Deal Detail screens; commitments, risks, and tasks remain read-only signal rows.
- Critical flow 98 now opens each linked record and returns to Company 360 before fixture cleanup.
Tests:
CompanyDetailNavigationSourceTestscripts/tests/test_company_360_mobile_policy.py.maestro/critical/98_company_360_signals.yaml
Proof boundary:
- Source and contract gates prove callback ownership, accessible target semantics, and typed route
wiring. The guarded Android flow passed on
Lumi_Pixel7_API35with identical built/installed prodDebug APK SHA-2561c222ee6ff2eb66717e0f9d31d425de37fa93fb8884a8d91bb80ed3270076bd4; it opened both detail routes, asserted the seeded deal title, and verified org:user:contact:pipeline:deal cleanup at0:0:0:0:0. The same flow passed on the approvedQC iPhone 17 Pro v2simulator (0A8C774B-C1D3-4A43-816C-81D3D3D849D8) with installed build 184 SHA-2567ef070ed90244ff5af384a383218ffd0f1790d0f9a0f55d6c7b00fd8c9717bc7. These remain emulator/simulator mechanics evidence; named-device, store, deployed, and empty/error evidence remain open.
2026-08-04 — Make the populated Contacts directory searchable
Type: Mobile CRM / field usability
Summary:
- The shared Contacts directory now exposes a compact local search field once contacts are available, matching name, title, email, phone, or Zalo without issuing a request for every keystroke.
- No-match results use an authored empty state and a stable Clear search action; clearing the query restores the complete tenant-scoped directory.
- Critical flow 146 and its Android runner own two disposable contacts, exact APK custody, the match/no-match/clear journey, and single-runtime cleanup.
Tests:
ContactsScreenModelTestscripts/tests/test_crm_contact_search_policy.py.maestro/critical/146_contacts_search.yamlscripts/run-android-contact-search-proof.sh
Proof boundary:
- The guarded Android run passed flow 146 on
Lumi_Pixel7_API35with the exact installed prodDebug APK SHA-2567440baf4978524c6dd1b0cf1c94b5738abf4f52179658afd1732f1b0faed1651; match, no-match, and clear recovery all completed and the runner cleaned its disposable tenant/runtime. - This slice registers no iOS search flow yet; the shared iOS surface and
named-device search mechanics remain open in
F27.GAP006. - Emulator output is mechanics evidence only; named Android floor-device, signed iPhone, accessibility, and PM scoring remain open.
2026-07-25 — Web deal terminal actions no longer depend on optional terminal stages
Type: Bug fix / CRM lifecycle
Summary:
- Deal-card and detail-sheet Won/Lost actions now always submit the canonical terminal status. A matching terminal-stage row is included when configured, but its absence no longer turns the action into a silent no-op.
- Lost still sends the required reason, Won still respects the backend amount requirement, and handled validation errors now name the missing field.
- Focused component regressions cover Won and Lost without dedicated terminal stages plus required-amount feedback. Browser and deployed dev proof remain open until the exact release SHA is live.
Tests:
frontend/src/test/crm/DealsCrud.test.tsx
2026-07-19 — Mobile Company detail renders its 360 signals
Type: Mobile CRM / data completeness
Summary:
- Shared Company detail now loads the existing tenant-scoped Company signals endpoint and company-filtered Contacts endpoint alongside the base record.
- Linked contacts and open deals, commitments, risks, and tasks render in grouped sections. Empty related data is explicit, and related-data failures remain recoverable without hiding the company itself.
- Critical flow 98 proves a disposable company’s linked contact and open deal, then returns to the Company directory. Its platform runners verify exact artifact custody and remove CRM dependencies before deleting the disposable tenant.
Tests:
CompanyDetailScreenModelTestscripts/tests/test_company_360_mobile_policy.py.maestro/critical/98_company_360_signals.yamlscripts/run-company-360-signals-proof.shscripts/run-ios-company-360-signals-proof.sh
Proof boundary:
- Pixel 7 API 35 emulator passed with built and installed prodDebug APK SHA-256
0c5c8f4bab52af6f24d8926893b17c13f0fe16e01cda5554eb97db0a186bf16c. - The single approved iOS 26.4 simulator passed the populated journey on
installed build 151 SHA-256
6b27e48261b4bfb0c7916a7dc2b71d34f8d9dc7860b6af5fe2bd3944f9d7f520and verified org:user:contact:pipeline:deal cleanup at0:0:0:0:0. - Named Android and iPhone plus empty/error proof remain open, so the PM-owned CRM-03 scores do not change.
2026-07-16 — Reconcile native company deletion with the directory
Type: Mobile CRUD / Test Coverage
Summary:
- Define one organization-scoped CRM screen-model owner across the native company directory, deal directory, and company-detail deletion callback.
- After confirmed deletion, reload the shared directory state before returning from detail so the deleted company cannot remain as a stale row.
- Add stable route and destructive-confirmation selectors plus a seeded native delete lifecycle flow.
Scope boundary:
- The deterministic flow may mutate only a freshly seeded local test database.
- Emulator/simulator output does not change CRM-03 scores or replace the PM’s named Android floor-device and iPhone 12 evidence.
Verification:
CompanyDeleteSourceTest: 2/2 passed.- Pixel 7 API 35 emulator: destructive flow 1/1 passed in 43 seconds; APK source
6fbd0c3a9, flow35abda36b, built/installed SHA-256c69d989c5c16a01f4e4508b3e14af8bd18b5d336dcb401c801d557b85cad38f0. - Returned directory excluded
ABC Corp; isolated backend count changed 1 to 0. - Named Android floor-device, iPhone 12, refresh/error, and PM scoring remain open.
2026-07-19 — Mobile Company forms follow keyboard order
Type: Mobile UX / verification
Summary:
- Shared Create and Edit Company forms now declare the same six-field visual focus order: Name → Industry → Website → Tax code → Address → City.
- The first five fields use IME
Next; City usesDoneand clears focus. No keyboard action implicitly saves CRM data. - Critical flow 96 proves the Create path on a disposable tenant and stops before Save. Source proof covers the identical Edit contract; named-device and runtime Edit proof remain open.
Tests:
scripts/tests/test_android_mobile_input_policy.py.maestro/critical/96_crm_company_ime_next.yamlscripts/run-crm-company-ime-next-proof.sh
2026-07-20 — iOS Contact mutations reconcile the owning directory
Type: Bug fix / Mobile runtime verification
Summary:
- Successful create, edit, and delete callbacks now advance an iOS route-owned Contact revision. The independently hosted Contact list and detail KMP controllers are recreated from current server state instead of retaining the snapshot that existed before the mutation.
- Critical flow 27 now selects the Business tab by its stable shell identifier, follows the Name → Title → Phone → Email → Zalo IME contract, and focuses the populated edit field at its trailing edge before clearing it.
scripts/run-ios-contact-crud-proof.showns a disposable organization, company, and user on an isolated local backend port, compiles that loopback route only underLUMI_CI_LOCAL, verifies the installed artifact, runs the lifecycle, and cleans up its exact server process and fixture. The shared iOS Maestro runner refuses to start while another iOS or Android runtime occupies the machine-wide mobile lane.
Runtime evidence:
- Flow 27 passed on the single
QC iPhone 17 Pro v2iOS 26.4 simulator (0A8C774B-C1D3-4A43-816C-81D3D3D849D8) using build 151, installed executable SHA-2563f160c749dd302a0840858addf6f8c07c5675ff4bf5b5c137543c9c2e9e495f8from the final run after rebasing ontomain. - The run proved create/read, edit/detail-and-list reconciliation, confirmed delete, directory absence, and disposable-fixture cleanup. Simulator mechanics do not close the signed named-iPhone, bilingual error-state, accessibility, or PM-scoring gaps.
Tests:
scripts/tests/test-crm09-maestro-flow.sh.maestro/critical/27_contacts_crud_flow.yamlscripts/run-ios-contact-crud-proof.sh
2026-07-20 — iOS Edit Contact follows the visible keyboard order
Type: Mobile verification / Testability
Summary:
- Contact detail Edit and Delete expose
contact_detail_editandcontact_detail_deleteon their clickable roots. Automation no longer uses visible action text that can also occur inside contact data. - Critical flow 97 enters the populated Edit form through the stable Edit action, traverses Name → Title → Phone → Email → Zalo with Return, leaves Save visible, and stops without submitting.
scripts/run-ios-edit-contact-ime-next-proof.showns an isolated loopback backend and disposable tenant/contact fixture, then delegates build, signature, installed-executable custody, and single-simulator enforcement to the shared iOS Maestro runner.
Runtime evidence:
- Flow 97 passed on the single
QC iPhone 17 Pro v2iOS 26.4 simulator using installed build 151 SHA-256073f0e03face451487f97b70e3fb76bf5ddae79cc2a0049f34dd7d32b71022b0. - The runner verified proof organization, user, and contact cleanup at
0:0:0. This is simulator mechanics evidence only; named-device geometry, failure-state, accessibility, and PM scoring remain open.
Tests:
scripts/tests/test_ios_edit_contact_ime_policy.py.maestro/critical/97_edit_contact_ime_next.yamlscripts/run-ios-edit-contact-ime-next-proof.sh
2026-07-19 — Contact CRUD proof follows the active app language
Type: Mobile UX / verification
Summary:
- The Business Contact row now exposes the locale-independent
business_lane_contactsautomation identifier. - Critical flow 27 uses stable control identifiers where available and explicit English/Vietnamese selectors for user-visible copy.
- The Vietnamese Pixel 7 API 35 emulator journey completed create, detail, edit/readback, confirmed delete, and directory reconciliation against an exact built/installed prodDebug APK.
Scope boundary:
- Emulator output is mechanics evidence only. CRM-09 remains unscored until the named Android floor device and signed iPhone evidence gates pass.
Tests:
scripts/tests/test-crm09-maestro-flow.sh.maestro/critical/27_contacts_crud_flow.yaml
2026-07-19 — Mobile Edit Contact follows keyboard order
Type: Mobile UX / verification
Summary:
- Shared Edit Contact now matches Create Contact’s editable Name → Title → Phone → Email → Zalo focus order.
- The first four fields use IME
Next; Zalo usesDoneand clears focus. No keyboard action implicitly updates CRM data. - Critical flow 97 proves the populated Edit path on a disposable tenant and stops before Save. Android emulator and iOS simulator mechanics now pass; named-device proof remains open.
Tests:
scripts/tests/test_android_mobile_input_policy.py.maestro/critical/97_edit_contact_ime_next.yamlscripts/run-edit-contact-ime-next-proof.shscripts/run-ios-edit-contact-ime-next-proof.sh
2026-07-15 — Add a reusable native contact CRUD proof flow
Type: Documentation / Test Coverage
Summary:
- Added a cross-platform Maestro contract for the mobile contact create, detail, edit, and confirmed-delete path.
Reason:
- The F27 implementation and shared KMP routes were present, but the committed mobile coverage stopped at the Contacts directory and could not prove the required contact lifecycle end to end.
Impact:
- Android and iOS can run the same flow against a tenant-scoped seeded backend.
- Simulator/emulator output remains UI-only; named-device execution is still the PM gate for CRM-09 scoring.
Tests:
scripts/tests/test-crm09-maestro-flow.sh.maestro/critical/27_contacts_crud_flow.yaml
Notes:
- The flow creates a uniquely named contact, edits it, confirms deletion, and asserts the deleted record is absent from the directory. It does not change the PM-owned mobile quality registry or any score.
- The name and company automation tags are bound to the actual editable and clickable controls, avoiding container-only semantics and coordinate taps.
- The detail hero exposes a stable name tag for the post-edit read assertion; emulator retries reached create/list/detail/edit, but no PM score is claimed until a clean delete-complete run and named-device evidence are recorded.
2026-07-14 — Mobile: harden Contact form verification targets (FRM-13/14)
Type: Mobile CRUD / verification hardening
Summary:
- Added stable screen, required-field, company-picker, and Save tags to the shared Create Contact and Edit Contact forms.
- Added source guards for IME-safe weighted scrolling, in-flow Save actions, and the existing create/update submit callbacks.
Scope boundary:
- Source hardening only. FRM-13/14 populated form, validation, save round-trip, and named-device keyboard evidence remain PM-owned mobile-audit work.
Tests:
git diff --checkPYENV_VERSION=system python3 scripts/check_docs_truth.pybash scripts/check-platform-parity.sh
2026-07-14 — Mobile: keep Edit Company Save above the keyboard (FRM-11)
Type: UX fix (shared KMP form layout)
Summary:
- Edit Company now uses a weighted scroll region with an in-flow Save CTA so the keyboard cannot cover the action.
- Added stable screen/action tags and a source guard against bottom-overlay Save regressions.
Scope boundary:
- Source hardening only. FRM-11 populated edit, validation, and named-device keyboard evidence remain PM-owned mobile-audit work.
Tests:
git diff --checkPYENV_VERSION=system python3 scripts/check_docs_truth.pybash scripts/check-platform-parity.sh
2026-07-14 — Restore populated-directory contact creation on native shells
Type: Mobile reachability / CRUD
Summary:
- The shared Contacts directory now keeps its create FAB visible after the first contact exists; the empty-state CTA remains for zero-contact organizations.
- iOS and macOS now route that callback to the existing shared CreateContact screen, matching Android’s Navigation3 route.
- Added a source regression guard covering the shared affordance and Android/iOS/macOS callback chain. Device round-trip evidence remains PM-owned by the mobile audit.
2026-07-02 — Fix §11 stale test-class names
Type: Documentation
Summary:
- §7 Row 6 test-class reference:
TestBriefGeneration→TestBriefDealItems(actual class at test_crm.py:1392). - §11 Row 4 evidence class name:
TestCRMNameResolution→TestNameResolution(actual class at test_crm.py:1151). - §11 Row 6 evidence:
Extended \TestBriefGeneration`→TestBriefDealItems` with named test methods and line reference. - No code changes.
2026-06-26 — Close §11 audit gaps: orphaned custom field deletion + backward stage move tests
Type: QA/Test
Summary:
- Added
TestCustomFields::test_field_definition_deletion_orphans_key_at_next_write: creates a FieldDefinition, creates a company with that key incustom_fields, deletes the FieldDefinition, then asserts that a subsequent PATCH with the now-orphaned key returns 400. Closes spec §11 row 9. - Added
TestDeal::test_backward_stage_move_stamps_stage_changed_at: moves a deal forward (stage1→stage2) then backward (stage2→stage1), assertsstage_changed_atis set after both moves, and verifiesdays_in_current_stage == 0immediately after the backward move. Closes spec §11 row 10. - Updated §11 summary: 10/10 match (was 8/10).
- POLISH QA/QC raised from 6 → 8; evidence updated in
docs/ui-ux/F27-relationships.md.
Impact:
- No model, migration, or API changes — pure test additions (backend only).
- Full suite: 4583 passed, 97.90% coverage, 0 failures.
2026-06-21 — Pipeline stage delete confirmation
Type: Feature (CRUD completion)
Summary: useDeleteStage existed but CrmSettingsPage deleted a stage immediately on Trash click with no guard. Added an admin-gated destructive confirmation dialog (warns that deals in the stage may be affected) between the affordance and the delete. i18n keys added to en/vi. testids stage-delete-{id}/stage-delete-confirm.
2026-06-19 — Complete Company / Deal / Activity edit + delete UI
Type: Feature (CRUD completion)
Summary: Backend Company/Deal/CrmActivity ViewSets supported PATCH/DELETE, but the UI lagged: companies had no edit/delete, deals had only stage-move + bulk-delete (no per-deal edit or delete), and useDeleteCrmActivity existed but was unwired. Added useUpdateCompany/useDeleteCompany, useDeleteDeal; Company360 header now has Edit (name/industry/relationship/custom fields) + Delete dialogs; each DealCard has Edit (title/amount/stage/expected close) + Delete; ActivityAgenda rows get a confirmation Delete. Deletes are audit-logged (F16). i18n keys added to en/vi (companies, deals). (Contacts already had full edit/delete — unchanged.)
2026-06-15 — Implement §10 Q1: Organization.default_currency (unblocks F69 MYR)
Type: Feature
Summary:
- Added
Organization.default_currency(CharField(max_length=3), defaultUSD; migrationorganizations/0009_organization_default_currency). ISO 4217 codes are 3 chars, so the field ismax_length=3(the §12 Q1 note said 10 — tightened to 3). - New
apps/organizations/currency.py:currency_for_language()maps owner UI language → currency (vi→VND,en→USD,ar→SAR,ms→MYR,id/tl/fil→…,lv→EUR; region subtag stripped; fallback USD).create_organizationsets the field fromrequest.user.language. - Serializer exposes it (
fields="__all__") on/api/organizations/current/. FrontendDealamount displays fall back toorg.default_currencyviaformatCurrency. - Audit row 8 moved from “decided” to actually-implemented (was prematurely ✅ as a decision).
Reason:
- Resolves §12 Q1 and unblocks the F69 MYR currency display AC. See F69 §13 (2026-06-15) for the frontend wiring.
Impact:
- Append-only migration; tenant-scoped reads; no breaking change. Backfill: existing orgs keep the model default
USDuntil re-derived (acceptable — display falls back per-deal currency first).
Tests:
backend/tests/test_organization_currency.py(mapping, model default, create-from-language, per-tenant exposure, cross-tenant isolation). Full backend suite green at 99.19% coverage.
2026-06-18 — Visual QC: make /companies feel like a real CRM operating surface
Type: UX Fix
Summary:
- The live
/companiesscreenshots showed two visual problems: the list page felt too bare for a primary CRM lane, and a company with no linked signals collapsed into a lot of empty space that read more like an unfinished screen than a calm empty state. CompaniesPage.tsxnow opens with a compact relationship-graph summary, count tiles for companies/customers/unresolved names, clearer unresolved-name guidance, and lighter list-card helper copy.- The company detail view now carries a short operating description, quick stats for people/signals/relationship tags, a calmer segmented tab strip, and a proper empty-state panel when no signals are linked yet.
- Screenshot flow now captures
/companiesdirectly infrontend/screenshots/17-companies/, including the create form trigger fix for the next visual passes.
Verification:
frontend/src/test/CompaniesPage.test.tsxfrontend/screenshots/17-companies/01-companies-list.pngfrontend/screenshots/17-companies/02-companies-detail.png
2026-06-14 — Strategy v2 realignment (positioning + vertical vocabulary)
Type: Documentation Change
Summary:
- §title: “good-enough CRM” → “the contractor’s customer/supplier/subcontractor graph”.
- §1: reframed the relationship graph around the fit-out contractor’s khách hàng / NCC (supplier) / thầu phụ (subcontractor); named the real competitor (the owner’s Excel customer sheet + lost-in-Zalo history, not Salesforce/HubSpot); recast “deal” prose with báo giá–đặt cọc–công trình vocabulary; reframed “Generic CRMs log activity” → “The Excel sheet logs activity”.
- §3 Scope Boundaries: added a cross-link clarifying that external client/supplier/subcontractor confirmations (duyệt báo giá, NCC xác nhận giá/ngày giao) route via F71 (Loop 2) — F27 owns the relationship record, F71 owns the external no-login surface.
Reason:
- Realign to competitive-strategy-v2.md and the registry Positioning doctrine (strategy v2, 2026-06-14): competitor is Zalo/WhatsApp + Excel + owner memory (not US SaaS); Vietnamese-first vertical vocabulary; the data graph is the moat; F71 is Loop 2.
Impact:
- Positioning / vocabulary only. No model, field, API, test, or status change. Any behavior change is tracked in §11 Implementation Audit, not claimed done here.
Tests:
- None (documentation change).
Notes:
- Code-level names (
Company/Contact/Deal/Pipeline/Stage/FieldDefinition, §4 fields, §5 API paths, §7 test classes) left untouched — they are code. Only customer-facing positioning prose changed.
2026-06-14 — Restructure to TEMPLATE.md section order; expand §11 audit (SP fleet)
Type: Documentation Change
Summary:
- Numbered “Current Behavior” as §8; §8 Diagrams → §10; §9 Implementation Audit (narrative) → §11 (10-row table with reference study note); Open Questions (unnumbered) → §12; §12 Edge Cases absorbed into §11 note; §13 Error States → §9; §14 Bug Log → §13; §11 Reference Study absorbed into §11 note + §4 data model note.
- §11 audit: 10 rows, 8 ✅, 2 gaps (orphaned custom fields, backward stage not tested).
2026-06-12 — Added Edge Cases + Error States sections
Type: Documentation Change
Summary:
- Added §12 Edge Cases (E1–E9) and §13 Error States (ES1–ES9). Each case grounded in existing code paths and test coverage.