partial — repository backend proof is strong, but browser execution and deployed extraction-quality evidence remain unproven in this checkout.

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 http or https website 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_reason choice on lost (the only mandatory friction — it’s the learning loop); won requires amount, closed requires closed_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.
  • 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_days for staleness), optional team binding; deals are filterable per pipeline/team.
  • 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 in custom_fields JSONField, 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 company FK; existing customer_name strings remain as raw capture.
  • 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_stage exceeds the stage’s expected_days (default 14) — above an amount floor. Capped by F12’s noise rules.
  • 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.deal link (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/Deal row 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’s Lower("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_fields JSON. 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 company FK on Task, Commitment, Risk; nullable deal FK 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)

MethodPathMin roleNotes
CRUD/api/companies/member`?relationship=customer
GET/api/companies/<id>/signals/viewerThe 360: deals/commitments/risks/tasks aggregated
CRUD/api/contacts/member
CRUD/api/pipelines/ (+ nested stages)adminStage 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 http or https value 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, deals namespaces, en+vi.

7. Test Plan

#CaseTypeWhere
1CRUD all entities; cross-tenant blocked on every onebackendnew classes in backend/tests/
3Name resolution: exact/normalized match sets FK; ambiguous/miss leaves stringbackendsame
4360 aggregation correctness (only that company’s signals)backendsame
5Deal lifecycle incl. lost_reason requirement; stage-rename mappingbackendsame
6Brief deal-risk candidates respect capsbackendextend TestBriefDealItems
7Company list/360, deal board, stage movee2enew describe("Companies"), describe("Deals")
8Native contact CRUD: create → detail → edit → delete with the same tenant-scoped API recordplatform UI.maestro/critical/27_contacts_crud_flow.yaml; named-device execution required for PM scoring
9Native populated Contacts search and recovery: match recognizable metadata → no-match → clear → bounded refresh failure with stale rows → Retryplatform UI.maestro/critical/146_contacts_search.yaml; scripts/run-android-contact-search-proof.sh; named-device execution remains required for PM scoring
10Native populated Companies search: match recognizable metadata → no-match state → clear back to full directoryplatform 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
11Native Company Detail website: accept only http/https, expose one accessible action, and hand off to the system browsershared JVM + platform UICompanyDetail360PresentationTest; CompanyDetailNavigationSourceTest; .maestro/critical/98_company_360_signals.yaml through the lifecycle-owned Android/iOS Company 360 runners
12For 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 secondaryshared JVM + platform UICompanyDetailNavigationSourceTest; 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

#ErrorUser messageHTTPLoggedNotes
ES1Cross-tenant company access — member of org A tries to access org B’s company UUID.Standard 404404NoCompanyViewSet queryset filters by request.tenant.
ES2Duplicate company name (case-insensitive per org) — two companies with name “Vincom” and “vincom” in the same org.{"name": ["Company with this Name already exists."]}400NoEnforced by UniqueConstraint(Lower("name"), "organization").
ES3Cross-org contact company — a contact is created with a company_id from a different org.{"company": ["Company does not exist."]}400NoContactSerializer validates company.organization == request.tenant in validate_company().
ES4Stage delete with active deals — admin tries to delete a stage that has deals in it.stages.error_stage_has_deals409NoChecked in StageViewSet.perform_destroy() — counts deals linked to stage.
ES5Custom field undefined key — PATCH on a company with a custom_fields key that has no matching FieldDefinition.{"custom_fields": {"<key>": ["Unknown field '<key>'."]}}400Novalidate_custom_fields() in validators.py rejects any key not in the org+entity_type FieldDefinition set.
ES6Custom field wrong type — a number-type field gets a string value.{"custom_fields": {"<key>": ["Expected number, got str."]}}400NoType enforcement per field_type.
ES7Terminal deal reopen — a won/lost deal is PATCHed with status=open.{"detail": "Won/lost deals cannot be reopened."}400NoChecked in DealViewSet serializer validation.
ES8Unresolved name with zero companies — GET /api/companies/unresolved/ called when the org has no companies.Returns [] (empty list) — nothing to resolve to.200 []NoQueryset filters correctly; no explicit empty-org test.
ES9360 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).200NoSerializer handles empty querysets; no explicit empty-signals test.
ES10Business 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/aYesEndpoint-level failure metadata is kept in the shared summary; cancellation still propagates. F26 and F50 own approval and Forecast rows.
ES11Total 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/aYesThe repository throws after all section results fail; BusinessHomeScreenModel retains LoadState.Error.last.
ES12Contacts 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/aNoFiltering is local to the tenant-scoped response; clearing the query restores all rows.
ES13Companies 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/aNoFiltering is local to the tenant-scoped response; clearing the query restores all rows.
ES14Company 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/aNoShared code permits only http and https; it never dispatches javascript, file, mailto, or another capability as a company website.
ES15Populated 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/aYesLoadState.Error.last remains the visible directory source and both load and refresh recover stale data through dataOrStale().
ES16Populated 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/aYesBoth 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 expose contactDeals with 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_only
  • ContactDetailScreenModelTest.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 existing CompanySignalsResponse; 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 currency so 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 formatted 15.000.000 VND, rejects raw 15000000, 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:

  • ContactsScreenModelTest
  • scripts/tests/test_crm_contact_search_policy.py
  • .maestro/critical/146_contacts_search.yaml
  • scripts/run-android-contact-search-proof.sh

Proof boundary:

  • Guarded flow 146 most recently passed on Lumi_Pixel7_API35 with identical built/installed prodDebug APK SHA-256 71d75415869893e46e7ba423be6882e55e4df7d453848950bd3218de65e33fa1. Both contacts remained visible across two allowlisted 503 responses, Retry forwarded a successful request, transport text stayed hidden, fixture cleanup reached 0: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_API35 with identical built/installed prodDebug APK SHA-256 71d75415869893e46e7ba423be6882e55e4df7d453848950bd3218de65e33fa1. Two directory and two exact-detail 503s each preserved their populated surface; both explicit retries reached forwarded 200 responses, transport copy stayed hidden, cleanup reached 0: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)

#ClaimStatusEvidence
1Company CRUD: case-insensitive unique name per org (Lower("name")), relationship flags (is_customer/supplier/partner)✅ matchTestCompany; test_name_unique_per_org_case_insensitive
2Deal lifecycle: stage change stamps stage_changed_at; won requires amount; won/lost requires closed_on✅ matchTestDeal::test_stage_change_stamps_stage_changed_at; TestDeal::test_won_deal_requires_amount
3Custom fields: defined-key validation, type enforcement, undefined-key rejection✅ matchTestCustomFields backend tests
4Name→company resolution: exact/normalized match sets FK; miss leaves string untouched (never guess-match)✅ matchTestNameResolution backend tests (test_crm.py:1151)
5Company 360: /api/companies/<id>/signals/ aggregates deals/commitments/risks/tasks✅ matchTestCompany360Full::test_signals_aggregates_company_data
6Brief deal-risk candidates: closing ≤7d with open commitments, stalled > expected_days✅ matchTestBriefDealItems (test_crm.py:1392; test_closing_soon_deal_with_open_commitment_appears_in_brief, test_stalled_deal_above_threshold_appears_in_brief)
7Stage delete blocked when deals exist; terminal deal cannot be reopened✅ matchTestStageViewSet::test_stage_delete_blocked_when_deal_exists; TestDealTerminalState::test_won_deal_cannot_be_reopened
8Organization.default_currency set from owner language at org creation; Deal.currency defaults to it✅ matchImplemented 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
9Orphaned custom fields after FieldDefinition deletion — next write rejects undefined keys✅ matchE9; 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)
10Backward stage move stamps stage_changed_at and resets days counter✅ matchE2; 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’s language at org creation: vi→VND, en→USD, ar→SAR, ms→MYR, id→IDR, fil→PHP, lv→EUR. Change Deal.currency serializer default to organization.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/https values 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:

  • CompanyDetail360PresentationTest
  • CompanyDetailNavigationSourceTest
  • scripts/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 MobileUxInvariantsTestPart2 regression that previously flagged the directory as a scrollable search surface without an inset owner.

Tests:

  • MobileUxInvariantsTestPart2.searchAndDataListScreensWithTopLevelLumiFormFieldMustApplyImePadding
  • scripts/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.retryRelatedReloadsOnlyLinkedData
  • CompanyDetailScreenModelTest.retryRelatedIsIgnoredWhileFullRefreshIsInProgress
  • CompanyDetail360PresentationTest
  • scripts/tests/test_company_360_mobile_policy.py
  • CompanyDetailNavigationSourceTest
  • .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 deal action 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 typed createDeal(companyId, contactId) route so generic, company-origin, and contact-origin capture share one destination implementation.

Tests:

  • CompanyDetailNavigationSourceTest
  • WorkspaceTests
  • .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 shows 360 Proof Company selected, Back returned to Company Detail, the prior Contact/Deal/Task journey passed, fixture cleanup was 0:0:0:0:0:0, and the emulator shut down. The current iOS XCFramework compiled for arm64 device and simulator; focused CompanyBreadthTests (2) and CompanyDetailBridgeTests (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.dp bottom 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 to 0: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:

  • CompanyDetail360PresentationTest
  • scripts/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_API35 with identical built/installed prodDebug APK SHA-256 446c46a2b877c39bc34bc89c7306e1a74e0141b1e6372c12c09f57c31ddae474; the runner retained the destination-owned count frame, completed Contact/Deal navigation and website handoff, verified fixture cleanup at 0: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:

  • CrmCompanySearchTest
  • scripts/tests/test_crm_company_search_policy.py
  • .maestro/critical/148_ios_company_search.yaml
  • scripts/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 v2 simulator (0A8C774B-C1D3-4A43-816C-81D3D3D849D8) with installed build 185 SHA-256 a26c4402af21fdfef08591df255be07c2a9b944483f43c788b8febb7a33c6330; the disposable tenant cleanup verified org:user:contact:pipeline:deal at 0: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:

  • CompanyDetailNavigationSourceTest
  • scripts/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_API35 with identical built/installed prodDebug APK SHA-256 1c222ee6ff2eb66717e0f9d31d425de37fa93fb8884a8d91bb80ed3270076bd4; it opened both detail routes, asserted the seeded deal title, and verified org:user:contact:pipeline:deal cleanup at 0:0:0:0:0. The same flow passed on the approved QC iPhone 17 Pro v2 simulator (0A8C774B-C1D3-4A43-816C-81D3D3D849D8) with installed build 184 SHA-256 7ef070ed90244ff5af384a383218ffd0f1790d0f9a0f55d6c7b00fd8c9717bc7. 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:

  • ContactsScreenModelTest
  • scripts/tests/test_crm_contact_search_policy.py
  • .maestro/critical/146_contacts_search.yaml
  • scripts/run-android-contact-search-proof.sh

Proof boundary:

  • The guarded Android run passed flow 146 on Lumi_Pixel7_API35 with the exact installed prodDebug APK SHA-256 7440baf4978524c6dd1b0cf1c94b5738abf4f52179658afd1732f1b0faed1651; 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:

  • CompanyDetailScreenModelTest
  • scripts/tests/test_company_360_mobile_policy.py
  • .maestro/critical/98_company_360_signals.yaml
  • scripts/run-company-360-signals-proof.sh
  • scripts/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 6b27e48261b4bfb0c7916a7dc2b71d34f8d9dc7860b6af5fe2bd3944f9d7f520 and verified org:user:contact:pipeline:deal cleanup at 0: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, flow 35abda36b, built/installed SHA-256 c69d989c5c16a01f4e4508b3e14af8bd18b5d336dcb401c801d557b85cad38f0.
  • 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 uses Done and 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.yaml
  • scripts/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.sh owns a disposable organization, company, and user on an isolated local backend port, compiles that loopback route only under LUMI_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 v2 iOS 26.4 simulator (0A8C774B-C1D3-4A43-816C-81D3D3D849D8) using build 151, installed executable SHA-256 3f160c749dd302a0840858addf6f8c07c5675ff4bf5b5c137543c9c2e9e495f8 from the final run after rebasing onto main.
  • 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.yaml
  • scripts/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_edit and contact_detail_delete on 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.sh owns 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 v2 iOS 26.4 simulator using installed build 151 SHA-256 073f0e03face451487f97b70e3fb76bf5ddae79cc2a0049f34dd7d32b71022b0.
  • 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.yaml
  • scripts/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_contacts automation 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 uses Done and 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.yaml
  • scripts/run-edit-contact-ime-next-proof.sh
  • scripts/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 --check
  • PYENV_VERSION=system python3 scripts/check_docs_truth.py
  • bash 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 --check
  • PYENV_VERSION=system python3 scripts/check_docs_truth.py
  • bash 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 in custom_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), asserts stage_changed_at is set after both moves, and verifies days_in_current_stage == 0 immediately 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), default USD; migration organizations/0009_organization_default_currency). ISO 4217 codes are 3 chars, so the field is max_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_organization sets the field from request.user.language.
  • Serializer exposes it (fields="__all__") on /api/organizations/current/. Frontend Deal amount displays fall back to org.default_currency via formatCurrency.
  • 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 USD until 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 /companies screenshots 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.tsx now 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 /companies directly in frontend/screenshots/17-companies/, including the create form trigger fix for the next visual passes.

Verification:

  • frontend/src/test/CompaniesPage.test.tsx
  • frontend/screenshots/17-companies/01-companies-list.png
  • frontend/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.