diff --git a/.changeset/2060-items-11-12-15-periods-refusals-account-map.md b/.changeset/2060-items-11-12-15-periods-refusals-account-map.md new file mode 100644 index 000000000..6cd2b4373 --- /dev/null +++ b/.changeset/2060-items-11-12-15-periods-refusals-account-map.md @@ -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. diff --git a/content/docs/sales/forecasting.mdx b/content/docs/sales/forecasting.mdx index c8d8ad9f9..3b88e6618 100644 --- a/content/docs/sales/forecasting.mdx +++ b/content/docs/sales/forecasting.mdx @@ -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 diff --git a/content/docs/sales/forecasting.zh-Hans.mdx b/content/docs/sales/forecasting.zh-Hans.mdx index e7ac7561a..5804af7da 100644 --- a/content/docs/sales/forecasting.zh-Hans.mdx +++ b/content/docs/sales/forecasting.zh-Hans.mdx @@ -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** 共用同一个起始日标题,在它之下作为两个周期分开列出。 ## 预测是如何创建的 diff --git a/content/docs/sales/forecasting.zh-Hant.mdx b/content/docs/sales/forecasting.zh-Hant.mdx index 0c83e1688..3524e7d8a 100644 --- a/content/docs/sales/forecasting.zh-Hant.mdx +++ b/content/docs/sales/forecasting.zh-Hant.mdx @@ -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** 共用同一個起始日標題,在它之下作為兩個週期分開列出。 ## 預測是如何建立的 diff --git a/src/marketing/objects/campaign.hook.ts b/src/marketing/objects/campaign.hook.ts index 98ad29a09..8205f7253 100644 --- a/src/marketing/objects/campaign.hook.ts +++ b/src/marketing/objects/campaign.hook.ts @@ -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, + ); } }, }; diff --git a/src/revenue/objects/product.hook.ts b/src/revenue/objects/product.hook.ts index 88ffd9bb4..1a4b7aae8 100644 --- a/src/revenue/objects/product.hook.ts +++ b/src/revenue/objects/product.hook.ts @@ -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. */ @@ -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, ); diff --git a/src/sales/data/sales.seed.ts b/src/sales/data/sales.seed.ts index 9a285b930..9f57fd600 100644 --- a/src/sales/data/sales.seed.ts +++ b/src/sales/data/sales.seed.ts @@ -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, @@ -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', @@ -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', @@ -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', @@ -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', @@ -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', @@ -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', @@ -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', @@ -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', @@ -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', diff --git a/src/sales/views/forecast.view.ts b/src/sales/views/forecast.view.ts index e90abfff8..c4c8f6eaf 100644 --- a/src/sales/views/forecast.view.ts +++ b/src/sales/views/forecast.view.ts @@ -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'] }, }, diff --git a/test/account-seed-locations.test.ts b/test/account-seed-locations.test.ts new file mode 100644 index 000000000..766803cb2 --- /dev/null +++ b/test/account-seed-locations.test.ts @@ -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; + +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]); + } + }); + }); +}); diff --git a/test/forecast-list-grouping.test.ts b/test/forecast-list-grouping.test.ts new file mode 100644 index 000000000..9fccb55bd --- /dev/null +++ b/test/forecast-list-grouping.test.ts @@ -0,0 +1,54 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { ForecastViews } from '../src/sales/views/forecast.view'; +import { Forecast } from '../src/sales/objects/forecast.object'; + +/** + * All Forecasts lists its periods newest first, each under a heading a manager + * can read (#2060 item 11, C-09). + * + * The list grouped by the TEXT `period_label`, descending, so the periods ran + * as text: "Sep 2026 → Q4 2026 → Q4 2025 → Q3 2026 … → Oct 2026 → Aug 2026" + * (measured in the console on 18.0.0-next.5). A grouping field orders its + * groups by its own value and the spec offers no key to order one field's + * groups by another, so the view groups on two levels: `period_start` + * (a date, so `desc` is time order) and, under it, `period_label`, the heading + * that names the period. The second level is not decoration: a quarter and + * the month that opens it share a `period_start`, and without it their rows + * would sit under one heading. + * + * What the console draws from this declaration was measured in a browser and + * is recorded on the PR, not re-implemented here: the header rows and their + * order are the platform's (`compileListViewGroupQuery` and the grid's own + * sort over them). This file pins the app's half, the declaration. + */ + +const allForecasts = ForecastViews.list as unknown as { + name: string; + sort: Array<{ field: string; order: string }>; + grouping: { fields: Array<{ field: string; order?: string }> }; +}; + +describe('All Forecasts groups its periods in time order (#2060 item 11)', () => { + it('is the view this pin is about', () => { + expect(allForecasts.name).toBe('all_forecasts'); + }); + + it('groups by Period Start, newest first, and names each period under it by its label', () => { + expect(allForecasts.grouping.fields.map((g) => [g.field, g.order])).toEqual([ + ['period_start', 'desc'], + ['period_label', 'desc'], + ]); + }); + + it('the outer level is a date, so its descending order is time order, and the inner level is the text label', () => { + const fields = Forecast.fields as Record; + expect(fields.period_start!.type).toBe('date'); + expect(fields.period_label!.type).toBe('text'); + }); + + it('the rows run in the same order as the groups', () => { + expect(allForecasts.sort[0]).toEqual({ field: 'period_start', order: 'desc' }); + }); +}); diff --git a/test/hooks-runtime-sales.test.ts b/test/hooks-runtime-sales.test.ts index 4b99efb3d..24dae49b9 100644 --- a/test/hooks-runtime-sales.test.ts +++ b/test/hooks-runtime-sales.test.ts @@ -6,6 +6,9 @@ import { hotcrmStack, signUpPerson, systemUpdate, recordEngineWrites, runShippedHook, today, daysFromNow, type Person, } from './helpers/verify-stack'; import { hookNamed } from './helpers/composed-stack'; +import { Product } from '../src/revenue/objects/product.object'; +import { OpportunityLineItem } from '../src/revenue/objects/opportunity_line_item.object'; +import { QuoteLineItem } from '../src/revenue/objects/quote_line_item.object'; type Rec = Record; @@ -1260,7 +1263,22 @@ describe('product_catalog', () => { expect((await adminProduct({ sku: `crm-pro-${n}`, list_price: 10 })).sku).toBe(`CRM-PRO-${n}`); }); - it('refuses to delete a product referenced by any line item, historical included', async () => { + // The refusal counts what it names, LINE ITEMS, each count with its own + // plural and no zero listed, and it names the retirement field by its label + // (#2060 item 12, B-16: "referenced by 4 opportunity(ies) and 2 quote(s)" + // counted four lines on two deals as four deals). The nouns and the label + // are read off the objects, so a renamed label its refusal does not follow + // turns this red. + const lines = (n: number, object: Rec): string => + `${n} ${(n === 1 ? object.label : object.pluralLabel).toLowerCase()}`; + const deleteRefusal = async (id: string): Promise => { + const err = await verify.hooks.run('crm_product', 'delete', { id }, as(admin)).then(() => null, (e) => e); + expect(err).toMatchObject({ code: 'DELETE_RESTRICTED', status: 409 }); + return err; + }; + const retire = () => `To retire it instead, turn off ${(Product.fields as Rec).is_active.label}.`; + + it('refuses to delete a product referenced by any line item, historical included, counting the lines', async () => { const product = await adminProduct({ list_price: 100 }); const { opp } = await quoteParties(); await verify.seed('crm_opportunity_line_item', [{ crm_opportunity: opp.id, crm_product: product.id, quantity: 1, unit_price: 100 }]); @@ -1268,8 +1286,29 @@ describe('product_catalog', () => { const quote = await repQuotes(); await verify.seed('crm_quote_line_item', [{ crm_quote: quote.id, crm_product: product.id, quantity: 1, unit_price: 100 }]); } - await expect(verify.hooks.run('crm_product', 'delete', { id: product.id }, as(admin))) - .rejects.toThrow(/referenced by 1 opportunity\(ies\) and 2 quote\(s\)/); + const err = await deleteRefusal(product.id); + expect(err.message).toBe( + `Cannot delete product: it is on ${lines(1, OpportunityLineItem)} and ${lines(2, QuoteLineItem)}. ${retire()}`, + ); + expect(err.message).not.toMatch(/\(ies\)|\(s\)|is_active/); + }); + + it('counts three lines on one opportunity as three line items, and lists no zero', async () => { + const product = await adminProduct({ list_price: 100 }); + const { opp } = await quoteParties(); + for (let i = 0; i < 3; i++) { + await verify.seed('crm_opportunity_line_item', [{ crm_opportunity: opp.id, crm_product: product.id, quantity: 1, unit_price: 100 }]); + } + expect((await deleteRefusal(product.id)).message).toBe( + `Cannot delete product: it is on ${lines(3, OpportunityLineItem)}. ${retire()}`, + ); + + const quoted = await adminProduct({ list_price: 100 }); + const quote = await repQuotes(); + await verify.seed('crm_quote_line_item', [{ crm_quote: quote.id, crm_product: quoted.id, quantity: 1, unit_price: 100 }]); + expect((await deleteRefusal(quoted.id)).message).toBe( + `Cannot delete product: it is on ${lines(1, QuoteLineItem)}. ${retire()}`, + ); }); it('allows deleting an unreferenced product', async () => { diff --git a/test/hooks-runtime-service.test.ts b/test/hooks-runtime-service.test.ts index 506233739..1b32a587d 100644 --- a/test/hooks-runtime-service.test.ts +++ b/test/hooks-runtime-service.test.ts @@ -3,6 +3,7 @@ import { describe, it, expect, beforeAll, vi } from 'vitest'; import type { VerifyStack } from '@objectstack/verify'; import campaignHooks, { CAMPAIGN_METRIC_WRITE_KEYS } from '../src/marketing/objects/campaign.hook'; +import { Campaign } from '../src/marketing/objects/campaign.object'; import leadHooks from '../src/sales/objects/lead.hook'; import knowledgeArticlePublish from '../src/service/objects/knowledge_article.hook'; import { knowledgeArticles } from '../src/service/data/service.seed'; @@ -380,14 +381,42 @@ describe('campaign_validation', () => { const marketerCampaign = (doc: Rec) => verify.hooks.run('crm_campaign', 'insert', { name: `Launch ${++k}`, status: 'planning', ...doc }, as(marketer)); - it('rejects a start_date after the end_date', async () => { - await expect(marketerCampaign({ start_date: '2026-06-01', end_date: '2026-01-01' })).rejects.toThrow(/must not be after end_date/); + // The refusals name each field by the label the form shows, never by its API + // key (#2060 item 12, C-11: "Campaign start_date (…) must not be after + // end_date (…)"). The labels are read off the object, so a renamed label + // that its refusal does not follow turns this red. + const label = (field: string): string => (Campaign.fields as Rec)[field].label; + const statusLabel = (value: string): string => + ((Campaign.fields as Rec).status.options as Rec[]).find((o) => o.value === value)!.label; + + it('rejects a Start Date after the End Date, naming both fields by their labels', async () => { + const err = await marketerCampaign({ start_date: '2026-06-01', end_date: '2026-01-01' }).then(() => null, (e) => e); + expect(err).toMatchObject({ code: 'VALIDATION_FAILED', status: 400 }); + expect(err.message).toBe( + `The campaign's ${label('start_date')} (2026-06-01) must not be after its ${label('end_date')} (2026-01-01).`, + ); + expect(err.message).not.toMatch(/start_date|end_date/); + }); + + it('refuses a campaign stored as In Progress without both dates, naming the status and the fields by their labels', async () => { + // Reachable on a real write: the hook runs before the engine's own + // required-field check, so an In Progress insert that leaves out a date + // meets this refusal, not the engine's (measured on 18.0.0-next.5). + for (const doc of [{ start_date: '2026-01-01' }, {}]) { + const err = await marketerCampaign({ status: 'in_progress', ...doc }).then(() => null, (e) => e); + expect(err).toMatchObject({ code: 'VALIDATION_FAILED', status: 400 }); + expect(err.message).toBe( + `A campaign cannot be ${statusLabel('in_progress')} without both a ${label('start_date')} and an ${label('end_date')}.`, + ); + expect(err.message).not.toMatch(/start_date|end_date|in_progress/); + } }); it('a campaign without both dates cannot be stored at all', async () => { // Both dates are REQUIRED on `crm_campaign`, so the engine refuses a - // campaign missing one on every writer — the hook's own "no in_progress - // without both dates" rule never meets one. + // campaign missing one on every writer. The hook's own "not In Progress + // without both dates" rule meets one only on an In Progress insert, where + // it runs first (the case above); an update cannot clear a date at all. await expect(marketerCampaign({ start_date: '2026-01-01' })).rejects.toMatchObject({ code: 'VALIDATION_FAILED', fields: expect.arrayContaining([expect.objectContaining({ field: 'end_date' })]), diff --git a/test/line-item-cascade.test.ts b/test/line-item-cascade.test.ts index 3358cbbfc..86ef64598 100644 --- a/test/line-item-cascade.test.ts +++ b/test/line-item-cascade.test.ts @@ -96,8 +96,8 @@ describe('the line-item objects declare cascade on their parent lookup', () => { * lookup on a line item". `crm_product` stays on the default, which the same * escalation turns into `restrict`: retiring a catalog product must never * shred the priced history of the deals that sold it. `product.hook.ts` says - * the same thing in its own `beforeDelete` ("Set is_active=false to retire - * instead"), and the engine-level answer below proves the restrict survives + * the same thing in its own `beforeDelete` ("To retire it instead, turn off + * Active"), and the engine-level answer below proves the restrict survives * independently of that hook. */ it.each([