diff --git a/.changeset/2330-in-app-docs-modules.md b/.changeset/2330-in-app-docs-modules.md new file mode 100644 index 00000000..006aa530 --- /dev/null +++ b/.changeset/2330-in-app-docs-modules.md @@ -0,0 +1,14 @@ +--- +"hotcrm": minor +--- + +Clause-②: no +Metadata-protocol: none + +**A Marketing Manual under Help, and five more module pages, each with a Chinese version.** The service, revenue and marketing modules now explain their own rules in the app: + +- User Manual, Service: **Case Triage** (what the assistant reads when it triages a case, the order it weighs things in, and why setting the priority, escalating and closing stay with a person). +- Revenue Manual: **Products & Price Books** (what a product carries, how its price reaches a deal or quote line, why a sold product is retired rather than deleted, and what price books do today) and **Billing Hand-off** (the two moments a won deal or an activated contract is sent to your billing system, and what travels with it). The Revenue Manual's one group is now titled *Revenue* rather than *Quotes*. +- New **Marketing Manual**, opened from **Help** after the Revenue Manual: **Campaigns** (statuses, member statuses, what each number counts and what ROI includes) and **Campaign Automation** (when Enroll Members, Add to Campaign and the nightly auto-completion run, and what each refuses). The manual and its Help entry belong to the marketing module and leave with it. + +The billing hand-off's two moments, the auto-completion time and the buttons the triage page names are checked against the compiled app, so a changed flow or a renamed button fails the build until the page follows. diff --git a/.github/markdown-link-check-config.json b/.github/markdown-link-check-config.json index c489d350..0860ecd4 100644 --- a/.github/markdown-link-check-config.json +++ b/.github/markdown-link-check-config.json @@ -23,6 +23,10 @@ "pattern": "^\\./(crm_revenue_[a-z_]+\\.md)$", "replacement": "{{BASEURL}}/src/revenue/docs/$1" }, + { + "pattern": "^\\./(crm_marketing_[a-z_]+\\.md)$", + "replacement": "{{BASEURL}}/src/marketing/docs/$1" + }, { "pattern": "^\\./(crm_overview\\.md)$", "replacement": "{{BASEURL}}/src/docs/$1" diff --git a/content/docs/getting-started/quick-tour.mdx b/content/docs/getting-started/quick-tour.mdx index a73c814d..aed4c642 100644 --- a/content/docs/getting-started/quick-tour.mdx +++ b/content/docs/getting-started/quick-tour.mdx @@ -57,7 +57,7 @@ Inside HotCRM, the left nav opens with two pinned items — **Home**, which is t | **Marketing** | Campaigns | | **Insights** | CRM Overview, Forecasts | | **Reports** | Opportunities by Stage, Won Opportunities by Owner, Pipeline Coverage, Opportunity Funnel by Owner, Lead Engagement, Accounts by Industry & Type, Customer Churn Signals, Cases by Status and Priority, SLA Performance, Cases Opened by Day | -| **Help** | User Manual, Admin Manual, Revenue Manual | +| **Help** | User Manual, Admin Manual, Revenue Manual, Marketing Manual | **Reports** and **Help** are collapsed when the app loads — **Service**, **Sales**, **My Work**, **Activity**, **Marketing** and **Insights** open themselves. Click **Reports** open before deciding a report isn't there. **Help** opens the in-app manuals; **Admin Manual** is shown to administrators only. diff --git a/content/docs/getting-started/quick-tour.zh-Hans.mdx b/content/docs/getting-started/quick-tour.zh-Hans.mdx index 14f8c36c..8719db6f 100644 --- a/content/docs/getting-started/quick-tour.zh-Hans.mdx +++ b/content/docs/getting-started/quick-tour.zh-Hans.mdx @@ -59,7 +59,7 @@ description: HotCRM 的 5 分钟演练 —— 主页、你的第一条线索、 | **Marketing** | Campaigns | | **Insights** | CRM Overview、Forecasts | | **Reports** | Opportunities by Stage、Won Opportunities by Owner、Pipeline Coverage、Opportunity Funnel by Owner、Lead Engagement、Accounts by Industry & Type、Customer Churn Signals、Cases by Status and Priority、SLA Performance、Cases Opened by Day | -| **Help** | User Manual、Admin Manual、Revenue Manual | +| **Help** | User Manual、Admin Manual、Revenue Manual、Marketing Manual | 上表用的是源码里的英文 label,也就是英文界面上看到的名字。简体中文界面下,它们显示为语言包里的译名——例如 **Inbox** 显示为「待我审批」、**Knowledge** 显示为「知识库」、**My Work** 显示为「我的工作」。 diff --git a/content/docs/getting-started/quick-tour.zh-Hant.mdx b/content/docs/getting-started/quick-tour.zh-Hant.mdx index 36147800..3ba55f45 100644 --- a/content/docs/getting-started/quick-tour.zh-Hant.mdx +++ b/content/docs/getting-started/quick-tour.zh-Hant.mdx @@ -59,7 +59,7 @@ description: HotCRM 的 5 分鐘演練 —— 主頁、你的第一條線索、 | **Marketing** | Campaigns | | **Insights** | CRM Overview、Forecasts | | **Reports** | Opportunities by Stage、Won Opportunities by Owner、Pipeline Coverage、Opportunity Funnel by Owner、Lead Engagement、Accounts by Industry & Type、Customer Churn Signals、Cases by Status and Priority、SLA Performance、Cases Opened by Day | -| **Help** | User Manual、Admin Manual、Revenue Manual | +| **Help** | User Manual、Admin Manual、Revenue Manual、Marketing Manual | 上表用的是原始碼裡的英文 label,也就是英文介面上看到的名字。本應用提供 en / zh-CN / ja-JP / es-ES 四種介面語言,沒有繁體語言包——簡體中文介面下這些名字會顯示為語言包裡的譯名,例如 **Inbox** 顯示為「待我审批」、**Knowledge** 顯示為「知识库」、**My Work** 顯示為「我的工作」。 diff --git a/src/marketing/books/crm-marketing-manual.book.ts b/src/marketing/books/crm-marketing-manual.book.ts new file mode 100644 index 00000000..e82b7c8f --- /dev/null +++ b/src/marketing/books/crm-marketing-manual.book.ts @@ -0,0 +1,23 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineBook } from '@objectstack/spec'; + +/** + * The Marketing Manual (#2330) — the marketing module's own pages, from + * `src/marketing/docs/`. Built as the Revenue Manual is + * (`src/revenue/books/crm-revenue-manual.book.ts`): a book of this package, + * opened from *Help* by the entry `src/marketing/index.ts` contributes, so + * the book, its entry and its pages leave together when the module is + * uninstalled. The app's pages never link here; these pages may link to the + * app's. + */ +export const CrmMarketingManual = defineBook({ + name: 'crm_marketing_manual', + label: 'HotCRM Marketing Manual', + description: 'Campaigns: what the numbers count, and what runs on its own.', + icon: 'megaphone', + order: 4, + groups: [ + { key: 'marketing', label: 'Campaigns', order: 1, include: 'crm_marketing_*' }, + ], +}); diff --git a/src/marketing/books/index.ts b/src/marketing/books/index.ts new file mode 100644 index 00000000..e3a7ba25 --- /dev/null +++ b/src/marketing/books/index.ts @@ -0,0 +1,8 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Doc Book Barrel (ADR-0046 §6) — the marketing module's table of contents + * over its `src/marketing/docs/*.md` pages. + */ + +export { CrmMarketingManual } from './crm-marketing-manual.book'; diff --git a/src/marketing/docs/crm_marketing_automation.md b/src/marketing/docs/crm_marketing_automation.md new file mode 100644 index 00000000..03a061f0 --- /dev/null +++ b/src/marketing/docs/crm_marketing_automation.md @@ -0,0 +1,86 @@ +--- +title: Campaign Automation — Enrolment & Auto-Completion +description: When each campaign flow runs, what it does, what it refuses, and what is kept up to date for you. +order: 2 +# sources: the flows this doc documents. The completion schedule is guarded by +# test/docs-drift.test.ts; build ignores unknown frontmatter keys. +sources: + - flow:campaign_enrollment + - flow:campaign_lead_member_enroll + - flow:campaign_contact_member_enroll + - flow:campaign_completion +--- + +# Campaign Automation — Enrolment & Auto-Completion + +For marketers and the administrators who support them. Three things put people +on campaigns or close campaigns. Two are buttons a person presses; one runs on +a schedule. None of them sends an email: a campaign records who it reached, and +the sending happens in your email tool. + +## Enroll Members — a whole segment at once + +**When it runs:** when you press **Enroll Members** on a campaign. The button is +offered while the campaign is **Planning** or **In Progress**, and only to +people who may add campaign members (Marketing User and the administrators). No +schedule runs it: the segment is chosen on its screen, and a scheduled run would +have nobody to choose it. + +**What it does:** its screen asks two things. First whether to enroll **leads** +or **contacts** (one side per run), then the segment on that side: leads by +status (*New*, *Contacted* or *Qualified*), contacts by department. It then adds +everyone in that segment who has an email address and has not opted out of +email; a lead must also not be converted already, because a converted lead is +now a contact. Each new member starts at **Sent**. + +**What it skips and refuses:** anyone already on the campaign is skipped, so +running it again tops the campaign up and never counts a person twice. If the +campaign is no longer open, nobody is added and the run says so. If anyone in +the segment is refused (for example, by permission), the run ends with both +counts, how many were enrolled and how many were refused, and the refused +people are not on the campaign. + +## Add to Campaign — the people you picked + +**When it runs:** when you run **Add to Campaign** on leads or contacts: on +**All Leads** or **All Contacts**, tick rows and run it from the selection +toolbar, or run it on one row from that row's menu. You pick the campaign. It +is offered to the same people as **Enroll Members**. + +**What it does:** adds each person you picked as a member at **Sent**, one by +one. Someone already on that campaign is skipped, and the run still reports +success for them. + +**What it refuses:** a campaign that is not **Planning** or **In Progress**, and +a person who has opted out of email. Nobody is added, the campaign's numbers do +not move, and the message names the campaign or the person. The same two +refusals apply however else a member is created: **New** on a campaign's +member list, an import, or the API. + +## Campaign Auto-Completion — every night + +**When it runs:** every day at **02:00**, where the deployment has switched +scheduled work on. A default install boots it unbound: there, a campaign stays +**In Progress** past its **End Date** until someone changes it, so complete it +yourself. + +**What it does:** sets every **In Progress** campaign whose **End Date** has +passed to **Completed**. It changes the status and nothing else; the campaign +takes no new members from then on. + +## Kept up to date for you + +These are not flows you run, and they run on every install: + +- **The campaign's numbers** are recomputed whenever a member is added, answers + or is removed, whenever one of its leads is converted, and whenever a deal + that names the campaign is saved or deleted. What each number counts is in + [Campaigns](./crm_marketing_campaigns.md). +- **Converted members.** When a lead is converted, each of its memberships at + **Sent** or **Responded** becomes **Converted**. +- **Opt-outs.** Setting a member to **Unsubscribed** ticks **Email Opt Out** on + that lead or contact, so both buttons above leave them out from then on. + +--- + +**Related:** how a lead is converted — see [Leads](./crm_sales_leads.md). diff --git a/src/marketing/docs/crm_marketing_automation.zh.md b/src/marketing/docs/crm_marketing_automation.zh.md new file mode 100644 index 00000000..8fbd6b0e --- /dev/null +++ b/src/marketing/docs/crm_marketing_automation.zh.md @@ -0,0 +1,42 @@ +--- +title: 营销活动自动化——加入成员与自动完成 +description: 每个营销活动流程何时运行、做什么、拒绝什么,以及哪些内容会自动保持最新。 +--- + +# 营销活动自动化——加入成员与自动完成 + +写给市场人员和为他们提供支持的管理员。有三样东西会把人加入营销活动或结束营销活动。其中两样是由人点击的按钮,一样按计划自动运行。它们都不发送邮件:营销活动记录的是触达了谁,发送在你的邮件工具里完成。 + +## 批量加入成员——一次加入整个细分人群 + +**何时运行:**在营销活动上点击**批量加入成员**时。只有活动处于**规划中**或**进行中**时才提供这个按钮,而且只提供给可以添加营销活动成员的人(**市场专员**和管理员)。没有任何计划任务会运行它:细分人群要在它的界面上选择,计划任务运行时没有人来选。 + +**它做什么:**它的界面先问两件事。一是加入**线索**还是**联系人**(每次运行只加入一种),二是这一种里的细分人群:线索按状态(*新建*、*已联系*或*已确认*),联系人按部门。然后它把这个细分人群中有邮箱、且没有退订邮件的人全部加入;线索还必须尚未转化,因为已转化的线索现在是联系人。每个新成员都从**已发送**开始。 + +**它跳过和拒绝什么:**已经在活动中的人会被跳过,所以再运行一次只会补齐,绝不会把同一个人算两次。活动已不在可加入成员的状态时,不会加入任何人,运行结果会说明这一点。细分人群中如果有人被拒绝(例如因为权限),运行结束时会给出两个数字:加入了多少人、拒绝了多少人;被拒绝的人不在活动中。 + +## 加入营销活动——加入你选中的人 + +**何时运行:**在线索或联系人上运行**加入营销活动**时:在**全部线索**或**全部联系人**中勾选若干行,从选择工具栏运行;或者在某一行的菜单里对这一行运行。营销活动由你挑选。它提供给与**批量加入成员**相同的人。 + +**它做什么:**把你选中的每个人逐一加入为**已发送**状态的成员。已经在该活动中的人会被跳过,运行仍对他们报告成功。 + +**它拒绝什么:**不处于**规划中**或**进行中**的营销活动,以及已退订邮件的人。这时不会加入任何人,活动的数字也不会变化,提示会写明是哪个活动或哪个人。无论成员以其他什么方式创建,这两条拒绝都同样适用:活动成员列表上的**新建**、导入,或 API。 + +## 营销活动自动完成——每天夜间 + +**何时运行:**在已开启计划任务的部署中,每天 **02:00**。默认安装启动时不绑定它:在那里,过了**结束日期**的活动会一直保持**进行中**,直到有人修改,所以请自己把它设为已完成。 + +**它做什么:**把**结束日期**已过的每个**进行中**活动设为**已完成**。它只改状态,不改别的;从那以后活动不再加入新成员。 + +## 自动为你保持最新 + +这些不是由你运行的流程,在每个安装中都会运行: + +- **营销活动的数字**会在以下情况重算:成员加入、回应或被移除,活动中的某条线索转化,以及关联了该活动的商机被保存或删除。每个数字统计什么,见[营销活动](./crm_marketing_campaigns.md)。 +- **已转化的成员。** 线索转化时,它处于**已发送**或**已响应**的每条成员记录都会变为**已转化**。 +- **退订。** 把成员设为**已退订**,会在对应线索上勾选**拒收邮件**(联系人上为**拒绝邮件**),之后上面两个按钮都会把这个人排除在外。 + +--- + +**相关:** 线索如何转化——见[线索](./crm_sales_leads.md)。 diff --git a/src/marketing/docs/crm_marketing_campaigns.md b/src/marketing/docs/crm_marketing_campaigns.md new file mode 100644 index 00000000..ba3de35f --- /dev/null +++ b/src/marketing/docs/crm_marketing_campaigns.md @@ -0,0 +1,91 @@ +--- +title: Campaigns — Members, Responses & ROI +description: A campaign's status, what each member status means, what every number on the campaign counts, and what ROI includes. +order: 1 +# sources: the objects this doc documents. Build ignores unknown frontmatter keys. +sources: + - object:crm_campaign + - object:crm_campaign_member +--- + +# Campaigns — Members, Responses & ROI + +For marketers, and for the sales team that reads what marketing is running. A +campaign is one marketing effort (an email, a webinar, a trade show) with a +budget, a run of dates and a list of the people it reached. This page explains +what its numbers count, so you can trust them or know when not to. + +## A campaign's status + +| Status | What it means | +| --- | --- | +| **Planning** | Being prepared. It can already take members. | +| **In Progress** | Running. It takes members, and their answers are recorded. | +| **Completed** | Over. It takes no new members, so its member counts stay as they closed; a deal that names it and is won later still adds to its revenue. | +| **Aborted** | Called off before it finished. It takes no new members. | + +An **In Progress** campaign whose **End Date** has passed is set to +**Completed** for you: see [Campaign Automation](./crm_marketing_automation.md). +You can also complete one yourself when it ends early. + +Two rules hold on every save: **End Date** must be after **Start Date**, and +both are required. An **Actual Cost** above the **Budgeted Cost** does not stop +a save; it is there for you to notice. + +## Members and their status + +A **campaign member** is one person on one campaign: either a lead or a +contact, never both, and only once per campaign. A person enrolled once as a +lead and once as a contact is two memberships. How people get onto a campaign +is in [Campaign Automation](./crm_marketing_automation.md). + +| Member status | What it means | What sets it | +| --- | --- | --- | +| **Sent** | The campaign reached them. Every new member starts here. | Enrolment | +| **Responded** | They answered: replied, registered, downloaded. | **Mark Responded** on the member, by someone who may edit members | +| **Converted** | Their lead became a customer. | Automatic, when that lead is converted | +| **Unsubscribed** | They asked to stop hearing from you. | Someone who may edit members, on the member | + +Most members stay at **Sent**. A member at **Sent** or **Responded** whose lead +is converted becomes **Converted** for you; an **Unsubscribed** member never +does. Setting a member to **Unsubscribed** also ticks **Email Opt Out** on that +lead or contact, which keeps them out of every later campaign. + +HotCRM does not track opens, clicks or bounces. What it can observe is a person +answering and a lead converting, and those are the statuses it has. + +## What the numbers count + +The numbers sit on the campaign itself, and they are kept current as members +are added, answer or leave, as their leads convert, and as deals name the +campaign. A running campaign shows real figures; you do not wait for it to end. + +- **Number Sent** — every member of the campaign, whatever their status. +- **Number of Responses** — members at **Responded** or **Converted**: a member + whose lead converted answered too. +- **Response Rate %** — responses as a share of the number sent. +- **Number of Leads** — the different leads among the members. +- **Converted Leads** — how many of those leads have been converted. +- **Opportunities Created** — deals whose **Campaign** is this campaign. +- **Won Opportunities** — how many of those deals are **Closed Won**. +- **Actual Revenue** — the amounts of those won deals, added up. + +## What ROI counts + +**ROI %** is (**Actual Revenue** − **Actual Cost**) ÷ **Actual Cost**, as a +percentage. Three things follow from that: + +- **Only won deals count.** Open pipeline is never added to **Actual Revenue**, + however promising. +- **Only deals that name the campaign count.** A deal is credited to a campaign + by its **Campaign** field, one campaign per deal. Set it on the deal: nothing + fills it in for you, converting a lead included, and a deal without it earns + the campaign nothing. +- **ROI is 0 until you enter a cost.** With no **Actual Cost**, there is + nothing to measure a return against, and **ROI %** reads 0. Enter the cost as + soon as you know it. + +--- + +**Related:** how a deal moves to **Closed Won** — see +[Opportunities](./crm_sales_opportunities.md). diff --git a/src/marketing/docs/crm_marketing_campaigns.zh.md b/src/marketing/docs/crm_marketing_campaigns.zh.md new file mode 100644 index 00000000..3a080458 --- /dev/null +++ b/src/marketing/docs/crm_marketing_campaigns.zh.md @@ -0,0 +1,61 @@ +--- +title: 营销活动——成员、响应与投资回报 +description: 营销活动的状态、每种成员状态的含义、活动上每个数字统计的是什么,以及投资回报率包含什么。 +--- + +# 营销活动——成员、响应与投资回报 + +写给市场人员,也写给要了解市场部在做什么的销售团队。营销活动是一次市场推广(一封邮件、一场线上研讨会、一次展会),有预算、起止日期和它触达的人员名单。本页说明它的各个数字统计的是什么,让你知道什么时候可以相信它们,什么时候不能。 + +## 营销活动的状态 + +| 状态 | 含义 | +| --- | --- | +| **规划中** | 正在准备。已经可以加入成员。 | +| **进行中** | 正在开展。可以加入成员,成员的回应会被记录。 | +| **已完成** | 已结束。不再加入新成员,所以成员相关的数字停留在结束时的样子;之后赢单且关联了它的商机,仍会计入它的收入。 | +| **已中止** | 未完成就取消了。不再加入新成员。 | + +**结束日期**已过的**进行中**活动会自动设为**已完成**:见[营销活动自动化](./crm_marketing_automation.md)。活动提前结束时,你也可以自己把它设为已完成。 + +每次保存都执行两条规则:**结束日期**必须晚于**开始日期**,两者都必填。**实际成本**超过**预算成本**不会阻止保存;它是留给你注意的。 + +## 成员及其状态 + +**营销活动成员**是某个人在某个营销活动中的一条记录:要么是线索,要么是联系人,不能两者都是,并且每个活动只能出现一次。一个人以线索身份加入一次、又以联系人身份加入一次,就是两条成员记录。人员如何加入活动,见[营销活动自动化](./crm_marketing_automation.md)。 + +| 成员状态 | 含义 | 由谁设定 | +| --- | --- | --- | +| **已发送** | 活动触达了他们。每个新成员都从这里开始。 | 加入活动时 | +| **已响应** | 他们有了回应:回复、报名、下载。 | 由可编辑成员的人在成员上点**标记为已响应** | +| **已转化** | 他们的线索成为了客户。 | 该线索转化时自动设定 | +| **已退订** | 他们要求不再接收你的消息。 | 由可编辑成员的人在成员上设定 | + +大多数成员停留在**已发送**。处于**已发送**或**已响应**的成员,其线索转化后会自动变为**已转化**;**已退订**的成员永远不会。把成员设为**已退订**,还会在对应线索上勾选**拒收邮件**(联系人上为**拒绝邮件**),使这个人不再进入之后的任何活动。 + +HotCRM 不跟踪打开、点击或退信。它能观察到的是一个人作出回应、一条线索完成转化,它的成员状态也就只有这些。 + +## 各个数字统计的是什么 + +这些数字就在营销活动记录上,并且随时保持最新:成员加入、回应或移除,其线索转化,以及商机关联到该活动时都会重算。正在进行的活动显示的就是真实数字,不必等它结束。 + +- **发送数量**——活动的全部成员,不论状态。 +- **响应数量**——处于**已响应**或**已转化**的成员:线索已转化的成员也算作回应过。 +- **响应率(%)**——响应数量占发送数量的比例。 +- **线索数量**——成员中不同线索的个数。 +- **已转化线索数**——这些线索中已转化的个数。 +- **已创建商机数**——**营销活动**字段为本活动的商机。 +- **已赢得商机**——这些商机中处于**成交**的个数。 +- **实际收入**——这些赢单商机的金额之和。 + +## 投资回报率统计什么 + +**投资回报率(%)**是(**实际收入** − **实际成本**)÷ **实际成本**,以百分比表示。由此可知三点: + +- **只计赢单。** 未成交的商机不论多有希望,都不计入**实际收入**。 +- **只计关联了本活动的商机。** 商机通过它的**营销活动**字段归属某个活动,每个商机只归属一个。请在商机上填写它:没有任何机制会替你填写,线索转化也不会;没有填写的商机不会给活动带来任何收入。 +- **填写成本之前投资回报率为 0。** 没有**实际成本**,就没有衡量回报的基数,**投资回报率(%)**显示为 0。知道成本后请尽快填写。 + +--- + +**相关:** 商机如何进入**成交**——见[商机](./crm_sales_opportunities.md)。 diff --git a/src/marketing/index.ts b/src/marketing/index.ts index d0f6389a..c6c69afa 100644 --- a/src/marketing/index.ts +++ b/src/marketing/index.ts @@ -3,6 +3,7 @@ import { defineStack, type ObjectStackDefinition, type ObjectStackDefinitionInput } from '@objectstack/spec'; import { EnrollLeadsAction, MarkRespondedAction } from './actions/index.js'; +import { CrmMarketingManual } from './books/index.js'; import { CampaignEnrollmentFlow, CampaignCompletionFlow, CampaignLeadMemberEnrollFlow, CampaignContactMemberEnrollFlow, } from './flows/index.js'; @@ -75,7 +76,8 @@ export const createHotCrmMarketingStack = ( /** * The one entry this module puts into the app's Marketing group, which the * app keeps with no children of its own — as it keeps `group_service` — so - * the sidebar reads exactly as before. Rule R3; see `src/service/index.ts`. + * the sidebar reads exactly as before, and its manual under Help (below). + * Rule R3; see `src/service/index.ts`. */ navigationContributions: [ { @@ -86,6 +88,18 @@ export const createHotCrmMarketingStack = ( { id: 'nav_campaign', type: 'object', objectName: 'crm_campaign', label: 'Campaigns', icon: 'megaphone' }, ], }, + // This module's manual, under the app's *Help* group (#2330), as the + // revenue module contributes its own (`src/revenue/index.ts`): it lands + // after the app's two manuals and the Revenue Manual, and it leaves with + // the module, as the book does. + { + app: 'crm_enterprise', + group: 'group_help', + priority: 50, + items: [ + { id: 'nav_help_marketing', type: 'doc', book: 'crm_marketing_manual', label: 'Marketing Manual', icon: 'megaphone' }, + ], + }, ], }, @@ -101,6 +115,8 @@ export const createHotCrmMarketingStack = ( flows: marketingFlows, actions: [EnrollLeadsAction, MarkRespondedAction], sharingRules: marketingSharingRules, + // Its manual (#2330), over `src/marketing/docs/`. See the book's own note. + books: [CrmMarketingManual], ...collections, }); diff --git a/src/revenue/books/crm-revenue-manual.book.ts b/src/revenue/books/crm-revenue-manual.book.ts index 91970a4c..60d8cf53 100644 --- a/src/revenue/books/crm-revenue-manual.book.ts +++ b/src/revenue/books/crm-revenue-manual.book.ts @@ -13,10 +13,10 @@ import { defineBook } from '@objectstack/spec'; export const CrmRevenueManual = defineBook({ name: 'crm_revenue_manual', label: 'HotCRM Revenue Manual', - description: 'Quotes: what the system fills in for you and what it does on its own.', + description: 'Quotes, products and price books, and the hand-off of a won deal to billing.', icon: 'receipt', order: 3, groups: [ - { key: 'revenue', label: 'Quotes', order: 1, include: 'crm_revenue_*' }, + { key: 'revenue', label: 'Revenue', order: 1, include: 'crm_revenue_*' }, ], }); diff --git a/src/revenue/docs/crm_revenue_billing_handoff.md b/src/revenue/docs/crm_revenue_billing_handoff.md new file mode 100644 index 00000000..ac62c467 --- /dev/null +++ b/src/revenue/docs/crm_revenue_billing_handoff.md @@ -0,0 +1,79 @@ +--- +title: Billing Hand-off — When a Won Deal Goes to Billing +description: Where HotCRM's part ends, the two moments a deal is handed to your billing system, and what travels with it. +order: 3 +# sources: the flows this doc documents. The two moments are pinned to the +# flows' start conditions by test/docs-drift.test.ts; build ignores unknown +# frontmatter keys. +sources: + - flow:billing_handoff_closed_won + - flow:billing_handoff_contract_activated + - object:crm_opportunity + - object:crm_contract +--- + +# Billing Hand-off — When a Won Deal Goes to Billing + +For sales representatives, sales managers and the administrator who connects +HotCRM to billing. HotCRM runs the commercial side of a sale: finding the +customer, agreeing the price, signing the contract. It does not invoice. There +are no orders, invoices or payments in HotCRM; your billing system keeps those, +and HotCRM tells it when there is something to bill. + +## The two moments + +HotCRM tells your billing system twice, each time a record **crosses into** a +state: + +| When | What it says | +| --- | --- | +| A deal's **Stage** becomes **Closed Won** | We won this deal. | +| A contract's **Status** becomes **Activated** | This agreement is in force, on these terms. | + +Each moment is sent once. A won deal keeps being edited afterwards (a PO number +added, an owner changed), and none of those saves sends it again. A deal that is +moved out of **Closed Won** and later won again crosses a second time, and is +sent a second time. + +Most billing teams act on one of the two. For subscriptions it is usually the +contract, because that is the message that carries the billing terms. Contracts +are described in [Contracts](./crm_sales_contracts.md), deals in +[Opportunities](./crm_sales_opportunities.md). + +## What travels with it + +Each message is one complete package, so billing never has to come back and ask: + +- **The record itself.** For a won deal: its name, amount, close date, type and + owner. For a contract: its number, type, dates and term, its value, and the + two fields that are there for billing, **Billing Frequency** and + **Payment Terms**, with whether it renews automatically and when it was + signed. +- **The customer.** The account's name and account number, its billing address + and country, phone and website: what billing needs to open the customer. +- **What was sold.** Every line item of the deal: product, description, + quantity, list price, sales price, discount and line total. A contract sends + the line items of the deal it came from; a contract made without a deal sends + an empty list. + +The package is a fixed list. A field your administrator adds to accounts or +deals is not sent until the hand-off itself is changed, so billing never +receives a surprise. + +## What does not come back + +Nothing flows back into HotCRM. No field here tells you whether an invoice was +raised or paid, and none pretends to. HotCRM keeps no stock figure either: +closing a deal reduces no inventory. + +## For the administrator + +- **Delivery is retried.** The message is queued, not sent from your save, and + a receiver that is down receives it when it comes back. Every attempt is + logged by the platform as an HTTP delivery; there is no *Billing* entry in + HotCRM's own navigation. +- **Where it is sent is set at deployment.** The address and the signing secret + are part of the app as deployed, not a Setup screen: changing them is a + rebuild and redeploy. Until they are set, the messages go to an example + address, fail, and are logged as failed, so an unconnected hand-off is + visible rather than silent. diff --git a/src/revenue/docs/crm_revenue_billing_handoff.zh.md b/src/revenue/docs/crm_revenue_billing_handoff.zh.md new file mode 100644 index 00000000..ac15a3bf --- /dev/null +++ b/src/revenue/docs/crm_revenue_billing_handoff.zh.md @@ -0,0 +1,40 @@ +--- +title: 计费交接——赢单何时交给计费 +description: HotCRM 的职责到哪里为止,商机交给计费系统的两个时刻,以及随之发送的内容。 +--- + +# 计费交接——赢单何时交给计费 + +写给销售代表、销售经理,以及负责把 HotCRM 接到计费系统的管理员。HotCRM 负责销售的商务一侧:找到客户、谈定价格、签下合同。它不开票。HotCRM 里没有订单、发票或付款;这些由你的计费系统保存,HotCRM 负责在有东西要计费时通知它。 + +## 两个时刻 + +HotCRM 会通知你的计费系统两次,每次都是在一条记录**进入**某个状态的那一刻: + +| 何时 | 它说的是 | +| --- | --- | +| 商机的**阶段**变为**成交** | 这笔商机我们赢了。 | +| 合同的**状态**变为**已激活** | 这份协议按这些条款生效了。 | + +每个时刻只发送一次。赢单之后商机还会被继续编辑(补上采购单号、换负责人),这些保存都不会再次发送。商机被移出**成交**、之后又再次赢单,就是第二次进入,会再发送一次。 + +大多数计费团队只按其中一个时刻行动。订阅业务通常按合同,因为带着计费条款的是这一条。合同见[合同](./crm_sales_contracts.md),商机见[商机](./crm_sales_opportunities.md)。 + +## 随之发送的内容 + +每条消息都是一个完整的包,计费系统不必回头再来问: + +- **记录本身。** 赢单的商机:名称、金额、预计成交日期、类型和负责人。合同:编号、类型、起止日期和期限、合同金额,以及专为计费而设的两个字段**计费周期**和**付款条款**,另附是否自动续约和签署日期。 +- **客户。** 客户的名称和客户编号、账单地址和国家、电话和网站:计费系统开户所需的信息。 +- **卖了什么。** 商机的每一行明细:产品、描述、数量、标价、销售单价、折扣和行合计。合同发送的是它所来自的那笔商机的明细;没有关联商机的合同发送一个空列表。 + +这个包是固定的一组字段。管理员给客户或商机新增的字段,在交接本身被修改之前不会发送,所以计费系统不会收到意料之外的内容。 + +## 不会回传的内容 + +没有任何信息回传到 HotCRM。这里没有字段告诉你发票是否已开具或已付款,也没有字段假装知道。HotCRM 也不保存库存数量:赢单不会扣减任何库存。 + +## 写给管理员 + +- **发送会重试。** 消息先进入队列,而不是在你保存时直接发出;接收方宕机时,恢复后仍会收到。每次尝试都由平台记录为一条 HTTP 投递;HotCRM 自己的导航里没有*计费*入口。 +- **发送地址在部署时设定。** 地址和签名密钥是所部署应用的一部分,不在设置界面里:修改它们需要重新构建和部署。在设定之前,消息会发往一个示例地址、发送失败并记为失败,所以一个尚未接通的交接是看得见的,而不是悄无声息。 diff --git a/src/revenue/docs/crm_revenue_products.md b/src/revenue/docs/crm_revenue_products.md new file mode 100644 index 00000000..6b8c1ebf --- /dev/null +++ b/src/revenue/docs/crm_revenue_products.md @@ -0,0 +1,90 @@ +--- +title: Products & Price Books — The Catalog Behind Every Line +description: What a product carries, how its price reaches a deal or a quote, how to retire one, and what price books do today. +order: 2 +# sources: the objects this doc documents. Build ignores unknown frontmatter keys. +sources: + - object:crm_product + - object:crm_price_book + - object:crm_price_book_entry + - object:crm_opportunity_line_item + - object:crm_quote_line_item +--- + +# Products & Price Books — The Catalog Behind Every Line + +For sales representatives, sales managers and whoever keeps the catalog. A +product is anything you sell: software, hardware, a service, a subscription, a +support plan. Deals and quotes price their lines from it, so a catalog kept +right is a quote that is right the first time. + +## What a product carries + +- **Product Code** is given to every product when it is saved, so each one has + a short reference that never changes. +- **SKU** is stored in capitals, however it was typed. +- **Category** says how the product is delivered, **Product Family** where it + sits in your portfolio; reports slice the product mix by both. +- **List Price** is the published price, and **Cost** what it costs you. + +Two pricing rules hold on every save. A negative **List Price** is refused, and +so is a **List Price** below the **Cost**: a product that loses money on every +sale is a typing mistake far more often than a plan. + +HotCRM keeps no stock and no billing schedule on a product. Nothing counts down +when a deal is won, and how a subscription is billed belongs to the contract: +see [Billing Hand-off](./crm_revenue_billing_handoff.md). + +## How a product's price reaches a line + +When you pick a product on a deal's line item or a quote's line item, the +line's **List Price** is copied from the product. On a new line, **Sales Price** +starts at that list price too; change it to the price you negotiated, and it +stays yours. + +Later changes to the catalog do not reach lines already written. Raise a +product's **List Price** and the deals and quotes you have already priced keep +the price they were given; a line picks up the new list price only when its +product is picked again. A product's name is the one exception: rename a product +and every line that sold it shows the new name, closed deals and settled quotes +included, because a name is not a figure. + +## Retiring a product + +A product that has been sold cannot be deleted. Once it is on any deal's line +item or any quote's line item, past ones included, the delete is refused and +the message says how many lines hold it. Turn off **Active** instead: the +product stays on every deal and quote that sold it, and **Active** tells your +team it is no longer offered. + +## Price books + +A **price book** is a named list of prices, so the same product can carry a +different price for a different audience: a partner tier, a region, a +promotion. A book holds one **entry** per product, with that product's +**List Price** in the book and whether the entry is **Active**. + +- **One standard book.** Exactly one book is marked **Standard**. A second book + cannot be marked **Standard**, and the standard book can neither lose the mark + nor be deleted; to stop using it, turn off **Active**. HotCRM's sample data + includes a book named *Standard* with an entry for every sample product. A + product you add later gets no entry in it by itself: add one by hand. +- **One entry per product, per book.** A second entry for a product the book + already prices is refused, and the message names the book and the product so + you can edit the existing entry instead. The same product can have an entry in + every book. +- **Validity.** **Valid From** and **Valid To** record the window a book is + meant for. Either may stay empty, and **Valid To** may not fall before + **Valid From**. Nothing switches a book on or off by these dates yet. +- **Who maintains them.** Administrators, and sales managers for the books + they own; sales reps read every book. + +**What price books do not do yet:** quotes and deals do not read them. A line +still takes its list price from the product, as described above. Choosing a +book on a quote, and pricing its lines from that book, is the next step of this +feature; this page will describe it when it ships. + +--- + +**Related:** how a quote totals its lines and expires — see +[Quotes](./crm_revenue_quotes.md). diff --git a/src/revenue/docs/crm_revenue_products.zh.md b/src/revenue/docs/crm_revenue_products.zh.md new file mode 100644 index 00000000..e2bb63de --- /dev/null +++ b/src/revenue/docs/crm_revenue_products.zh.md @@ -0,0 +1,44 @@ +--- +title: 产品与价格表——每一行明细背后的目录 +description: 产品记录了什么、它的价格如何带到商机和报价单上、如何停用一个产品,以及价格表目前能做什么。 +--- + +# 产品与价格表——每一行明细背后的目录 + +写给销售代表、销售经理和维护产品目录的人。产品是你卖的任何东西:软件、硬件、服务、订阅、支持套餐。商机和报价单的明细行都从它取价,所以目录维护得准,报价一次就准。 + +## 产品记录了什么 + +- **产品代码**在每个产品保存时自动生成,每个产品都有一个永不变化的简短编号。 +- **SKU** 一律以大写保存,不管输入时怎么写。 +- **产品类别**说明产品如何交付,**产品系列**说明它在产品组合中的位置;报表按这两项切分产品结构。 +- **标价**是公开价格,**成本**是你的成本。 + +每次保存都执行两条价格规则。**标价**为负数会被拒绝,**标价**低于**成本**也会被拒绝:一个每卖一次都亏钱的产品,多半是输错了,而不是有意为之。 + +HotCRM 不在产品上记录库存或计费计划。赢单不会扣减任何数量,订阅如何计费属于合同的事:见[计费交接](./crm_revenue_billing_handoff.md)。 + +## 产品价格如何带到明细行 + +你在商机明细或报价单明细上选择产品时,这一行的**标价**会从产品复制过来。新建的明细行上,**销售单价**也从这个标价起步;把它改成谈定的价格后,它就保持你填的值。 + +目录后来的改动不会带到已写好的明细行。提高某个产品的**标价**后,已经定价的商机和报价单保持原来的价格;只有重新选择这一行的产品,它才会取到新的标价。产品名称是唯一的例外:产品改名后,卖过它的每一行都显示新名称,已成交的商机和已定案的报价单也一样,因为名称不是金额。 + +## 停用产品 + +卖出过的产品不能删除。只要它出现在任何商机明细或报价单明细上(包括过去的),删除就会被拒绝,提示会说明有多少行引用了它。请改为关闭**是否启用**:产品仍保留在卖过它的每一笔商机和每一张报价单上,**是否启用**则告诉团队它已不再销售。 + +## 价格表 + +**价格表**是一组有名称的价格,让同一个产品对不同的对象有不同的价格:合作伙伴档位、某个地区、一次促销。一个价格表对每个产品只有一个**条目**,记录这个产品在该表中的**标价**,以及该条目**是否启用**。 + +- **只有一个标准价格表。** 恰好有一个价格表被标记为**标准价格表**。第二个价格表不能再标记为**标准价格表**;标准价格表既不能取消这个标记,也不能删除;要停用它,请关闭**是否启用**。HotCRM 的示例数据里有一个名为 *Standard* 的价格表,为每个示例产品各有一个条目。之后新增的产品不会自动在其中获得条目:请手工添加。 +- **每个价格表中每个产品只有一个条目。** 为价格表中已有价格的产品再加一个条目会被拒绝,提示会写明价格表和产品,方便你改为编辑已有的条目。同一个产品可以在每个价格表里各有一个条目。 +- **有效期。** **有效期自**和**有效期至**记录这个价格表适用的时间段。两者都可以留空,**有效期至**不能早于**有效期自**。目前还没有任何机制按这两个日期启用或停用价格表。 +- **谁来维护。** 管理员,以及销售经理(限其负责的价格表);销售代表可以查看所有价格表。 + +**价格表目前还不做的事:**报价单和商机还不读取价格表。明细行仍按上文所述从产品取标价。在报价单上选择价格表、并按该表为明细行定价,是这项功能的下一步;功能上线时本页会补充说明。 + +--- + +**相关:** 报价单如何汇总明细、如何过期——见[报价单](./crm_revenue_quotes.md)。 diff --git a/src/sales/translations/es-ES/app.ts b/src/sales/translations/es-ES/app.ts index 9a98b331..3d67d587 100644 --- a/src/sales/translations/es-ES/app.ts +++ b/src/sales/translations/es-ES/app.ts @@ -82,6 +82,7 @@ export const appSurface: Omit = { nav_help_manual: { label: 'Manual de Usuario' }, nav_help_admin: { label: 'Manual de Administración' }, nav_help_revenue: { label: 'Manual de Ingresos' }, + nav_help_marketing: { label: 'Manual de Marketing' }, }, }, }, diff --git a/src/sales/translations/ja-JP/app.ts b/src/sales/translations/ja-JP/app.ts index 4686b5dc..65fe7654 100644 --- a/src/sales/translations/ja-JP/app.ts +++ b/src/sales/translations/ja-JP/app.ts @@ -82,6 +82,7 @@ export const appSurface: Omit = { nav_help_manual: { label: 'ユーザーマニュアル' }, nav_help_admin: { label: '管理者マニュアル' }, nav_help_revenue: { label: '収益マニュアル' }, + nav_help_marketing: { label: 'マーケティングマニュアル' }, }, }, }, diff --git a/src/sales/translations/zh-CN/app.ts b/src/sales/translations/zh-CN/app.ts index 3d00a2e3..b378d0a4 100644 --- a/src/sales/translations/zh-CN/app.ts +++ b/src/sales/translations/zh-CN/app.ts @@ -87,6 +87,7 @@ export const appSurface: Omit = { nav_help_manual: { label: '使用手册' }, nav_help_admin: { label: '管理员手册' }, nav_help_revenue: { label: '收入手册' }, + nav_help_marketing: { label: '营销手册' }, }, }, }, diff --git a/src/service/docs/crm_service_triage.md b/src/service/docs/crm_service_triage.md new file mode 100644 index 00000000..49a57537 --- /dev/null +++ b/src/service/docs/crm_service_triage.md @@ -0,0 +1,74 @@ +--- +title: Case Triage — How the Assistant Weighs a Case +description: What the assistant reads when it triages a case, the order it weighs things in, and the point where a person takes over. +order: 3 +# sources: the skill and the object this doc documents. The buttons it names +# are pinned to the skill by test/docs-ai-skills.test.ts; build ignores +# unknown frontmatter keys. +sources: + - skill:case_triage + - object:crm_case +--- + +# Case Triage — How the Assistant Weighs a Case + +For service agents and service managers whose deployment runs the platform's +assistant. The assistant is the platform's, available on ObjectOS Cloud and +Enterprise; a self-hosted open-source install has none, and there you triage +every case yourself, by the same rules as below. + +Triage answers one question: how urgent is this case, and why? The assistant +proposes the answer. A person decides it. + +## When it starts + +When you are on a case, the triage skill is ready on its own: ask the assistant +to triage, prioritise or classify the case. You can also ask from anywhere and +name the case. + +## What it reads + +It reads the case afresh every time, never from memory of an earlier answer: +first the shape of a case (so a field your administrator added is used straight +away), then the case itself. It reads as you, so it sees exactly the fields and +records you can see. + +## How it weighs a case + +It weighs four things, in this order of importance: + +1. **Who the customer is.** The account's **Customer Tier** and the value of + their contracts. +2. **Whether they are blocked.** A customer who cannot work and has no + workaround outranks one who is inconvenienced. +3. **The clock.** How long the case has been open against its **SLA Due Date**. +4. **The tone.** The sentiment of the customer's latest message. + +The answer is one **Priority** (Low, Medium, High or Critical) and the **one** +reason that drove it, citing the case number and the field values it used. One +reason, not a list: that is the sentence you can check, and the one you can +paste. + +## Where a person takes over + +The assistant cannot change the case. It holds no tool that writes, so every +step that follows is yours: + +- **Setting the priority.** Edit **Priority** yourself. Setting it to + **Critical** escalates the case at once, without waiting for the SLA clock: + see [Cases](./crm_service_cases.md). +- **Escalating.** When it judges the case critical, it says so, points you to + **Escalate Case** on the case, and offers a reason you can paste. Escalation + asks a person for that reason on purpose. +- **Closing.** When the case is resolved in substance, it points you to + **Close Case** and offers a resolution summary you can paste. +- **Replying to the customer.** It hands the reply to the email-drafting skill, + which writes a draft. Nothing is sent until you send it. + +When you disagree with its priority, yours stands: nothing it says is recorded +on the case. + +--- + +**Related:** every skill the assistant uses in HotCRM — see +[Using the Assistant](./crm_ai_skills.md). diff --git a/src/service/docs/crm_service_triage.zh.md b/src/service/docs/crm_service_triage.zh.md new file mode 100644 index 00000000..029f49ba --- /dev/null +++ b/src/service/docs/crm_service_triage.zh.md @@ -0,0 +1,44 @@ +--- +title: 工单分诊——助手如何权衡一张工单 +description: 助手分诊工单时读取什么、按什么顺序权衡,以及从哪一步起由人接手。 +--- + +# 工单分诊——助手如何权衡一张工单 + +写给部署中运行了平台助手的客服专员和服务经理。助手是平台提供的,在 ObjectOS Cloud 和 Enterprise 上可用;自行部署的开源版本没有助手,那里的每张工单都由你按下文同样的规则自己分诊。 + +分诊回答一个问题:这张工单有多紧急,为什么?答案由助手提出,由人决定。 + +## 何时启用 + +你打开一张工单时,分诊技能会自动就绪:请助手对这张工单做分诊、定优先级或归类即可。你也可以在任何地方提问,并说明是哪张工单。 + +## 它读取什么 + +它每次都重新读取工单,从不凭之前回答的记忆:先读取工单的结构(所以管理员新加的字段会立刻被用上),再读取这张工单本身。它以你的身份读取,看到的字段和记录与你完全相同。 + +## 它如何权衡 + +它按重要性从高到低权衡四件事: + +1. **客户是谁。** 客户的**客户分层**和他们的合同金额。 +2. **客户是否被卡住。** 无法工作且没有变通办法的客户,优先于只是不方便的客户。 +3. **时间。** 工单已开启多久,对照它的 **SLA 到期**。 +4. **语气。** 客户最近一条消息的情绪。 + +答案是一个**优先级**(低、中、高或严重),加上决定它的**那一个**理由,并写明工单编号和它依据的字段值。只给一个理由,而不是一串:这是你能核对、也能直接粘贴的那句话。 + +## 从哪一步起由人接手 + +助手不能修改工单。它没有任何可以写入的工具,所以之后的每一步都由你来做: + +- **设定优先级。** 由你亲自编辑**优先级**。设为**严重**会立即升级工单,不等 SLA 计时:见[工单](./crm_service_cases.md)。 +- **升级。** 它判断工单属于严重时会明说,引导你使用工单上的**升级工单**,并给出可以直接粘贴的原因。升级特意要求由人填写这个原因。 +- **关闭。** 工单实质上已解决时,它会引导你使用**关闭工单**,并给出可以粘贴的解决说明。 +- **回复客户。** 它把回复交给邮件撰写技能,由那个技能写出草稿。在你亲自发送之前,什么都不会发出。 + +你不同意它给的优先级时,以你的为准:它说的任何话都不会记到工单上。 + +--- + +**相关:** 助手在 HotCRM 中使用的每项技能——见[使用助手](./crm_ai_skills.md)。 diff --git a/test/docs-ai-skills.test.ts b/test/docs-ai-skills.test.ts index 968f5f3d..44bd2cd8 100644 --- a/test/docs-ai-skills.test.ts +++ b/test/docs-ai-skills.test.ts @@ -76,3 +76,36 @@ describe('the assistant page covers the registered skills (#2329)', () => { }); } }); + +/** + * The case-triage page (#2330) says where the assistant stops: it holds no + * tool that writes, so setting the priority, escalating and closing are the + * person's, through the case's own buttons. Both halves are read from the + * compiled stack — the skill's tools, and the labels of the two buttons its + * instructions point at — so a triage skill that gains a write tool, or a + * renamed button, turns the page red. + */ +describe('the case-triage page states where a person takes over (#2330)', () => { + const TRIAGE = SKILLS.find((s) => s.name === 'case_triage'); + const READ_TOOLS = ['describe_object', 'get_record', 'query_records', 'aggregate_data']; + + it('the triage skill holds read tools only', () => { + expect(TRIAGE, 'case_triage is registered').toBeDefined(); + expect(((TRIAGE!.tools ?? []) as string[]).filter((t) => !READ_TOOLS.includes(t))).toEqual([]); + }); + + for (const L of LOCALES) { + const file = `src/service/docs/crm_service_triage${L.suffix}`; + it(`${file} names the buttons the skill points at`, () => { + const text = readFileSync(join(REPO_ROOT, file), 'utf8'); + const missing = ['escalate_case', 'close_case'] + .map((name) => ACTIONS.find((a) => a.name === name)) + .map((a, i) => { + if (!a) throw new Error(`no registered action ${['escalate_case', 'close_case'][i]}`); + return L.actionLabel(a); + }) + .filter((label) => !text.includes(`**${label}**`)); + expect(missing).toEqual([]); + }); + } +}); diff --git a/test/docs-books.test.ts b/test/docs-books.test.ts index a1ed3ac7..e4b62029 100644 --- a/test/docs-books.test.ts +++ b/test/docs-books.test.ts @@ -71,9 +71,9 @@ const docEntries = (): AnyRec[] => { describe('the in-app manuals claim every page (#2327)', () => { it('reads real pages and real books', () => { expect(PAGES.length, 'no package docs found under src/').toBeGreaterThanOrEqual(9); - expect(BOOKS.map((b) => b.name).sort()).toEqual(['crm_admin_manual', 'crm_manual', 'crm_revenue_manual']); - // The three packages that ship pages, so the module directories are really read. - expect(new Set(PAGES.map((p) => p.packageId)).size).toBeGreaterThanOrEqual(3); + expect(BOOKS.map((b) => b.name).sort()).toEqual(['crm_admin_manual', 'crm_manual', 'crm_marketing_manual', 'crm_revenue_manual']); + // The four packages that ship pages (#2330 added marketing), so the module directories are really read. + expect(new Set(PAGES.map((p) => p.packageId)).size).toBe(4); }); it('every page is claimed by a group of some book — none falls to Uncategorized', () => { diff --git a/test/docs-drift.test.ts b/test/docs-drift.test.ts index fa5821f2..5191e1ab 100644 --- a/test/docs-drift.test.ts +++ b/test/docs-drift.test.ts @@ -5,7 +5,7 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { REPO_ROOT } from './helpers/repo-root'; import { resolveMetadataFile } from './helpers/src-roster'; -import stack from './helpers/composed-stack'; +import stack, { packFor } from './helpers/composed-stack'; /* * ─── SPLIT BY FAMILY (#1196) ───────────────────────────────────────────────── @@ -89,6 +89,8 @@ const SALES_LEADS = 'docs/crm_sales_leads.md'; const ADMIN_KNOBS = 'docs/crm_admin_automation.md'; const QUOTES = 'revenue/docs/crm_revenue_quotes.md'; const CASES = 'service/docs/crm_service_cases.md'; +const BILLING = 'revenue/docs/crm_revenue_billing_handoff.md'; +const CAMPAIGN_FLOWS = 'marketing/docs/crm_marketing_automation.md'; /** cron → the human label the docs use. Unknown cron ⇒ deliberate failure. */ const CRON_LABEL: Record = { @@ -97,6 +99,7 @@ const CRON_LABEL: Record = { '0 * * * *': ['hourly', '每小时'], '0 8 * * *': ['08:00'], '0 0 * * *': ['00:00'], + '0 2 * * *': ['02:00'], }; type Rule = { @@ -154,6 +157,19 @@ const capCel = (flowName: string, re: RegExp): string => { return m[1]; }; +/** + * A select value → the bold label the pages give it: the object's own label for + * the English page, the zh-CN pack's for `.zh.md` (#2330). Either spelling + * satisfies a rule; `withZh` lists both pages, so each must carry its own. + */ +const optionLabel = (objectName: string, field: string) => (raw: string): string[] => { + const object = (((stack as AnyRec).objects ?? []) as AnyRec[]).find((o) => o.name === objectName); + const option = ((object?.fields?.[field]?.options ?? []) as AnyRec[]).find((o) => o.value === raw); + if (!option) throw new Error(`drift test out of date: ${objectName}.${field} has no option "${raw}"`); + const zh = packFor('zh-CN')?.objects?.[objectName]?.fields?.[field]?.options?.[raw]; + return [`**${option.label}**`, ...(zh ? [`**${zh}**`] : [])]; +}; + const money = (raw: string) => [`$${Number(raw).toLocaleString('en-US')}`, Number(raw).toLocaleString('en-US')]; const cronDisplay = (raw: string) => { const label = CRON_LABEL[raw]; @@ -238,6 +254,26 @@ const RULES: Rule[] = [ display: cronDisplay, docs: withZh(ADMIN_KNOBS), }, + { + label: 'campaign auto-completion schedule', + extract: () => cap('campaign-completion.flow.ts', /schedule: '([^']+)'/), + display: cronDisplay, + docs: withZh(CAMPAIGN_FLOWS), + }, + { + // The moment a won deal is handed to billing (#2330): the stage the + // start condition's TRANSITION tests, named by its label on the page. + label: 'billing hand-off: the deal stage that sends it', + extract: () => capCel('billing_handoff_closed_won', /record\.stage == "([a-z_]+)"/), + display: optionLabel('crm_opportunity', 'stage'), + docs: withZh(BILLING), + }, + { + label: 'billing hand-off: the contract status that sends it', + extract: () => capCel('billing_handoff_contract_activated', /record\.status == "([a-z_]+)"/), + display: optionLabel('crm_contract', 'status'), + docs: withZh(BILLING), + }, { label: 'contract expiration sweep schedule', extract: () => cap('contract-expiration.flow.ts', /schedule: '([^']+)'/), diff --git a/test/docs-lifecycle-diagrams.test.ts b/test/docs-lifecycle-diagrams.test.ts index 838118a8..3e29c3b3 100644 --- a/test/docs-lifecycle-diagrams.test.ts +++ b/test/docs-lifecycle-diagrams.test.ts @@ -101,7 +101,9 @@ describe('the lifecycle diagrams in the package docs draw the real state machine const others = [ 'docs/crm_overview', 'docs/crm_admin_access', 'docs/crm_admin_automation', 'service/docs/crm_service_knowledge', 'docs/crm_getting_started', 'docs/crm_sales_activities', 'docs/crm_analytics_metrics', 'docs/crm_ai_skills', - 'docs/crm_admin_setup', 'docs/crm_admin_data_import', + 'docs/crm_admin_setup', 'docs/crm_admin_data_import', 'service/docs/crm_service_triage', + 'revenue/docs/crm_revenue_billing_handoff', 'revenue/docs/crm_revenue_products', + 'marketing/docs/crm_marketing_campaigns', 'marketing/docs/crm_marketing_automation', ].flatMap((p) => [`src/${p}.md`, `src/${p}.zh.md`]); expect(others.filter((f) => diagramsIn(readFileSync(join(REPO_ROOT, f), 'utf8')).length > 0)).toEqual([]); });