diff --git a/src/actions/contract-lifecycle.actions.ts b/src/actions/contract-lifecycle.actions.ts index 67ac001..76b006d 100644 --- a/src/actions/contract-lifecycle.actions.ts +++ b/src/actions/contract-lifecycle.actions.ts @@ -341,6 +341,12 @@ export const TerminateContractAction: Action = { * covers the ordinary case (the F12 sweep flags `is_expiring` and the * notification points here) and `expired` covers the one that ran out before * anyone acted. + * + * Once the run completes, the console takes the user to the draft it made + * (#99 item 3; a flow action's `onSuccess`, objectstack#25800, 18.2.0): + * `${result.output.renewal.id}` reads the flow's `renewal` output variable, + * the record `create_draft` wrote. A refused run (a second press) shows its + * sentence and does not move. */ export const StartRenewalAction: Action = { name: 'start_renewal', @@ -353,5 +359,6 @@ export const StartRenewalAction: Action = { locations: ['record_header'], visible: P`record.status == 'active' || record.status == 'expired'`, successMessage: 'Renewal draft created.', + onSuccess: { navigate: '/apps/clm/clm_contract/record/${result.output.renewal.id}' }, refreshAfter: true, }; diff --git a/src/flows/contract-intake.flow.ts b/src/flows/contract-intake.flow.ts index b90990a..9745569 100644 --- a/src/flows/contract-intake.flow.ts +++ b/src/flows/contract-intake.flow.ts @@ -11,23 +11,32 @@ import { must, ref } from './_values.js'; * * ## Shape, and why it is this shape * - * Measured on the pinned @objectstack 17.3.0 packages — the console's screen - * runner (`@objectstack/console` `RecordDetailView` chunk) and - * `service-automation`'s screen executor — rather than assumed: + * Measured on the pinned @objectstack packages — the console's screen runner + * (`ScreenView` / `FlowRunner`) and `service-automation`'s screen executor — + * rather than assumed: * + * - Ask everything, write at the end. Every screen comes before the first + * write: core fields, the counterparty, the first version's file and the + * submission choice are all collected, and only then are the counterparty + * (when created inline), the contract, version 1 and the submission + * written, in one uninterrupted run. Closing the dialog part-way leaves the + * run paused (18.2.0 does not cancel a run when its dialog closes, + * objectstack#25801's second half) — but nothing has been written, so an + * abandoned wizard leaves no draft behind (#99 item 4). * - A flat screen renders text / number / currency / date / email / textarea - * / boolean, and a select when `options` are declared. It has NO record - * picker and NO file input: a `lookup`-typed field falls through to a plain - * text box. So the four pickers the intake needs — the contract type, the - * counterparty, our signing entity and the requesting department — are - * collected by the `launch_contract` ACTION's param dialog - * (`src/actions/contract.actions.ts`), whose field-backed lookup params - * render the real record pickers. The flow receives them as the input - * variables `contract_type`, `party`, `our_entity` and `department`. The - * department was a select on the core screen while it was an option list; - * since #109 it is a business-unit row, so it moved to the dialog with the - * other pickers. On 17.3.0 those pickers did NOT inherit the fields' - * `lookupFilters`; re-measured on 18.1.0 they do — the dialog's + * / boolean, a select when `options` are declared, a record picker for + * `type: 'lookup'` with a `reference` (18.x, objectstack#17306), and the + * platform upload widget for `type: 'file'` (18.2.0, objectstack#25801). + * The parent contract is such a picker on the core screen (#161), and the + * first version's file is such an upload on the version screen. + * - The contract type, the counterparty, our signing entity and the + * requesting department are still collected by the `launch_contract` + * ACTION's param dialog (`src/actions/contract.actions.ts`), before the + * first screen: the type decides which fields the screens ask, and a + * picked counterparty is judged (`refuse_blocked`) before anything is + * asked. The flow receives them as the input variables `contract_type`, + * `party`, `our_entity` and `department`. On 17.3.0 those pickers did NOT + * inherit the fields' `lookupFilters`; re-measured on 18.1.0 they do — the dialog's * counterparty picker fetches * `GET /api/v1/data/clm_party?top=50&filter=["risk_flag","!=","blocked"]` * (38 of 40), and the entity and department pickers filter to company-kind @@ -63,12 +72,16 @@ import { must, ref } from './_values.js'; * parked, so a headless caller is answered at once; the console shows the * sentence alone, with no success toast. Each one sits before any screen * and before the first write, so a refused launch leaves nothing behind. - * - The first version's file has two headless spellings — `draft_from_template` - * (the type's `template_file` becomes v1, kind `draft`) or a pre-uploaded - * `first_version_file` id — and one interactive one: an OBJECT-FORM screen - * on `clm_contract_version`, the platform's real form with its file widget, - * prefilled with the new contract. An object form always pauses, which is - * why the two headless spellings exist. + * - Version 1 comes from the type's `template_file` (`draft_from_template`, + * offered only on a type that has one) or from an uploaded file + * (`first_version_file`, a `sys_file` id). A person answers both on + * `screen_document`, where the upload widget leaves the file unowned until + * `create_file_version` writes its id and so claims it. A headless caller + * passes either one up front and meets no screen; one that passes neither + * pauses on `screen_document`, still before any write. + * - Submitting is an explicit, required choice with no default (#162): the + * screens' footer button reads the platform's own word on every step, so + * the field, not the button, says whether the contract goes to legal. * * ## Every write runs as the launching user, and the graph respects §04 * @@ -98,8 +111,8 @@ import { must, ref } from './_values.js'; * "Field write denied". A new party is therefore born unflagged and * unscreened, and legal maintains those fields afterwards (on a party * legal does not own, writes are owner-scoped today, see #152). Should the - * create still be refused, it fails ATOMICALLY: `create_party` runs before - * any other write, so the run leaves nothing behind. + * create still be refused, the run leaves nothing behind: `create_party` + * is the first write, after the last screen. * * Flow copy is authored in English (the source language, DESIGN.md §01); the * zh-CN text of each screen and each refusal is its key under @@ -135,7 +148,7 @@ const NOT_INTERACTIVE = P`vars.interactive != true`; export const ContractIntakeFlow: Flow = { name: 'contract_intake', label: 'Launch Contract', - description: 'Launch a contract: pick the type, fill the core and the type-specific fields, choose or create the counterparty, attach the first version, optionally schedule a payment and submit.', + description: 'Launch a contract: pick the type, fill the core and the type-specific fields, choose or create the counterparty, attach the first version, then submit it or keep it as a draft. Nothing is written until the last step.', type: 'screen', status: 'active', successMessage: 'Contract launched.', @@ -170,7 +183,8 @@ export const ContractIntakeFlow: Flow = { // First version. { name: 'draft_from_template', type: 'boolean', isInput: true, isOutput: false, defaultValue: false }, { name: 'first_version_file', type: 'text', isInput: true, isOutput: false }, - // The submit toggle. + // The submission choice: a required select on the last screen (no default + // there), a boolean a headless caller may pass (absent = keep the draft). { name: 'submit_now', type: 'boolean', isInput: true, isOutput: false, defaultValue: false }, ], @@ -256,9 +270,9 @@ export const ContractIntakeFlow: Flow = { { name: 'payment_terms', label: 'Payment terms', type: 'select', required: true, options: optionsOf(Contract, 'payment_terms'), visibleWhen: askedFor('payment_terms') }, { name: 'confidentiality_term_months', label: 'Confidentiality term (months)', type: 'number', required: true, visibleWhen: askedFor('confidentiality_term_months') }, { name: 'auto_renew', label: 'Auto-renews', type: 'boolean', defaultValue: ref('auto_renew'), visibleWhen: askedFor('auto_renew') }, - // A flat screen has no record picker (see the header): the parent - // contract is collected as its record id. - { name: 'parent_contract', label: 'Parent contract (record id)', type: 'text', required: true, placeholder: 'The framework or main contract this one sits under', visibleWhen: askedFor('parent_contract') }, + // A record picker (see the header), the same one the record form + // draws for the field; the chosen contract's id binds the variable. + { name: 'parent_contract', label: 'Parent contract', type: 'lookup', reference: 'clm_contract', required: true, inlineHelpText: 'The framework or main contract this one sits under.', visibleWhen: askedFor('parent_contract') }, ], }, }, @@ -279,21 +293,6 @@ export const ContractIntakeFlow: Flow = { ], }, }, - { - id: 'create_party', type: 'create_record', label: 'Create Counterparty', - config: { - objectName: 'clm_party', - fields: { - name: ref('new_party_name'), - party_kind: ref('new_party_kind'), - registration_no: ref('new_party_registration_no'), - contact_name: ref('new_party_contact_name'), - contact_email: ref('new_party_contact_email'), - }, - outputVariable: 'createdParty', - }, - }, - { id: 'use_new_party', type: 'assignment', label: 'Use New Counterparty', config: { assignments: { partyId: ref('createdParty.id') } } }, { id: 'get_party', type: 'get_record', label: 'Load Counterparty', config: { objectName: 'clm_party', filter: { id: must('party') }, outputVariable: 'partyRecord' }, @@ -319,25 +318,71 @@ export const ContractIntakeFlow: Flow = { { id: 'use_picked_party', type: 'assignment', label: 'Use Picked Counterparty', config: { assignments: { partyId: ref('partyRecord.id') } } }, // ── Step 4: the first version ───────────────────────────────────────── + // Asked of every person, and of a headless caller that supplied neither + // spelling of version 1. Still nothing written. { id: 'decision_document_screen', type: 'decision', label: 'Ask About The Document?' }, { id: 'screen_document', type: 'screen', label: 'First Version', config: { title: 'First version', - description: 'The document this contract starts from. By default you upload it on the next step.', + description: 'The document this contract starts from. It becomes version 1.', fields: [ // Carrier, as `intakeFields` on the core screen: `visibleWhen` sees // only this screen's own fields, so the type's answer is brought onto // the screen (server-interpolated, a boolean) and hidden by its own // predicate. The toggle — and the sentence about the template, its // help text — is then offered only on a type that carries a - // template; on one that does not, the description above is the - // whole screen and there is nothing to tick. + // template. { name: 'templateAvailable', label: 'Template available', type: 'boolean', defaultValue: ref('templateAvailable'), visibleWhen: 'templateAvailable == null' }, { name: 'draft_from_template', label: 'Draft from template', type: 'boolean', defaultValue: ref('draft_from_template'), visibleWhen: 'templateAvailable == true', inlineHelpText: 'This contract type carries a template. Tick to attach it as version 1 instead of uploading a file.' }, + // The upload, required whenever the template is not the source — so + // on a type with no template this step always asks for the file and + // is never an empty page (#162). The predicate names both fields on + // this screen, so a headless `draft_from_template` on a type with no + // template still shows (and requires) the upload. + { name: 'first_version_file', label: 'Document', type: 'file', required: true, visibleWhen: '!(draft_from_template == true && templateAvailable == true)' }, ], }, }, + + // ── Step 5: submit or keep the draft ────────────────────────────────── + { id: 'decision_schedule_screen', type: 'decision', label: 'Ask About Submission?' }, + { + id: 'screen_schedule', type: 'screen', label: 'Submission', + config: { + title: 'Submit the contract', + description: 'Choose whether this contract goes to legal review or approval now, as its type decides, or stays a draft you can submit later.', + fields: [ + // Required with no default: the person picks one, and pressing the + // footer button without a pick is refused (#162). + { + name: 'submit_now', label: 'Submission', type: 'select', required: true, + options: [ + { value: true, label: 'Submit to legal / approval now' }, + { value: false, label: 'Save as a draft for now' }, + ], + }, + ], + }, + }, + + // ── The writes: everything above is answered, nothing below asks ────── + { id: 'decision_new_party', type: 'decision', label: 'Counterparty To Create?' }, + { + id: 'create_party', type: 'create_record', label: 'Create Counterparty', + config: { + objectName: 'clm_party', + fields: { + name: ref('new_party_name'), + party_kind: ref('new_party_kind'), + registration_no: ref('new_party_registration_no'), + contact_name: ref('new_party_contact_name'), + contact_email: ref('new_party_contact_email'), + }, + outputVariable: 'createdParty', + }, + }, + { id: 'use_new_party', type: 'assignment', label: 'Use New Counterparty', config: { assignments: { partyId: ref('createdParty.id') } } }, { id: 'create_contract', type: 'create_record', label: 'Create Contract', config: { @@ -411,31 +456,6 @@ export const ContractIntakeFlow: Flow = { outputVariable: 'versionRecord', }, }, - { - // The platform's own form for the version, with its file widget. - id: 'screen_upload', type: 'screen', label: 'Upload First Version', - config: { - title: 'Upload the first version', - description: 'The document this contract starts from. It becomes version 1.', - objectName: 'clm_contract_version', - mode: 'create', - defaults: { contract: ref('contractRecord.id'), version_no: 1, kind: 'draft', turn: 'internal', is_current: true }, - idVariable: 'versionId', - }, - }, - - // ── Step 5: submit ──────────────────────────────────────────────────── - { id: 'decision_schedule_screen', type: 'decision', label: 'Ask About Submission?' }, - { - id: 'screen_schedule', type: 'screen', label: 'Submission', - config: { - title: 'Submit the contract', - description: 'Choose whether to submit now. Submitting routes the contract to legal review or to approval, as its type decides.', - fields: [ - { name: 'submit_now', label: 'Submit now', type: 'boolean', defaultValue: ref('submit_now') }, - ], - }, - }, { id: 'decision_submit', type: 'decision', label: 'Submit Now?' }, { // The write that enters F2, as the launcher like every other write here: @@ -482,29 +502,35 @@ export const ContractIntakeFlow: Flow = { { id: 'e27a', source: 'decision_core_screen', target: 'screen_core', type: 'default', condition: INTERACTIVE, label: 'Interactive' }, { id: 'e27b', source: 'decision_core_screen', target: 'decision_party_given', type: 'default', condition: NOT_INTERACTIVE, label: 'Headless' }, { id: 'e17', source: 'screen_core', target: 'decision_party_given', type: 'default' }, - // Three ways out, partitioned: a party already resolved up front, an - // inline party supplied headlessly, or the screen that asks for one. + // Three ways on, partitioned: a party already resolved up front, an + // inline party supplied headlessly (both go straight on), or the screen + // that asks for one. No counterparty is created here — see the writes. { id: 'e17b', source: 'decision_party_given', target: 'decision_document_screen', type: 'default', condition: P`has(vars.partyId) && vars.partyId != null && vars.partyId != ""`, label: 'Resolved' }, - { id: 'e19', source: 'decision_party_given', target: 'create_party', type: 'default', condition: P`(!has(vars.partyId) || vars.partyId == null || vars.partyId == "") && has(vars.new_party_name) && vars.new_party_name != null && vars.new_party_name != ""`, label: 'Inline' }, + { id: 'e19', source: 'decision_party_given', target: 'decision_document_screen', type: 'default', condition: P`(!has(vars.partyId) || vars.partyId == null || vars.partyId == "") && has(vars.new_party_name) && vars.new_party_name != null && vars.new_party_name != ""`, label: 'Inline' }, { id: 'e20', source: 'decision_party_given', target: 'screen_party', type: 'default', condition: P`(!has(vars.partyId) || vars.partyId == null || vars.partyId == "") && (!has(vars.new_party_name) || vars.new_party_name == null || vars.new_party_name == "")`, label: 'Ask' }, - { id: 'e21', source: 'screen_party', target: 'create_party', type: 'default' }, + { id: 'e21', source: 'screen_party', target: 'decision_document_screen', type: 'default' }, + // The version screen: every person sees it; a headless caller only when it + // supplied no source for version 1 (a usable template ask, or a file id). + { id: 'e29', source: 'decision_document_screen', target: 'decision_schedule_screen', type: 'default', condition: P`vars.interactive != true && ((vars.draft_from_template == true && vars.templateAvailable == true) || (has(vars.first_version_file) && vars.first_version_file != null && vars.first_version_file != ""))`, label: 'Headless, source given' }, + { id: 'e30', source: 'decision_document_screen', target: 'screen_document', type: 'default', condition: P`vars.interactive == true || !((vars.draft_from_template == true && vars.templateAvailable == true) || (has(vars.first_version_file) && vars.first_version_file != null && vars.first_version_file != ""))`, label: 'Ask' }, + { id: 'e31', source: 'screen_document', target: 'decision_schedule_screen', type: 'default' }, + { id: 'e39', source: 'decision_schedule_screen', target: 'decision_new_party', type: 'default', condition: NOT_INTERACTIVE, label: 'Headless' }, + { id: 'e40', source: 'decision_schedule_screen', target: 'screen_schedule', type: 'default', condition: INTERACTIVE, label: 'Interactive' }, + { id: 'e41', source: 'screen_schedule', target: 'decision_new_party', type: 'default' }, + // The writes, after the last screen. A counterparty first when none was + // picked, then the contract, version 1, and the submission. + { id: 'e19b', source: 'decision_new_party', target: 'create_contract', type: 'default', condition: P`has(vars.partyId) && vars.partyId != null && vars.partyId != ""`, label: 'Picked' }, + { id: 'e19c', source: 'decision_new_party', target: 'create_party', type: 'default', condition: P`!has(vars.partyId) || vars.partyId == null || vars.partyId == ""`, label: 'Create' }, { id: 'e22', source: 'create_party', target: 'use_new_party', type: 'default' }, - { id: 'e27', source: 'use_new_party', target: 'decision_document_screen', type: 'default' }, - { id: 'e29', source: 'decision_document_screen', target: 'create_contract', type: 'default', condition: NOT_INTERACTIVE, label: 'Headless' }, - { id: 'e30', source: 'decision_document_screen', target: 'screen_document', type: 'default', condition: INTERACTIVE, label: 'Interactive' }, - { id: 'e31', source: 'screen_document', target: 'create_contract', type: 'default' }, + { id: 'e27', source: 'use_new_party', target: 'create_contract', type: 'default' }, { id: 'e32', source: 'create_contract', target: 'decision_version', type: 'default' }, - // Version 1: the template (when asked for and available), a supplied file, - // or the upload form. Partitioned in that order. + // Version 1: the template when asked for and available, else the file — + // `screen_document` requires the file whenever the template is not the + // source, and a headless run reaches here only with one of the two. { id: 'e33', source: 'decision_version', target: 'create_template_version', type: 'default', condition: P`vars.draft_from_template == true && vars.templateAvailable == true`, label: 'Template' }, - { id: 'e34', source: 'decision_version', target: 'create_file_version', type: 'default', condition: P`!(vars.draft_from_template == true && vars.templateAvailable == true) && has(vars.first_version_file) && vars.first_version_file != null && vars.first_version_file != ""`, label: 'File' }, - { id: 'e35', source: 'decision_version', target: 'screen_upload', type: 'default', condition: P`!(vars.draft_from_template == true && vars.templateAvailable == true) && (!has(vars.first_version_file) || vars.first_version_file == null || vars.first_version_file == "")`, label: 'Upload' }, - { id: 'e36', source: 'create_template_version', target: 'decision_schedule_screen', type: 'default' }, - { id: 'e37', source: 'create_file_version', target: 'decision_schedule_screen', type: 'default' }, - { id: 'e38', source: 'screen_upload', target: 'decision_schedule_screen', type: 'default' }, - { id: 'e39', source: 'decision_schedule_screen', target: 'decision_submit', type: 'default', condition: NOT_INTERACTIVE, label: 'Headless' }, - { id: 'e40', source: 'decision_schedule_screen', target: 'screen_schedule', type: 'default', condition: INTERACTIVE, label: 'Interactive' }, - { id: 'e41', source: 'screen_schedule', target: 'decision_submit', type: 'default' }, + { id: 'e34', source: 'decision_version', target: 'create_file_version', type: 'default', condition: P`!(vars.draft_from_template == true && vars.templateAvailable == true)`, label: 'File' }, + { id: 'e36', source: 'create_template_version', target: 'decision_submit', type: 'default' }, + { id: 'e37', source: 'create_file_version', target: 'decision_submit', type: 'default' }, { id: 'e45', source: 'decision_submit', target: 'submit_contract', type: 'default', condition: P`vars.submit_now == true`, label: 'Submit' }, { id: 'e46', source: 'decision_submit', target: 'end', type: 'default', condition: P`vars.submit_now != true`, label: 'Keep draft' }, { id: 'e47', source: 'submit_contract', target: 'end', type: 'default' }, diff --git a/src/flows/renewal-notice.flow.ts b/src/flows/renewal-notice.flow.ts index 9401d2e..6256992 100644 --- a/src/flows/renewal-notice.flow.ts +++ b/src/flows/renewal-notice.flow.ts @@ -240,7 +240,10 @@ export const RenewalStartFlow: Flow = { type: 'autolaunched', status: 'active', runAs: 'user', - variables: [{ name: 'recordId', type: 'text', isInput: true, isOutput: false }], + variables: [ + { name: 'recordId', type: 'text', isInput: true, isOutput: false }, + { name: 'renewal', type: 'object', isOutput: true }, + ], successMessage: 'Renewal draft created.', nodes: [ { id: 'start', type: 'start', label: 'Start', config: { objectName: 'clm_contract' } }, diff --git a/src/translations/en/flows.ts b/src/translations/en/flows.ts index 64cae22..6c24caa 100644 --- a/src/translations/en/flows.ts +++ b/src/translations/en/flows.ts @@ -39,7 +39,7 @@ export const flowSurface: Pick = { payment_terms: { label: 'Payment terms', options: contractOptions('payment_terms') }, confidentiality_term_months: { label: 'Confidentiality term (months)' }, auto_renew: { label: 'Auto-renews' }, - parent_contract: { label: 'Parent contract (record id)', placeholder: 'The framework or main contract this one sits under' }, + parent_contract: { label: 'Parent contract', inlineHelpText: 'The framework or main contract this one sits under.' }, }, }, screen_party: { @@ -55,24 +55,24 @@ export const flowSurface: Pick = { }, screen_document: { title: 'First version', - description: 'The document this contract starts from. By default you upload it on the next step.', + description: 'The document this contract starts from. It becomes version 1.', fields: { templateAvailable: { label: 'Template available' }, draft_from_template: { label: 'Draft from template', inlineHelpText: 'This contract type carries a template. Tick to attach it as version 1 instead of uploading a file.', }, + first_version_file: { label: 'Document' }, }, }, - screen_upload: { - title: 'Upload the first version', - description: 'The document this contract starts from. It becomes version 1.', - }, screen_schedule: { title: 'Submit the contract', - description: 'Choose whether to submit now. Submitting routes the contract to legal review or to approval, as its type decides.', + description: 'Choose whether this contract goes to legal review or approval now, as its type decides, or stays a draft you can submit later.', fields: { - submit_now: { label: 'Submit now' }, + submit_now: { + label: 'Submission', + options: { true: 'Submit to legal / approval now', false: 'Save as a draft for now' }, + }, }, }, }, diff --git a/src/translations/zh-CN/flows.ts b/src/translations/zh-CN/flows.ts index 3aa5de1..59fb6ba 100644 --- a/src/translations/zh-CN/flows.ts +++ b/src/translations/zh-CN/flows.ts @@ -39,7 +39,7 @@ export const flowSurface: Pick = { payment_terms: { label: '付款条件', options: contractOptions('payment_terms') }, confidentiality_term_months: { label: '保密期限(月)' }, auto_renew: { label: '自动续约' }, - parent_contract: { label: '主合同(记录 ID)', placeholder: '本合同所属的框架合同或主合同' }, + parent_contract: { label: '主合同', inlineHelpText: '本合同所属的框架合同或主合同。' }, }, }, screen_party: { @@ -55,24 +55,24 @@ export const flowSurface: Pick = { }, screen_document: { title: '首个版本', - description: '这份合同的起点文件。默认在下一步上传。', + description: '这份合同的起点文件,它会成为第 1 版。', fields: { templateAvailable: { label: '有模板' }, draft_from_template: { label: '从模板起草', inlineHelpText: '这类合同带有模板。勾选后,模板会作为第 1 版附上,不用再上传文件。', }, + first_version_file: { label: '文件' }, }, }, - screen_upload: { - title: '上传首个版本', - description: '这份合同的起点文件,它会成为第 1 版。', - }, screen_schedule: { title: '提交合同', - description: '选择是否现在提交。提交后,合同按类型的设置进入法务审查或审批。', + description: '选择现在就提交,按合同类型的设置进入法务审查或审批;还是先保存为草稿,之后再提交。', fields: { - submit_now: { label: '立即提交' }, + submit_now: { + label: '提交方式', + options: { true: '现在提交给法务 / 审批', false: '先保存为草稿' }, + }, }, }, },