Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/2060-items-11-12-15-periods-refusals-account-map.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"hotcrm": patch
---

**All Forecasts lists periods newest first, two refusals read the way the screens do, and the demo accounts appear on the map.**

- **All Forecasts.** The list now groups by **Period Start**, newest first, and under each start date by the period's label, so every period still has a heading of its own (**Q4 2026**). Before, it grouped by the label alone, which sorts as text: *Sep 2026*, then *Q4 2026*, then *Q4 2025*, with *Oct 2026* near the bottom. A quarter and the month that opens it start on the same day, so **Q4 2026** and **Oct 2026** sit under one start date as two periods.
- **Campaign dates.** A campaign whose **Start Date** falls after its **End Date** is refused with "The campaign's Start Date (…) must not be after its End Date (…)". A campaign saved as *In Progress* without both dates is refused with "A campaign cannot be In Progress without both a Start Date and an End Date". Before, both messages named the fields `start_date`, `end_date` and the status `in_progress`.
- **Deleting a product.** A product still on any opportunity or quote line is refused as before, and the message now counts what it names: "Cannot delete product: it is on 4 opportunity line items and 2 quote line items. To retire it instead, turn off Active." Before, it read "referenced by 4 opportunity(ies) and 2 quote(s)" for four lines on two deals, listed a zero, and named the field `is_active`.
- **Accounts by Location.** Each demo account now carries an **Office Location** at its billing address, so the map shows all nine offices. Before, every demo account left it blank and the map reported "9 records with missing or invalid coordinates excluded". A database seeded before this release keeps its blank locations: the demo accounts are written once and never overwritten. Fill **Office Location** on an account to put it on the map.
2 changes: 1 addition & 1 deletion content/docs/sales/forecasting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ The advice is therefore what it always was — when you write a snapshot by hand

**A row stored before these rules shipped stays refused until its window is corrected.** The check reads the whole record on every save, not just the field you touched, so even an unrelated edit to such a row — a quota correction, a note — is rejected until the dates agree with the label. The correction itself is always accepted: put `period_start` and `period_end` back on the calendar period, and the row saves normally again.

So you'll never see a record labelled “2026-07-01 through ???” — only the human label. The list view and dashboards group by `period_label` directly.
So you'll never see a record labelled “2026-07-01 through ???” — only the human label. Dashboards group by `period_label` directly. The **All Forecasts** list groups on two levels, newest period first: by `period_start`, and under each start date by `period_label`, so every period gets a heading of its own (**Q4 2026**). It cannot group by the label alone, because a label sorts as text: *Sep 2026* would come before *Q4 2026*, and *Q4 2025* before *Oct 2026*. Nor can it group by the start date alone, because a quarter and the month that opens it start on the same day: **Q4 2026** and **Oct 2026** share one start-date heading and sit under it as two periods.

## How forecasts get created

Expand Down
2 changes: 1 addition & 1 deletion content/docs/sales/forecasting.zh-Hans.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ description: 按负责人和周期的销售管道快照——营收预测的基

**在这些规则上线之前就已经存错的行,会一直被拒收,直到它的窗口被改正。** 这项校验每次保存时读的是整条记录,而不只是你改动的那个字段,所以哪怕是一处无关的编辑——改个配额、加条备注——也会被拒绝,直到日期和标签对得上。改正本身则永远是被允许的:把 `period_start` 和 `period_end` 拨回到对应的日历周期,这一行就又能正常保存了。

因此你绝不会看到一条标记为"2026-07-01 至 ???"的记录——只有人类可读的标签。列表视图和仪表盘会直接按 `period_label` 分组。
因此你绝不会看到一条标记为"2026-07-01 至 ???"的记录——只有人类可读的标签。仪表盘会直接按 `period_label` 分组。**全部预测**列表则分两层分组,最新的周期排在最前:先按 `period_start`,再在每个起始日之下按 `period_label`,所以每个周期都有自己的一行标题(**Q4 2026**)。它不能只按标签分组,因为标签是按文本排序的:*Sep 2026* 会排在 *Q4 2026* 前面,*Q4 2025* 会排在 *Oct 2026* 前面。它也不能只按起始日分组,因为一个季度和开启它的那个月是同一天开始的:**Q4 2026** 和 **Oct 2026** 共用同一个起始日标题,在它之下作为两个周期分开列出。

## 预测是如何创建的

Expand Down
2 changes: 1 addition & 1 deletion content/docs/sales/forecasting.zh-Hant.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ description: 按負責人和週期的銷售管道快照——營收預測的基

**在這些規則上線之前就已經存錯的資料列,會一直被拒收,直到它的區間被改正。** 這項檢核每次儲存時讀的是整筆記錄,而不只是你改動的那個欄位,所以哪怕是一處無關的編輯——改個配額、加條備註——也會被拒絕,直到日期和標籤對得上。改正本身則永遠是被允許的:把 `period_start` 和 `period_end` 撥回到對應的日曆週期,這筆資料列就又能正常儲存了。

因此你絕不會看到一筆標記為「2026-07-01 至 ???」的記錄——只有人類可讀的標籤。列表視圖和儀表板會直接按 `period_label` 分組。
因此你絕不會看到一筆標記為「2026-07-01 至 ???」的記錄——只有人類可讀的標籤。儀表板會直接按 `period_label` 分組。**全部預測**清單則分兩層分組,最新的週期排在最前:先按 `period_start`,再在每個起始日之下按 `period_label`,所以每個週期都有自己的一列標題(**Q4 2026**)。它不能只按標籤分組,因為標籤是按文字排序的:*Sep 2026* 會排在 *Q4 2026* 前面,*Q4 2025* 會排在 *Oct 2026* 前面。它也不能只按起始日分組,因為一個季度和開啟它的那個月是同一天開始的:**Q4 2026** 和 **Oct 2026** 共用同一個起始日標題,在它之下作為兩個週期分開列出。

## 預測是如何建立的

Expand Down
18 changes: 16 additions & 2 deletions src/marketing/objects/campaign.hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,15 +80,29 @@ const campaignValidation: Hook = {
(typeof input.end_date === 'string' && input.end_date) ||
(typeof previous?.end_date === 'string' && (previous.end_date as string)) ||
undefined;
// The refusals name each field by its label, as the form shows it, never
// by its API key (#2060 item 12, C-11: "Campaign start_date (…) must not
// be after end_date (…)"). A lowered body cannot read the object's
// metadata, so the labels are written out here;
// `test/hooks-runtime-service.test.ts` reads them off `crm_campaign` and
// fails when a label and its refusal part ways.
if (start && end && start > end) {
throw refuse(`Campaign start_date (${start}) must not be after end_date (${end}).`, 'VALIDATION_FAILED', 400);
throw refuse(
`The campaign's Start Date (${start}) must not be after its End Date (${end}).`,
'VALIDATION_FAILED',
400,
);
}
const status =
(typeof input.status === 'string' && input.status) ||
(typeof previous?.status === 'string' && (previous.status as string)) ||
undefined;
if (status === 'in_progress' && (!start || !end)) {
throw refuse('Campaign cannot move to in_progress without both start_date and end_date.', 'VALIDATION_FAILED', 400);
throw refuse(
'A campaign cannot be In Progress without both a Start Date and an End Date.',
'VALIDATION_FAILED',
400,
);
}
},
};
Expand Down
13 changes: 11 additions & 2 deletions src/revenue/objects/product.hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import type { HookApi } from '../../sales/objects/_hook-api';
* - Normalizes `sku` to uppercase.
* - Refuses delete when the product is referenced by ANY opportunity or quote
* line item (historical included — deals keep their line history); suggests
* deactivating (`is_active=false`) instead.
* deactivating (turning off Active, `is_active`) instead.
* - `product_title_mirror`: keeps a renamed product's title on its line items.
*/

Expand Down Expand Up @@ -67,8 +67,17 @@ const productHook: Hook = {
api.object('crm_quote_line_item').count({ where: { crm_product: id } }).catch(() => 0),
]);
if (oppRefs + quoteRefs > 0) {
// The sentence counts what it names (#2060 item 12, B-16). Both counts
// are LINE ITEMS: three lines of this product on one deal are three,
// not one deal, and "4 opportunity(ies) and 2 quote(s)" said deals and
// quotes. Each count carries its own plural and a zero is not listed,
// as `contact_integrity` words its refusal. The remedy names the field
// by its label, Active, not by its API key (the C-11 class).
const held: string[] = [];
if (oppRefs > 0) held.push(`${oppRefs} opportunity ${oppRefs === 1 ? 'line item' : 'line items'}`);
if (quoteRefs > 0) held.push(`${quoteRefs} quote ${quoteRefs === 1 ? 'line item' : 'line items'}`);
throw refuse(
`Cannot delete product: referenced by ${oppRefs} opportunity(ies) and ${quoteRefs} quote(s). Set is_active=false to retire instead.`,
`Cannot delete product: it is on ${held.join(' and ')}. To retire it instead, turn off Active.`,
'DELETE_RESTRICTED',
409,
);
Expand Down
18 changes: 18 additions & 0 deletions src/sales/data/sales.seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,15 @@ import { celDaysAgo, linesTotal, type LineSpec } from './_shared';
// Phone numbers moved with the addresses: a Munich account answering a Denver
// number is the kind of incoherence an evaluator notices before the feature.
//
// `office_location` moved with them too, and it is what Accounts by Location
// plots (#2060 item 15, A-13): the map view's `locationField`, a
// `Field.location` stored as `{ lat, lng }`. Each account is pinned at its
// billing address's building. Every row used to leave it blank, so the map
// drew nothing and reported "9 records with missing or invalid coordinates
// excluded". `test/account-seed-locations.test.ts` holds every row to the
// field's value shape. Insert-once (below) means a database seeded before this
// keeps its blank locations; only a fresh one gets the pins.
//
// ─── `last_activity_date`: the churn clock, and who is allowed to set it ───
//
// The three "Quiet 30 / 60 / 90+ Days" tiles on the Sales Activity dashboard,
Expand Down Expand Up @@ -191,6 +200,7 @@ export const accounts = defineSeed(Account, {
number_of_employees: 250,
phone: '+1-415-555-0100',
billing_address: { street: '500 Howard Street, Suite 400', city: 'San Francisco', state: 'CA', postalCode: '94105', country: 'US' },
office_location: { lat: 37.7887, lng: -122.3966 },
website: 'https://acme.example.com',
tier: 'enterprise',
segment: 'growth',
Expand Down Expand Up @@ -248,6 +258,7 @@ three regional teams (NA, EMEA, APAC).
number_of_employees: 800,
phone: '+1-312-555-0200',
billing_address: { street: '233 South Wacker Drive', city: 'Chicago', state: 'IL', postalCode: '60606', country: 'US' },
office_location: { lat: 41.8789, lng: -87.6359 },
website: 'https://globex.example.com',
tier: 'enterprise',
segment: 'net_new',
Expand All @@ -264,6 +275,7 @@ three regional teams (NA, EMEA, APAC).
number_of_employees: 150,
phone: '+1-212-555-0300',
billing_address: { street: '1 Liberty Plaza, 23rd Floor', city: 'New York', state: 'NY', postalCode: '10006', country: 'US' },
office_location: { lat: 40.7094, lng: -74.011 },
website: 'https://initech.example.com',
tier: 'mid_market',
segment: 'at_risk',
Expand All @@ -285,6 +297,7 @@ three regional teams (NA, EMEA, APAC).
// reason that rule's `$in` list is not a single-country list in disguise.
phone: '+1-416-555-0400',
billing_address: { street: '181 Bay Street, Suite 2200', city: 'Toronto', state: 'ON', postalCode: 'M5J 2T3', country: 'CA' },
office_location: { lat: 43.6469, lng: -79.3787 },
website: 'https://starkmed.example.com',
tier: 'mid_market',
segment: 'stable',
Expand Down Expand Up @@ -317,6 +330,7 @@ three regional teams (NA, EMEA, APAC).
number_of_employees: 2000,
phone: '+1-650-555-0500',
billing_address: { street: '3000 Hanover Street', city: 'Palo Alto', state: 'CA', postalCode: '94304', country: 'US' },
office_location: { lat: 37.4125, lng: -122.1476 },
website: 'https://wayne.example.com',
tier: 'strategic',
segment: 'growth',
Expand Down Expand Up @@ -347,6 +361,7 @@ three regional teams (NA, EMEA, APAC).
// europe_territory, half 1: DE.
phone: '+49-89-5550-0600',
billing_address: { street: 'Leopoldstrasse 21', city: 'Munich', state: 'BY', postalCode: '80802', country: 'DE' },
office_location: { lat: 48.1567, lng: 11.5847 },
website: 'https://northwind.example.com',
tier: 'enterprise',
segment: 'net_new',
Expand All @@ -365,6 +380,7 @@ three regional teams (NA, EMEA, APAC).
number_of_employees: 420,
phone: '+1-512-555-0700',
billing_address: { street: '600 Congress Avenue, Floor 14', city: 'Austin', state: 'TX', postalCode: '78701', country: 'US' },
office_location: { lat: 30.2687, lng: -97.7428 },
website: 'https://vertex.example.com',
tier: 'enterprise',
segment: 'growth',
Expand All @@ -387,6 +403,7 @@ three regional teams (NA, EMEA, APAC).
// without a subdivision is a normal shape, and only `country` is read.
phone: '+44-20-7946-0800',
billing_address: { street: '20 Fenchurch Street', city: 'London', postalCode: 'EC3M 3BY', country: 'UK' },
office_location: { lat: 51.5113, lng: -0.0836 },
website: 'https://lattice.example.com',
tier: 'mid_market',
segment: 'stable',
Expand All @@ -411,6 +428,7 @@ three regional teams (NA, EMEA, APAC).
// blank.
phone: '+65-6555-0900',
billing_address: { street: '10 Marina Boulevard, #30-01', city: 'Singapore', postalCode: '018983', country: 'SG' },
office_location: { lat: 1.2797, lng: 103.8543 },
website: 'https://apexlogistics.example.com',
tier: 'enterprise',
segment: 'net_new',
Expand Down
28 changes: 27 additions & 1 deletion src/sales/views/forecast.view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,33 @@ export const ForecastViews = defineView({
{ field: 'period_start', order: 'desc' },
{ field: 'owner_id', order: 'asc' },
],
grouping: { fields: [{ field: 'period_label', order: 'desc' }] },
// Newest period first, each under a heading a manager can read (#2060
// item 11, C-09). A grouping field orders its groups by its OWN value, and
// nothing else: the spec's `GroupingFieldSchema` is `field` / `order` /
// `collapsed`, with no key that orders one field's groups by another. So
// grouping on the text `period_label` sorted the periods as text ("Sep
// 2026 → Q4 2026 → Q4 2025 …"). Hence two levels:
//
// • `period_start` desc puts the periods in time order. Its heading is
// the stored date as the console draws a date key today: raw
// (`2026-10-01`, measured on 18.0.0-next.5), not the column's "Oct 1".
// • `period_label` names the period under it ("Q4 2026"). It cannot be
// dropped: a quarter and the month that opens it start on the same
// day (Q4 2026 and Oct 2026), so `period_start` alone would put a
// quarter's row and a month's row under one heading. `desc` puts the
// quarter's heading ahead of that month's ("Q" sorts after every
// month that opens a quarter).
//
// The single-level form (one heading per period, ordered by
// `period_start`, titled by `period_label`) is not expressible in the
// spec today; it is a platform gap, reported on the card rather than
// routed around here (AGENTS.md "Scope").
grouping: {
fields: [
{ field: 'period_start', order: 'desc' },
{ field: 'period_label', order: 'desc' },
],
},
pagination: { pageSize: 50 },
appearance: { allowedVisualizations: ['grid'] },
},
Expand Down
65 changes: 65 additions & 0 deletions test/account-seed-locations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect, beforeAll } from 'vitest';
import type { VerifyStack } from '@objectstack/verify';
import { valueSchemaFor } from '@objectstack/spec/data';
import { CrmSeedData } from '../objectstack.composition';
import { Account } from '../src/sales/objects/account.object';
import { AccountViews } from '../src/sales/views/account.view';
import { hotcrmStack } from './helpers/verify-stack';

/**
* Every seeded account gives Accounts by Location a pin (#2060 item 15, A-13).
*
* The map view plots `crm_account.office_location`, a `Field.location`, and
* every seeded account left it blank, so the demo's map drew nothing and said
* "9 records with missing or invalid coordinates excluded from the map"
* (measured on 18.0.0-next.5). The seeds now carry each office's coordinates
* in the shape the field declares. This file holds them to it twice: against
* the field's own value contract (`valueSchemaFor`, ADR-0104), and as the
* booted app stores them, which is the value the map reads.
*/

type Rec = Record<string, any>;

const accountSeed = CrmSeedData.find((s: Rec) => s.object === 'crm_account') as { records: Rec[] } | undefined;
const records: Rec[] = accountSeed?.records ?? [];
const map = (AccountViews.listViews as Rec).account_map as Rec;
const locationField = map.map.locationField as string;

describe('seeded accounts carry an office location (#2060 item 15)', () => {
it('the map plots the field the seeds fill', () => {
expect(map.type).toBe('map');
expect(locationField).toBe('office_location');
expect((Account.fields as Rec)[locationField].type).toBe('location');
});

it('every seeded account carries a value the field declares', () => {
expect(records.length, 'no seed dataset for crm_account').toBeGreaterThan(0);
const shape = valueSchemaFor((Account.fields as Rec)[locationField]);
const bad = records
.filter((r) => !shape.safeParse(r[locationField]).success)
.map((r) => `${r.name}: ${JSON.stringify(r[locationField])}`);
expect(bad, `accounts the map cannot plot:\n ${bad.join('\n ')}`).toEqual([]);
});

it('no two accounts share a pin', () => {
const pins = records.map((r) => `${r[locationField]?.lat},${r[locationField]?.lng}`);
expect(new Set(pins).size).toBe(records.length);
});

describe('as the booted app stores them', () => {
let verify: VerifyStack;
beforeAll(async () => {
verify = await hotcrmStack();
}, 120_000);

it('every seeded account is stored with its coordinates', async () => {
for (const seeded of records) {
const [row] = await verify.rows('crm_account', { name: seeded.name });
expect(row, `${seeded.name} was not seeded`).toBeDefined();
expect(row![locationField], seeded.name).toEqual(seeded[locationField]);
}
});
});
});
Loading
Loading