shutro-erp 0.3.5 → 0.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/account_asset/.kosh/bindings.d.ts +22 -0
- package/dist/account_asset/.kosh/bindings.js +15 -0
- package/dist/account_asset/.kosh/schema.d.ts +1755 -0
- package/dist/account_asset/board.d.ts +61 -0
- package/dist/account_asset/board.js +86 -0
- package/dist/account_asset/books.d.ts +42 -0
- package/dist/account_asset/books.js +398 -0
- package/dist/account_asset/demo.d.ts +7 -0
- package/dist/account_asset/demo.js +72 -0
- package/dist/account_asset/index.d.ts +9 -0
- package/dist/account_asset/index.js +335 -0
- package/dist/account_asset/module.d.ts +7622 -0
- package/dist/account_asset/module.js +230 -0
- package/dist/account_budget/.kosh/bindings.d.ts +22 -0
- package/dist/account_budget/.kosh/bindings.js +15 -0
- package/dist/account_budget/.kosh/schema.d.ts +1767 -0
- package/dist/account_budget/budget.d.ts +5 -0
- package/dist/account_budget/budget.js +142 -0
- package/dist/account_budget/demo.d.ts +6 -0
- package/dist/account_budget/demo.js +30 -0
- package/dist/account_budget/index.d.ts +4 -0
- package/dist/account_budget/index.js +223 -0
- package/dist/account_budget/module.d.ts +13863 -0
- package/dist/account_budget/module.js +167 -0
- package/dist/account_portal/.kosh/bindings.js +1 -1
- package/dist/account_portal/.kosh/schema.d.ts +123 -4
- package/dist/account_portal/module.d.ts +146 -0
- package/dist/accounting/.kosh/bindings.js +1 -1
- package/dist/accounting/.kosh/schema.d.ts +123 -4
- package/dist/accounting/index.d.ts +1 -1
- package/dist/accounting/index.js +131 -3
- package/dist/accounting/module.d.ts +296 -0
- package/dist/accounting/module.js +32 -1
- package/dist/approvals/.kosh/bindings.d.ts +22 -0
- package/dist/approvals/.kosh/bindings.js +15 -0
- package/dist/approvals/.kosh/schema.d.ts +611 -0
- package/dist/approvals/index.d.ts +18 -0
- package/dist/approvals/index.js +302 -0
- package/dist/approvals/mails.d.ts +25 -0
- package/dist/approvals/mails.js +82 -0
- package/dist/approvals/module.d.ts +1 -0
- package/dist/approvals/module.js +12 -0
- package/dist/approvals/web/History.d.ts +4 -0
- package/dist/approvals/web/History.js +13 -0
- package/dist/approvals/web/Inbox.d.ts +9 -0
- package/dist/approvals/web/Inbox.js +114 -0
- package/dist/approvals/web/Mine.d.ts +7 -0
- package/dist/approvals/web/Mine.js +27 -0
- package/dist/approvals/web/index.d.ts +13 -0
- package/dist/approvals/web/index.js +22 -0
- package/dist/approvals/web/messages.d.ts +2 -0
- package/dist/approvals/web/messages.js +119 -0
- package/dist/approvals/web/shared.d.ts +59 -0
- package/dist/approvals/web/shared.js +46 -0
- package/dist/attendance/.kosh/bindings.d.ts +22 -0
- package/dist/attendance/.kosh/bindings.js +15 -0
- package/dist/attendance/.kosh/schema.d.ts +1048 -0
- package/dist/attendance/days.d.ts +96 -0
- package/dist/attendance/days.js +430 -0
- package/dist/attendance/demo.d.ts +7 -0
- package/dist/attendance/demo.js +58 -0
- package/dist/attendance/index.d.ts +4 -0
- package/dist/attendance/index.js +513 -0
- package/dist/attendance/module.d.ts +3969 -0
- package/dist/attendance/module.js +412 -0
- package/dist/attendance/routes.d.ts +16 -0
- package/dist/attendance/routes.js +266 -0
- package/dist/attendance/web/Check.d.ts +8 -0
- package/dist/attendance/web/Check.js +40 -0
- package/dist/attendance/web/Kiosk.d.ts +8 -0
- package/dist/attendance/web/Kiosk.js +62 -0
- package/dist/attendance/web/PinButton.d.ts +7 -0
- package/dist/attendance/web/PinButton.js +29 -0
- package/dist/attendance/web/index.d.ts +13 -0
- package/dist/attendance/web/index.js +22 -0
- package/dist/attendance/web/messages.d.ts +2 -0
- package/dist/attendance/web/messages.js +61 -0
- package/dist/cli/init.js +32 -0
- package/dist/cli/templates.d.ts +1 -1
- package/dist/cli/templates.js +89 -0
- package/dist/contacts/module.js +13 -2
- package/dist/crm/.kosh/bindings.js +1 -1
- package/dist/crm/.kosh/schema.d.ts +2 -1
- package/dist/crm/module.d.ts +2 -0
- package/dist/crm/module.js +2 -1
- package/dist/documents/.kosh/bindings.d.ts +22 -0
- package/dist/documents/.kosh/bindings.js +15 -0
- package/dist/documents/.kosh/schema.d.ts +862 -0
- package/dist/documents/demo.d.ts +6 -0
- package/dist/documents/demo.js +22 -0
- package/dist/documents/documents.d.ts +98 -0
- package/dist/documents/documents.js +318 -0
- package/dist/documents/index.d.ts +30 -0
- package/dist/documents/index.js +442 -0
- package/dist/documents/module.d.ts +2781 -0
- package/dist/documents/module.js +214 -0
- package/dist/documents/web/Versions.d.ts +7 -0
- package/dist/documents/web/Versions.js +45 -0
- package/dist/documents/web/index.d.ts +11 -0
- package/dist/documents/web/index.js +16 -0
- package/dist/documents/web/messages.d.ts +2 -0
- package/dist/documents/web/messages.js +21 -0
- package/dist/event_account/.kosh/bindings.js +1 -1
- package/dist/event_account/.kosh/schema.d.ts +123 -4
- package/dist/event_account/module.d.ts +146 -0
- package/dist/expense/.kosh/bindings.d.ts +22 -0
- package/dist/expense/.kosh/bindings.js +15 -0
- package/dist/expense/.kosh/schema.d.ts +1299 -0
- package/dist/expense/demo.d.ts +7 -0
- package/dist/expense/demo.js +69 -0
- package/dist/expense/index.d.ts +3 -0
- package/dist/expense/index.js +495 -0
- package/dist/expense/module.d.ts +4747 -0
- package/dist/expense/module.js +647 -0
- package/dist/expense_account/.kosh/bindings.d.ts +22 -0
- package/dist/expense_account/.kosh/bindings.js +15 -0
- package/dist/expense_account/.kosh/schema.d.ts +1936 -0
- package/dist/expense_account/index.d.ts +3 -0
- package/dist/expense_account/index.js +74 -0
- package/dist/expense_account/module.d.ts +9153 -0
- package/dist/expense_account/module.js +318 -0
- package/dist/helpdesk/.kosh/bindings.d.ts +22 -0
- package/dist/helpdesk/.kosh/bindings.js +15 -0
- package/dist/helpdesk/.kosh/schema.d.ts +885 -0
- package/dist/helpdesk/demo.d.ts +7 -0
- package/dist/helpdesk/demo.js +65 -0
- package/dist/helpdesk/hours.d.ts +17 -0
- package/dist/helpdesk/hours.js +94 -0
- package/dist/helpdesk/index.d.ts +10 -0
- package/dist/helpdesk/index.js +461 -0
- package/dist/helpdesk/module.d.ts +2940 -0
- package/dist/helpdesk/module.js +326 -0
- package/dist/helpdesk/routes.d.ts +12 -0
- package/dist/helpdesk/routes.js +132 -0
- package/dist/helpdesk/sla.d.ts +37 -0
- package/dist/helpdesk/sla.js +226 -0
- package/dist/hr/index.d.ts +1 -1
- package/dist/hr/index.js +1 -1
- package/dist/l10n_bd/.kosh/bindings.js +1 -1
- package/dist/l10n_bd/.kosh/schema.d.ts +123 -4
- package/dist/l10n_bd/module.d.ts +146 -0
- package/dist/l10n_bd_payroll/.kosh/bindings.d.ts +22 -0
- package/dist/l10n_bd_payroll/.kosh/bindings.js +15 -0
- package/dist/l10n_bd_payroll/.kosh/schema.d.ts +2312 -0
- package/dist/l10n_bd_payroll/data.d.ts +47 -0
- package/dist/l10n_bd_payroll/data.js +347 -0
- package/dist/l10n_bd_payroll/index.d.ts +4 -0
- package/dist/l10n_bd_payroll/index.js +24 -0
- package/dist/l10n_bd_payroll/module.d.ts +12932 -0
- package/dist/l10n_bd_payroll/module.js +86 -0
- package/dist/l10n_in/.kosh/bindings.js +1 -1
- package/dist/l10n_in/.kosh/schema.d.ts +123 -4
- package/dist/l10n_in/module.d.ts +147 -0
- package/dist/mail/inbound/handle.js +6 -1
- package/dist/maintenance_stock/.kosh/bindings.js +1 -1
- package/dist/maintenance_stock/.kosh/schema.d.ts +77 -2
- package/dist/maintenance_stock/module.d.ts +788 -66
- package/dist/modules-sdk/index.d.ts +1 -1
- package/dist/modules-sdk/manifest.d.ts +39 -2
- package/dist/mrp/.kosh/bindings.js +1 -1
- package/dist/mrp/.kosh/schema.d.ts +77 -2
- package/dist/mrp/index.d.ts +1 -1
- package/dist/mrp/index.js +2 -1
- package/dist/mrp/module.d.ts +781 -54
- package/dist/mrp/module.js +64 -2
- package/dist/mrp_account/.kosh/bindings.js +1 -1
- package/dist/mrp_account/.kosh/schema.d.ts +198 -5
- package/dist/mrp_account/module.d.ts +4852 -2541
- package/dist/payment/.kosh/bindings.js +1 -1
- package/dist/payment/.kosh/schema.d.ts +125 -5
- package/dist/payment/index.d.ts +41 -0
- package/dist/payment/index.js +131 -74
- package/dist/payment/module.d.ts +210 -6
- package/dist/payment/module.js +19 -3
- package/dist/payment/settle.js +10 -1
- package/dist/payroll/.kosh/bindings.d.ts +22 -0
- package/dist/payroll/.kosh/bindings.js +15 -0
- package/dist/payroll/.kosh/schema.d.ts +1390 -0
- package/dist/payroll/compute.d.ts +35 -0
- package/dist/payroll/compute.js +398 -0
- package/dist/payroll/demo.d.ts +55 -0
- package/dist/payroll/demo.js +121 -0
- package/dist/payroll/formula.d.ts +62 -0
- package/dist/payroll/formula.js +320 -0
- package/dist/payroll/index.d.ts +230 -0
- package/dist/payroll/index.js +713 -0
- package/dist/payroll/module.d.ts +9390 -0
- package/dist/payroll/module.js +606 -0
- package/dist/payroll/routes.d.ts +10 -0
- package/dist/payroll/routes.js +112 -0
- package/dist/payroll_account/.kosh/bindings.d.ts +22 -0
- package/dist/payroll_account/.kosh/bindings.js +15 -0
- package/dist/payroll_account/.kosh/schema.d.ts +2308 -0
- package/dist/payroll_account/index.d.ts +3 -0
- package/dist/payroll_account/index.js +40 -0
- package/dist/payroll_account/module.d.ts +12396 -0
- package/dist/payroll_account/module.js +256 -0
- package/dist/permissions/approvals.d.ts +131 -9
- package/dist/permissions/approvals.js +662 -57
- package/dist/permissions/index.d.ts +1 -1
- package/dist/permissions/index.js +2 -2
- package/dist/permissions/models.js +191 -12
- package/dist/permissions/routes.d.ts +6 -1
- package/dist/permissions/routes.js +37 -4
- package/dist/permissions/web/ApprovalPanel.d.ts +5 -4
- package/dist/permissions/web/ApprovalPanel.js +39 -13
- package/dist/permissions/web/index.d.ts +6 -4
- package/dist/permissions/web/index.js +13 -4
- package/dist/permissions/web/messages.d.ts +2 -0
- package/dist/permissions/web/messages.js +43 -0
- package/dist/portal/index.js +1 -1
- package/dist/pos/.kosh/bindings.js +1 -1
- package/dist/pos/.kosh/schema.d.ts +123 -4
- package/dist/pos/index.js +4 -0
- package/dist/pos/module.d.ts +147 -0
- package/dist/pos_stock/.kosh/bindings.js +1 -1
- package/dist/pos_stock/.kosh/schema.d.ts +198 -5
- package/dist/pos_stock/module.d.ts +914 -46
- package/dist/product/.kosh/bindings.js +1 -1
- package/dist/product/.kosh/schema.d.ts +2 -1
- package/dist/product/index.js +13 -0
- package/dist/product/module.d.ts +2 -0
- package/dist/product/module.js +2 -1
- package/dist/project/.kosh/bindings.js +1 -1
- package/dist/project/.kosh/schema.d.ts +672 -1
- package/dist/project/index.d.ts +3 -1
- package/dist/project/index.js +901 -14
- package/dist/project/module.d.ts +9892 -390
- package/dist/project/module.js +1530 -13
- package/dist/project/routes.d.ts +44 -0
- package/dist/project/routes.js +314 -0
- package/dist/project/schedule.d.ts +72 -0
- package/dist/project/schedule.js +192 -0
- package/dist/project/web/Refresh.d.ts +8 -0
- package/dist/project/web/Refresh.js +29 -0
- package/dist/project/web/Schedule.d.ts +9 -0
- package/dist/project/web/Schedule.js +93 -0
- package/dist/project/web/Structure.d.ts +8 -0
- package/dist/project/web/Structure.js +86 -0
- package/dist/project/web/Workload.d.ts +8 -0
- package/dist/project/web/Workload.js +43 -0
- package/dist/project/web/index.d.ts +14 -0
- package/dist/project/web/index.js +25 -0
- package/dist/project/web/messages.d.ts +2 -0
- package/dist/project/web/messages.js +85 -0
- package/dist/project_account/.kosh/bindings.d.ts +22 -0
- package/dist/project_account/.kosh/bindings.js +15 -0
- package/dist/project_account/.kosh/schema.d.ts +2241 -0
- package/dist/project_account/index.d.ts +3 -0
- package/dist/project_account/index.js +27 -0
- package/dist/project_account/module.d.ts +11539 -0
- package/dist/project_account/module.js +76 -0
- package/dist/project_portal/.kosh/bindings.js +1 -1
- package/dist/project_portal/.kosh/schema.d.ts +672 -1
- package/dist/project_portal/module.d.ts +4798 -129
- package/dist/project_purchase/.kosh/bindings.d.ts +22 -0
- package/dist/project_purchase/.kosh/bindings.js +15 -0
- package/dist/project_purchase/.kosh/schema.d.ts +2320 -0
- package/dist/project_purchase/index.d.ts +9 -0
- package/dist/project_purchase/index.js +22 -0
- package/dist/project_purchase/module.d.ts +25147 -0
- package/dist/project_purchase/module.js +56 -0
- package/dist/purchase/.kosh/bindings.js +1 -1
- package/dist/purchase/.kosh/schema.d.ts +97 -4
- package/dist/purchase/module.d.ts +13 -2
- package/dist/purchase/module.js +10 -1
- package/dist/purchase_account/.kosh/bindings.js +1 -1
- package/dist/purchase_account/.kosh/schema.d.ts +126 -6
- package/dist/purchase_account/index.js +1 -0
- package/dist/purchase_account/module.d.ts +176 -1
- package/dist/purchase_account/module.js +13 -2
- package/dist/purchase_portal/.kosh/bindings.js +1 -1
- package/dist/purchase_portal/.kosh/schema.d.ts +97 -4
- package/dist/purchase_portal/module.d.ts +6 -1
- package/dist/purchase_stock/.kosh/bindings.js +1 -1
- package/dist/purchase_stock/.kosh/schema.d.ts +174 -5
- package/dist/purchase_stock/index.d.ts +1 -1
- package/dist/purchase_stock/index.js +9 -2
- package/dist/purchase_stock/module.d.ts +840 -68
- package/dist/purchase_stock/module.js +242 -6
- package/dist/purchase_stock_account/.kosh/bindings.js +1 -1
- package/dist/purchase_stock_account/.kosh/schema.d.ts +203 -7
- package/dist/purchase_stock_account/module.d.ts +8163 -5514
- package/dist/purchase_stock_account/module.js +11 -5
- package/dist/recruitment/.kosh/bindings.js +1 -1
- package/dist/recruitment/.kosh/schema.d.ts +2 -1
- package/dist/recruitment/module.d.ts +2 -0
- package/dist/recruitment/module.js +2 -1
- package/dist/sale_account/.kosh/bindings.js +1 -1
- package/dist/sale_account/.kosh/schema.d.ts +123 -4
- package/dist/sale_account/module.d.ts +147 -0
- package/dist/sale_portal/.kosh/bindings.js +1 -1
- package/dist/sale_portal/.kosh/schema.d.ts +2 -1
- package/dist/sale_portal/module.d.ts +1 -0
- package/dist/sale_project/.kosh/bindings.js +1 -1
- package/dist/sale_project/.kosh/schema.d.ts +673 -1
- package/dist/sale_project/module.d.ts +4800 -129
- package/dist/sale_project/module.js +17 -1
- package/dist/sale_stock/.kosh/bindings.js +1 -1
- package/dist/sale_stock/.kosh/schema.d.ts +77 -2
- package/dist/sale_stock/module.d.ts +789 -66
- package/dist/sales/.kosh/bindings.js +1 -1
- package/dist/sales/.kosh/schema.d.ts +2 -1
- package/dist/sales/index.js +4 -0
- package/dist/sales/module.d.ts +1 -0
- package/dist/sign/.kosh/bindings.d.ts +22 -0
- package/dist/sign/.kosh/bindings.js +15 -0
- package/dist/sign/.kosh/schema.d.ts +990 -0
- package/dist/sign/index.d.ts +14 -0
- package/dist/sign/index.js +350 -0
- package/dist/sign/module.d.ts +4478 -0
- package/dist/sign/module.js +158 -0
- package/dist/sign/sign.d.ts +82 -0
- package/dist/sign/sign.js +474 -0
- package/dist/stock/.kosh/bindings.js +1 -1
- package/dist/stock/.kosh/schema.d.ts +77 -2
- package/dist/stock/demo.d.ts +2 -1
- package/dist/stock/demo.js +28 -1
- package/dist/stock/index.d.ts +2 -1
- package/dist/stock/index.js +233 -4
- package/dist/stock/module.d.ts +2363 -837
- package/dist/stock/module.js +569 -14
- package/dist/stock/replenish.d.ts +38 -0
- package/dist/stock/replenish.js +176 -0
- package/dist/stock/web/Replenishment.d.ts +8 -0
- package/dist/stock/web/Replenishment.js +84 -0
- package/dist/stock/web/index.d.ts +4 -2
- package/dist/stock/web/index.js +6 -2
- package/dist/stock/web/messages.js +52 -0
- package/dist/stock_account/.kosh/bindings.js +1 -1
- package/dist/stock_account/.kosh/schema.d.ts +198 -5
- package/dist/stock_account/index.js +10 -0
- package/dist/stock_account/module.d.ts +969 -101
- package/dist/trade_finance/index.js +2 -0
- package/dist/trade_finance/module.js +2 -0
- package/dist/trade_finance_account/.kosh/bindings.js +1 -1
- package/dist/trade_finance_account/.kosh/schema.d.ts +123 -4
- package/dist/trade_finance_account/index.js +1 -0
- package/dist/trade_finance_account/module.d.ts +146 -0
- package/dist/trade_finance_account/module.js +1 -0
- package/dist/trade_finance_purchase/.kosh/bindings.js +1 -1
- package/dist/trade_finance_purchase/.kosh/schema.d.ts +97 -4
- package/dist/trade_finance_purchase/module.d.ts +6 -1
- package/dist/trade_finance_sale/.kosh/bindings.js +1 -1
- package/dist/trade_finance_sale/.kosh/schema.d.ts +2 -1
- package/dist/trade_finance_sale/module.d.ts +1 -0
- package/dist/ui/app.js +24 -12
- package/dist/ui/components/AppIcon.js +8 -1
- package/dist/ui/i18n/messages.d.ts +4 -0
- package/dist/ui/i18n/messages.js +8 -0
- package/dist/ui/views/FormView.js +4 -2
- package/dist/ui/views/KanbanView.js +9 -1
- package/dist/ui/views/ListView.js +4 -2
- package/dist/ui/views/widgets.js +7 -1
- package/dist/website/index.js +1 -0
- package/dist/website/module.js +1 -0
- package/dist/website_crm/.kosh/bindings.js +1 -1
- package/dist/website_crm/.kosh/schema.d.ts +2 -1
- package/dist/website_crm/module.d.ts +1 -0
- package/dist/website_sale/.kosh/bindings.d.ts +22 -0
- package/dist/website_sale/.kosh/bindings.js +15 -0
- package/dist/website_sale/.kosh/schema.d.ts +2284 -0
- package/dist/website_sale/couriers.d.ts +50 -0
- package/dist/website_sale/couriers.js +59 -0
- package/dist/website_sale/demo.d.ts +7 -0
- package/dist/website_sale/demo.js +90 -0
- package/dist/website_sale/growth.d.ts +31 -0
- package/dist/website_sale/growth.js +163 -0
- package/dist/website_sale/index.d.ts +49 -0
- package/dist/website_sale/index.js +538 -0
- package/dist/website_sale/module.d.ts +22027 -0
- package/dist/website_sale/module.js +397 -0
- package/dist/website_sale/shipments.d.ts +16 -0
- package/dist/website_sale/shipments.js +113 -0
- package/dist/website_sale/shop.d.ts +81 -0
- package/dist/website_sale/shop.js +649 -0
- package/dist/website_sale_stock/.kosh/bindings.d.ts +22 -0
- package/dist/website_sale_stock/.kosh/bindings.js +15 -0
- package/dist/website_sale_stock/.kosh/schema.d.ts +2681 -0
- package/dist/website_sale_stock/index.d.ts +9 -0
- package/dist/website_sale_stock/index.js +20 -0
- package/dist/website_sale_stock/module.d.ts +24998 -0
- package/dist/website_sale_stock/module.js +28 -0
- package/package.json +83 -2
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
import { audit, HttpProblem } from '../server/index.js';
|
|
2
2
|
const ref = (env, name) => env.model(name);
|
|
3
|
+
/** Many-to-many fields read here: loaded by the object spec (defaults plus the relation), read as ids. */
|
|
4
|
+
const MANY = new Set(['approvers', 'approverUsers']);
|
|
5
|
+
const specOf = (keys) => keys.some((k) => MANY.has(k))
|
|
6
|
+
? Object.fromEntries(keys.filter((k) => MANY.has(k)).map((k) => [k, true]))
|
|
7
|
+
: keys;
|
|
3
8
|
async function find(env, model, where, keys, order) {
|
|
4
9
|
const set = await ref(env, model).search({ where, ...(order === undefined ? {} : { order }) });
|
|
5
|
-
return set.length === 0 ? [] : (await set.load(keys)).read(keys);
|
|
10
|
+
return set.length === 0 ? [] : (await set.load(specOf(keys))).read(keys);
|
|
6
11
|
}
|
|
7
12
|
const REQUEST_KEYS = [
|
|
13
|
+
'dueAt',
|
|
14
|
+
'escalatedTo',
|
|
15
|
+
'approvers',
|
|
16
|
+
'required',
|
|
17
|
+
'round',
|
|
8
18
|
'policy',
|
|
9
19
|
'levelName',
|
|
10
20
|
'sequence',
|
|
@@ -28,17 +38,110 @@ function info(row) {
|
|
|
28
38
|
targetModel: String(row['targetModel']),
|
|
29
39
|
targetId: row['targetId'],
|
|
30
40
|
company: row['company'] ?? null,
|
|
31
|
-
approverRole: String(row['approverRole']),
|
|
41
|
+
approverRole: String(row['approverRole'] ?? ''),
|
|
42
|
+
approvers: Array.isArray(row['approvers']) ? row['approvers'] : [],
|
|
43
|
+
required: Number(row['required'] ?? 1),
|
|
44
|
+
approvals: Number(row['approvals'] ?? 0),
|
|
45
|
+
round: Number(row['round'] ?? 1),
|
|
46
|
+
dueAt: row['dueAt'] instanceof Date
|
|
47
|
+
? row['dueAt'].toISOString()
|
|
48
|
+
: typeof row['dueAt'] === 'string'
|
|
49
|
+
? row['dueAt']
|
|
50
|
+
: null,
|
|
51
|
+
escalatedTo: typeof row['escalatedTo'] === 'string' && row['escalatedTo'] !== ''
|
|
52
|
+
? row['escalatedTo']
|
|
53
|
+
: null,
|
|
32
54
|
state: row['state'],
|
|
33
55
|
requestedBy: row['requestedBy'] ?? null,
|
|
34
56
|
decidedBy: row['decidedBy'] ?? null,
|
|
35
|
-
decidedAt: at instanceof Date ? at.toISOString() : null,
|
|
57
|
+
decidedAt: at instanceof Date ? at.toISOString() : typeof at === 'string' ? at : null,
|
|
36
58
|
comment: row['comment'] ?? null,
|
|
37
59
|
};
|
|
38
60
|
}
|
|
39
|
-
/**
|
|
61
|
+
/** Requests as the API returns them, each with its count of approvals. */
|
|
62
|
+
async function loadRequests(su, where, order) {
|
|
63
|
+
const rows = await find(su, 'approval.request', where, REQUEST_KEYS, order);
|
|
64
|
+
if (rows.length === 0)
|
|
65
|
+
return [];
|
|
66
|
+
const votes = await find(su, 'approval.vote', { request: { in: rows.map((r) => r['id']) }, decision: 'approve' }, ['request']);
|
|
67
|
+
const count = new Map();
|
|
68
|
+
for (const v of votes)
|
|
69
|
+
count.set(v['request'], (count.get(v['request']) ?? 0) + 1);
|
|
70
|
+
return rows.map((r) => info({ ...r, approvals: count.get(r['id']) ?? 0 }));
|
|
71
|
+
}
|
|
72
|
+
/** The requests of a record's current round (cancelled ones, and earlier rounds, are history). */
|
|
40
73
|
async function currentRequests(su, model, id) {
|
|
41
|
-
|
|
74
|
+
const all = await loadRequests(su, { targetModel: model, targetId: id }, [
|
|
75
|
+
'round desc',
|
|
76
|
+
'sequence asc',
|
|
77
|
+
'id asc',
|
|
78
|
+
]);
|
|
79
|
+
const round = all[0]?.round ?? 0;
|
|
80
|
+
return all
|
|
81
|
+
.filter((r) => r.round === round && r.state !== 'cancelled')
|
|
82
|
+
.sort((a, b) => a.sequence - b.sequence || a.id - b.id);
|
|
83
|
+
}
|
|
84
|
+
/** The holders of a role in a company (or in every company). */
|
|
85
|
+
async function holders(su, role, company) {
|
|
86
|
+
const rows = await find(su, 'auth.user_group', { group: role, ...(company === null ? {} : { or: [{ company }, { company: null }] }) }, ['user']);
|
|
87
|
+
return [...new Set(rows.map((r) => r['user']))];
|
|
88
|
+
}
|
|
89
|
+
/** The user of the requester's manager in Workforce, when it is installed and they have one. */
|
|
90
|
+
async function managerOf(su, requester) {
|
|
91
|
+
try {
|
|
92
|
+
const [me] = await find(su, 'hr.employee', { user: requester }, ['manager'], ['id asc']);
|
|
93
|
+
const manager = me?.['manager'];
|
|
94
|
+
if (typeof manager !== 'number')
|
|
95
|
+
return null;
|
|
96
|
+
const [boss] = await find(su, 'hr.employee', { id: manager }, ['user']);
|
|
97
|
+
return typeof boss?.['user'] === 'number' ? boss['user'] : null;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return null; // Workforce is not installed
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/** The user in a field of the record (a many-to-one to users), or null. */
|
|
104
|
+
async function userIn(su, model, id, field) {
|
|
105
|
+
try {
|
|
106
|
+
const [row] = (await ref(su, model).browse([id]).load([field])).read([field]);
|
|
107
|
+
const value = row?.[field];
|
|
108
|
+
return typeof value === 'number' ? value : null;
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Who decides a level, and how many approvals it needs: named people, a manager or a field's person (the role when
|
|
116
|
+
* there is nobody), a role's holders when all or a number of them must approve; never the requester.
|
|
117
|
+
*/
|
|
118
|
+
async function approversOf(su, level, target, requester) {
|
|
119
|
+
const role = typeof level['approverRole'] === 'string' ? level['approverRole'] : '';
|
|
120
|
+
let people = [];
|
|
121
|
+
const kind = level['approverKind'] ?? 'role';
|
|
122
|
+
if (kind === 'users')
|
|
123
|
+
people = level['approverUsers'] ?? [];
|
|
124
|
+
else if (kind === 'manager') {
|
|
125
|
+
const manager = await managerOf(su, requester);
|
|
126
|
+
if (manager !== null)
|
|
127
|
+
people = [manager];
|
|
128
|
+
}
|
|
129
|
+
else if (kind === 'field') {
|
|
130
|
+
const person = await userIn(su, target.model, target.id, String(level['approverField'] ?? ''));
|
|
131
|
+
if (person !== null)
|
|
132
|
+
people = [person];
|
|
133
|
+
}
|
|
134
|
+
people = people.filter((u) => u !== requester);
|
|
135
|
+
const quorum = level['quorum'] ?? 'any';
|
|
136
|
+
if (people.length === 0 && quorum !== 'any' && role !== '')
|
|
137
|
+
people = (await holders(su, role, target.company)).filter((u) => u !== requester);
|
|
138
|
+
const count = Math.max(1, Number(level['quorumCount'] ?? 1));
|
|
139
|
+
const required = quorum === 'all'
|
|
140
|
+
? Math.max(1, people.length)
|
|
141
|
+
: quorum === 'count'
|
|
142
|
+
? Math.min(count, Math.max(1, people.length))
|
|
143
|
+
: 1;
|
|
144
|
+
return { role: people.length > 0 && kind === 'users' ? '' : role, approvers: people, required };
|
|
42
145
|
}
|
|
43
146
|
/** The record's company, when the model has one. */
|
|
44
147
|
async function companyOf(su, model, id) {
|
|
@@ -66,11 +169,24 @@ export async function requestApprovals(records) {
|
|
|
66
169
|
});
|
|
67
170
|
if (stale.length > 0)
|
|
68
171
|
await stale.write({ state: 'cancelled' });
|
|
172
|
+
const [last] = await find(su, 'approval.request', { targetModel: modelName, targetId: id }, ['round'], ['round desc']);
|
|
173
|
+
const round = Number(last?.['round'] ?? 0) + 1;
|
|
69
174
|
const company = (await companyOf(su, modelName, id)) ?? env.companyId;
|
|
70
175
|
const policies = await find(su, 'approval.policy', { targetModel: modelName, or: [{ company: null }, { company }] }, ['name'], ['sequence asc', 'id asc']);
|
|
71
176
|
const rows = [];
|
|
72
177
|
for (const policy of policies) {
|
|
73
|
-
const levels = await find(su, 'approval.level', { policy: policy['id'] }, [
|
|
178
|
+
const levels = await find(su, 'approval.level', { policy: policy['id'] }, [
|
|
179
|
+
'sequence',
|
|
180
|
+
'name',
|
|
181
|
+
'condition',
|
|
182
|
+
'approverKind',
|
|
183
|
+
'approverRole',
|
|
184
|
+
'approverUsers',
|
|
185
|
+
'approverField',
|
|
186
|
+
'quorum',
|
|
187
|
+
'quorumCount',
|
|
188
|
+
'deadlineHours',
|
|
189
|
+
], ['sequence asc', 'id asc']);
|
|
74
190
|
for (const level of levels) {
|
|
75
191
|
const condition = level['condition'];
|
|
76
192
|
const applies = condition === null ||
|
|
@@ -79,6 +195,7 @@ export async function requestApprovals(records) {
|
|
|
79
195
|
.length > 0;
|
|
80
196
|
if (!applies)
|
|
81
197
|
continue;
|
|
198
|
+
const who = await approversOf(su, level, { model: modelName, id, company }, requester);
|
|
82
199
|
rows.push({
|
|
83
200
|
policy: policy['id'],
|
|
84
201
|
level: level['id'],
|
|
@@ -87,7 +204,13 @@ export async function requestApprovals(records) {
|
|
|
87
204
|
targetModel: modelName,
|
|
88
205
|
targetId: id,
|
|
89
206
|
company,
|
|
90
|
-
approverRole:
|
|
207
|
+
approverRole: who.role,
|
|
208
|
+
approvers: { set: who.approvers },
|
|
209
|
+
required: who.required,
|
|
210
|
+
round,
|
|
211
|
+
...(typeof level['deadlineHours'] === 'number' && level['deadlineHours'] > 0
|
|
212
|
+
? { dueAt: new Date(Date.now() + level['deadlineHours'] * 3_600_000) }
|
|
213
|
+
: {}),
|
|
91
214
|
requestedBy: requester,
|
|
92
215
|
});
|
|
93
216
|
}
|
|
@@ -108,7 +231,8 @@ export async function requestApprovals(records) {
|
|
|
108
231
|
await su.emit('approval.requested', {
|
|
109
232
|
model: modelName,
|
|
110
233
|
id,
|
|
111
|
-
roles: [...new Set(rows.map((r) => r['approverRole']))],
|
|
234
|
+
roles: [...new Set(rows.map((r) => r['approverRole']).filter((r) => r !== ''))],
|
|
235
|
+
users: [...new Set(rows.flatMap((r) => r['approvers'].set))],
|
|
112
236
|
});
|
|
113
237
|
}
|
|
114
238
|
return created;
|
|
@@ -121,6 +245,8 @@ export async function approvalState(env, model, id) {
|
|
|
121
245
|
return 'none';
|
|
122
246
|
if (requests.some((r) => r.state === 'rejected'))
|
|
123
247
|
return 'rejected';
|
|
248
|
+
if (requests.some((r) => r.state === 'sent_back'))
|
|
249
|
+
return 'sent_back';
|
|
124
250
|
return requests.every((r) => r.state === 'approved') ? 'approved' : 'pending';
|
|
125
251
|
}
|
|
126
252
|
/** Transition guard: the records whose approvals are pending or rejected (records no level applied to may go). */
|
|
@@ -129,7 +255,7 @@ export async function approvalGuard(records) {
|
|
|
129
255
|
const blocked = [];
|
|
130
256
|
for (const id of ids) {
|
|
131
257
|
const state = await approvalState(env, modelName, id);
|
|
132
|
-
if (state === 'pending' || state === 'rejected')
|
|
258
|
+
if (state === 'pending' || state === 'rejected' || state === 'sent_back')
|
|
133
259
|
blocked.push(id);
|
|
134
260
|
}
|
|
135
261
|
return blocked;
|
|
@@ -137,35 +263,114 @@ export async function approvalGuard(records) {
|
|
|
137
263
|
function refuse(status, type, title, detail, hint) {
|
|
138
264
|
throw new HttpProblem(status, type, title, detail, hint);
|
|
139
265
|
}
|
|
140
|
-
/**
|
|
141
|
-
function
|
|
266
|
+
/** `env`'s user as a decider (RFC 0025 §3.3: delegations active today count). */
|
|
267
|
+
export async function whoIs(su, env) {
|
|
268
|
+
const today = env.today();
|
|
269
|
+
const delegations = await find(su, 'approval.delegation', { delegate: env.uid, dateFrom: { lte: today }, dateTo: { gte: today } }, ['user']);
|
|
270
|
+
const users = [...new Set(delegations.map((d) => d['user']))];
|
|
271
|
+
const groups = users.length === 0
|
|
272
|
+
? []
|
|
273
|
+
: await find(su, 'auth.user_group', { user: { in: users } }, ['user', 'group', 'company']);
|
|
274
|
+
return {
|
|
275
|
+
uid: env.uid,
|
|
276
|
+
administrator: env.administrator,
|
|
277
|
+
groups: env.groups,
|
|
278
|
+
companyId: env.companyId ?? null,
|
|
279
|
+
delegators: users.map((user) => ({
|
|
280
|
+
user,
|
|
281
|
+
roles: groups
|
|
282
|
+
.filter((g) => g['user'] === user)
|
|
283
|
+
.map((g) => ({
|
|
284
|
+
group: String(g['group']),
|
|
285
|
+
company: g['company'] ?? null,
|
|
286
|
+
})),
|
|
287
|
+
})),
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
const holds = (roles, role, company) => role !== '' &&
|
|
291
|
+
roles.some((r) => r.group === role && (r.company === null || company === null || r.company === company));
|
|
292
|
+
/**
|
|
293
|
+
* Whether `who` may decide `request` as an approver: as themselves (`{ for: null }`) — named, holding the role, or
|
|
294
|
+
* the escalation role once it is late —, or for someone away who delegated to them (`{ for: <user> }`); null when not.
|
|
295
|
+
*/
|
|
296
|
+
function approverFor(who, request) {
|
|
297
|
+
if (request.approvers.includes(who.uid))
|
|
298
|
+
return { for: null };
|
|
299
|
+
if (request.approvers.length === 0 && (who.administrator || who.groups.has(request.approverRole)))
|
|
300
|
+
return { for: null };
|
|
301
|
+
if (request.escalatedTo !== null && (who.administrator || who.groups.has(request.escalatedTo)))
|
|
302
|
+
return { for: null };
|
|
303
|
+
for (const d of who.delegators) {
|
|
304
|
+
if (d.user === request.requestedBy)
|
|
305
|
+
continue;
|
|
306
|
+
if (request.approvers.includes(d.user))
|
|
307
|
+
return { for: d.user };
|
|
308
|
+
if (request.approvers.length === 0 && holds(d.roles, request.approverRole, request.company))
|
|
309
|
+
return { for: d.user };
|
|
310
|
+
if (request.escalatedTo !== null && holds(d.roles, request.escalatedTo, request.company))
|
|
311
|
+
return { for: d.user };
|
|
312
|
+
}
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Why `env`'s user may not decide `request` now — its turn, its approvers (or role), its company, not the requester,
|
|
317
|
+
* not decided by them already —, or null when they may.
|
|
318
|
+
*/
|
|
319
|
+
function mayDecide(who, request, round, decided = new Map()) {
|
|
320
|
+
const no = (reason) => ({ reason, onBehalfOf: null });
|
|
142
321
|
if (request.state !== 'pending')
|
|
143
|
-
return 'decided';
|
|
322
|
+
return no('decided');
|
|
144
323
|
if (round.some((r) => r.sequence < request.sequence && r.state !== 'approved'))
|
|
145
|
-
return 'not-your-turn';
|
|
146
|
-
if (request.requestedBy ===
|
|
147
|
-
return 'separation-of-duties';
|
|
148
|
-
if (request.company !== null && request.company !==
|
|
149
|
-
return 'other-company';
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
324
|
+
return no('not-your-turn');
|
|
325
|
+
if (request.requestedBy === who.uid)
|
|
326
|
+
return no('separation-of-duties');
|
|
327
|
+
if (request.company !== null && request.company !== who.companyId)
|
|
328
|
+
return no('other-company');
|
|
329
|
+
const as = approverFor(who, request);
|
|
330
|
+
if (as === null)
|
|
331
|
+
return no('not-approver');
|
|
332
|
+
const done = decided.get(request.id) ?? new Set();
|
|
333
|
+
if (done.has(who.uid) || (as.for !== null && done.has(as.for)))
|
|
334
|
+
return no('voted');
|
|
335
|
+
return { reason: null, onBehalfOf: as.for };
|
|
336
|
+
}
|
|
337
|
+
/** Who has decided each request of `ids` already (the voters, and those they decided for). */
|
|
338
|
+
async function decidedOn(su, ids) {
|
|
339
|
+
const out = new Map();
|
|
340
|
+
if (ids.length === 0)
|
|
341
|
+
return out;
|
|
342
|
+
for (const v of await find(su, 'approval.vote', { request: { in: [...ids] } }, [
|
|
343
|
+
'request',
|
|
344
|
+
'user',
|
|
345
|
+
'onBehalfOf',
|
|
346
|
+
])) {
|
|
347
|
+
const set = out.get(v['request']) ?? new Set();
|
|
348
|
+
set.add(v['user']);
|
|
349
|
+
if (typeof v['onBehalfOf'] === 'number')
|
|
350
|
+
set.add(v['onBehalfOf']);
|
|
351
|
+
out.set(v['request'], set);
|
|
352
|
+
}
|
|
353
|
+
return out;
|
|
153
354
|
}
|
|
154
355
|
/**
|
|
155
|
-
*
|
|
156
|
-
*
|
|
356
|
+
* Decides a request as `env`'s user: approves (the request is approved once it has the approvals it needs), rejects
|
|
357
|
+
* or sends back for changes (with a reason, unless the policy says otherwise; the other open requests of the round are
|
|
358
|
+
* cancelled). @throws {HttpProblem} 404 unknown, 409 decided / not its turn / decided by them already, 403 not an
|
|
359
|
+
* approver, the requester, or another company, 422 a reason missing
|
|
157
360
|
*/
|
|
158
|
-
export async function decide(env, requestId, decision, comment) {
|
|
361
|
+
export async function decide(env, requestId, decision, comment, from) {
|
|
159
362
|
const actor = env.uid;
|
|
160
363
|
return env.asSuperuser({ reason: `${decision} approval ${requestId}` }, async (su) => {
|
|
161
|
-
const [
|
|
162
|
-
if (
|
|
364
|
+
const [request] = await loadRequests(su, { id: requestId }, ['id asc']);
|
|
365
|
+
if (request === undefined)
|
|
163
366
|
refuse(404, 'missing-record', 'Not found', `No approval request ${requestId}.`);
|
|
164
|
-
const request = info(row);
|
|
165
367
|
const round = await currentRequests(su, request.targetModel, request.targetId);
|
|
166
|
-
const
|
|
368
|
+
const who = await whoIs(su, env);
|
|
369
|
+
const { reason, onBehalfOf } = mayDecide(who, request, round, await decidedOn(su, [request.id]));
|
|
167
370
|
if (reason === 'decided' || request.state === 'cancelled')
|
|
168
371
|
refuse(409, 'approval-decided', 'Already decided', `This request is ${request.state}.`);
|
|
372
|
+
if (reason === 'voted')
|
|
373
|
+
refuse(409, 'approval-voted', 'Already decided by you', 'Another approver decides the rest.');
|
|
169
374
|
if (reason === 'not-your-turn')
|
|
170
375
|
refuse(409, 'approval-not-your-turn', 'Earlier levels first', 'A lower approval level is still open.', 'Wait for the earlier approvers.');
|
|
171
376
|
if (reason === 'separation-of-duties')
|
|
@@ -173,63 +378,450 @@ export async function decide(env, requestId, decision, comment) {
|
|
|
173
378
|
if (reason === 'other-company')
|
|
174
379
|
refuse(403, 'company-not-allowed', 'Other company', `This request belongs to company ${request.company}.`, 'Switch to that company (X-Company).');
|
|
175
380
|
if (reason === 'not-approver')
|
|
176
|
-
refuse(403, 'access', 'Not an approver',
|
|
177
|
-
|
|
381
|
+
refuse(403, 'access', 'Not an approver', request.approvers.length > 0
|
|
382
|
+
? 'This request is decided by the people it names.'
|
|
383
|
+
: `Approving needs the role ${request.approverRole} in this company.`);
|
|
384
|
+
const [policy] = request.policy === null
|
|
385
|
+
? []
|
|
386
|
+
: await find(su, 'approval.policy', { id: request.policy }, [
|
|
387
|
+
'onApproved',
|
|
388
|
+
'onRejected',
|
|
389
|
+
'onSentBack',
|
|
390
|
+
'reasonRequired',
|
|
391
|
+
]);
|
|
392
|
+
const text = comment?.trim() ?? '';
|
|
393
|
+
if (decision !== 'approve' && policy?.['reasonRequired'] !== false && text === '')
|
|
394
|
+
refuse(422, 'reason-required', 'Say why', decision === 'reject' ? 'A rejection says why.' : 'Say what to change.');
|
|
395
|
+
await ref(su, 'approval.vote').create([
|
|
396
|
+
{
|
|
397
|
+
request: request.id,
|
|
398
|
+
user: actor,
|
|
399
|
+
decision,
|
|
400
|
+
comment: text === '' ? null : text,
|
|
401
|
+
at: new Date(),
|
|
402
|
+
onBehalfOf,
|
|
403
|
+
ip: from?.ip ?? null,
|
|
404
|
+
},
|
|
405
|
+
]);
|
|
406
|
+
const approvals = request.approvals + (decision === 'approve' ? 1 : 0);
|
|
407
|
+
const state = decision === 'reject'
|
|
408
|
+
? 'rejected'
|
|
409
|
+
: decision === 'send_back'
|
|
410
|
+
? 'sent_back'
|
|
411
|
+
: approvals >= request.required
|
|
412
|
+
? 'approved'
|
|
413
|
+
: 'pending';
|
|
414
|
+
const target = { model: request.targetModel, id: request.targetId };
|
|
178
415
|
await ref(su, 'approval.request')
|
|
179
416
|
.browse([request.id])
|
|
180
417
|
.write({
|
|
181
418
|
state,
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
419
|
+
...(state === 'pending'
|
|
420
|
+
? {}
|
|
421
|
+
: { decidedBy: actor, decidedAt: new Date(), comment: text === '' ? null : text }),
|
|
185
422
|
});
|
|
186
|
-
|
|
187
|
-
if (state === 'rejected') {
|
|
423
|
+
if (state === 'rejected' || state === 'sent_back') {
|
|
188
424
|
const open = round
|
|
189
425
|
.filter((r) => r.id !== request.id && r.state === 'pending')
|
|
190
426
|
.map((r) => r.id);
|
|
191
427
|
if (open.length > 0)
|
|
192
428
|
await ref(su, 'approval.request').browse(open).write({ state: 'cancelled' });
|
|
193
429
|
}
|
|
194
|
-
const done = state === 'rejected' ||
|
|
430
|
+
const done = state === 'rejected' ||
|
|
431
|
+
state === 'sent_back' ||
|
|
432
|
+
(state === 'approved' && round.every((r) => r.id === request.id || r.state === 'approved'));
|
|
195
433
|
await audit(su, {
|
|
196
|
-
kind: `approval.${state}`,
|
|
197
|
-
summary: `${request.levelName} ${
|
|
434
|
+
kind: `approval.${state === 'pending' ? 'voted' : state}`,
|
|
435
|
+
summary: `${request.levelName} ${decision === 'send_back' ? 'sent back' : `${decision}d`} for ${target.model} ${target.id}.`,
|
|
198
436
|
actor,
|
|
199
437
|
company: request.company,
|
|
200
438
|
targetModel: target.model,
|
|
201
439
|
targetId: target.id,
|
|
202
|
-
details: {
|
|
440
|
+
details: {
|
|
441
|
+
request: request.id,
|
|
442
|
+
comment: text === '' ? null : text,
|
|
443
|
+
approvals,
|
|
444
|
+
required: request.required,
|
|
445
|
+
onBehalfOf,
|
|
446
|
+
ip: from?.ip ?? null,
|
|
447
|
+
},
|
|
203
448
|
});
|
|
204
449
|
await su.emit('approval.decided', { ...target, request: request.id, state, final: done });
|
|
205
|
-
if (done &&
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
450
|
+
if (done && policy !== undefined) {
|
|
451
|
+
const key = state === 'approved' ? 'onApproved' : state === 'rejected' ? 'onRejected' : 'onSentBack';
|
|
452
|
+
await runTransition(su, target, policy[key]);
|
|
453
|
+
}
|
|
454
|
+
const [after] = await loadRequests(su, { id: request.id }, ['id asc']);
|
|
455
|
+
return after;
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
/** Runs a policy's transition on the record (as the system), when it names one. */
|
|
459
|
+
async function runTransition(su, target, transition) {
|
|
460
|
+
if (typeof transition !== 'string' || transition === '')
|
|
461
|
+
return;
|
|
462
|
+
const record = ref(su, target.model).browse([target.id]);
|
|
463
|
+
const run = record[transition];
|
|
464
|
+
if (typeof run !== 'function')
|
|
465
|
+
refuse(409, 'invalid-transition', 'Invalid transition', `${target.model} has no transition '${transition}'.`);
|
|
466
|
+
await run.call(record);
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* The requester withdraws the pending requests of their record's current round; the policy's `onWithdrawn`
|
|
470
|
+
* transition runs. @throws {HttpProblem} 404 when nothing is pending, 403 when they did not ask
|
|
471
|
+
*/
|
|
472
|
+
export async function withdraw(env, model, id) {
|
|
473
|
+
await ref(env, model).browse([id]).load(['id']); // as the caller: ACL and rules decide
|
|
474
|
+
const actor = env.uid;
|
|
475
|
+
return env.asSuperuser({ reason: `withdraw approvals of ${model} ${id}` }, async (su) => {
|
|
476
|
+
const pending = (await currentRequests(su, model, id)).filter((r) => r.state === 'pending');
|
|
477
|
+
if (pending.length === 0)
|
|
478
|
+
refuse(404, 'missing-record', 'Nothing to withdraw', 'No approval is pending.');
|
|
479
|
+
if (!env.administrator && pending.some((r) => r.requestedBy !== actor))
|
|
480
|
+
refuse(403, 'access', 'Not yours', 'Only who asked for the approvals withdraws them.');
|
|
481
|
+
await ref(su, 'approval.request')
|
|
482
|
+
.browse(pending.map((r) => r.id))
|
|
483
|
+
.write({ state: 'cancelled' });
|
|
484
|
+
await audit(su, {
|
|
485
|
+
kind: 'approval.withdrawn',
|
|
486
|
+
summary: `Approvals withdrawn for ${model} ${id}.`,
|
|
487
|
+
actor,
|
|
488
|
+
company: pending[0]?.company ?? null,
|
|
489
|
+
targetModel: model,
|
|
490
|
+
targetId: id,
|
|
491
|
+
details: { requests: pending.map((r) => r.id) },
|
|
492
|
+
});
|
|
493
|
+
await su.emit('approval.decided', { model, id, state: 'withdrawn', final: true });
|
|
494
|
+
const policyIds = [
|
|
495
|
+
...new Set(pending.map((r) => r.policy).filter((p) => p !== null)),
|
|
496
|
+
];
|
|
497
|
+
for (const pid of policyIds) {
|
|
498
|
+
const [policy] = await find(su, 'approval.policy', { id: pid }, ['onWithdrawn']);
|
|
499
|
+
await runTransition(su, { model, id }, policy?.['onWithdrawn']);
|
|
500
|
+
}
|
|
501
|
+
return pending.length;
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
/** `purchase.order` as people say it: "Purchase order". */
|
|
505
|
+
const labelOf = (model) => {
|
|
506
|
+
const words = model.replace(/[._]/g, ' ');
|
|
507
|
+
return words.charAt(0).toUpperCase() + words.slice(1);
|
|
508
|
+
};
|
|
509
|
+
/** Cards of `requests` (the records' names, the requesters, each round's steps), as the system. */
|
|
510
|
+
async function cardsOf(su, requests, overrides = new Map()) {
|
|
511
|
+
if (requests.length === 0)
|
|
512
|
+
return [];
|
|
513
|
+
const names = new Map();
|
|
514
|
+
for (const model of new Set(requests.map((r) => r.targetModel))) {
|
|
515
|
+
const ids = [
|
|
516
|
+
...new Set(requests.filter((r) => r.targetModel === model).map((r) => r.targetId)),
|
|
517
|
+
];
|
|
518
|
+
try {
|
|
519
|
+
for (const row of (await ref(su, model).browse(ids).load(['displayName'])).read([
|
|
520
|
+
'displayName',
|
|
521
|
+
]))
|
|
522
|
+
names.set(`${model}:${row['id']}`, String(row['displayName'] ?? ''));
|
|
523
|
+
}
|
|
524
|
+
catch {
|
|
525
|
+
// a model no longer installed: its records are named by id
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
const users = new Map();
|
|
529
|
+
const askers = [
|
|
530
|
+
...new Set(requests.map((r) => r.requestedBy).filter((u) => u !== null)),
|
|
531
|
+
];
|
|
532
|
+
if (askers.length > 0)
|
|
533
|
+
for (const row of (await ref(su, 'res.users').browse(askers).load(['name'])).read(['name']))
|
|
534
|
+
users.set(row['id'], String(row['name'] ?? ''));
|
|
535
|
+
const created = new Map();
|
|
536
|
+
for (const row of await find(su, 'approval.request', { id: { in: requests.map((r) => r.id) } }, [
|
|
537
|
+
'createdAt',
|
|
538
|
+
]))
|
|
539
|
+
created.set(row['id'], row['createdAt'] instanceof Date ? row['createdAt'].toISOString() : String(row['createdAt']));
|
|
540
|
+
const rounds = new Map();
|
|
541
|
+
const out = [];
|
|
542
|
+
for (const r of requests) {
|
|
543
|
+
const key = `${r.targetModel}:${r.targetId}:${r.round}`;
|
|
544
|
+
let steps = rounds.get(key);
|
|
545
|
+
if (steps === undefined) {
|
|
546
|
+
steps = (await loadRequests(su, { targetModel: r.targetModel, targetId: r.targetId, round: r.round }, ['sequence asc', 'id asc'])).filter((x) => x.state !== 'cancelled' || x.id === r.id);
|
|
547
|
+
rounds.set(key, steps);
|
|
548
|
+
}
|
|
549
|
+
const o = overrides.get(r.id) ?? {};
|
|
550
|
+
out.push({
|
|
551
|
+
id: r.id,
|
|
552
|
+
levelName: r.levelName,
|
|
553
|
+
state: o.state ?? r.state,
|
|
554
|
+
round: r.round,
|
|
555
|
+
approvals: r.approvals,
|
|
556
|
+
required: r.required,
|
|
557
|
+
waitingSince: created.get(r.id) ?? new Date().toISOString(),
|
|
558
|
+
decidedAt: o.decidedAt ?? r.decidedAt,
|
|
559
|
+
dueAt: r.dueAt,
|
|
560
|
+
escalatedTo: r.escalatedTo,
|
|
561
|
+
comment: o.comment === undefined ? r.comment : o.comment,
|
|
562
|
+
record: {
|
|
563
|
+
model: r.targetModel,
|
|
564
|
+
id: r.targetId,
|
|
565
|
+
name: names.get(`${r.targetModel}:${r.targetId}`) || `#${r.targetId}`,
|
|
566
|
+
label: labelOf(r.targetModel),
|
|
567
|
+
},
|
|
568
|
+
requester: r.requestedBy === null ? null : { id: r.requestedBy, name: users.get(r.requestedBy) ?? '' },
|
|
569
|
+
steps: steps.map((x) => ({
|
|
570
|
+
id: x.id,
|
|
571
|
+
levelName: x.levelName,
|
|
572
|
+
sequence: x.sequence,
|
|
573
|
+
state: x.state,
|
|
574
|
+
approvals: x.approvals,
|
|
575
|
+
required: x.required,
|
|
576
|
+
})),
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
return out;
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* The Approvals app's lists (RFC 0025 §3.1): `inbox` — what waits for the user's decision, oldest first —, `mine`
|
|
583
|
+
* — the records they asked approvals for, the latest round of each, where it stands —, `history` — what they decided,
|
|
584
|
+
* newest first.
|
|
585
|
+
*/
|
|
586
|
+
export async function approvalCards(env, list) {
|
|
587
|
+
if (list === 'inbox') {
|
|
588
|
+
const waiting = await inbox(env);
|
|
589
|
+
return env.asSuperuser({ reason: 'approval cards' }, (su) => cardsOf(su, waiting));
|
|
590
|
+
}
|
|
591
|
+
const me = env.uid;
|
|
592
|
+
return env.asSuperuser({ reason: 'approval cards' }, async (su) => {
|
|
593
|
+
if (list === 'mine') {
|
|
594
|
+
const asked = await loadRequests(su, { requestedBy: me }, [
|
|
595
|
+
'round desc',
|
|
596
|
+
'sequence asc',
|
|
597
|
+
'id asc',
|
|
209
598
|
]);
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
599
|
+
// One card per record: the latest round, its first open (or deciding) level, the round's state.
|
|
600
|
+
const byRecord = new Map();
|
|
601
|
+
for (const r of asked) {
|
|
602
|
+
const key = `${r.targetModel}:${r.targetId}`;
|
|
603
|
+
const kept = byRecord.get(key);
|
|
604
|
+
if (kept === undefined || kept[0]?.round === r.round)
|
|
605
|
+
byRecord.set(key, [...(kept ?? []), r]);
|
|
606
|
+
}
|
|
607
|
+
const chosen = [];
|
|
608
|
+
const overrides = new Map();
|
|
609
|
+
for (const round of byRecord.values()) {
|
|
610
|
+
const live = round.filter((r) => r.state !== 'cancelled');
|
|
611
|
+
const decisive = live.find((r) => r.state === 'rejected' || r.state === 'sent_back') ??
|
|
612
|
+
live.find((r) => r.state === 'pending') ??
|
|
613
|
+
live.at(-1) ??
|
|
614
|
+
round[0];
|
|
615
|
+
if (decisive === undefined)
|
|
616
|
+
continue;
|
|
617
|
+
const state = live.length === 0
|
|
618
|
+
? 'cancelled'
|
|
619
|
+
: live.some((r) => r.state === 'rejected')
|
|
620
|
+
? 'rejected'
|
|
621
|
+
: live.some((r) => r.state === 'sent_back')
|
|
622
|
+
? 'sent_back'
|
|
623
|
+
: live.every((r) => r.state === 'approved')
|
|
624
|
+
? 'approved'
|
|
625
|
+
: 'pending';
|
|
626
|
+
chosen.push(decisive);
|
|
627
|
+
overrides.set(decisive.id, { state });
|
|
217
628
|
}
|
|
629
|
+
return (await cardsOf(su, chosen, overrides)).sort((a, b) => b.waitingSince.localeCompare(a.waitingSince));
|
|
218
630
|
}
|
|
219
|
-
const
|
|
220
|
-
|
|
631
|
+
const votes = await find(su, 'approval.vote', { user: me }, ['request', 'decision', 'comment', 'at'], ['at desc', 'id desc']);
|
|
632
|
+
const requests = await loadRequests(su, { id: { in: votes.map((v) => v['request']) } }, ['id asc']);
|
|
633
|
+
const byId = new Map(requests.map((r) => [r.id, r]));
|
|
634
|
+
const STATE = { approve: 'approved', reject: 'rejected', send_back: 'sent_back' };
|
|
635
|
+
const ordered = [];
|
|
636
|
+
const overrides = new Map();
|
|
637
|
+
for (const v of votes.slice(0, 200)) {
|
|
638
|
+
const r = byId.get(v['request']);
|
|
639
|
+
if (r === undefined)
|
|
640
|
+
continue;
|
|
641
|
+
ordered.push(r);
|
|
642
|
+
overrides.set(r.id, {
|
|
643
|
+
state: STATE[v['decision']],
|
|
644
|
+
decidedAt: v['at'] instanceof Date ? v['at'].toISOString() : String(v['at']),
|
|
645
|
+
comment: v['comment'] ?? null,
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
return cardsOf(su, ordered, overrides);
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
/** Approves many requests (each checked as one decision); the ones refused say why. */
|
|
652
|
+
export async function approveMany(env, ids, comment) {
|
|
653
|
+
let approved = 0;
|
|
654
|
+
const failed = [];
|
|
655
|
+
for (const id of ids.slice(0, 100))
|
|
656
|
+
try {
|
|
657
|
+
await decide(env, id, 'approve', comment);
|
|
658
|
+
approved += 1;
|
|
659
|
+
}
|
|
660
|
+
catch (error) {
|
|
661
|
+
failed.push({ id, reason: error instanceof Error ? error.message : String(error) });
|
|
662
|
+
}
|
|
663
|
+
return { approved, failed };
|
|
664
|
+
}
|
|
665
|
+
/** The inbox's header: waiting, waiting over three days, decided this month, the median hours to decide them. */
|
|
666
|
+
export async function approvalStats(env) {
|
|
667
|
+
const waiting = await approvalCards(env, 'inbox');
|
|
668
|
+
const now = Date.now();
|
|
669
|
+
const overdue = waiting.filter((c) => now - Date.parse(c.waitingSince) >= 72 * 3_600_000).length;
|
|
670
|
+
const me = env.uid;
|
|
671
|
+
return env.asSuperuser({ reason: 'approval stats' }, async (su) => {
|
|
672
|
+
const start = new Date();
|
|
673
|
+
start.setDate(1);
|
|
674
|
+
start.setHours(0, 0, 0, 0);
|
|
675
|
+
const votes = await find(su, 'approval.vote', { user: me, at: { gte: start } }, [
|
|
676
|
+
'request',
|
|
677
|
+
'at',
|
|
678
|
+
]);
|
|
679
|
+
const asked = new Map();
|
|
680
|
+
if (votes.length > 0)
|
|
681
|
+
for (const row of await find(su, 'approval.request', { id: { in: votes.map((v) => v['request']) } }, ['createdAt']))
|
|
682
|
+
asked.set(row['id'], new Date(row['createdAt']).getTime());
|
|
683
|
+
const hours = votes
|
|
684
|
+
.map((v) => (new Date(v['at']).getTime() - (asked.get(v['request']) ?? 0)) /
|
|
685
|
+
3_600_000)
|
|
686
|
+
.sort((a, b) => a - b);
|
|
687
|
+
const mid = Math.floor(hours.length / 2);
|
|
688
|
+
const median = hours.length === 0
|
|
689
|
+
? null
|
|
690
|
+
: hours.length % 2 === 1
|
|
691
|
+
? (hours[mid] ?? 0)
|
|
692
|
+
: ((hours[mid - 1] ?? 0) + (hours[mid] ?? 0)) / 2;
|
|
693
|
+
return {
|
|
694
|
+
waiting: waiting.length,
|
|
695
|
+
overdue,
|
|
696
|
+
decidedThisMonth: votes.length,
|
|
697
|
+
medianHours: median === null ? null : Math.round(median * 10) / 10,
|
|
698
|
+
};
|
|
221
699
|
});
|
|
222
700
|
}
|
|
701
|
+
/** The people who may decide `request` now: its approvers (or its role's holders), the escalation role's once late,
|
|
702
|
+
* and whoever they delegated to today; never the requester. */
|
|
703
|
+
export async function decidersOf(su, request, today) {
|
|
704
|
+
const people = new Set(request.approvers);
|
|
705
|
+
if (request.approvers.length === 0 && request.approverRole !== '')
|
|
706
|
+
for (const u of await holders(su, request.approverRole, request.company))
|
|
707
|
+
people.add(u);
|
|
708
|
+
if (request.escalatedTo !== null)
|
|
709
|
+
for (const u of await holders(su, request.escalatedTo, request.company))
|
|
710
|
+
people.add(u);
|
|
711
|
+
if (people.size > 0)
|
|
712
|
+
for (const d of await find(su, 'approval.delegation', { user: { in: [...people] }, dateFrom: { lte: today }, dateTo: { gte: today } }, ['delegate']))
|
|
713
|
+
people.add(d['delegate']);
|
|
714
|
+
if (request.requestedBy !== null)
|
|
715
|
+
people.delete(request.requestedBy);
|
|
716
|
+
return [...people];
|
|
717
|
+
}
|
|
718
|
+
/** The pending requests of a record whose turn it is (every lower level approved). */
|
|
719
|
+
export async function turnOf(su, model, id) {
|
|
720
|
+
const round = await currentRequests(su, model, id);
|
|
721
|
+
return round.filter((r) => r.state === 'pending' &&
|
|
722
|
+
!round.some((x) => x.sequence < r.sequence && x.state !== 'approved'));
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* Deadlines (RFC 0025 §3.3), as the system: each pending request with a deadline is reminded once at half of it and,
|
|
726
|
+
* once late, escalated to its level's role (who may then decide too), audited; returns what was done, with who to
|
|
727
|
+
* tell.
|
|
728
|
+
*/
|
|
729
|
+
export async function sweepDeadlines(su, now = new Date()) {
|
|
730
|
+
const out = [];
|
|
731
|
+
const rows = await find(su, 'approval.request', { state: 'pending', dueAt: { isNull: false } }, [
|
|
732
|
+
'dueAt',
|
|
733
|
+
'remindedAt',
|
|
734
|
+
'escalatedTo',
|
|
735
|
+
'createdAt',
|
|
736
|
+
'level',
|
|
737
|
+
]);
|
|
738
|
+
for (const row of rows) {
|
|
739
|
+
const due = new Date(row['dueAt']).getTime();
|
|
740
|
+
const start = new Date(row['createdAt']).getTime();
|
|
741
|
+
const [request] = await loadRequests(su, { id: row['id'] }, ['id asc']);
|
|
742
|
+
if (request === undefined)
|
|
743
|
+
continue;
|
|
744
|
+
if (now.getTime() >= due && (row['escalatedTo'] ?? null) === null) {
|
|
745
|
+
const [level] = typeof row['level'] === 'number'
|
|
746
|
+
? await find(su, 'approval.level', { id: row['level'] }, ['escalateRole'])
|
|
747
|
+
: [];
|
|
748
|
+
const role = typeof level?.['escalateRole'] === 'string' ? level['escalateRole'] : '';
|
|
749
|
+
if (role !== '') {
|
|
750
|
+
await ref(su, 'approval.request')
|
|
751
|
+
.browse([request.id])
|
|
752
|
+
.write({
|
|
753
|
+
escalatedTo: role,
|
|
754
|
+
escalatedAt: now,
|
|
755
|
+
remindedAt: row['remindedAt'] == null ? now : new Date(row['remindedAt']),
|
|
756
|
+
});
|
|
757
|
+
await audit(su, {
|
|
758
|
+
kind: 'approval.escalated',
|
|
759
|
+
summary: `${request.levelName} escalated to ${role} for ${request.targetModel} ${request.targetId}.`,
|
|
760
|
+
actor: null,
|
|
761
|
+
company: request.company,
|
|
762
|
+
targetModel: request.targetModel,
|
|
763
|
+
targetId: request.targetId,
|
|
764
|
+
details: { request: request.id, role },
|
|
765
|
+
});
|
|
766
|
+
await su.emit('approval.escalated', { request: request.id, role });
|
|
767
|
+
out.push({ kind: 'escalated', request: { ...request, escalatedTo: role } });
|
|
768
|
+
continue;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
if ((row['remindedAt'] ?? null) === null && now.getTime() >= start + (due - start) / 2) {
|
|
772
|
+
await ref(su, 'approval.request').browse([request.id]).write({ remindedAt: now });
|
|
773
|
+
out.push({ kind: 'reminded', request });
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
return out;
|
|
777
|
+
}
|
|
778
|
+
/** One-time links deciding `request` from an email, one per person who may decide it now (7 days). */
|
|
779
|
+
export async function issueLinks(su, request, today, newToken, digestOf) {
|
|
780
|
+
const out = [];
|
|
781
|
+
const expiresAt = new Date(Date.now() + 7 * 86_400_000);
|
|
782
|
+
for (const user of await decidersOf(su, request, today)) {
|
|
783
|
+
const token = newToken();
|
|
784
|
+
await ref(su, 'approval.link').create([
|
|
785
|
+
{ request: request.id, user, tokenHash: digestOf(token), expiresAt },
|
|
786
|
+
]);
|
|
787
|
+
out.push({ user, token });
|
|
788
|
+
}
|
|
789
|
+
return out;
|
|
790
|
+
}
|
|
791
|
+
/** The link of a token, while it is unused and not expired: who decides, which request. */
|
|
792
|
+
export async function linkOf(su, tokenHash) {
|
|
793
|
+
const [link] = await find(su, 'approval.link', { tokenHash, usedAt: { isNull: true } }, [
|
|
794
|
+
'request',
|
|
795
|
+
'user',
|
|
796
|
+
'expiresAt',
|
|
797
|
+
]);
|
|
798
|
+
if (link === undefined || new Date(link['expiresAt']).getTime() < Date.now())
|
|
799
|
+
return null;
|
|
800
|
+
const [request] = await loadRequests(su, { id: link['request'] }, ['id asc']);
|
|
801
|
+
return request === undefined
|
|
802
|
+
? null
|
|
803
|
+
: { id: link['id'], user: link['user'], request };
|
|
804
|
+
}
|
|
805
|
+
/** Marks a link used (as the system). */
|
|
806
|
+
export async function useLink(su, id) {
|
|
807
|
+
await ref(su, 'approval.link').browse([id]).write({ usedAt: new Date() });
|
|
808
|
+
}
|
|
809
|
+
/** A user's groups in a company (direct grants), to act as them from a link. */
|
|
810
|
+
export async function groupsOf(su, user, company) {
|
|
811
|
+
return (await find(su, 'auth.user_group', { user, ...(company === null ? {} : { or: [{ company }, { company: null }] }) }, ['group'])).map((r) => String(r['group']));
|
|
812
|
+
}
|
|
223
813
|
/** The pending requests `env`'s user may decide now, in the current company. */
|
|
224
814
|
export async function inbox(env) {
|
|
225
815
|
return env.asSuperuser({ reason: 'approval inbox' }, async (su) => {
|
|
226
|
-
const pending =
|
|
816
|
+
const pending = await loadRequests(su, { state: 'pending' }, ['id asc']);
|
|
817
|
+
const who = await whoIs(su, env);
|
|
818
|
+
const decided = await decidedOn(su, pending.map((r) => r.id));
|
|
227
819
|
const out = [];
|
|
228
820
|
for (const request of pending) {
|
|
229
|
-
if (
|
|
821
|
+
if (approverFor(who, request) === null)
|
|
230
822
|
continue;
|
|
231
823
|
const round = await currentRequests(su, request.targetModel, request.targetId);
|
|
232
|
-
if (mayDecide(
|
|
824
|
+
if (mayDecide(who, request, round, decided).reason === null)
|
|
233
825
|
out.push(request);
|
|
234
826
|
}
|
|
235
827
|
return out;
|
|
@@ -238,9 +830,7 @@ export async function inbox(env) {
|
|
|
238
830
|
/** The requests of a record, when `env` may read the record (record rules apply: 404 otherwise). */
|
|
239
831
|
export async function requestsOf(env, model, id) {
|
|
240
832
|
await ref(env, model).browse([id]).load(['id']); // as the caller: ACL and rules decide
|
|
241
|
-
return env.asSuperuser({ reason: 'approval history' },
|
|
242
|
-
'id asc',
|
|
243
|
-
])).map(info));
|
|
833
|
+
return env.asSuperuser({ reason: 'approval history' }, (su) => loadRequests(su, { targetModel: model, targetId: id }, ['round asc', 'sequence asc', 'id asc']));
|
|
244
834
|
}
|
|
245
835
|
/** Installs the default policies the modules declare (`approvals` in manifests) that do not exist yet, by name. */
|
|
246
836
|
export async function installApprovalPolicies(env, modules) {
|
|
@@ -258,6 +848,9 @@ export async function installApprovalPolicies(env, modules) {
|
|
|
258
848
|
targetModel: spec.model,
|
|
259
849
|
...(spec.onApproved === undefined ? {} : { onApproved: spec.onApproved }),
|
|
260
850
|
...(spec.onRejected === undefined ? {} : { onRejected: spec.onRejected }),
|
|
851
|
+
...(spec.onSentBack === undefined ? {} : { onSentBack: spec.onSentBack }),
|
|
852
|
+
...(spec.onWithdrawn === undefined ? {} : { onWithdrawn: spec.onWithdrawn }),
|
|
853
|
+
...(spec.reasonRequired === undefined ? {} : { reasonRequired: spec.reasonRequired }),
|
|
261
854
|
},
|
|
262
855
|
]);
|
|
263
856
|
await ref(env, 'approval.level').create(spec.levels.map((level, i) => ({
|
|
@@ -266,6 +859,18 @@ export async function installApprovalPolicies(env, modules) {
|
|
|
266
859
|
name: level.name,
|
|
267
860
|
condition: level.condition ?? null,
|
|
268
861
|
approverRole: level.approverRole,
|
|
862
|
+
approverKind: level.approver === 'manager'
|
|
863
|
+
? 'manager'
|
|
864
|
+
: typeof level.approver === 'object'
|
|
865
|
+
? 'field'
|
|
866
|
+
: 'role',
|
|
867
|
+
...(typeof level.approver === 'object' ? { approverField: level.approver.field } : {}),
|
|
868
|
+
quorum: level.quorum === undefined || level.quorum === 'any'
|
|
869
|
+
? 'any'
|
|
870
|
+
: level.quorum === 'all'
|
|
871
|
+
? 'all'
|
|
872
|
+
: 'count',
|
|
873
|
+
...(typeof level.quorum === 'number' ? { quorumCount: level.quorum } : {}),
|
|
269
874
|
})));
|
|
270
875
|
created += 1;
|
|
271
876
|
}
|