diff --git a/.changeset/1992-case-created-at.md b/.changeset/1992-case-created-at.md new file mode 100644 index 000000000..b73206c61 --- /dev/null +++ b/.changeset/1992-case-created-at.md @@ -0,0 +1,37 @@ +--- +'hotcrm': patch +--- + +Cases you create now count in "Cases Opened by Priority × Day" and in the Customer Service dashboard's date range + +A case created in the app or through the REST API never showed up in the +**Cases Opened by Priority × Day** report or inside the **Customer Service** +dashboard's date range. Both read the case's **Created Date** field, and nothing +filled that field in on a real case: only the demo data set it. So a new case +stored no Created Date, the report left it out, and the dashboard range skipped +it. Managers saw the 38 demo cases and none of their own. + +**FROM → TO.** Cases now use one creation timestamp, the platform's own +`created_at`, which is recorded on every case however it is raised. This is the +same change opportunities got earlier (#575). + +- `crm_case.created_date` is removed. Read `created_at` instead, the field every + object already carries. An integration that read `created_date` through the + API should switch to `created_at`. +- The `case_metrics` dataset's day dimension is now `created_at` (label + **Created**, still bucketed by day). It was `created_date`. A saved query or + widget that grouped by `created_date` should group by `created_at`. +- **Cases Opened by Priority × Day** buckets on `created_at` and no longer + filters anything out. The Customer Service dashboard's date range and its + **Daily Case Volume** chart use `created_at`. So does the **Case Timeline** + view, whose bars now start on the day each case was created. Before, a case + created in the app had no start date there. +- **Resolution Time (Hours)** is measured from `created_at` to **Closed Date**. +- The **SLA & Priority** group on a case has six fields instead of seven. + **Created Date** is no longer one of them, and the four language packs no + longer translate it. + +The demo data keeps its history. The seeded cases now set `created_at` to the +day each one was opened. Since `@objectstack/*` 17.7.0 the platform keeps that +value when a fresh database is seeded, so the report and the dashboard spread +the demo cases over their own days, not the day you ran the demo. diff --git a/content/docs/service/cases.mdx b/content/docs/service/cases.mdx index 87fbe4dff..770f9ff85 100644 --- a/content/docs/service/cases.mdx +++ b/content/docs/service/cases.mdx @@ -52,7 +52,7 @@ This drives reporting (which channels generate the most volume) and is auto-fill ## What a case record stores -`crm_case` declares **25 fields** (`src/service/objects/case.object.ts`), and three different things organise them: the object's own field groups, the case detail screen, and the case form. Only the first of the three has six sections, and no two of them agree — so it is worth knowing which one you are being shown. +`crm_case` declares **24 fields** (`src/service/objects/case.object.ts`), and three different things organise them: the object's own field groups, the case detail screen, and the case form. Only the first of the three has six sections, and no two of them agree — so it is worth knowing which one you are being shown. ### The object's field groups @@ -62,7 +62,7 @@ The six names below are real, but not one of them is a screen: they are `crm_cas | --- | --- | | **Case Information** | Case Number, Subject, Display Title, Description, Account, Contact, Status, Case Type | | **Origin & Routing** | Case Owner, Case Origin | -| **SLA & Priority** | Priority, Created Date, Closed Date, First Response Date, Resolution Time (Hours), SLA Due Date, SLA Violated | +| **SLA & Priority** | Priority, Closed Date, First Response Date, Resolution Time (Hours), SLA Due Date, SLA Violated | | **Resolution** | Resolution, Resolved by Article | | **Escalation** *(collapsed by default)* | Escalated, Escalated Date, Escalation Reason | | **System** *(collapsed by default)* | Internal Notes, Is Closed | @@ -71,7 +71,7 @@ Five of those six rows used to be written differently on this page, and the diff ### The detail screen -The **Details** tab of the case detail page (`src/service/pages/case_detail.page.ts`) declares **three** sections, holding **10** of the 25 fields: +The **Details** tab of the case detail page (`src/service/pages/case_detail.page.ts`) declares **three** sections, holding **10** of the 24 fields: | Section | Fields | | --- | --- | @@ -85,7 +85,7 @@ The tab is shorter than its section names suggest, and that is deliberate: a sec ### The form -The case form (`src/service/views/case.view.ts`) is **not** tabbed. It is a single section, holding **9** of the 25 fields: +The case form (`src/service/views/case.view.ts`) is **not** tabbed. It is a single section, holding **9** of the 24 fields: | Section | Fields | | --- | --- | @@ -101,7 +101,7 @@ The two are not one screen wearing different chrome — they overlap in only fou - **First response date** (`first_response_date`) — stamped the first time an interaction that **already took place** is recorded against the case. Its single writer is the `event_activity_bubble` hook (`src/sales/objects/event.hook.ts`), which fires on any `crm_event` reaching **Held** status with the case as its related record — so **Log a Call** and **Log a Meeting** stamp it, and so does an interaction entered any other way, including straight onto the case's activity list. It reads the stored value back before writing, so a second interaction never overwrites the first. Two things are deliberately *not* a first response: a **status change** (an agent can move a case to *In Progress* and investigate for an hour while the customer hears nothing) and a meeting merely *scheduled*, which is `planned` rather than held. On a case worked entirely through comments the field stays empty. - **Closed flag** — set automatically when status changes to *Closed*. - **Closed date** — stamped to NOW when status changes to *Closed*. -- **Resolution time (hours)** — calculated as the difference between *Created date* and *Closed date*. +- **Resolution time (hours)** — calculated as the time between the moment the case was created and its *Closed date*. The creation moment is the platform's own timestamp, written on every case however it was raised; a case has no separate *Created Date* field of its own. - **SLA due date** — stamped on **every** case with a recognised priority. `case_sla_defaults` looks up the case's priority against its account's **Customer Tier** and fills the field with *now + the matching number of calendar hours*, when a case is created and whenever the field is still empty. It is written once and never recomputed, and a date somebody typed in by hand is never overwritten. - **SLA Violated** (`is_sla_violated`) — there is no field called *SLA Breached?*. The hourly `case_sla_monitor` sweep sets it to true on cases that are **still open** (status is neither *Resolved* nor *Closed*) and whose **SLA Due Date** has already passed. It never compares resolution time against the target, so a case with no due date is never a candidate, and a case resolved late but before the next sweep is never flagged. - **Account's last activity date** — bumped to today when the case is updated. @@ -197,7 +197,7 @@ When you open a case, you'll see: - **Status path** — a Salesforce-style horizontal flow showing New → In Progress → Waiting on Customer → Escalated → Resolved → Closed, so an agent can see at a glance where the case stands and what the next stop is. - **Customer panel** — there is no such panel. The page (`src/service/pages/case_detail.page.ts`) declares two regions and nothing else: a header holding the three components above, and a main region holding the tab strip below. What customer context there is sits in those: the **account** appears twice over — the header subtitle and the Key Information strip — and the **primary contact** is a field in the *Details* tab's *Case Information* section. **Contract tier** and **open cases this month** are on no case surface at all. A case links to no contract, and `crm_contract` carries no tier field either; the nearest real field is **Customer Tier** on the account record, which this page does not show. Nothing in this app counts a customer's cases by month. - **Three tabs:** - - **Details** — the case description, resolution and internal notes in full, but not "all metadata fields": **10** of the object's **25**, in the three sections listed under *What a case record stores* above. Of the fifteen that are not on the tab, seven are elsewhere on this same screen: **Status**, **Priority**, **SLA Due Date**, **SLA Violated**, **Case Owner** and **Account** are on the **Key Information** strip above the tabs, and **Subject** is half the page title. The remaining eight are on no case screen at all — **Created Date**, **Closed Date**, **Escalated Date**, **First Response Date**, **Is Closed**, **Priority Rank**, **Display Title** and **Resolved by Article**. **Created Date**, **Closed Date** and **Is Closed** are stamped as the case opens and closes and are read by the *Case Timeline* and *Service Workflow* views rather than by this screen, **Escalated Date** is written by the escalation flows and displayed nowhere, **Priority Rank** is the sort key behind *My Open Cases*, **Display Title** is the object's `nameField`, the title formula that names a case wherever one is referenced rather than a field you read here, and **Resolved by Article** is read by the deflection measures rather than by a screen. **First Response Date** is the omission worth knowing about: *What happens automatically* above explains at length how it gets stamped, and this is not the tab you can read it on. + - **Details** — the case description, resolution and internal notes in full, but not "all metadata fields": **10** of the object's **24**, in the three sections listed under *What a case record stores* above. Of the fourteen that are not on the tab, seven are elsewhere on this same screen: **Status**, **Priority**, **SLA Due Date**, **SLA Violated**, **Case Owner** and **Account** are on the **Key Information** strip above the tabs, and **Subject** is half the page title. The remaining seven are on no case screen at all — **Closed Date**, **Escalated Date**, **First Response Date**, **Is Closed**, **Priority Rank**, **Display Title** and **Resolved by Article**. **Closed Date** and **Is Closed** are stamped as the case closes and are read by the *Case Timeline* and *Service Workflow* views rather than by this screen, **Escalated Date** is written by the escalation flows and displayed nowhere, **Priority Rank** is the sort key behind *My Open Cases*, **Display Title** is the object's `nameField`, the title formula that names a case wherever one is referenced rather than a field you read here, and **Resolved by Article** is read by the deflection measures rather than by a screen. **First Response Date** is the omission worth knowing about: *What happens automatically* above explains at length how it gets stamped, and this is not the tab you can read it on. - **Related** — one list, not four: **Open Tasks** — the `crm_task` records pointing at this case through **Related Case** (`related_to_case`), filtered to those whose status is not *Completed*, ten at a time. That is the whole tab. There is no attachments list: files can be attached to a case (the object enables them), but no component on this page lists them. There is no linked-opportunity list, because `crm_case` has no opportunity relationship in either direction. And there is no milestone list — the case's three milestones, *escalated*, *resolved* and *closed*, are emitted as entries in the **Activity** timeline, not as records here. - **Activity** — a unified timeline (more on this below). - **AI Reference Rail** — there is no rail on this page, of any kind. The main region holds the tab strip and nothing else, and not one component on the page is AI-driven. The service skills are real — see *How the AI assistant helps* below — but you reach them by asking, not from a panel beside the case. The one reference rail this app does render sits on the opportunity detail page, and even there it lists related records (quotes, products, open tasks) rather than suggestions. diff --git a/content/docs/service/cases.zh-Hans.mdx b/content/docs/service/cases.zh-Hans.mdx index e826f1e99..53b6d7fd3 100644 --- a/content/docs/service/cases.zh-Hans.mdx +++ b/content/docs/service/cases.zh-Hans.mdx @@ -52,7 +52,7 @@ description: 客户支持工单——主题、优先级、状态、解决方案 ## 工单记录存储的内容 -`crm_case` 一共声明 **25 个字段**(`src/service/objects/case.object.ts`),而把它们组织起来的东西有三样:对象自己的字段分组、工单详情界面、工单表单。三者里只有第一样是六个分区,而且两两都不一致——所以要紧的是分清你看的是哪一样。 +`crm_case` 一共声明 **24 个字段**(`src/service/objects/case.object.ts`),而把它们组织起来的东西有三样:对象自己的字段分组、工单详情界面、工单表单。三者里只有第一样是六个分区,而且两两都不一致——所以要紧的是分清你看的是哪一样。 ### 对象自己的字段分组 @@ -62,7 +62,7 @@ description: 客户支持工单——主题、优先级、状态、解决方案 | --- | --- | | **Case Information** | 工单编号、主题、显示名称、描述、账户、联系人、状态、类型 | | **Origin & Routing** | 负责人、来源 | -| **SLA & Priority** | 优先级、创建日期、关闭日期、首次响应日期、解决时间(小时)、SLA 截止日期、SLA Violated | +| **SLA & Priority** | 优先级、关闭日期、首次响应日期、解决时间(小时)、SLA 截止日期、SLA Violated | | **Resolution** | 解决方案、解决所用文章 | | **Escalation** *(默认折叠)* | 升级标记、升级日期、升级原因 | | **System** *(默认折叠)* | 内部备注、关闭标记 | @@ -71,7 +71,7 @@ description: 客户支持工单——主题、优先级、状态、解决方案 ### 详情界面 -工单详情页(`src/service/pages/case_detail.page.ts`)的 **Details** 页签声明了**三个**分区,装着 25 个字段中的 **10** 个: +工单详情页(`src/service/pages/case_detail.page.ts`)的 **Details** 页签声明了**三个**分区,装着 24 个字段中的 **10** 个: | 分区 | 字段 | | --- | --- | @@ -85,7 +85,7 @@ description: 客户支持工单——主题、优先级、状态、解决方案 ### 表单 -工单表单(`src/service/views/case.view.ts`)**不是**分页签的。它只有一个分区,装着 25 个字段中的 **9** 个: +工单表单(`src/service/views/case.view.ts`)**不是**分页签的。它只有一个分区,装着 24 个字段中的 **9** 个: | 分区 | 字段 | | --- | --- | @@ -101,7 +101,7 @@ description: 客户支持工单——主题、优先级、状态、解决方案 - **首次响应日期**(`first_response_date`)——在工单上记录一次**已经发生过**的互动时才打戳,而且只打第一次。它唯一的写入方是 `event_activity_bubble` 钩子(`src/sales/objects/event.hook.ts`):任何一条以该工单为关联记录、状态到达 **Held** 的 `crm_event` 都会触发它——所以 **Log a Call** 与 **Log a Meeting** 会打戳,用别的方式录入的互动(包括直接在工单的活动列表上录入)同样会。它写之前先把库里存着的值读回来,所以第二次互动不会覆盖第一次。有两件事被刻意排除在「首次响应」之外:**状态变更**(客服可以把工单转到 *In Progress* 然后自己查一小时,客户什么都没听到),以及仅仅**预约**了一场会议——那是 `planned` 而不是 held。所以一张全程靠评论处理的工单,这个字段始终是空的。 - **关闭标记**——状态变为 *Closed* 时自动设置。 - **关闭日期**——状态变为 *Closed* 时标记为 NOW。 -- **解决时间(小时)**——按 *Created date* 与 *Closed date* 之间的差值计算。 +- **解决时间(小时)**——按工单创建的时刻与 *Closed date* 之间的差值计算。创建时刻是平台自己的时间戳,不论工单从哪里提交都会写上;工单没有另外一个属于自己的 *Created Date* 字段。 - **SLA 截止日期**——给**每一张**优先级可识别的工单标记。`case_sla_defaults` 拿工单的优先级去和其账户的**客户层级**对查,把该字段填成 *当前时间 + 相应的日历小时数*;建单时打戳,此后只要该字段仍为空也会打。它只写一次、从不重算,有人手工填过的日期也永远不会被覆盖。 - **SLA Violated**(`is_sla_violated`)——不存在名为 *SLA Breached?* 的字段。每小时一次的 `case_sla_monitor` 扫描把它置真,选中的是**仍未结**(状态既不是 *Resolved* 也不是 *Closed*)且 **SLA 截止日期** 已经过去的工单。它从不拿解决时间去比对目标,所以没有截止日期的工单永远不是候选,晚解决但赶在下一次扫描前解决的工单也永远不会被标记。 - **账户的最后活动日期**——工单更新时刷新为今天。 @@ -197,7 +197,7 @@ description: 客户支持工单——主题、优先级、状态、解决方案 - **状态路径**——一个 Salesforce 风格的水平流程,显示 New → In Progress → Waiting on Customer → Escalated → Resolved → Closed,让客服一眼就能看出工单的进展和下一站。 - **客户面板**——不存在这样一个面板。这一页(`src/service/pages/case_detail.page.ts`)只声明了两个区域,再无其他:一个页眉区域装着上面那三个组件,一个主区域装着下面那组页签。客户上下文就散落在这两处:**账户**在页面上出现了两次——页眉副标题和关键信息摘要栏;**主要联系人**则是 *Details* 页签 *Case Information* 分区里的一个字段。**合同等级**与**本月未结工单**在工单的任何界面上都没有承载:工单不关联任何合同,`crm_contract` 上也没有等级字段;最接近的真实字段是账户记录上的 **Customer Tier**(中文界面作「客户分层」),而这一页并不展示它。本应用里也没有任何东西按月统计一个客户的工单数。 - **三个标签页:** - - **Details**——完整的工单描述、解决方案和内部备注是有的,但不是「所有元数据字段」:是对象 **25** 个字段里的 **10** 个,分在上文《工单记录存储的内容》列出的那三个分区里。不在这个页签上的十五个里,有七个就在同一个界面的别处:**状态**、**优先级**、**SLA 截止日期**、**SLA Violated**、**负责人**、**账户**在页签上方的**关键信息**摘要栏里,**主题**则是页面标题的一半。剩下八个在工单的任何界面上都没有:**创建日期**、**关闭日期**、**升级日期**、**首次响应日期**、**关闭标记**、**优先级排序**、**显示名称**、**解决所用文章**。**创建日期**、**关闭日期**与**关闭标记**是工单开启和关闭时打上的,由 *工单时间线* 与 *服务流转* 两个视图读取,而不是被这个界面读取;**升级日期**由升级流程写入,但哪里都不展示,**优先级排序**是 *我的待处理工单* 背后的排序键,**显示名称**是对象的 `nameField`,一个在任何地方引用工单时给它命名的标题公式,而不是你在这里能读到的字段,**解决所用文章**则是被知识库转移率指标读取,而不是被某个界面读取。这里面最该知道的缺席者是**首次响应日期**:上文《自动发生的事》整整一段讲清了它是怎么打戳的,而你读不到它的地方正是这个页签。 + - **Details**——完整的工单描述、解决方案和内部备注是有的,但不是「所有元数据字段」:是对象 **24** 个字段里的 **10** 个,分在上文《工单记录存储的内容》列出的那三个分区里。不在这个页签上的十四个里,有七个就在同一个界面的别处:**状态**、**优先级**、**SLA 截止日期**、**SLA Violated**、**负责人**、**账户**在页签上方的**关键信息**摘要栏里,**主题**则是页面标题的一半。剩下七个在工单的任何界面上都没有:**关闭日期**、**升级日期**、**首次响应日期**、**关闭标记**、**优先级排序**、**显示名称**、**解决所用文章**。**关闭日期**与**关闭标记**是工单关闭时打上的,由 *工单时间线* 与 *服务流转* 两个视图读取,而不是被这个界面读取;**升级日期**由升级流程写入,但哪里都不展示,**优先级排序**是 *我的待处理工单* 背后的排序键,**显示名称**是对象的 `nameField`,一个在任何地方引用工单时给它命名的标题公式,而不是你在这里能读到的字段,**解决所用文章**则是被知识库转移率指标读取,而不是被某个界面读取。这里面最该知道的缺席者是**首次响应日期**:上文《自动发生的事》整整一段讲清了它是怎么打戳的,而你读不到它的地方正是这个页签。 - **Related**——只有一个列表,不是四个:**Open Tasks**——通过**关联工单**(`related_to_case`)指向本工单的 `crm_task` 记录,过滤掉状态为 *Completed* 的,一次最多十条。整个页签就这些。没有附件列表:工单是可以挂附件的(对象上开了这项能力),但这一页没有任何组件把它们列出来。没有关联商机列表,因为 `crm_case` 与商机之间双向都不存在任何关系字段。也没有里程碑列表——工单的三个里程碑(*escalated*、*resolved*、*closed*)是以条目形式发到 **Activity** 时间线上的,不是在这里当记录列出来。 - **Activity**——统一的时间线(下文详述)。 - **AI 参考栏**——这一页上不存在任何形式的参考栏。主区域里除了那组页签什么都没有,页面上也没有任何一个组件是 AI 驱动的。服务技能本身是真实存在的——见下文《AI Copilot 如何提供帮助》一节——但你是靠提问去用它们,而不是靠工单旁边的一块面板。本应用里唯一真正渲染出来的参考栏在商机详情页上,而且即便在那里,它列的也是关联记录(报价、产品、未完成任务),不是什么建议。 diff --git a/content/docs/service/cases.zh-Hant.mdx b/content/docs/service/cases.zh-Hant.mdx index aab7f0262..4b0dab740 100644 --- a/content/docs/service/cases.zh-Hant.mdx +++ b/content/docs/service/cases.zh-Hant.mdx @@ -54,7 +54,7 @@ description: 客戶支援工單——主題、優先順序、狀態、解決方 ## 工單記錄儲存的內容 -`crm_case` 一共宣告 **25 個欄位**(`src/service/objects/case.object.ts`),而把它們組織起來的東西有三樣:物件自己的欄位分組、工單詳情介面、工單表單。三者裡只有第一樣是六個分區,而且兩兩都不一致——所以要緊的是分清你看的是哪一樣。 +`crm_case` 一共宣告 **24 個欄位**(`src/service/objects/case.object.ts`),而把它們組織起來的東西有三樣:物件自己的欄位分組、工單詳情介面、工單表單。三者裡只有第一樣是六個分區,而且兩兩都不一致——所以要緊的是分清你看的是哪一樣。 ### 物件自己的欄位分組 @@ -64,7 +64,7 @@ description: 客戶支援工單——主題、優先順序、狀態、解決方 | --- | --- | | **Case Information** | 工單編號、主題、顯示名稱、描述、帳戶、聯絡人、狀態、類型 | | **Origin & Routing** | 負責人、來源 | -| **SLA & Priority** | 優先順序、建立日期、關閉日期、首次回應日期、解決時間(小時)、SLA 截止日期、SLA Violated | +| **SLA & Priority** | 優先順序、關閉日期、首次回應日期、解決時間(小時)、SLA 截止日期、SLA Violated | | **Resolution** | 解決方案、解決所用文章 | | **Escalation** *(預設折疊)* | 升級標記、升級日期、升級原因 | | **System** *(預設折疊)* | 內部備註、關閉標記 | @@ -73,7 +73,7 @@ description: 客戶支援工單——主題、優先順序、狀態、解決方 ### 詳情介面 -工單詳情頁(`src/service/pages/case_detail.page.ts`)的 **Details** 頁籤宣告了**三個**分區,裝著 25 個欄位中的 **10** 個: +工單詳情頁(`src/service/pages/case_detail.page.ts`)的 **Details** 頁籤宣告了**三個**分區,裝著 24 個欄位中的 **10** 個: | 分區 | 欄位 | | --- | --- | @@ -87,7 +87,7 @@ description: 客戶支援工單——主題、優先順序、狀態、解決方 ### 表單 -工單表單(`src/service/views/case.view.ts`)**不是**分頁籤的。它只有一個分區,裝著 25 個欄位中的 **9** 個: +工單表單(`src/service/views/case.view.ts`)**不是**分頁籤的。它只有一個分區,裝著 24 個欄位中的 **9** 個: | 分區 | 欄位 | | --- | --- | @@ -103,7 +103,7 @@ description: 客戶支援工單——主題、優先順序、狀態、解決方 - **首次回應日期**(`first_response_date`)——在工單上記錄一次**已經發生過**的互動時才打戳,而且只打第一次。它唯一的寫入方是 `event_activity_bubble` 鉤子(`src/sales/objects/event.hook.ts`):任何一筆以該工單為關聯記錄、狀態到達 **Held** 的 `crm_event` 都會觸發它——所以 **Log a Call** 與 **Log a Meeting** 會打戳,用別的方式錄入的互動(包括直接在工單的活動列表上錄入)同樣會。它寫之前先把庫裡存著的值讀回來,所以第二次互動不會覆蓋第一次。有兩件事被刻意排除在「首次回應」之外:**狀態變更**(客服可以把工單轉到 *In Progress* 然後自己查一小時,客戶什麼都沒聽到),以及僅僅**預約**了一場會議——那是 `planned` 而不是 held。所以一張全程靠評論處理的工單,這個欄位始終是空的。 - **關閉標記**——狀態變為 *Closed* 時自動設定。 - **關閉日期**——狀態變為 *Closed* 時標記為 NOW。 -- **解決時間(小時)**——按 *Created date* 與 *Closed date* 之間的差值計算。 +- **解決時間(小時)**——按工單建立的時刻與 *Closed date* 之間的差值計算。建立時刻是平台自己的時間戳,不論工單從哪裡提交都會寫上;工單沒有另外一個屬於自己的 *Created Date* 欄位。 - **SLA 截止日期**——給**每一張**優先順序可識別的工單標記。`case_sla_defaults` 拿工單的優先順序去和其帳戶的**客戶層級**對查,把該欄位填成 *目前時間 + 相應的日曆小時數*;建單時打戳,此後只要該欄位仍為空也會打。它只寫一次、從不重算,有人手工填過的日期也永遠不會被覆蓋。 - **SLA Violated**(`is_sla_violated`)——不存在名為 *SLA Breached?* 的欄位。每小時一次的 `case_sla_monitor` 掃描把它置真,選中的是**仍未結**(狀態既不是 *Resolved* 也不是 *Closed*)且 **SLA 截止日期** 已經過去的工單。它從不拿解決時間去比對目標,所以沒有截止日期的工單永遠不是候選,晚解決但趕在下一次掃描前解決的工單也永遠不會被標記。 - **帳戶的最後活動日期**——工單更新時刷新為今天。 @@ -199,7 +199,7 @@ description: 客戶支援工單——主題、優先順序、狀態、解決方 - **狀態路徑**——一個 Salesforce 風格的水平流程,顯示 New → In Progress → Waiting on Customer → Escalated → Resolved → Closed,讓客服一眼就能看出工單的進展和下一站。 - **客戶面板**——不存在這樣一個面板。這一頁(`src/service/pages/case_detail.page.ts`)只宣告了兩個區域,再無其他:一個頁首區域裝著上面那三個元件,一個主區域裝著下面那組頁籤。客戶脈絡就散落在這兩處:**帳戶**在頁面上出現了兩次——頁首副標題和關鍵資訊摘要欄;**主要聯絡人**則是 *Details* 頁籤 *Case Information* 分區裡的一個欄位。**合約等級**與**本月未結工單**在工單的任何介面上都沒有承載:工單不關聯任何合約,`crm_contract` 上也沒有等級欄位;最接近的真實欄位是帳戶記錄上的 **Customer Tier**(中文介面作「客戶分層」),而這一頁並不展示它。本應用裡也沒有任何東西按月統計一個客戶的工單數。 - **三個標籤頁:** - - **Details**——完整的工單描述、解決方案和內部備註是有的,但不是「所有元資料欄位」:是物件 **25** 個欄位裡的 **10** 個,分在上文《工單記錄儲存的內容》列出的那三個分區裡。不在這個頁籤上的十五個裡,有七個就在同一個介面的別處:**狀態**、**優先順序**、**SLA 截止日期**、**SLA Violated**、**負責人**、**帳戶**在頁籤上方的**關鍵資訊**摘要欄裡,**主題**則是頁面標題的一半。剩下八個在工單的任何介面上都沒有:**建立日期**、**關閉日期**、**升級日期**、**首次回應日期**、**關閉標記**、**優先順序排序**、**顯示名稱**、**解決所用文章**。**建立日期**、**關閉日期**與**關閉標記**是工單開啟和關閉時打上的,由 *工單時間線* 與 *服務流轉* 兩個檢視讀取,而不是被這個介面讀取;**升級日期**由升級流程寫入,但哪裡都不展示,**優先順序排序**是 *我的待處理工單* 背後的排序鍵,**顯示名稱**是物件的 `nameField`,一個在任何地方引用工單時給它命名的標題公式,而不是你在這裡能讀到的欄位,**解決所用文章**則是被知識庫轉移率指標讀取,而不是被某個介面讀取。這裡面最該知道的缺席者是**首次回應日期**:上文《自動發生的事》整整一段講清了它是怎麼打戳的,而你讀不到它的地方正是這個頁籤。 + - **Details**——完整的工單描述、解決方案和內部備註是有的,但不是「所有元資料欄位」:是物件 **24** 個欄位裡的 **10** 個,分在上文《工單記錄儲存的內容》列出的那三個分區裡。不在這個頁籤上的十四個裡,有七個就在同一個介面的別處:**狀態**、**優先順序**、**SLA 截止日期**、**SLA Violated**、**負責人**、**帳戶**在頁籤上方的**關鍵資訊**摘要欄裡,**主題**則是頁面標題的一半。剩下七個在工單的任何介面上都沒有:**關閉日期**、**升級日期**、**首次回應日期**、**關閉標記**、**優先順序排序**、**顯示名稱**、**解決所用文章**。**關閉日期**與**關閉標記**是工單關閉時打上的,由 *工單時間線* 與 *服務流轉* 兩個檢視讀取,而不是被這個介面讀取;**升級日期**由升級流程寫入,但哪裡都不展示,**優先順序排序**是 *我的待處理工單* 背後的排序鍵,**顯示名稱**是物件的 `nameField`,一個在任何地方引用工單時給它命名的標題公式,而不是你在這裡能讀到的欄位,**解決所用文章**則是被知識庫轉移率指標讀取,而不是被某個介面讀取。這裡面最該知道的缺席者是**首次回應日期**:上文《自動發生的事》整整一段講清了它是怎麼打戳的,而你讀不到它的地方正是這個頁籤。 - **Related**——只有一個列表,不是四個:**Open Tasks**——透過**關聯工單**(`related_to_case`)指向本工單的 `crm_task` 記錄,過濾掉狀態為 *Completed* 的,一次最多十筆。整個頁籤就這些。沒有附件列表:工單是可以掛附件的(物件上開了這項能力),但這一頁沒有任何元件把它們列出來。沒有關聯商機列表,因為 `crm_case` 與商機之間雙向都不存在任何關係欄位。也沒有里程碑列表——工單的三個里程碑(*escalated*、*resolved*、*closed*)是以條目形式發到 **Activity** 時間線上的,不是在這裡當記錄列出來。 - **Activity**——統一的時間線(下文詳述)。 - **AI 參考欄**——這一頁上不存在任何形式的參考欄。主區域裡除了那組頁籤什麼都沒有,頁面上也沒有任何一個元件是 AI 驅動的。服務技能本身是真實存在的——見下文《AI Copilot 如何提供幫助》一節——但你是靠提問去用它們,而不是靠工單旁邊的一塊面板。本應用裡唯一真正渲染出來的參考欄在商機詳情頁上,而且即便在那裡,它列的也是關聯記錄(報價、產品、未完成任務),不是什麼建議。 diff --git a/docs/STATUS.md b/docs/STATUS.md index 98967ad3e..093d341db 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -19,7 +19,7 @@ loader registers: ```text HotCRM v3.1.0 -Data: 18 Objects 359 Fields +Data: 18 Objects 358 Fields UI: 1 Apps 14 Views 8 Pages 5 Dashboards 10 Reports 31 Actions Logic: 32 Flows Security: 12 Positions 7 Permissions diff --git a/src/sales/data/_shared.ts b/src/sales/data/_shared.ts index b73f61d82..be5cde26a 100644 --- a/src/sales/data/_shared.ts +++ b/src/sales/data/_shared.ts @@ -42,10 +42,12 @@ import { cel } from '@objectstack/spec'; * * The old conclusion survives the corrected premise, and is still the rule: * - * 1. Seeding historical values into readonly fields (`created_date`, - * `stage_entry_date`, `last_contacted_date`, `actual_revenue`) is legitimate + * 1. Seeding historical values into readonly fields (`stage_entry_date`, + * `last_contacted_date`, `actual_revenue`, and the platform's own + * `created_at` — which the case seeds author since #1992) is legitimate * and load-bearing — it is the only way demo reports get history — and the - * platform explicitly preserves explicit seed values. + * platform explicitly preserves explicit seed values (`created_at` on the + * first insert since @objectstack/objectql 17.7.0, objectstack#21646). * 2. Every seeded value of a hook-owned field MUST equal what the hook would * compute (`is_closed` ⇔ status, `resolution_time_hours` ⇔ closed−created, * opportunity `probability`/`forecast_category`/`expected_revenue` ⇔ stage, diff --git a/src/sales/data/sales.seed.ts b/src/sales/data/sales.seed.ts index 4688b53ea..1c444a127 100644 --- a/src/sales/data/sales.seed.ts +++ b/src/sales/data/sales.seed.ts @@ -80,8 +80,8 @@ import { celDaysAgo, linesTotal, type LineSpec } from './_shared'; // so hooks could write it, and no hook derives it from anything on the account. // It is a HISTORICAL fact — "when did anyone last speak to this customer" — and // authoring history into a field whose writers only ever stamp the current date -// is doctrine rule 1, the same licence `stage_entry_date` and `created_date` -// already use. +// is doctrine rule 1, the same licence `stage_entry_date` and the case seeds' +// `created_at` already use. // // What the writers DO impose is a constraint on the events seeded beside these // rows, and it decides the whole layout below. Lifecycle hooks run over seed diff --git a/src/sales/translations/en/objects.service.ts b/src/sales/translations/en/objects.service.ts index 876494b24..add77131f 100644 --- a/src/sales/translations/en/objects.service.ts +++ b/src/sales/translations/en/objects.service.ts @@ -52,7 +52,6 @@ export const service: Record = { options: { email: 'Email', phone: 'Phone', web: 'Web', chat: 'Chat', social_media: 'Social Media' }, }, owner_id: { label: 'Case Owner' }, - created_date: { label: 'Created Date' }, closed_date: { label: 'Closed Date' }, first_response_date: { label: 'First Response Date' }, resolution_time_hours: { label: 'Resolution Time (Hours)' }, diff --git a/src/sales/translations/es-ES/app.ts b/src/sales/translations/es-ES/app.ts index 925a7b84b..9e6294147 100644 --- a/src/sales/translations/es-ES/app.ts +++ b/src/sales/translations/es-ES/app.ts @@ -217,7 +217,7 @@ export const appSurface: Omit = { label: 'Métricas de casos', description: 'Capa semántica para el recuento de casos, el tiempo de resolución y el SLA', dimensions: { - created_date: { + created_at: { label: 'Creado', }, origin: { diff --git a/src/sales/translations/es-ES/objects.service.ts b/src/sales/translations/es-ES/objects.service.ts index 05f8ffaf9..e0c931864 100644 --- a/src/sales/translations/es-ES/objects.service.ts +++ b/src/sales/translations/es-ES/objects.service.ts @@ -68,7 +68,6 @@ export const service: Record = { options: { email: 'Email', phone: 'Teléfono', web: 'Web', chat: 'Chat', social_media: 'Redes Sociales' }, }, owner_id: { label: 'Propietario del Caso' }, - created_date: { label: 'Fecha de Creación' }, closed_date: { label: 'Fecha de Cierre' }, first_response_date: { label: 'Fecha de Primera Respuesta' }, resolution_time_hours: { label: 'Tiempo de Resolución (Horas)' }, diff --git a/src/sales/translations/ja-JP/app.ts b/src/sales/translations/ja-JP/app.ts index 435d621a8..992e88ae9 100644 --- a/src/sales/translations/ja-JP/app.ts +++ b/src/sales/translations/ja-JP/app.ts @@ -219,7 +219,7 @@ export const appSurface: Omit = { label: 'ケース指標', description: 'ケース数・解決時間・SLA を集計するセマンティックレイヤー', dimensions: { - created_date: { + created_at: { label: '作成日', }, origin: { diff --git a/src/sales/translations/ja-JP/objects.service.ts b/src/sales/translations/ja-JP/objects.service.ts index bf4a31702..bcb20c2bf 100644 --- a/src/sales/translations/ja-JP/objects.service.ts +++ b/src/sales/translations/ja-JP/objects.service.ts @@ -66,7 +66,6 @@ export const service: Record = { options: { email: 'メール', phone: '電話', web: 'ウェブ', chat: 'チャット', social_media: 'ソーシャルメディア' }, }, owner_id: { label: 'ケース担当者' }, - created_date: { label: '作成日' }, closed_date: { label: 'クローズ日' }, first_response_date: { label: '初回応答日' }, resolution_time_hours: { label: '解決時間(時間)' }, diff --git a/src/sales/translations/zh-CN/app.ts b/src/sales/translations/zh-CN/app.ts index 673cb8e2f..18029d2b5 100644 --- a/src/sales/translations/zh-CN/app.ts +++ b/src/sales/translations/zh-CN/app.ts @@ -218,7 +218,7 @@ export const appSurface: Omit = { label: '工单指标', description: '统计工单数量、解决时长与 SLA 的语义层', dimensions: { - created_date: { + created_at: { label: '创建时间', }, origin: { diff --git a/src/sales/translations/zh-CN/objects.service.ts b/src/sales/translations/zh-CN/objects.service.ts index a3f45e223..8031526ff 100644 --- a/src/sales/translations/zh-CN/objects.service.ts +++ b/src/sales/translations/zh-CN/objects.service.ts @@ -72,7 +72,6 @@ export const service: Record = { label: '工单来源', options: { email: '邮件', phone: '电话', web: '网站', chat: '在线客服', social_media: '社交媒体' }, }, - created_date: { label: '创建日期' }, closed_date: { label: '关闭日期' }, first_response_date: { label: '首次响应日期' }, resolution_time_hours: { label: '解决耗时(小时)' }, diff --git a/src/service/dashboards/service.dashboard.ts b/src/service/dashboards/service.dashboard.ts index 5bd84fc44..45fe12681 100644 --- a/src/service/dashboards/service.dashboard.ts +++ b/src/service/dashboards/service.dashboard.ts @@ -46,7 +46,11 @@ export const ServiceDashboard: Dashboard = { // executes it against a real SQLite database and compares every widget to a // ground truth computed in the same run, so a re-regression fails CI here // rather than being discovered as an all-zero dashboard again. - dateRange: { field: 'created_date', defaultRange: 'last_90_days', allowCustomRange: true }, + // + // The range windows `created_at`, the platform's creation stamp (#1992). It + // used to window `created_date`, which nothing wrote on a real case, so + // every case created through the UI or REST fell outside every range. + dateRange: { field: 'created_at', defaultRange: 'last_90_days', allowCustomRange: true }, globalFilters: [ { @@ -169,7 +173,7 @@ export const ServiceDashboard: Dashboard = { title: 'Daily Case Volume', description: 'New cases created over the last 30 days', type: 'area', - filter: { created_date: { $gte: '{30_days_ago}' } }, + filter: { created_at: { $gte: '{30_days_ago}' } }, // Opted out ON PURPOSE, re-decided in #1157 rather than inherited. // // The floor above is no longer inert: on 17.0.0 `{30_days_ago}` resolves @@ -186,7 +190,7 @@ export const ServiceDashboard: Dashboard = { // is the intended reading. filterBindings: { dateRange: false }, colorVariant: 'blue', - dataset: 'case_metrics', dimensions: ['created_date'], values: ['case_count'], + dataset: 'case_metrics', dimensions: ['created_at'], values: ['case_count'], layout: { x: 0, y: 6, w: 8, h: 4 }, chartConfig: { showLegend: false, diff --git a/src/service/data/service.seed.ts b/src/service/data/service.seed.ts index 283b80add..92e94053b 100644 --- a/src/service/data/service.seed.ts +++ b/src/service/data/service.seed.ts @@ -43,7 +43,10 @@ const TIER_BY_ACCOUNT = new Map( ); /** - * `created_date + matrix(priority, tier)`, as a CEL expression. + * `created_at + matrix(priority, tier)`, as a CEL expression — the case's + * authored creation instant (the platform's own `created_at`, which the seed + * loader keeps on first insert since @objectstack/objectql 17.7.0, + * objectstack#21646) plus the cell `case_sla_defaults` would have applied. * * `daysAgo(n)` is a UTC midnight, so the hour offset has to be added on top of * it — hence `+ duration('Nh')` rather than a second day-granular helper. @@ -82,7 +85,7 @@ export const cases = defineSeed(Case, { is_closed: false, is_sla_violated: false, is_escalated: false, - created_date: cel`daysAgo(2)`, + created_at: cel`daysAgo(2)`, sla_due_date: celCaseSlaDue(2, 'high', 'Acme Corporation'), }, { @@ -99,7 +102,7 @@ export const cases = defineSeed(Case, { is_sla_violated: true, is_escalated: true, escalation_reason: 'Customer threatening churn', - created_date: cel`daysAgo(5)`, + created_at: cel`daysAgo(5)`, sla_due_date: celCaseSlaDue(5, 'critical', 'Globex Industries'), }, { @@ -120,7 +123,7 @@ export const cases = defineSeed(Case, { is_sla_violated: false, is_escalated: false, resolution_time_hours: 24.0, - created_date: cel`daysAgo(3)`, + created_at: cel`daysAgo(3)`, closed_date: cel`daysAgo(2)`, sla_due_date: celCaseSlaDue(3, 'medium', 'Initech Solutions'), }, @@ -147,7 +150,7 @@ export const cases = defineSeed(Case, { resolved_by_article: 'API Rate Limits', // closed−created delta, as case.hook computes it. resolution_time_hours: 24.0, - created_date: cel`daysAgo(7)`, + created_at: cel`daysAgo(7)`, closed_date: cel`daysAgo(6)`, sla_due_date: celCaseSlaDue(7, 'high', 'Wayne Enterprises'), }, @@ -164,7 +167,7 @@ export const cases = defineSeed(Case, { is_closed: false, is_sla_violated: false, is_escalated: false, - created_date: cel`daysAgo(1)`, + created_at: cel`daysAgo(1)`, sla_due_date: celCaseSlaDue(1, 'medium', 'Stark Medical'), }, { @@ -184,7 +187,7 @@ export const cases = defineSeed(Case, { is_closed: false, is_sla_violated: false, is_escalated: false, - created_date: cel`daysAgo(4)`, + created_at: cel`daysAgo(4)`, sla_due_date: celCaseSlaDue(4, 'low', 'Acme Corporation'), }, { @@ -201,7 +204,7 @@ export const cases = defineSeed(Case, { is_sla_violated: true, is_escalated: true, escalation_reason: 'Affects 30% of mobile users', - created_date: cel`daysAgo(3)`, + created_at: cel`daysAgo(3)`, sla_due_date: celCaseSlaDue(3, 'critical', 'Globex Industries'), }, { @@ -221,7 +224,7 @@ export const cases = defineSeed(Case, { resolution: 'Delivered CSV bulk-import in the 9.4 release; shared the docs link with the customer.', // closed−created delta, as case.hook computes it. resolution_time_hours: 48.0, - created_date: cel`daysAgo(10)`, + created_at: cel`daysAgo(10)`, closed_date: cel`daysAgo(8)`, sla_due_date: celCaseSlaDue(10, 'low', 'Wayne Enterprises'), }, @@ -325,7 +328,7 @@ export const cases = defineSeed(Case, { // / escalation_reason_required). ...(status === 'closed' ? { resolution: 'Resolved per standard runbook; root cause documented and customer confirmed.' } : {}), ...(status === 'escalated' ? { escalation_reason: 'Escalated to tier-2 engineering for SLA-risk review.' } : {}), - created_date: celDaysAgo(ageDays), + created_at: celDaysAgo(ageDays), // Resolved cases also carry closed_date: case.hook stamps it as the // resolved-date proxy while keeping is_closed=false. ...(settled ? { closed_date: celDaysAgo(ageDays - resolutionDays) } : {}), diff --git a/src/service/datasets/case.dataset.ts b/src/service/datasets/case.dataset.ts index 0134b19b5..5ed5de7f0 100644 --- a/src/service/datasets/case.dataset.ts +++ b/src/service/datasets/case.dataset.ts @@ -17,7 +17,9 @@ export const CaseDataset = defineDataset({ { name: 'priority', label: 'Priority', field: 'priority', type: 'string' }, { name: 'origin', label: 'Origin', field: 'origin', type: 'string' }, { name: 'type', label: 'Type', field: 'type', type: 'string' }, - { name: 'created_date', label: 'Created', field: 'created_date', type: 'date', dateGranularity: 'day' }, + // The platform's creation stamp (#1992) — the same dimension + // `account_metrics` and `lead_metrics` declare, bucketed by day here. + { name: 'created_at', label: 'Created', field: 'created_at', type: 'date', dateGranularity: 'day' }, // Article ranking by resolutions (#601) — group closed cases by the // article that resolved them. `type: 'lookup'`, the same spelling the // `owner` dimension on `opportunity_metrics` uses for `owner_id`. diff --git a/src/service/objects/case.hook.ts b/src/service/objects/case.hook.ts index 6cd14f0e3..8dc1b2b90 100644 --- a/src/service/objects/case.hook.ts +++ b/src/service/objects/case.hook.ts @@ -302,9 +302,12 @@ const caseValidation: Hook = { (typeof input.closed_date === 'string' && input.closed_date) || (typeof ctx.previous?.closed_date === 'string' && (ctx.previous.closed_date as string)) || undefined; + // The creation instant is the platform's `created_at` (#1992): stamped + // by `sys_stamp_audit_insert` before this hook runs on an insert (a + // seed row keeps its authored value), and read back off the stored row + // on an update. const createdDate = - (typeof input.created_date === 'string' && input.created_date) || - (typeof ctx.previous?.created_date === 'string' && (ctx.previous.created_date as string)) || + (typeof input.created_at === 'string' && input.created_at) || (typeof ctx.previous?.created_at === 'string' && (ctx.previous.created_at as string)) || undefined; if (closedDate && createdDate) { diff --git a/src/service/objects/case.object.ts b/src/service/objects/case.object.ts index c0a9d146e..873bab487 100644 --- a/src/service/objects/case.object.ts +++ b/src/service/objects/case.object.ts @@ -187,12 +187,18 @@ export const Case = ObjectSchema.create({ // Assignment // SLA and Metrics - created_date: Field.datetime({ - label: 'Created Date', - group: 'sla', - readonly: true, - }), - + // + // NO `created_date` here (#1992, the #575 B2 precedent on + // `crm_opportunity`): the platform already injects `created_at` on every + // object and stamps it on every insert, while this duplicate had no writer + // at all — only the seed data set it, so every case created through the + // UI or REST stored null and fell out of "Cases Opened by Priority × Day" + // and the service dashboard's date range. Surfaces that need the creation + // instant read `created_at` (the `case_metrics` dataset, the dashboard + // range, the `case_timeline` view, `case_sla_defaults`' resolution time); + // the seeds author it directly, and the seed loader keeps an authored + // `created_at` on first insert since @objectstack/objectql 17.7.0 + // (objectstack#21646). closed_date: Field.datetime({ label: 'Closed Date', group: 'sla', diff --git a/src/service/reports/case.report.ts b/src/service/reports/case.report.ts index 5f112ba48..887173f42 100644 --- a/src/service/reports/case.report.ts +++ b/src/service/reports/case.report.ts @@ -29,21 +29,24 @@ export const SlaPerformanceReport: Report = { }; /** - * Daily case inflow by priority — matrix over `created_date`. Support managers + * Daily case inflow by priority — matrix over `created_at`. Support managers * use this to spot priority spikes (e.g. a P1 burst on Tuesday) and staff * accordingly. * - * `case_metrics.created_date` declares the day bucket, so records created at + * `case_metrics.created_at` declares the day bucket, so records created at * different times on the same day aggregate into one column. + * + * No `runtimeFilter` (#1992). The column used to be `created_date`, which + * nothing wrote on a real case, so this report carried + * `{ created_date: { $ne: null } }` against a headerless '—' column and every + * case created through the UI or REST fell out of it. `created_at` is the + * platform's own creation stamp, written on every insert, so there is no + * empty bucket to exclude and every case opened is counted. */ export const CasesOpenedByDayPriorityReport: Report = { name: 'cases_opened_by_day_priority', label: 'Cases Opened by Priority × Day', description: 'Daily case inflow split by priority', - dataset: 'case_metrics', rows: ['priority'], columns: ['created_date'], values: ['case_count'], + dataset: 'case_metrics', rows: ['priority'], columns: ['created_at'], values: ['case_count'], type: 'matrix', - // `created_date` is stamped by the platform, not required by the schema — a - // case that reaches the table without one would group into a headerless '—' - // column. "Cases opened" needs an open date; exclude the empty bucket. - runtimeFilter: { created_date: { $ne: null } }, }; diff --git a/src/service/views/case.view.ts b/src/service/views/case.view.ts index 28c87c223..7c205dedd 100644 --- a/src/service/views/case.view.ts +++ b/src/service/views/case.view.ts @@ -107,7 +107,11 @@ export const CaseViews = defineView({ data: { provider: 'object', object: 'crm_case' }, columns: ['case_number', 'subject'], timeline: { - startDateField: 'created_date', + // `created_at`, the platform's own creation stamp — the object's + // duplicate `created_date` was removed in #1992 because nothing wrote + // it on a real case, so every user-created case started its bar at + // null. Same spelling as `deal_timeline` on crm_opportunity (#575 B2). + startDateField: 'created_at', endDateField: 'closed_date', titleField: 'subject', groupByField: 'owner_id', @@ -327,8 +331,9 @@ export const CaseViews = defineView({ * `mode === 'create'` `Je` additionally drops formula / summary / autonumber * fields). An authored section renders its list VERBATIM. That is the whole * mechanism behind this defect: the platform would have hidden - * `created_date`, `closed_date`, `resolution_time_hours` and `is_closed` - * from a creator by itself, and naming them in a section put them back. + * `created_date` (retired in #1992), `closed_date`, `resolution_time_hours` + * and `is_closed` from a creator by itself, and naming them in a section put + * them back. * * ## ⛔ Why not gate the old sections on a predicate instead * @@ -350,7 +355,10 @@ export const CaseViews = defineView({ * still has the surface it belongs on (pinned in * `test/case-create-form-narrowing.test.ts`, both directions): * - * created_date, closed_date readonly; `case_timeline` start/end + * closed_date readonly; `case_timeline` end + * (its start is the platform's + * `created_at`; `created_date` was + * retired in #1992) * sla_due_date `case.hook.ts` stamps it from the * priority × tier matrix; list column, * sort key, `sla_calendar.startDateField`, diff --git a/test/action-references.test.ts b/test/action-references.test.ts index 2da8ea399..54632760b 100644 --- a/test/action-references.test.ts +++ b/test/action-references.test.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect } from 'vitest'; +import { applySystemFields } from '@objectstack/objectql'; import stack from '../objectstack.config'; import { type AnyRec, @@ -402,8 +403,16 @@ describe('dashboard date ranges window a field the query layer can actually comp return [...names]; }; - const fieldType = (objectName: string, field: string): string | undefined => - objects.find((o) => o.name === objectName)?.fields?.[field]?.type; + /** + * A field's type on the object AS THE PLATFORM MATERIALISES IT: `created_at` + * and the rest of the audit family are injected by `applySystemFields`, never + * listed in `fields`, and the Service dashboard windows `created_at` since + * #1992 retired the app's own `crm_case.created_date`. + */ + const fieldType = (objectName: string, field: string): string | undefined => { + const obj = objects.find((o) => o.name === objectName); + return obj ? (applySystemFields(obj as never, { multiTenant: false }) as AnyRec).fields?.[field]?.type : undefined; + }; it('every dashboard dateRange field exists on the objects its widgets aggregate', () => { // A range field that no underlying object defines is silently dropped or diff --git a/test/case-create-form-narrowing.test.ts b/test/case-create-form-narrowing.test.ts index dde2f4570..c8f9fe2e0 100644 --- a/test/case-create-form-narrowing.test.ts +++ b/test/case-create-form-narrowing.test.ts @@ -91,10 +91,9 @@ const CREATOR_AUTHORABLE = new Set([ * that exists; a field with no surface AND no answer does not belong here. */ const LIFECYCLE_MAINTAINED: Record = { - created_date: { - why: 'readonly on the object; stamped at insert', - keeps: ['case_timeline.startDateField'], - }, + // `created_date` left this roster with the field (#1992): it had no writer, + // and the creation instant is the platform's `created_at`, which no form + // ever offered. `case_timeline` starts on it — `test/case-creation-date.test.ts`. first_response_date: { why: '`event.hook.ts` is its single writer; shown on the record page through the `sla` group (#970)', keeps: ['detail.details'], @@ -304,7 +303,7 @@ describe('crm_case create form — retention direction', () => { /** * #1428 — `internal_notes` was the third field that left with the Resolution - * section, and unlike the ten above it kept NO surface: not a list column, + * section, and unlike the nine above it kept NO surface: not a list column, * not a filter, not a section on the record page. It is staff prose somebody * has to type, so "no human surface" was never a design, and this is the pin * that keeps its replacement surface from being refactored away silently. diff --git a/test/case-creation-date.test.ts b/test/case-creation-date.test.ts new file mode 100644 index 000000000..51a5c9689 --- /dev/null +++ b/test/case-creation-date.test.ts @@ -0,0 +1,244 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ObjectKernel } from '@objectstack/core'; +import { DefaultDatasourcePlugin, AppPlugin } from '@objectstack/runtime'; +import { ObjectQLPlugin } from '@objectstack/objectql'; +import { MetadataPlugin } from '@objectstack/metadata'; +import { AnalyticsService } from '@objectstack/service-analytics'; +import { SEED_WRITE_EXECUTION_CONTEXT } from '@objectstack/spec/kernel'; +import { SysUser, SysMember, SysOrganization } from '@objectstack/platform-objects/identity'; +import stack from '../objectstack.config'; +import { CaseDataset } from '../src/service/datasets/case.dataset'; +import { CasesOpenedByDayPriorityReport } from '../src/service/reports/case.report'; +import { ServiceDashboard } from '../src/service/dashboards/service.dashboard'; +import { identityObjects } from './helpers/identity-objects'; + +/** + * `crm_case` has one creation stamp, and the platform owns it (#1992). + * + * The object declared a `readonly` `created_date` beside the `created_at` the + * platform injects and stamps on every insert. Only the seed data wrote it, so + * a case created through the UI or REST stored null — and "Cases Opened by + * Priority × Day" filtered `created_date $ne null` while the service + * dashboard's range keyed on it: managers saw the 38 seeded cases and never a + * real one. The field is retired the way #575 B2 retired the same duplicate on + * `crm_opportunity` (`test/opportunity-creation-date.test.ts`), and every + * reader moved to `created_at`. + * + * The retirement waited on the platform (AGENTS.md §2): until + * @objectstack/objectql 17.7.0 the seed loader overwrote an authored + * `created_at` with the boot instant on first insert (objectstack#21646), so + * moving the seeds onto it would have collapsed the demo history onto one day. + * The second half of the executed check below pins that the history survives. + */ + +type AnyRec = Record; + +// The object registry announces every registered object on stdout at `info`. +process.env.OS_REGISTRY_LOG ??= 'silent'; + +const objects: AnyRec[] = (stack as any).objects ?? []; +const views: AnyRec[] = (stack as any).views ?? []; +const reports: AnyRec[] = (stack as any).reports ?? []; +const kase = objects.find((o) => o.name === 'crm_case') as AnyRec | undefined; +const caseViews = views.find((v) => (v.list?.data?.object ?? v.object) === 'crm_case') as AnyRec | undefined; + +/** Every locale pack the stack ships, as `[locale, pack]` (see opportunity-creation-date.test.ts). */ +const localePacks: [string, AnyRec][] = ((stack as any).translations ?? []).flatMap( + (bundle: AnyRec) => Object.entries(bundle) as [string, AnyRec][], +); + +/** The object field a `case_metrics` dimension reads. */ +const fieldOf = (dimension: string): string | undefined => + (CaseDataset.dimensions as AnyRec[]).find((d) => d.name === dimension)?.field; + +describe('crm_case has no duplicate creation field', () => { + it('found the case object, its views and the locale packs', () => { + expect(kase, 'crm_case not registered').toBeTruthy(); + expect(caseViews?.listViews, 'no crm_case listViews found').toBeTruthy(); + expect( + localePacks.filter(([, pack]) => pack?.objects?.crm_case?.fields).length, + 'no locale pack labels crm_case fields — the staleness check below would pass vacuously', + ).toBeGreaterThan(0); + }); + + it('crm_case declares no created_date', () => { + expect(Object.keys(kase?.fields ?? {})).not.toContain('created_date'); + }); + + it('no locale pack still labels it', () => { + const stale = localePacks + .filter(([, pack]) => pack?.objects?.crm_case?.fields?.created_date) + .map(([locale]) => locale); + expect(stale, `locales still labelling crm_case.created_date: ${stale.join(', ')}`).toEqual([]); + }); + + it('every surface that reads the creation instant reads created_at', () => { + const reportColumns = (CasesOpenedByDayPriorityReport.columns ?? []) as string[]; + expect(reportColumns.map(fieldOf), 'the daily-inflow report buckets on another field').toEqual(['created_at']); + expect(ServiceDashboard.dateRange?.field, 'the service dashboard range').toBe('created_at'); + expect(caseViews?.listViews?.case_timeline?.timeline?.startDateField, 'case_timeline start').toBe('created_at'); + }); + + it('nothing over crm_case still names created_date', () => { + // The generalisation: a predicate, bucket or range on a field the object + // does not have raises nothing anywhere — the rows simply drop out. + const surfaces: [string, unknown][] = [ + ['case_metrics', CaseDataset], + ...reports.filter((r) => r.dataset === 'case_metrics').map((r) => [`report ${r.name}`, r] as [string, unknown]), + ['service_dashboard', ServiceDashboard], + ['crm_case views', caseViews], + ]; + const stale = surfaces.filter(([, s]) => JSON.stringify(s).includes('created_date')).map(([name]) => name); + expect(stale, `crm_case surfaces still naming created_date: ${stale.join(', ')}`).toEqual([]); + }); +}); + +/** + * The property the card is about, EXECUTED rather than read: a case written + * with no date at all — the shape a UI or REST create has — is counted by + * "Cases Opened by Priority × Day" and inside the service dashboard's range, + * and a seeded case keeps the creation day it was authored with. + * + * The shipped stack runs on a real kernel so the platform's own + * `sys_stamp_audit_insert` hook (registered by `ObjectQLPlugin`) does the + * stamping — the fixture never writes `created_at` for the user-created case, + * which is the whole point. SQLite (through `driver-sql`'s knex path) because + * that is where the report's day bucket is lowered in production. + */ +describe('a case created without any date is counted', () => { + const DAY = 86_400_000; + const SEEDED_AGE_DAYS = 5; + const utcDay = (ms: number): string => new Date(ms).toISOString().slice(0, 10); + + let kernel: AnyRec; + let ql: AnyRec; + let analytics: AnalyticsService; + let stored: Record<'user' | 'seed', AnyRec>; + let authoredSeedInstant: string; + + beforeAll(async () => { + kernel = new ObjectKernel({ logger: { level: 'silent' } } as never); + await kernel.use(new DefaultDatasourcePlugin({ driver: 'sqlite-wasm', config: { filename: ':memory:' } } as never)); + await kernel.use(new MetadataPlugin({ watch: false, artifactWatch: false, environmentId: 'proj_test' } as never)); + await kernel.use(new ObjectQLPlugin({ environmentId: 'proj_test' } as never)); + // 17.7.0 refuses an object name the registry does not hold (objectstack#21545). + await kernel.use(identityObjects(SysUser, SysMember, SysOrganization) as never); + await kernel.use(new AppPlugin(stack as never, undefined as never, { skipSeedData: true } as never)); + await kernel.bootstrap(); + ql = kernel.getService('objectql'); + + const read = async (id: string): Promise => + ((await ql.findOne('crm_case', { where: { id } }, { context: { isSystem: true } })) ?? {}) as AnyRec; + + // A user's create: no date of any kind in the payload. + const userRow = await ql.insert( + 'crm_case', + { + subject: 'case-creation-date: user-created', + description: 'Raised by a user, carrying no date of any kind.', + status: 'new', + priority: 'high', + origin: 'web', + }, + { context: { userId: 'usr_case_creation_date' } }, + ); + // A seed row, written in the seed loader's own execution context, carrying + // an authored creation instant the way `service.seed.ts` authors one. + authoredSeedInstant = new Date(Date.now() - SEEDED_AGE_DAYS * DAY).toISOString(); + const seedRow = await ql.insert( + 'crm_case', + { + subject: 'case-creation-date: seeded', + description: 'Written in the seed loader context with an authored creation instant.', + status: 'new', + priority: 'low', + origin: 'email', + created_at: authoredSeedInstant, + }, + { context: { ...SEED_WRITE_EXECUTION_CONTEXT } }, + ); + stored = { + user: await read(String(userRow?.id ?? userRow?.record?.id)), + seed: await read(String(seedRow?.id ?? seedRow?.record?.id)), + }; + + analytics = new AnalyticsService({ + // The bridge `AnalyticsServicePlugin` auto-wires at boot. + executeAggregate: async (objectName: string, opts: AnyRec) => + ql.aggregate(objectName, { + where: opts.filter, + groupBy: opts.groupBy, + aggregations: opts.aggregations?.map((a: AnyRec) => ({ function: a.method, field: a.field, alias: a.alias })), + timezone: opts.timezone, + context: opts.context, + }), + queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }), + logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} } as never, + }); + }, 120_000); + + afterAll(async () => { + await kernel?.shutdown?.(); + }); + + /** Run the shipped report exactly as declared: its rows × columns, its values, its own runtimeFilter. */ + const runReport = async (): Promise => { + const report = CasesOpenedByDayPriorityReport as AnyRec; + const result = await analytics.queryDataset( + CaseDataset as never, + { + dimensions: [...(report.rows ?? []), ...(report.columns ?? [])], + measures: report.values, + ...(report.runtimeFilter ? { runtimeFilter: report.runtimeFilter } : {}), + }, + { isSystem: true } as never, + ); + return result.rows as AnyRec[]; + }; + + it('the platform stamped the user-created case and kept the seeded instant', () => { + // Positive controls for everything below: without them a report that + // counted nothing and a stamp that wrote nothing would read the same. + expect(stored.user.created_at, 'the platform did not stamp created_at on a plain create').toBeTruthy(); + expect(Math.abs(Date.parse(String(stored.user.created_at)) - Date.now())).toBeLessThan(10 * 60_000); + expect( + new Date(String(stored.seed.created_at)).toISOString(), + 'a seed row lost its authored created_at on insert — objectstack#21646 regressed, and the demo history collapses onto the boot day', + ).toBe(authoredSeedInstant); + }); + + it('counts both cases in "Cases Opened by Priority × Day", each on its own day', async () => { + const rows = await runReport(); + const column = String((CasesOpenedByDayPriorityReport.columns ?? [])[0]); + const cell = (priority: string): AnyRec[] => rows.filter((r) => r.priority === priority); + + const total = rows.reduce((sum, r) => sum + (Number(r.case_count) || 0), 0); + expect(total, `the report dropped a case: ${JSON.stringify(rows)}`).toBe(2); + + const user = cell('high'); + expect(user.map((r) => Number(r.case_count)), JSON.stringify(rows)).toEqual([1]); + expect(String(user[0][column]).slice(0, 10), 'the user-created case is not on today').toBe(utcDay(Date.parse(String(stored.user.created_at)))); + + const seed = cell('low'); + expect(seed.map((r) => Number(r.case_count)), JSON.stringify(rows)).toEqual([1]); + expect(String(seed[0][column]).slice(0, 10), 'the seeded case is not on its authored day').toBe(utcDay(Date.parse(authoredSeedInstant))); + }); + + it('counts the user-created case inside the service dashboard range', async () => { + // The console lowers a preset to `{ $gte: '{N_days_ago}', $lte: '{today}' }` + // (see test/dashboard-date-range-window.test.ts) and ANDs it into every + // bound widget on `dateRange.field`. + const result = await analytics.queryDataset( + CaseDataset as never, + { + measures: ['case_count'], + runtimeFilter: { [String(ServiceDashboard.dateRange?.field)]: { $gte: '{7_days_ago}', $lte: '{today}' } } as never, + }, + { isSystem: true } as never, + ); + const total = (result.rows as AnyRec[]).reduce((sum, r) => sum + (Number(r.case_count) || 0), 0); + expect(total, 'the last-7-days range left a case out').toBe(2); + }); +}); diff --git a/test/dashboard-agent-global-filter.test.ts b/test/dashboard-agent-global-filter.test.ts index a410c80c9..e279584de 100644 --- a/test/dashboard-agent-global-filter.test.ts +++ b/test/dashboard-agent-global-filter.test.ts @@ -201,7 +201,7 @@ const truthMatches = (row: FixtureCase, filter: AnyRec | undefined): boolean => if (row.owner !== cond) return false; continue; } - if (field === 'created_date') { + if (field === 'created_at') { const spec = cond as AnyRec; for (const [op, comparand] of Object.entries(spec)) { const m = typeof comparand === 'string' ? comparand.match(/^\{(\d+)_days_ago\}$/) : null; @@ -211,7 +211,7 @@ const truthMatches = (row: FixtureCase, filter: AnyRec | undefined): boolean => // Every fixture row is in the past; a "<= today" ceiling keeps them all. continue; } else { - throw new Error(`truthMatches: unsupported created_date bound ${op}: ${JSON.stringify(comparand)}`); + throw new Error(`truthMatches: unsupported created_at bound ${op}: ${JSON.stringify(comparand)}`); } } continue; @@ -256,7 +256,7 @@ const keyOf = (v: unknown): string => (v === null || v === undefined || v === '' const CASE_COLUMNS = [ 'owner_id', 'subject', 'description', 'status', 'priority', 'origin', 'type', 'is_closed', 'is_sla_violated', 'resolution', 'resolution_time_hours', - 'resolved_by_article', 'created_date', + 'resolved_by_article', 'created_at', ]; let ql: AnyRec; @@ -294,7 +294,8 @@ beforeAll(async () => { is_sla_violated: row.is_sla_violated, resolution_time_hours: row.resolution_time_hours, resolved_by_article: row.resolved_by_article, - created_date: utcNoonDaysAgo(row.offset), + // The platform's creation stamp, which the dashboard range windows (#1992). + created_at: utcNoonDaysAgo(row.offset), // The app's own `resolution_required_for_closed` rule fires on these // inserts — a welcome sign the REAL object is under test. ...(row.is_closed ? { resolution: 'Resolved by the fixture.' } : {}), @@ -577,7 +578,7 @@ describe('every filter-bound widget on the dashboard partitions the same way', ( // widgets are compared on their totals rather than on a key format // this file would be pinning by accident. const dims = (w.dimensions ?? []) as string[]; - if (dims.length === 1 && dimensionField(dims[0]) !== 'created_date') { + if (dims.length === 1 && dimensionField(dims[0]) !== 'created_at') { const dim = dims[0]; const gAll = byGroup(rows.all, dim, measure); const gAlpha = byGroup(rows.alpha, dim, measure); diff --git a/test/dashboard-date-range-window.test.ts b/test/dashboard-date-range-window.test.ts index 93655fe9a..420afcf64 100644 --- a/test/dashboard-date-range-window.test.ts +++ b/test/dashboard-date-range-window.test.ts @@ -89,8 +89,17 @@ const datasets: AnyRec[] = (stack as any).datasets ?? []; const objects: AnyRec[] = (stack as any).objects ?? []; const datasetByName = new Map(datasets.map((d) => [d.name, d])); -const fieldType = (objectName: string, field: string): string | undefined => - objects.find((o) => o.name === objectName)?.fields?.[field]?.type; +/** + * A field's type on the object AS THE PLATFORM MATERIALISES IT. The Service + * dashboard windows `created_at` since #1992 retired `crm_case.created_date`, + * and `created_at` is injected by `applySystemFields`, never listed in `fields` + * — reading `fields` alone would drop the dashboard out of `datetimeWindowed` + * and leave every test below with nothing to run. + */ +const fieldType = (objectName: string, field: string): string | undefined => { + const obj = objects.find((o) => o.name === objectName); + return obj ? (applySystemFields(obj as never, { multiTenant: false }) as AnyRec).fields?.[field]?.type : undefined; +}; /** Objects a dashboard's widgets aggregate over, via their datasets. */ const objectsBehind = (d: AnyRec): string[] => { @@ -213,7 +222,8 @@ const CASE_OBJECT = { is_sla_violated: { type: 'boolean' }, resolution_time_hours: { type: 'number' }, resolved_by_article: { type: 'text' }, - created_date: { type: 'datetime' }, + // No creation column here: `created_at` is the platform's, added by + // `applySystemFields` below exactly as it is on the real object (#1992). }, } as const; @@ -252,7 +262,7 @@ beforeAll(async () => { for (const row of CASE_ROWS) { const { offset, ...rest } = row; - await api.object('crm_case').insert({ ...rest, owner_id: ownerOf(row), created_date: utcNoonDaysAgo(offset).toISOString() }); + await api.object('crm_case').insert({ ...rest, owner_id: ownerOf(row), created_at: utcNoonDaysAgo(offset).toISOString() }); } analytics = new AnalyticsService({ @@ -297,20 +307,20 @@ describe('a datetime window compares correctly on the real SQL path', () => { }); it('honours the $gte floor — objectstack#3912 made it match everything', async () => { - expect(await count({ created_date: { $gte: '{90_days_ago}' } })).toBe(within(90)); - expect(await count({ created_date: { $gte: '{30_days_ago}' } })).toBe(within(30)); - expect(await count({ created_date: { $gte: '{7_days_ago}' } })).toBe(within(7)); + expect(await count({ created_at: { $gte: '{90_days_ago}' } })).toBe(within(90)); + expect(await count({ created_at: { $gte: '{30_days_ago}' } })).toBe(within(30)); + expect(await count({ created_at: { $gte: '{7_days_ago}' } })).toBe(within(7)); }); it('honours the $lte ceiling — objectstack#3912 made it match nothing', async () => { // Everything in the fixture is in the past, so `<= today` keeps all of it. - expect(await count({ created_date: { $lte: '{today}' } })).toBe(CASE_ROWS.length); + expect(await count({ created_at: { $lte: '{today}' } })).toBe(CASE_ROWS.length); }); it('honours both bounds together — the shape that zeroed the dashboard', async () => { - expect(await count({ created_date: presetWindow(90) })).toBe(within(90)); - expect(await count({ created_date: presetWindow(30) })).toBe(within(30)); - expect(await count({ created_date: presetWindow(7) })).toBe(within(7)); + expect(await count({ created_at: presetWindow(90) })).toBe(within(90)); + expect(await count({ created_at: presetWindow(30) })).toBe(within(30)); + expect(await count({ created_at: presetWindow(7) })).toBe(within(7)); }); it('keeps same-day rows under a bare-date upper bound — objectstack#3777', async () => { @@ -325,7 +335,7 @@ describe('a datetime window compares correctly on the real SQL path', () => { // to do with #3777. const today = new Date(); const bareDate = `${today.getUTCFullYear()}-${String(today.getUTCMonth() + 1).padStart(2, '0')}-${String(today.getUTCDate()).padStart(2, '0')}`; - expect(await count({ created_date: { $lte: bareDate } })).toBe(CASE_ROWS.length); + expect(await count({ created_at: { $lte: bareDate } })).toBe(CASE_ROWS.length); }); it('reads `{today}` as the END of the UTC day, and `{yesterday}` as the one before', async () => { @@ -334,8 +344,8 @@ describe('a datetime window compares correctly on the real SQL path', () => { // could have: end-of-UTC-day (13 and 12 — the measured behaviour), // midnight-UTC (12 and 12 — objectstack#3777's symptom), and a ceiling // resolved on some other calendar (12 and 11, the #1450 failure). - expect(await count({ created_date: { $lte: '{today}' } })).toBe(CASE_ROWS.length); - expect(await count({ created_date: { $lte: '{yesterday}' } })).toBe(CASE_ROWS.length - 1); + expect(await count({ created_at: { $lte: '{today}' } })).toBe(CASE_ROWS.length); + expect(await count({ created_at: { $lte: '{yesterday}' } })).toBe(CASE_ROWS.length - 1); }); it('stamps the fixture on the window\'s calendar in EVERY host zone', () => { @@ -504,10 +514,10 @@ describe('every datetime-windowed dashboard answers under its own picker', () => function truthMatches(row: CaseRow, filter: AnyRec | undefined, windowDays: number | null): boolean { if (windowDays !== null && row.offset > windowDays) return false; for (const [field, cond] of Object.entries(filter ?? {})) { - if (field === 'created_date') { + if (field === 'created_at') { const gte = (cond as AnyRec)?.$gte; const m = typeof gte === 'string' ? gte.match(/^\{(\d+)_days_ago\}$/) : null; - if (!m) throw new Error(`truthMatches: unsupported created_date filter ${JSON.stringify(cond)}`); + if (!m) throw new Error(`truthMatches: unsupported created_at filter ${JSON.stringify(cond)}`); if (row.offset > Number(m[1])) return false; continue; } diff --git a/test/dataset-granularity.test.ts b/test/dataset-granularity.test.ts index 04d10bcbe..38e8fdce1 100644 --- a/test/dataset-granularity.test.ts +++ b/test/dataset-granularity.test.ts @@ -73,7 +73,7 @@ type Granularity = 'day' | 'week' | 'month' | 'quarter' | 'year'; */ const INTENDED_BUCKET: Record = { 'lead_metrics.last_contacted_date': 'month', - 'case_metrics.created_date': 'day', + 'case_metrics.created_at': 'day', 'account_metrics.created_at': 'month', 'opportunity_metrics.close_date': 'month', 'opportunity_metrics.close_quarter': 'quarter', diff --git a/test/docs-service-index-analytics.test.ts b/test/docs-service-index-analytics.test.ts index e6a0667d6..eb834e9da 100644 --- a/test/docs-service-index-analytics.test.ts +++ b/test/docs-service-index-analytics.test.ts @@ -255,7 +255,7 @@ describe('the source facts those four bullets now rest on (#948)', () => { it('the daily-inflow report puts priority in the rows and the day in the columns', () => { expect(CasesOpenedByDayPriorityReport.rows).toEqual(['priority']); - expect(CasesOpenedByDayPriorityReport.columns).toEqual(['created_date']); + expect(CasesOpenedByDayPriorityReport.columns).toEqual(['created_at']); expect(CasesOpenedByDayPriorityReport.label).toBe('Cases Opened by Priority × Day'); }); }); diff --git a/test/opportunity-creation-date.test.ts b/test/opportunity-creation-date.test.ts index 805d6960f..68c159aaf 100644 --- a/test/opportunity-creation-date.test.ts +++ b/test/opportunity-creation-date.test.ts @@ -17,9 +17,11 @@ import stack from '../objectstack.config'; * exists and is the platform's. `crm_lead`'s activity calendar had already * settled the spelling: `startDateField: 'created_at'`. * - * `crm_case.created_date` is a different field with the same name and is NOT - * affected — the case seeds write it deliberately (see the seed note in - * `src/data/index.ts`) and the Service dashboard windows on it. + * `crm_case.created_date` was a different field with the same name and was + * left alone here, because the case seeds wrote it and the Service dashboard + * windowed on it. It had the same defect on every case a user created, and + * #1992 retired it the same way once the platform kept a seed row's authored + * `created_at` (objectstack#21646) — pinned in `test/case-creation-date.test.ts`. */ type AnyRec = Record; @@ -55,11 +57,6 @@ describe('the duplicate creation field is gone', () => { expect(Object.keys(opportunity?.fields ?? {})).not.toContain('created_date'); }); - it('crm_case keeps its own created_date — this was not a global rename', () => { - const crmCase = objects.find((o) => o.name === 'crm_case') as AnyRec | undefined; - expect(crmCase?.fields?.created_date, 'crm_case.created_date was collateral damage').toBeTruthy(); - }); - it('no locale pack still translates it', () => { const stale = localePacks .filter(([, pack]) => pack?.objects?.crm_opportunity?.fields?.created_date) diff --git a/test/seed-consistency.test.ts b/test/seed-consistency.test.ts index fba1c813b..c38a115c9 100644 --- a/test/seed-consistency.test.ts +++ b/test/seed-consistency.test.ts @@ -464,7 +464,8 @@ describe('seeded case SLA due dates match the policy matrix (#595)', () => { /** * `sla_due_date` is a hook-owned field, and hooks do not run over seeds — so * the seeded value has to already BE what `case_sla_defaults` would have - * computed at the case's creation moment: `created_date` plus the + * computed at the case's creation moment: `created_at` (the platform's + * creation stamp, which the seeds author since #1992) plus the * priority × account-tier cell, in calendar hours. * * The seed generator derives these by construction (`celCaseSlaDue` in @@ -495,19 +496,19 @@ describe('seeded case SLA due dates match the policy matrix (#595)', () => { expect(blank, blank.join('\n')).toEqual([]); }); - it('derives every due date from created_date + the matrix cell', () => { + it('derives every due date from created_at + the matrix cell', () => { const problems: string[] = []; for (const c of cases) { - const created = celSource(c.created_date); + const created = celSource(c.created_at); const due = celSource(c.sla_due_date); const label = `${String(c.subject)} (${String(c.priority)} / ${String(c.crm_account)})`; if (!created || !due) { - problems.push(`${label}: created_date and sla_due_date must both be CEL expressions`); + problems.push(`${label}: created_at and sla_due_date must both be CEL expressions`); continue; } const age = /^daysAgo\((\d+)\)$/.exec(created); if (!age) { - problems.push(`${label}: created_date is not a daysAgo() expression — ${created}`); + problems.push(`${label}: created_at is not a daysAgo() expression — ${created}`); continue; } const hours = caseSlaCalendarHours(String(c.priority), tierOf.get(String(c.crm_account))); @@ -530,7 +531,7 @@ describe('seeded case SLA due dates match the policy matrix (#595)', () => { // no longer than its age. const problems: string[] = []; for (const c of cases.filter((r) => r.is_sla_violated === true)) { - const age = /^daysAgo\((\d+)\)$/.exec(celSource(c.created_date) ?? ''); + const age = /^daysAgo\((\d+)\)$/.exec(celSource(c.created_at) ?? ''); const hours = caseSlaCalendarHours(String(c.priority), tierOf.get(String(c.crm_account))); const label = `${String(c.subject)} (${String(c.priority)})`; if (!age || hours === undefined) {