diff --git a/src/actions/contract-lifecycle.actions.ts b/src/actions/contract-lifecycle.actions.ts index 0e86a0f..67ac001 100644 --- a/src/actions/contract-lifecycle.actions.ts +++ b/src/actions/contract-lifecycle.actions.ts @@ -68,6 +68,8 @@ const transition = ( ownerOnly?: boolean; /** The one confirm question of a param-less action whose write cannot be taken back. */ confirmText?: string; + /** Grey the button out while a precondition the hook will refuse is unmet (see below). */ + disabled?: ReturnType; }, ): Action => ({ name, @@ -145,6 +147,17 @@ const transition = ( : P`record.status == ${from}`, ...(requiredPermissions ? { requiredPermissions: [...requiredPermissions] } : {}), ...(options?.confirmText ? { confirmText: options.confirmText } : {}), + // `disabled` keeps the button on screen but greyed out while the record says + // the hook would refuse the press (issue #169) — where `visible` answers + // "is this the right stage", `disabled` answers "can it succeed yet". It reads + // only roll-ups on the record itself (`*_count` summary fields the engine + // recomputes on every child write), each with the same child filter as the + // guard it mirrors. Like `visible` it is a convenience, not a control: the + // guards in contract.hook.ts read the children directly and keep refusing. + // + // The console shows its generic reason on hover (「对此记录不可用」 / "Not + // available for this record"): ActionSchema has no key for an authored one. + ...(options?.disabled ? { disabled: options.disabled } : {}), // The status this button writes is the status the header shows, and the // status every one of these `visible` predicates reads. The spec defaults // `refreshAfter` to `false` (materialized at parse), and the record page @@ -216,28 +229,38 @@ export const AcceptContractAction: Action = transition( ['clm_legal.access'], ); -/** 送审 — review is done, start the approval ladder (F5). F6's deviation gate refuses - * this while any deviation is still `open`; the refusal comes from the hook. */ +/** 送审 — review is done, start the approval ladder (F5). The hook refuses it + * while any deviation is still `open` (F6's deviation gate) or before a + * legal-stage review is `approved`; the button is greyed out on both. */ export const SendForApprovalAction: Action = transition( 'send_for_approval', 'Send for Approval', 'git-branch', 'in_review', 'in_approval', - 'Close legal review and start approval. Not possible while any deviation is still open.', + 'Close legal review and start approval. Possible once a legal review is approved and no deviation is still open.', 'Sent for approval.', ['clm_legal.access'], + { disabled: P`record.open_deviation_count > 0 || record.approved_legal_review_count == 0` }, ); -/** 发起签署 — approved, now get it signed. */ +/** 发起签署 — approved, now get it signed. The hook refuses it unless the + * current version is a clean copy; the button is greyed out until it is. */ export const StartSigningAction: Action = transition( 'start_signing', 'Start Signing', 'pen-line', 'approved', 'signing', - 'Move an approved contract into signing.', + 'Move an approved contract into signing. Possible once the current version is a clean copy.', 'Signing started.', ['execute_contract'], + { disabled: P`record.current_clean_version_count == 0` }, ); /** 生效 — the execution formalities are complete. F9 (`contract_activate`) * stamps `activated_at` and opens the renewal-reminder obligation. * + * Greyed out until a `final_signed` version is filed. The hook's other + * activation guard — a completed signature round whose `formalities_done` + * covers the contract's `execution_formalities` — is NOT mirrored here: it + * compares a child's multiselect with a field on the parent, which a summary + * filter cannot express, so that refusal still arrives after the press. + * * The description no longer promises a payment schedule. Decision #14 was * ruled A on 2026-09-09: F9 expands NO intake-captured payment arrangement, * because nothing at intake can capture one and §04 gives the requester read @@ -251,6 +274,7 @@ export const ActivateContractAction: Action = transition( 'Bring a signed contract into force. Its activation date is recorded and, if it has an end date, a renewal reminder is set up.', 'Contract activated.', ['execute_contract'], + { disabled: P`record.final_signed_version_count == 0` }, ); /** diff --git a/src/objects/contract.object.ts b/src/objects/contract.object.ts index 4b5c1b6..2425e89 100644 --- a/src/objects/contract.object.ts +++ b/src/objects/contract.object.ts @@ -20,14 +20,18 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; * §07: AI proposes, a person adopts, the field is written on adoption); * - `is_backfilled` — the F16 `executed_upload` action only (§13 Q8). * - * The five roll-ups (`version_count`, `open_deviation_count`, - * `overdue_obligation_count`, `planned_amount`, `actual_amount`) are a sixth + * The eight roll-ups (`version_count`, `open_deviation_count`, + * `approved_legal_review_count`, `current_clean_version_count`, + * `final_signed_version_count`, `overdue_obligation_count`, + * `planned_amount`, `actual_amount`) are a sixth * such family: `summary` fields the ENGINE recomputes on every child insert, * update and delete (`ObjectQL.recomputeSummaries`, under an elevated system * context, which is why `readonly: true` does not lock the engine out). * Deliberately NOT `formula`: a formula is derived per read and cannot be - * filtered, sorted or listed on, and the two filtered counts here exist to be - * filtered and sorted on. + * filtered, sorted or listed on; `open_deviation_count` and + * `overdue_obligation_count` exist to be filtered and sorted on, and the + * three precondition counts are read by the header actions' `disabled` + * predicates (contract-lifecycle.actions.ts). */ export const Contract = ObjectSchema.create({ name: 'clm_contract', @@ -521,7 +525,7 @@ export const Contract = ObjectSchema.create({ // ─── Roll-ups — recomputed by the engine on every child write ────── // - // `relationshipField` is declared on all five although the engine can + // `relationshipField` is declared on every one although the engine can // infer it: inference takes the FIRST lookup/master_detail field on the // child that points back here, so it is order-dependent on a key nothing // else about the child pins. Naming it makes the roll-up survive a field @@ -564,6 +568,60 @@ export const Contract = ObjectSchema.create({ filter: { status: 'open' }, }, }), + // The three below exist so the header can grey out a transition whose + // precondition is unmet (issue #169). Each is the hook guard's own count, + // with the same child filter, rolled up onto the contract where an action's + // `disabled` predicate can read it. Like `open_deviation_count` they are a + // convenience and not a control: the guards in contract.hook.ts read the + // children directly and keep refusing whatever this number says. + approved_legal_review_count: Field.summary({ + label: 'Approved Legal Reviews', + group: 'rollup', + readonly: true, + scale: 0, + min: 0, + description: 'How many legal-stage reviews are approved. Sending for approval needs at least one; the check itself reads the reviews, not this number.', + summaryOperations: { + object: 'clm_review', + relationshipField: 'contract', + function: 'count', + field: 'id', + filter: { stage: 'legal', decision: 'approved' }, + }, + }), + current_clean_version_count: Field.summary({ + label: 'Current Clean Versions', + group: 'rollup', + readonly: true, + scale: 0, + min: 0, + // `is_current` moves between versions through contract-version.hook.ts, + // which writes through the engine: the version losing the flag is a child + // update, so this count is recomputed on that write too. + description: 'Whether the current version is a clean copy (1) or not (0). Starting signing needs one; the check itself reads the versions, not this number.', + summaryOperations: { + object: 'clm_contract_version', + relationshipField: 'contract', + function: 'count', + field: 'id', + filter: { kind: 'clean', is_current: true }, + }, + }), + final_signed_version_count: Field.summary({ + label: 'Final Signed Versions', + group: 'rollup', + readonly: true, + scale: 0, + min: 0, + description: 'How many final signed versions are filed. Activation needs at least one; the check itself reads the versions, not this number.', + summaryOperations: { + object: 'clm_contract_version', + relationshipField: 'contract', + function: 'count', + field: 'id', + filter: { kind: 'final_signed' }, + }, + }), overdue_obligation_count: Field.summary({ label: 'Overdue Obligations', group: 'rollup', diff --git a/src/translations/en/objects.contract.ts b/src/translations/en/objects.contract.ts index 7bdcbec..7f9c2bb 100644 --- a/src/translations/en/objects.contract.ts +++ b/src/translations/en/objects.contract.ts @@ -293,6 +293,18 @@ export const contract: Record = { label: 'Open Deviations', help: 'How many clause deviations are still open. Used for listing and sorting; sending for approval checks the deviations themselves, not this number.', }, + approved_legal_review_count: { + label: 'Approved Legal Reviews', + help: 'How many legal-stage reviews are approved. Sending for approval needs at least one; the check itself reads the reviews, not this number.', + }, + current_clean_version_count: { + label: 'Current Clean Versions', + help: 'Whether the current version is a clean copy (1) or not (0). Starting signing needs one; the check itself reads the versions, not this number.', + }, + final_signed_version_count: { + label: 'Final Signed Versions', + help: 'How many final signed versions are filed. Activation needs at least one; the check itself reads the versions, not this number.', + }, overdue_obligation_count: { label: 'Overdue Obligations', help: 'How many obligations are overdue. Only the daily check marks an obligation overdue.', @@ -428,12 +440,12 @@ export const contract: Record = { }, send_for_approval: { label: 'Send for Approval', - description: 'Close legal review and start approval. Not possible while any deviation is still open.', + description: 'Close legal review and start approval. Possible once a legal review is approved and no deviation is still open.', successMessage: 'Sent for approval.', }, start_signing: { label: 'Start Signing', - description: 'Move an approved contract into signing.', + description: 'Move an approved contract into signing. Possible once the current version is a clean copy.', successMessage: 'Signing started.', }, submit_contract: { diff --git a/src/translations/zh-CN/objects.contract.ts b/src/translations/zh-CN/objects.contract.ts index 1185a2b..79a0775 100644 --- a/src/translations/zh-CN/objects.contract.ts +++ b/src/translations/zh-CN/objects.contract.ts @@ -293,6 +293,18 @@ export const contract: Record = { label: '未决偏离数', help: '仍未处理的条款偏离条数,用于列表展示和排序;送审时检查的是偏离本身,而不是这个数字。', }, + approved_legal_review_count: { + label: '法务审查通过数', + help: '法务环节审查结论为「通过」的条数。送审至少要有一条;送审时检查的是审查记录本身,而不是这个数字。', + }, + current_clean_version_count: { + label: '当前清稿数', + help: '当前版本是否为清稿(1 是,0 否)。发起签署需要它为 1;发起签署时检查的是版本本身,而不是这个数字。', + }, + final_signed_version_count: { + label: '最终签署版数', + help: '已上传的最终签署版条数。生效至少要有一条;生效时检查的是版本本身,而不是这个数字。', + }, overdue_obligation_count: { label: '逾期义务数', help: '已经逾期的履约义务条数。只有每日检查会把义务标记为逾期。', @@ -428,12 +440,12 @@ export const contract: Record = { }, send_for_approval: { label: '送审', - description: '结束法务审查并开启审批。只要还有未决偏离,送审会被拒绝。', + description: '结束法务审查并开启审批。须有一条通过的法务审查,且没有未决偏离,才能送审。', successMessage: '已送审,进入审批。', }, start_signing: { label: '发起签署', - description: '把已批准的合同推进到签署中。', + description: '把已批准的合同推进到签署中。当前版本须为清稿,才能发起签署。', successMessage: '已进入签署。', }, submit_contract: {