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
package/dist/project/module.js
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The data layer of Projects (RFC 0008): projects with their
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* The data layer of Projects (RFC 0008, RFC 0026): programmes and projects with their case — code, type, sponsor,
|
|
3
|
+
* owner, priority, risk, scope, assumptions, constraints —, phases (nested) and work packages between a project and
|
|
4
|
+
* its tasks, tasks on a kanban of shared stages with sub-tasks, several assignees, tags, a category, milestones and
|
|
5
|
+
* finish-to-start dependencies, and timesheets whose hours add up on the task. Progress is computed — a task's from
|
|
6
|
+
* its sub-tasks, a work package's, a phase's and a project's from their tasks weighted by planned hours — and may be
|
|
7
|
+
* set by hand on phases and work packages. Users see every project and task of their companies and their own
|
|
8
|
+
* timesheets; managers see everything.
|
|
6
9
|
*/
|
|
7
10
|
import { contactsShutro } from '../contacts/index.js';
|
|
8
|
-
import { messagingShutro } from '../messaging/index.js';
|
|
11
|
+
import { messagingShutro, postMessage, setFollower } from '../messaging/index.js';
|
|
12
|
+
import { approvalGuard, permissionsShutro, requestApprovals } from '../permissions/index.js';
|
|
9
13
|
import { orm } from '../server/orm.js';
|
|
10
|
-
import { check, compute, defineModel, defineModule, extendModel, f, record, } from './.kosh/bindings.js';
|
|
14
|
+
import { check, compute, defineModel, defineModule, extendModel, f, method, onchange, record, records, } from './.kosh/bindings.js';
|
|
11
15
|
const READ = ['read'];
|
|
12
16
|
const WRITE = ['read', 'write', 'create', 'unlink'];
|
|
13
17
|
const ALL = ['read', 'write', 'create', 'unlink', 'export', 'import'];
|
|
@@ -17,8 +21,44 @@ export const CompanyProject = extendModel('res.company', {
|
|
|
17
21
|
fields: {
|
|
18
22
|
projectPlannedHoursDefault: f.decimal({ scale: 2, label: 'Planned hours of a new task' }),
|
|
19
23
|
projectTimesheetStep: f.selection({ none: 'Any', quarter: 'Quarter hours', half: 'Half hours', whole: 'Whole hours' }, { default: 'none', label: 'Timesheets in steps of' }),
|
|
24
|
+
projectRequirePlan: f.boolean({
|
|
25
|
+
default: false,
|
|
26
|
+
label: 'Require an approved plan',
|
|
27
|
+
help: 'A new project (not inside a programme) starts from an approved plan.',
|
|
28
|
+
}),
|
|
29
|
+
projectHealthBehindRisk: f.integer({
|
|
30
|
+
default: 10,
|
|
31
|
+
label: 'At risk when behind by (points)',
|
|
32
|
+
help: 'Progress this many points behind the share of time elapsed puts a project at risk.',
|
|
33
|
+
}),
|
|
34
|
+
projectHealthBehindOff: f.integer({
|
|
35
|
+
default: 25,
|
|
36
|
+
label: 'Off track when behind by (points)',
|
|
37
|
+
}),
|
|
38
|
+
projectHealthCostRisk: f.integer({
|
|
39
|
+
default: 90,
|
|
40
|
+
label: 'At risk when the budget is used to (%)',
|
|
41
|
+
help: 'Actual and committed costs at this share of the budget put a project at risk; beyond the budget, off track.',
|
|
42
|
+
}),
|
|
20
43
|
},
|
|
21
44
|
});
|
|
45
|
+
/** Whether the company asks for an approved plan before a project (RFC 0026 step 2). */
|
|
46
|
+
async function planRequired(env, company) {
|
|
47
|
+
const read = async (e) => (await e
|
|
48
|
+
.model('res.company')
|
|
49
|
+
.browse([company])
|
|
50
|
+
.load(['projectRequirePlan'])).read(['projectRequirePlan']);
|
|
51
|
+
let rows;
|
|
52
|
+
try {
|
|
53
|
+
rows = await read(env);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
if (!(error instanceof Error && error.name === 'AccessError'))
|
|
57
|
+
throw error;
|
|
58
|
+
rows = await env.withGrants({ grants: ['res.company:read'], reason: 'project settings' }, read);
|
|
59
|
+
}
|
|
60
|
+
return rows[0]?.['projectRequirePlan'] === true;
|
|
61
|
+
}
|
|
22
62
|
/** The timesheet steps as hours. */
|
|
23
63
|
const STEPS = {
|
|
24
64
|
quarter: orm.toDecimal('0.25'),
|
|
@@ -27,10 +67,21 @@ const STEPS = {
|
|
|
27
67
|
};
|
|
28
68
|
/** The current company's project settings. */
|
|
29
69
|
async function projectSettings(env) {
|
|
30
|
-
|
|
70
|
+
// Read as the writer; a system or portal scope without the right to companies reads it with the narrow grant.
|
|
71
|
+
const read = async (e) => (await e
|
|
31
72
|
.model('res.company')
|
|
32
|
-
.browse([
|
|
73
|
+
.browse([e.companyId])
|
|
33
74
|
.load(['projectPlannedHoursDefault', 'projectTimesheetStep'])).read(['projectPlannedHoursDefault', 'projectTimesheetStep']);
|
|
75
|
+
let rows;
|
|
76
|
+
try {
|
|
77
|
+
rows = await read(env);
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
if (!(error instanceof Error && error.name === 'AccessError'))
|
|
81
|
+
throw error;
|
|
82
|
+
rows = await env.withGrants({ grants: ['res.company:read'], reason: 'project settings' }, read);
|
|
83
|
+
}
|
|
84
|
+
const [company] = rows;
|
|
34
85
|
const planned = company?.['projectPlannedHoursDefault'];
|
|
35
86
|
return {
|
|
36
87
|
plannedHours: planned === null || planned === undefined ? null : String(planned),
|
|
@@ -39,8 +90,225 @@ async function projectSettings(env) {
|
|
|
39
90
|
: 'none',
|
|
40
91
|
};
|
|
41
92
|
}
|
|
93
|
+
const PRIORITIES = { low: 'Low', normal: 'Normal', high: 'High', critical: 'Critical' };
|
|
94
|
+
const STATUSES = {
|
|
95
|
+
draft: 'Draft',
|
|
96
|
+
in_progress: 'In progress',
|
|
97
|
+
on_hold: 'On hold',
|
|
98
|
+
done: 'Done',
|
|
99
|
+
cancelled: 'Cancelled',
|
|
100
|
+
};
|
|
101
|
+
const zero = orm.toDecimal(0);
|
|
102
|
+
const hundred = orm.toDecimal(100);
|
|
103
|
+
/** The progress of tasks, weighted by their planned hours (a task without any weighs one hour). */
|
|
104
|
+
function weighted(tasks) {
|
|
105
|
+
let total = zero;
|
|
106
|
+
let weight = zero;
|
|
107
|
+
for (const t of tasks) {
|
|
108
|
+
if (t.active === false)
|
|
109
|
+
continue;
|
|
110
|
+
const hours = orm.toDecimal(String(t.plannedHours ?? 0));
|
|
111
|
+
const w = hours.gt(0) ? hours : orm.toDecimal(1);
|
|
112
|
+
total = total.plus(w.times(orm.toDecimal(String(t.progress ?? 0))));
|
|
113
|
+
weight = weight.plus(w);
|
|
114
|
+
}
|
|
115
|
+
return weight.isZero() ? zero : total.div(weight).toDecimalPlaces(1);
|
|
116
|
+
}
|
|
117
|
+
/** What kind of project it is (a farm build, a software rollout): configuration of the company, not code. */
|
|
118
|
+
export const ProjectType = defineModel('project.type', {
|
|
119
|
+
label: 'Project type',
|
|
120
|
+
recName: 'name',
|
|
121
|
+
order: ['sequence asc', 'name asc'],
|
|
122
|
+
archivable: true,
|
|
123
|
+
companyScoped: { required: false },
|
|
124
|
+
access: { 'project.user': READ, 'project.manager': ALL },
|
|
125
|
+
fields: {
|
|
126
|
+
name: f.char({ required: true, translate: true }),
|
|
127
|
+
code: f.char({ required: true }),
|
|
128
|
+
sequence: f.integer({ required: true, default: 10 }),
|
|
129
|
+
description: f.text({ translate: true }),
|
|
130
|
+
},
|
|
131
|
+
sqlConstraints: {
|
|
132
|
+
oneCode: { unique: ['company', 'code'], message: 'Another type has this code.' },
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
/** A kind of task (Civil construction, Development), for one type of project or every one. */
|
|
136
|
+
export const ProjectTaskCategory = defineModel('project.task.category', {
|
|
137
|
+
label: 'Task category',
|
|
138
|
+
recName: 'name',
|
|
139
|
+
order: ['sequence asc', 'name asc'],
|
|
140
|
+
archivable: true,
|
|
141
|
+
companyScoped: { required: false },
|
|
142
|
+
access: { 'project.user': READ, 'project.manager': ALL },
|
|
143
|
+
fields: {
|
|
144
|
+
name: f.char({ required: true, translate: true }),
|
|
145
|
+
code: f.char({ required: true }),
|
|
146
|
+
sequence: f.integer({ required: true, default: 10 }),
|
|
147
|
+
type: f.many2one('project.type', { ondelete: 'set null', label: 'Project type' }),
|
|
148
|
+
},
|
|
149
|
+
sqlConstraints: {
|
|
150
|
+
oneCode: { unique: ['company', 'code'], message: 'Another task category has this code.' },
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
export const ProjectTag = defineModel('project.tag', {
|
|
154
|
+
label: 'Task tag',
|
|
155
|
+
recName: 'name',
|
|
156
|
+
order: ['name asc'],
|
|
157
|
+
archivable: true,
|
|
158
|
+
access: { 'project.user': ['read', 'create'], 'project.manager': ALL },
|
|
159
|
+
fields: {
|
|
160
|
+
name: f.char({ required: true, translate: true }),
|
|
161
|
+
color: f.char({ label: 'Colour' }),
|
|
162
|
+
},
|
|
163
|
+
sqlConstraints: { oneName: { unique: ['name'], message: 'Another tag has this name.' } },
|
|
164
|
+
});
|
|
165
|
+
/** Hourly costs: seen and set by Projects managers only (RFC 0026 step 3). */
|
|
166
|
+
const MANAGERS_ONLY = { read: ['project.manager'], write: ['project.manager'] };
|
|
167
|
+
/** Computed costs: seen by Projects managers only (computes read and write them for anyone). */
|
|
168
|
+
const MANAGERS_SEE = { read: ['project.manager'] };
|
|
169
|
+
/** A role people play in projects, with the hourly cost a member in it has unless given their own. */
|
|
170
|
+
export const ProjectRole = defineModel('project.role', {
|
|
171
|
+
label: 'Project role',
|
|
172
|
+
recName: 'name',
|
|
173
|
+
order: ['sequence asc', 'name asc'],
|
|
174
|
+
archivable: true,
|
|
175
|
+
companyScoped: { required: true },
|
|
176
|
+
access: { 'project.user': READ, 'project.manager': ALL },
|
|
177
|
+
fields: {
|
|
178
|
+
name: f.char({ required: true, translate: true }),
|
|
179
|
+
code: f.char({ size: 16 }),
|
|
180
|
+
sequence: f.integer({ required: true, default: 10 }),
|
|
181
|
+
defaultHourlyCost: f.decimal({
|
|
182
|
+
scale: 2,
|
|
183
|
+
label: 'Default hourly cost',
|
|
184
|
+
groups: MANAGERS_ONLY,
|
|
185
|
+
help: 'The cost of an hour of a member in this role who has no cost of their own.',
|
|
186
|
+
}),
|
|
187
|
+
},
|
|
188
|
+
sqlConstraints: {
|
|
189
|
+
oneCode: { unique: ['company', 'code'], message: 'Another role has this code.' },
|
|
190
|
+
costPositive: {
|
|
191
|
+
check: orm.sql `default_hourly_cost IS NULL OR default_hourly_cost >= 0`,
|
|
192
|
+
message: 'A cost is not negative.',
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
/** The highest total allocation of `spans` on any day of `[from, to]` (open ends: unbounded). */
|
|
197
|
+
export function peakAllocation(spans, from, to) {
|
|
198
|
+
const overlaps = (x) => (x.to === null || from === null || x.to >= from) &&
|
|
199
|
+
(x.from === null || to === null || x.from <= to);
|
|
200
|
+
const inside = spans.filter(overlaps);
|
|
201
|
+
// The total changes only where a span starts: try the range's start and every start inside it.
|
|
202
|
+
const points = new Set([from]);
|
|
203
|
+
for (const x of inside)
|
|
204
|
+
if (x.from !== null && (from === null || x.from > from))
|
|
205
|
+
points.add(x.from);
|
|
206
|
+
let peak = 0;
|
|
207
|
+
for (const day of points) {
|
|
208
|
+
let total = 0;
|
|
209
|
+
for (const x of inside)
|
|
210
|
+
if ((x.from === null || day === null || x.from <= day) &&
|
|
211
|
+
(x.to === null || day === null || x.to >= day))
|
|
212
|
+
total += x.allocation;
|
|
213
|
+
peak = Math.max(peak, total);
|
|
214
|
+
}
|
|
215
|
+
return peak;
|
|
216
|
+
}
|
|
217
|
+
/** Someone in a project's team: their role, how much of their time it takes, their hourly cost and when. */
|
|
218
|
+
export const ProjectMember = defineModel('project.member', {
|
|
219
|
+
label: 'Project member',
|
|
220
|
+
order: ['project asc', 'id asc'],
|
|
221
|
+
companyScoped: { required: true },
|
|
222
|
+
tracking: ['allocation', 'role', 'dateFrom', 'dateTo'],
|
|
223
|
+
access: { 'project.user': READ, 'project.manager': ALL },
|
|
224
|
+
rules: {
|
|
225
|
+
all: {
|
|
226
|
+
groups: ['project.user', 'project.manager'],
|
|
227
|
+
perms: ['read', 'write', 'create', 'unlink'],
|
|
228
|
+
domain: {},
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
fields: {
|
|
232
|
+
project: f.many2one('project.project', {
|
|
233
|
+
required: true,
|
|
234
|
+
checkCompany: true,
|
|
235
|
+
ondelete: 'cascade',
|
|
236
|
+
}),
|
|
237
|
+
user: f.many2one('res.users', { required: true, ondelete: 'restrict', label: 'Person' }),
|
|
238
|
+
role: f.many2one('project.role', { ondelete: 'set null', checkCompany: true }),
|
|
239
|
+
allocation: f.integer({
|
|
240
|
+
required: true,
|
|
241
|
+
default: 100,
|
|
242
|
+
label: 'Allocation %',
|
|
243
|
+
help: 'The share of their working time this project takes while they are in it.',
|
|
244
|
+
}),
|
|
245
|
+
hourlyCost: f.decimal({
|
|
246
|
+
scale: 2,
|
|
247
|
+
label: 'Hourly cost',
|
|
248
|
+
groups: MANAGERS_ONLY,
|
|
249
|
+
help: 'Their timesheets on this project cost this an hour; empty: their role’s default cost.',
|
|
250
|
+
}),
|
|
251
|
+
dateFrom: f.date({ label: 'From' }),
|
|
252
|
+
dateTo: f.date({ label: 'Until' }),
|
|
253
|
+
notes: f.text(),
|
|
254
|
+
/** Their projects take more than all their time on some day of this membership. */
|
|
255
|
+
overallocated: f.boolean({
|
|
256
|
+
label: 'Over-allocated',
|
|
257
|
+
...compute('project.member', 'overallocated', ['user', 'allocation', 'dateFrom', 'dateTo'], async (members) => {
|
|
258
|
+
const env = members.env;
|
|
259
|
+
const users = [
|
|
260
|
+
...new Set([...members].flatMap((m) => (m.user === null ? [] : [m.user.id]))),
|
|
261
|
+
];
|
|
262
|
+
const all = users.length === 0
|
|
263
|
+
? []
|
|
264
|
+
: // As the reader: every Projects user sees every team (a GET cannot audit a superuser scope).
|
|
265
|
+
(await env
|
|
266
|
+
.model('project.member')
|
|
267
|
+
.search({
|
|
268
|
+
where: { user: { in: users }, 'project.active': true },
|
|
269
|
+
})
|
|
270
|
+
.then((r) => r.load(['user', 'allocation', 'dateFrom', 'dateTo']))).map((m) => ({
|
|
271
|
+
id: m.id,
|
|
272
|
+
user: m.user.id,
|
|
273
|
+
from: m.dateFrom === null ? null : String(m.dateFrom),
|
|
274
|
+
to: m.dateTo === null ? null : String(m.dateTo),
|
|
275
|
+
allocation: m.allocation,
|
|
276
|
+
}));
|
|
277
|
+
for (const m of members) {
|
|
278
|
+
if (m.user === null) {
|
|
279
|
+
m.overallocated = false;
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
const from = m.dateFrom === null ? null : String(m.dateFrom);
|
|
283
|
+
const to = m.dateTo === null ? null : String(m.dateTo);
|
|
284
|
+
// The others as saved, this one as it is now (on the form, before it is saved).
|
|
285
|
+
const spans = [
|
|
286
|
+
...all.filter((x) => x.user === m.user?.id && x.id !== m.id),
|
|
287
|
+
{ from, to, allocation: m.allocation ?? 0 },
|
|
288
|
+
];
|
|
289
|
+
m.overallocated = peakAllocation(spans, from, to) > 100;
|
|
290
|
+
}
|
|
291
|
+
}),
|
|
292
|
+
}),
|
|
293
|
+
},
|
|
294
|
+
sqlConstraints: {
|
|
295
|
+
allocationRange: {
|
|
296
|
+
check: orm.sql `allocation > 0 AND allocation <= 100`,
|
|
297
|
+
message: 'An allocation is from 1 to 100 %.',
|
|
298
|
+
},
|
|
299
|
+
costPositive: {
|
|
300
|
+
check: orm.sql `hourly_cost IS NULL OR hourly_cost >= 0`,
|
|
301
|
+
message: 'A cost is not negative.',
|
|
302
|
+
},
|
|
303
|
+
dates: {
|
|
304
|
+
check: orm.sql `date_from IS NULL OR date_to IS NULL OR date_to >= date_from`,
|
|
305
|
+
message: 'A membership ends on or after it starts.',
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
});
|
|
42
309
|
export const ProjectTaskStage = defineModel('project.task.stage', {
|
|
43
310
|
label: 'Task stage',
|
|
311
|
+
archivable: true,
|
|
44
312
|
order: ['sequence asc', 'id asc'],
|
|
45
313
|
access: { 'project.user': READ, 'project.manager': ALL },
|
|
46
314
|
fields: {
|
|
@@ -52,13 +320,336 @@ export const ProjectTaskStage = defineModel('project.task.stage', {
|
|
|
52
320
|
done: f.boolean({ default: false, label: 'Closes the task' }),
|
|
53
321
|
},
|
|
54
322
|
});
|
|
323
|
+
const PLAN_STATES = {
|
|
324
|
+
draft: 'Draft',
|
|
325
|
+
submitted: 'Waiting for approval',
|
|
326
|
+
approved: 'Approved',
|
|
327
|
+
rejected: 'Rejected',
|
|
328
|
+
in_progress: 'In progress',
|
|
329
|
+
completed: 'Completed',
|
|
330
|
+
cancelled: 'Cancelled',
|
|
331
|
+
};
|
|
332
|
+
const me = { $env: 'uid' };
|
|
333
|
+
/** Moved by the plan (as the system) or by Projects managers only. */
|
|
334
|
+
const MANAGERS_WRITE = { write: ['project.manager'] };
|
|
335
|
+
/**
|
|
336
|
+
* A project plan (RFC 0026 step 2): the case for a project before it exists — objectives, expected outcome, the
|
|
337
|
+
* strategic goal, dates and an estimated budget —, submitted and decided through the approval engine (RFC 0025);
|
|
338
|
+
* once approved, a project starts from it with the plan's dates as its baseline.
|
|
339
|
+
*/
|
|
340
|
+
export const ProjectPlan = defineModel('project.plan', {
|
|
341
|
+
label: 'Project plan',
|
|
342
|
+
recName: 'title',
|
|
343
|
+
order: ['id desc'],
|
|
344
|
+
companyScoped: { required: true },
|
|
345
|
+
tracking: [
|
|
346
|
+
'state',
|
|
347
|
+
'estimatedBudget',
|
|
348
|
+
'approvedBudget',
|
|
349
|
+
'manager',
|
|
350
|
+
'sponsor',
|
|
351
|
+
'dateStart',
|
|
352
|
+
'dateEnd',
|
|
353
|
+
],
|
|
354
|
+
access: { 'project.user': ['read', 'write', 'create', 'unlink'], 'project.manager': ALL },
|
|
355
|
+
rules: {
|
|
356
|
+
// Seen by who wrote it, its manager and its sponsor; changed by them while a draft.
|
|
357
|
+
involved: {
|
|
358
|
+
groups: ['project.user'],
|
|
359
|
+
perms: ['read'],
|
|
360
|
+
domain: { or: [{ requester: me }, { manager: me }, { sponsor: me }] },
|
|
361
|
+
},
|
|
362
|
+
draftOwn: {
|
|
363
|
+
groups: ['project.user'],
|
|
364
|
+
perms: ['write', 'create', 'unlink'],
|
|
365
|
+
domain: {
|
|
366
|
+
or: [{ requester: me }, { manager: me }],
|
|
367
|
+
state: { in: ['draft', 'rejected'] },
|
|
368
|
+
},
|
|
369
|
+
},
|
|
370
|
+
// Its manager starts the project from it and completes it once approved (audited, tracked).
|
|
371
|
+
running: {
|
|
372
|
+
groups: ['project.user'],
|
|
373
|
+
perms: ['write'],
|
|
374
|
+
domain: { manager: me, state: { in: ['approved', 'in_progress'] } },
|
|
375
|
+
},
|
|
376
|
+
managers: {
|
|
377
|
+
groups: ['project.manager'],
|
|
378
|
+
perms: ['read', 'write', 'create', 'unlink'],
|
|
379
|
+
domain: {},
|
|
380
|
+
},
|
|
381
|
+
},
|
|
382
|
+
fields: {
|
|
383
|
+
/** Numbered when created: PLAN-0001. */
|
|
384
|
+
name: f.char({ readonly: true, sequence: 'project.plan', label: 'Number' }),
|
|
385
|
+
title: f.char({ required: true }),
|
|
386
|
+
type: f.many2one('project.type', { ondelete: 'set null', label: 'Project type' }),
|
|
387
|
+
requester: f.many2one('res.users', {
|
|
388
|
+
default: (env) => env.userId,
|
|
389
|
+
ondelete: 'restrict',
|
|
390
|
+
label: 'Written by',
|
|
391
|
+
}),
|
|
392
|
+
manager: f.many2one('res.users', {
|
|
393
|
+
default: (env) => env.userId,
|
|
394
|
+
ondelete: 'set null',
|
|
395
|
+
label: 'Project manager',
|
|
396
|
+
}),
|
|
397
|
+
sponsor: f.many2one('res.users', {
|
|
398
|
+
ondelete: 'set null',
|
|
399
|
+
label: 'Sponsor',
|
|
400
|
+
help: 'Approves the plan first; a Projects manager when there is none.',
|
|
401
|
+
}),
|
|
402
|
+
priority: f.selection(PRIORITIES, { required: true, default: 'normal' }),
|
|
403
|
+
dateStart: f.date({ label: 'Planned start' }),
|
|
404
|
+
dateEnd: f.date({ label: 'Planned end' }),
|
|
405
|
+
description: f.text(),
|
|
406
|
+
objectives: f.text(),
|
|
407
|
+
expectedOutcome: f.text({ label: 'Expected outcome' }),
|
|
408
|
+
strategicGoal: f.text({ label: 'Strategic goal' }),
|
|
409
|
+
estimatedBudget: f.decimal({
|
|
410
|
+
scale: 2,
|
|
411
|
+
label: 'Estimated budget',
|
|
412
|
+
help: 'Decides the approval levels: above 1 000 000 a Projects manager approves too.',
|
|
413
|
+
}),
|
|
414
|
+
approvedBudget: f.decimal({
|
|
415
|
+
scale: 2,
|
|
416
|
+
groups: MANAGERS_WRITE,
|
|
417
|
+
label: 'Approved budget',
|
|
418
|
+
help: 'The estimate unless changed before approving.',
|
|
419
|
+
}),
|
|
420
|
+
approvedAt: f.datetime({ label: 'Approved on', groups: MANAGERS_WRITE }),
|
|
421
|
+
projects: f.one2many('project.project', 'plan'),
|
|
422
|
+
projectCount: f.integer({
|
|
423
|
+
label: 'Projects',
|
|
424
|
+
...compute('project.plan', 'projectCount', ['projects'], (plans) => {
|
|
425
|
+
for (const p of plans)
|
|
426
|
+
p.projectCount = p.projects.length;
|
|
427
|
+
}),
|
|
428
|
+
}),
|
|
429
|
+
state: f.state({
|
|
430
|
+
states: PLAN_STATES,
|
|
431
|
+
initial: 'draft',
|
|
432
|
+
help: 'Draft, then submitted for approval; once approved, a project starts from it.',
|
|
433
|
+
transitions: {
|
|
434
|
+
submit: {
|
|
435
|
+
from: ['draft', 'rejected'],
|
|
436
|
+
to: 'submitted',
|
|
437
|
+
guard: records('project.plan', async (plans) => [...(await plans.load({}))]
|
|
438
|
+
.filter((p) => p.estimatedBudget === null ||
|
|
439
|
+
p.dateStart === null ||
|
|
440
|
+
p.dateEnd === null ||
|
|
441
|
+
(p.objectives ?? '').trim() === '')
|
|
442
|
+
.map((p) => p.id)),
|
|
443
|
+
message: 'A plan states its objectives, dates and estimated budget before it is submitted.',
|
|
444
|
+
after: records('project.plan', async (plans) => {
|
|
445
|
+
await requestApprovals(plans);
|
|
446
|
+
for (const p of await plans.load({}))
|
|
447
|
+
await plans.env.emit('project.plan.submitted', { id: p.id });
|
|
448
|
+
}),
|
|
449
|
+
},
|
|
450
|
+
approve: {
|
|
451
|
+
from: ['submitted'],
|
|
452
|
+
to: 'approved',
|
|
453
|
+
guard: records('project.plan', (plans) => approvalGuard(plans)),
|
|
454
|
+
message: 'Waiting for approvals.',
|
|
455
|
+
after: records('project.plan', async (plans) => {
|
|
456
|
+
for (const p of await plans.load({})) {
|
|
457
|
+
await plans.env
|
|
458
|
+
.model('project.plan')
|
|
459
|
+
.browse([p.id])
|
|
460
|
+
.write({
|
|
461
|
+
approvedAt: new Date(),
|
|
462
|
+
...(p.approvedBudget === null ? { approvedBudget: p.estimatedBudget } : {}),
|
|
463
|
+
});
|
|
464
|
+
await plans.env.emit('project.plan.approved', { id: p.id });
|
|
465
|
+
}
|
|
466
|
+
}),
|
|
467
|
+
},
|
|
468
|
+
reject: { from: ['submitted'], to: 'rejected' },
|
|
469
|
+
/** Sent back for changes or withdrawn: a draft again. */
|
|
470
|
+
redraft: { from: ['submitted', 'rejected', 'cancelled'], to: 'draft' },
|
|
471
|
+
/** A project starts from the approved plan, with the plan's dates as its baseline. */
|
|
472
|
+
start: {
|
|
473
|
+
from: ['approved'],
|
|
474
|
+
to: 'in_progress',
|
|
475
|
+
after: records('project.plan', async (plans) => {
|
|
476
|
+
for (const p of await plans.load({ projects: ['id'] })) {
|
|
477
|
+
if (p.projects.length > 0)
|
|
478
|
+
continue;
|
|
479
|
+
const created = await plans.env.model('project.project').create([
|
|
480
|
+
{
|
|
481
|
+
name: p.title,
|
|
482
|
+
plan: p.id,
|
|
483
|
+
type: p.type?.id ?? null,
|
|
484
|
+
manager: p.manager?.id ?? null,
|
|
485
|
+
sponsor: p.sponsor?.id ?? null,
|
|
486
|
+
priority: p.priority,
|
|
487
|
+
dateStart: p.dateStart,
|
|
488
|
+
dateEnd: p.dateEnd,
|
|
489
|
+
description: p.description,
|
|
490
|
+
company: p.company.id,
|
|
491
|
+
},
|
|
492
|
+
]);
|
|
493
|
+
// The baseline is the plan's, set by the system (managers alone move it by hand).
|
|
494
|
+
await plans.env.asSuperuser({ reason: 'project baseline from its plan' }, (su) => su.model('project.project').browse(created.ids).write({
|
|
495
|
+
baselineStart: p.dateStart,
|
|
496
|
+
baselineEnd: p.dateEnd,
|
|
497
|
+
baselineBudget: p.approvedBudget,
|
|
498
|
+
}));
|
|
499
|
+
}
|
|
500
|
+
}),
|
|
501
|
+
},
|
|
502
|
+
/** The baseline taken again from the projects', phases' and work packages' current dates. */
|
|
503
|
+
baseline: {
|
|
504
|
+
from: ['in_progress'],
|
|
505
|
+
to: 'in_progress',
|
|
506
|
+
requires: 'project.baseline',
|
|
507
|
+
after: records('project.plan', async (plans) => {
|
|
508
|
+
const env = plans.env;
|
|
509
|
+
for (const p of await plans.load({ projects: ['dateStart', 'dateEnd'] })) {
|
|
510
|
+
for (const project of p.projects) {
|
|
511
|
+
await env
|
|
512
|
+
.model('project.project')
|
|
513
|
+
.browse([project.id])
|
|
514
|
+
.write({ baselineStart: project.dateStart, baselineEnd: project.dateEnd });
|
|
515
|
+
for (const model of ['project.phase', 'project.work.package']) {
|
|
516
|
+
const rows = await env
|
|
517
|
+
.model(model)
|
|
518
|
+
.search({ where: { project: project.id } })
|
|
519
|
+
.then((r) => r.load(['dateStart', 'dateEnd']));
|
|
520
|
+
for (const row of rows)
|
|
521
|
+
await env
|
|
522
|
+
.model(model)
|
|
523
|
+
.browse([row.id])
|
|
524
|
+
.write({ baselineStart: row.dateStart, baselineEnd: row.dateEnd });
|
|
525
|
+
}
|
|
526
|
+
const tasks = await env
|
|
527
|
+
.model('project.task')
|
|
528
|
+
.search({ where: { project: project.id } })
|
|
529
|
+
.then((r) => r.load(['dateStart', 'deadline']));
|
|
530
|
+
for (const t of tasks)
|
|
531
|
+
await env
|
|
532
|
+
.model('project.task')
|
|
533
|
+
.browse([t.id])
|
|
534
|
+
.write({ baselineStart: t.dateStart, baselineEnd: t.deadline });
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}),
|
|
538
|
+
},
|
|
539
|
+
complete: { from: ['in_progress'], to: 'completed' },
|
|
540
|
+
cancel: { from: ['draft', 'submitted', 'approved', 'rejected'], to: 'cancelled' },
|
|
541
|
+
},
|
|
542
|
+
}),
|
|
543
|
+
},
|
|
544
|
+
sqlConstraints: {
|
|
545
|
+
budgetPositive: {
|
|
546
|
+
check: orm.sql `(estimated_budget IS NULL OR estimated_budget >= 0) AND (approved_budget IS NULL OR approved_budget >= 0)`,
|
|
547
|
+
message: 'Budgets are not negative.',
|
|
548
|
+
},
|
|
549
|
+
},
|
|
550
|
+
constraints: {
|
|
551
|
+
dates: {
|
|
552
|
+
...check('project.plan', ['dateStart', 'dateEnd'], async (plans) => plans
|
|
553
|
+
.filter((p) => p.dateStart !== null && p.dateEnd !== null && String(p.dateEnd) < String(p.dateStart))
|
|
554
|
+
.map((p) => p.id)),
|
|
555
|
+
message: 'A plan ends on or after it starts.',
|
|
556
|
+
},
|
|
557
|
+
},
|
|
558
|
+
});
|
|
559
|
+
export const HEALTH = { on_track: 'On track', at_risk: 'At risk', off_track: 'Off track' };
|
|
560
|
+
/** What a project's figures say, and why (RFC 0026 §2): the worst of schedule, milestones and cost. */
|
|
561
|
+
export function judgeHealth(p, rules) {
|
|
562
|
+
const reasons = [];
|
|
563
|
+
let level = 0;
|
|
564
|
+
const raise = (to, why) => {
|
|
565
|
+
level = Math.max(level, to);
|
|
566
|
+
reasons.push(why);
|
|
567
|
+
};
|
|
568
|
+
if (p.dateEnd !== null && p.today > p.dateEnd && p.progress < 100)
|
|
569
|
+
raise(2, `Past its deadline at ${p.progress}%.`);
|
|
570
|
+
else if (p.dateStart !== null && p.dateEnd !== null && p.dateEnd > p.dateStart) {
|
|
571
|
+
const span = Date.parse(p.dateEnd) - Date.parse(p.dateStart);
|
|
572
|
+
const gone = Math.min(Math.max(Date.parse(p.today) - Date.parse(p.dateStart), 0), span);
|
|
573
|
+
const elapsed = Math.round((gone / span) * 100);
|
|
574
|
+
const behind = elapsed - p.progress;
|
|
575
|
+
if (behind >= rules.behindOff)
|
|
576
|
+
raise(2, `${behind} points behind: ${elapsed}% of the time gone, ${p.progress}% done.`);
|
|
577
|
+
else if (behind >= rules.behindRisk)
|
|
578
|
+
raise(1, `${behind} points behind: ${elapsed}% of the time gone, ${p.progress}% done.`);
|
|
579
|
+
}
|
|
580
|
+
if (p.milestonesLate > 0)
|
|
581
|
+
raise(1, `${p.milestonesLate} milestone(s) late.`);
|
|
582
|
+
if (p.budget > 0) {
|
|
583
|
+
const used = Math.round((p.spent / p.budget) * 100);
|
|
584
|
+
if (used > 100)
|
|
585
|
+
raise(2, `${used}% of the budget used.`);
|
|
586
|
+
else if (used >= rules.costRisk)
|
|
587
|
+
raise(1, `${used}% of the budget used.`);
|
|
588
|
+
}
|
|
589
|
+
const health = level === 2 ? 'off_track' : level === 1 ? 'at_risk' : 'on_track';
|
|
590
|
+
return { health, reasons };
|
|
591
|
+
}
|
|
592
|
+
const BUDGET_CATEGORIES = {
|
|
593
|
+
labour: 'Labour',
|
|
594
|
+
materials: 'Materials',
|
|
595
|
+
services: 'Services',
|
|
596
|
+
equipment: 'Equipment',
|
|
597
|
+
other: 'Other',
|
|
598
|
+
};
|
|
599
|
+
/** A line of a project's budget (RFC 0026 step 4): what for, in which phase or work package, planned. */
|
|
600
|
+
export const ProjectBudgetLine = defineModel('project.budget.line', {
|
|
601
|
+
label: 'Project budget line',
|
|
602
|
+
order: ['project asc', 'sequence asc', 'id asc'],
|
|
603
|
+
companyScoped: { required: true },
|
|
604
|
+
access: { 'project.manager': ALL },
|
|
605
|
+
fields: {
|
|
606
|
+
project: f.many2one('project.project', {
|
|
607
|
+
required: true,
|
|
608
|
+
checkCompany: true,
|
|
609
|
+
ondelete: 'cascade',
|
|
610
|
+
}),
|
|
611
|
+
sequence: f.integer({ required: true, default: 10 }),
|
|
612
|
+
name: f.char({ required: true, label: 'What for' }),
|
|
613
|
+
category: f.selection(BUDGET_CATEGORIES, { required: true, default: 'other' }),
|
|
614
|
+
phase: f.many2one('project.phase', { ondelete: 'set null' }),
|
|
615
|
+
workPackage: f.many2one('project.work.package', {
|
|
616
|
+
ondelete: 'set null',
|
|
617
|
+
label: 'Work package',
|
|
618
|
+
}),
|
|
619
|
+
planned: f.decimal({ scale: 2, required: true, default: '0' }),
|
|
620
|
+
},
|
|
621
|
+
sqlConstraints: {
|
|
622
|
+
planned: { check: orm.sql `planned >= 0`, message: 'A planned amount is not negative.' },
|
|
623
|
+
},
|
|
624
|
+
constraints: {
|
|
625
|
+
sameProject: {
|
|
626
|
+
...check('project.budget.line', ['phase', 'workPackage', 'project'], async (lines) => (await lines.load({ phase: ['project'], workPackage: ['project'] }))
|
|
627
|
+
.filter((l) => (l.phase !== null && l.phase.project.id !== l.project.id) ||
|
|
628
|
+
(l.workPackage !== null && l.workPackage.project.id !== l.project.id))
|
|
629
|
+
.map((l) => l.id)),
|
|
630
|
+
message: 'A budget line’s phase and work package are in its project.',
|
|
631
|
+
},
|
|
632
|
+
},
|
|
633
|
+
});
|
|
55
634
|
export const ProjectProject = defineModel('project.project', {
|
|
56
635
|
label: 'Project',
|
|
57
636
|
recName: 'name',
|
|
58
637
|
order: ['sequence asc', 'name asc'],
|
|
59
638
|
archivable: true,
|
|
60
639
|
companyScoped: { required: true },
|
|
61
|
-
|
|
640
|
+
hierarchy: { parent: 'parentProject', path: 'parentPath' },
|
|
641
|
+
tracking: [
|
|
642
|
+
'manager',
|
|
643
|
+
'partner',
|
|
644
|
+
'dateEnd',
|
|
645
|
+
'sponsor',
|
|
646
|
+
'owner',
|
|
647
|
+
'riskLevel',
|
|
648
|
+
'priority',
|
|
649
|
+
'health',
|
|
650
|
+
'healthOverride',
|
|
651
|
+
'healthReason',
|
|
652
|
+
],
|
|
62
653
|
access: { 'project.user': ['read', 'write', 'create'], 'project.manager': ALL },
|
|
63
654
|
rules: {
|
|
64
655
|
all: {
|
|
@@ -69,16 +660,140 @@ export const ProjectProject = defineModel('project.project', {
|
|
|
69
660
|
},
|
|
70
661
|
fields: {
|
|
71
662
|
name: f.char({ required: true }),
|
|
663
|
+
/** Numbered when created: PRJ-0001. */
|
|
664
|
+
code: f.char({ readonly: true, sequence: 'project.project', label: 'Code' }),
|
|
665
|
+
type: f.many2one('project.type', { ondelete: 'set null', label: 'Project type' }),
|
|
666
|
+
/** The programme it belongs to (a project of projects). */
|
|
667
|
+
parentProject: f.many2one('project.project', { ondelete: 'restrict', label: 'Programme' }),
|
|
668
|
+
parentPath: f.char({ readonly: true, index: true }),
|
|
669
|
+
children: f.one2many('project.project', 'parentProject', { label: 'Projects' }),
|
|
670
|
+
/** The approved plan it started from (RFC 0026 step 2). */
|
|
671
|
+
plan: f.many2one('project.plan', { ondelete: 'restrict', checkCompany: true }),
|
|
672
|
+
baselineStart: f.date({ label: 'Baseline start', groups: MANAGERS_WRITE }),
|
|
673
|
+
baselineEnd: f.date({ label: 'Baseline end', groups: MANAGERS_WRITE }),
|
|
674
|
+
baselineBudget: f.decimal({ scale: 2, label: 'Baseline budget', groups: MANAGERS_WRITE }),
|
|
675
|
+
/** Days the deadline moved past the baseline (negative: earlier). */
|
|
676
|
+
slipDays: f.integer({
|
|
677
|
+
label: 'Days late against the baseline',
|
|
678
|
+
...compute('project.project', 'slipDays', ['dateEnd', 'baselineEnd'], (projects) => {
|
|
679
|
+
for (const p of projects)
|
|
680
|
+
p.slipDays =
|
|
681
|
+
p.dateEnd === null || p.baselineEnd === null
|
|
682
|
+
? 0
|
|
683
|
+
: Math.round((Date.parse(String(p.dateEnd)) - Date.parse(String(p.baselineEnd))) / 86_400_000);
|
|
684
|
+
}),
|
|
685
|
+
}),
|
|
72
686
|
partner: f.many2one('res.partner', { checkCompany: true, label: 'Customer' }),
|
|
73
687
|
manager: f.many2one('res.users', {
|
|
74
688
|
default: (env) => env.userId,
|
|
75
689
|
label: 'Project manager',
|
|
76
690
|
}),
|
|
691
|
+
sponsor: f.many2one('res.users', { ondelete: 'set null', label: 'Sponsor' }),
|
|
692
|
+
owner: f.many2one('res.users', { ondelete: 'set null', label: 'Owner' }),
|
|
693
|
+
priority: f.selection(PRIORITIES, { required: true, default: 'normal' }),
|
|
694
|
+
riskLevel: f.selection({ low: 'Low', medium: 'Medium', high: 'High', critical: 'Critical' }, { required: true, default: 'low', label: 'Risk' }),
|
|
695
|
+
location: f.char(),
|
|
77
696
|
sequence: f.integer({ required: true, default: 10 }),
|
|
78
697
|
dateStart: f.date({ label: 'Start' }),
|
|
79
698
|
dateEnd: f.date({ label: 'Deadline' }),
|
|
80
699
|
description: f.text(),
|
|
700
|
+
scope: f.text(),
|
|
701
|
+
assumptions: f.text(),
|
|
702
|
+
constraints: f.text(),
|
|
703
|
+
phases: f.one2many('project.phase', 'project'),
|
|
704
|
+
workPackages: f.one2many('project.work.package', 'project', { label: 'Work packages' }),
|
|
705
|
+
/** Done, as a percentage: its tasks' progress weighted by their planned hours. */
|
|
706
|
+
progress: f.decimal({
|
|
707
|
+
scale: 1,
|
|
708
|
+
store: true,
|
|
709
|
+
label: 'Progress %',
|
|
710
|
+
...compute('project.project', 'progress', [
|
|
711
|
+
'tasks.progress',
|
|
712
|
+
'tasks.done',
|
|
713
|
+
'tasks.stage',
|
|
714
|
+
'tasks.plannedHours',
|
|
715
|
+
'tasks.active',
|
|
716
|
+
'tasks.parentTask',
|
|
717
|
+
], (projects) => {
|
|
718
|
+
for (const p of projects)
|
|
719
|
+
p.progress = weighted([...p.tasks].filter((t) => t.parentTask?.id === undefined));
|
|
720
|
+
}),
|
|
721
|
+
}),
|
|
81
722
|
tasks: f.one2many('project.task', 'project'),
|
|
723
|
+
members: f.one2many('project.member', 'project', { label: 'Team' }),
|
|
724
|
+
timesheets: f.one2many('project.timesheet', 'project'),
|
|
725
|
+
/** Timesheet hours at each member's cost (RFC 0026 step 3); Projects managers only. */
|
|
726
|
+
budgetLines: f.one2many('project.budget.line', 'project', { label: 'Budget' }),
|
|
727
|
+
/** The budget lines' planned amounts together (managers only). */
|
|
728
|
+
budget: f.decimal({
|
|
729
|
+
scale: 2,
|
|
730
|
+
store: true,
|
|
731
|
+
label: 'Budget',
|
|
732
|
+
groups: MANAGERS_SEE,
|
|
733
|
+
...compute('project.project', 'budget', ['budgetLines.planned'], (projects) => {
|
|
734
|
+
for (const p of projects)
|
|
735
|
+
p.budget = [...p.budgetLines].reduce((t, l) => t.plus(l.planned), orm.toDecimal(0));
|
|
736
|
+
}),
|
|
737
|
+
}),
|
|
738
|
+
/** Posted costs from the books on the project's cost centre (written by the refresh; Accounting's bridge). */
|
|
739
|
+
bookedCost: f.decimal({ scale: 2, label: 'Booked costs', groups: MANAGERS_SEE }),
|
|
740
|
+
/** Confirmed purchase orders on its cost centre not billed yet (written by the refresh; Purchase's bridge). */
|
|
741
|
+
committedCost: f.decimal({ scale: 2, label: 'Committed', groups: MANAGERS_SEE }),
|
|
742
|
+
actualCost: f.decimal({
|
|
743
|
+
scale: 2,
|
|
744
|
+
label: 'Actual cost',
|
|
745
|
+
groups: MANAGERS_SEE,
|
|
746
|
+
help: 'Labour from timesheets plus costs booked on its cost centre.',
|
|
747
|
+
...compute('project.project', 'actualCost', ['labourCost', 'bookedCost'], (projects) => {
|
|
748
|
+
for (const p of projects)
|
|
749
|
+
p.actualCost = orm.toDecimal(p.labourCost ?? 0).plus(p.bookedCost ?? 0);
|
|
750
|
+
}),
|
|
751
|
+
}),
|
|
752
|
+
variance: f.decimal({
|
|
753
|
+
scale: 2,
|
|
754
|
+
label: 'Left in the budget',
|
|
755
|
+
groups: MANAGERS_SEE,
|
|
756
|
+
help: 'The budget less the actual and committed costs; negative when beyond it.',
|
|
757
|
+
...compute('project.project', 'variance', ['budget', 'labourCost', 'bookedCost', 'committedCost'], (projects) => {
|
|
758
|
+
for (const p of projects)
|
|
759
|
+
p.variance = orm
|
|
760
|
+
.toDecimal(p.budget ?? 0)
|
|
761
|
+
.minus(p.labourCost ?? 0)
|
|
762
|
+
.minus(p.bookedCost ?? 0)
|
|
763
|
+
.minus(p.committedCost ?? 0);
|
|
764
|
+
}),
|
|
765
|
+
}),
|
|
766
|
+
/** What the figures say (written by the refresh, hourly and on demand). */
|
|
767
|
+
healthComputed: f.selection(HEALTH, { label: 'Health by the figures' }),
|
|
768
|
+
healthNotes: f.text({ label: 'Why', help: 'What the figures said at the last refresh.' }),
|
|
769
|
+
healthCheckedAt: f.datetime({ label: 'Checked' }),
|
|
770
|
+
healthOverride: f.selection(HEALTH, {
|
|
771
|
+
label: 'Health set by the manager',
|
|
772
|
+
help: 'Overrides the figures until cleared; give the reason.',
|
|
773
|
+
}),
|
|
774
|
+
healthReason: f.text({ label: 'Reason for the override' }),
|
|
775
|
+
health: f.selection(HEALTH, {
|
|
776
|
+
store: true,
|
|
777
|
+
label: 'Health',
|
|
778
|
+
...compute('project.project', 'health', ['healthOverride', 'healthComputed'], (projects) => {
|
|
779
|
+
for (const p of projects)
|
|
780
|
+
p.health = p.healthOverride ?? p.healthComputed ?? 'on_track';
|
|
781
|
+
}),
|
|
782
|
+
}),
|
|
783
|
+
labourCost: f.decimal({
|
|
784
|
+
scale: 2,
|
|
785
|
+
store: true,
|
|
786
|
+
label: 'Labour cost',
|
|
787
|
+
groups: MANAGERS_SEE,
|
|
788
|
+
...compute('project.project', 'labourCost', ['timesheets.cost'], (projects) => {
|
|
789
|
+
for (const p of projects) {
|
|
790
|
+
let total = orm.toDecimal(0);
|
|
791
|
+
for (const t of p.timesheets)
|
|
792
|
+
total = total.plus(t.cost ?? 0);
|
|
793
|
+
p.labourCost = total;
|
|
794
|
+
}
|
|
795
|
+
}),
|
|
796
|
+
}),
|
|
82
797
|
taskCount: f.integer({
|
|
83
798
|
store: true,
|
|
84
799
|
label: 'Tasks',
|
|
@@ -101,13 +816,230 @@ export const ProjectProject = defineModel('project.project', {
|
|
|
101
816
|
}),
|
|
102
817
|
}),
|
|
103
818
|
},
|
|
819
|
+
constraints: {
|
|
820
|
+
// A programme never contains itself; dates run forward.
|
|
821
|
+
noCycle: {
|
|
822
|
+
...check('project.project', ['parentProject'], async (projects) => {
|
|
823
|
+
const bad = [];
|
|
824
|
+
for (const p of await projects.load({ parentProject: true })) {
|
|
825
|
+
const seen = new Set([p.id]);
|
|
826
|
+
let up = p.parentProject?.id ?? null;
|
|
827
|
+
while (up !== null) {
|
|
828
|
+
if (seen.has(up)) {
|
|
829
|
+
bad.push(p.id);
|
|
830
|
+
break;
|
|
831
|
+
}
|
|
832
|
+
seen.add(up);
|
|
833
|
+
const [row] = await projects.env
|
|
834
|
+
.model('project.project')
|
|
835
|
+
.browse([up])
|
|
836
|
+
.load({ parentProject: true });
|
|
837
|
+
up = row?.parentProject?.id ?? null;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
return bad;
|
|
841
|
+
}),
|
|
842
|
+
message: 'A programme cannot be inside one of its own projects.',
|
|
843
|
+
},
|
|
844
|
+
overrideReason: {
|
|
845
|
+
...check('project.project', ['healthOverride', 'healthReason'], async (projects) => projects
|
|
846
|
+
.filter((p) => p.healthOverride !== null && (p.healthReason ?? '').trim() === '')
|
|
847
|
+
.map((p) => p.id)),
|
|
848
|
+
message: 'Setting the health by hand needs the reason.',
|
|
849
|
+
},
|
|
850
|
+
dates: {
|
|
851
|
+
...check('project.project', ['dateStart', 'dateEnd'], async (projects) => projects
|
|
852
|
+
.filter((p) => p.dateStart !== null && p.dateEnd !== null && String(p.dateEnd) < String(p.dateStart))
|
|
853
|
+
.map((p) => p.id)),
|
|
854
|
+
message: 'A project ends on or after it starts.',
|
|
855
|
+
},
|
|
856
|
+
// Only an approved plan starts a project; the company may require one (App settings › Projects).
|
|
857
|
+
plan: {
|
|
858
|
+
...check('project.project', ['plan', 'parentProject', 'company'], async (projects) => {
|
|
859
|
+
const bad = [];
|
|
860
|
+
const loaded = await projects.load({ plan: true, company: true });
|
|
861
|
+
const exempt = await projects.env
|
|
862
|
+
.model('project.project')
|
|
863
|
+
.browse(projects.ids)
|
|
864
|
+
.planNotNeeded();
|
|
865
|
+
const required = new Map();
|
|
866
|
+
for (const p of loaded) {
|
|
867
|
+
if (p.plan !== null) {
|
|
868
|
+
if (!['approved', 'in_progress'].includes(p.plan.state))
|
|
869
|
+
bad.push(p.id);
|
|
870
|
+
continue;
|
|
871
|
+
}
|
|
872
|
+
if (p.parentProject !== null || exempt.has(p.id))
|
|
873
|
+
continue;
|
|
874
|
+
let need = required.get(p.company.id);
|
|
875
|
+
if (need === undefined) {
|
|
876
|
+
need = await planRequired(projects.env, p.company.id);
|
|
877
|
+
required.set(p.company.id, need);
|
|
878
|
+
}
|
|
879
|
+
if (need)
|
|
880
|
+
bad.push(p.id);
|
|
881
|
+
}
|
|
882
|
+
return bad;
|
|
883
|
+
}),
|
|
884
|
+
message: 'A project starts from an approved plan (App settings › Projects asks for one; projects inside a programme follow theirs).',
|
|
885
|
+
},
|
|
886
|
+
},
|
|
887
|
+
methods: {
|
|
888
|
+
/**
|
|
889
|
+
* Of these projects, those that need no plan whatever the company's setting. None here; an app that makes
|
|
890
|
+
* projects itself overrides it (`sale_project`: projects made by a confirmed order).
|
|
891
|
+
*/
|
|
892
|
+
planNotNeeded: method('project.project', async () => new Set()),
|
|
893
|
+
/**
|
|
894
|
+
* The booked and committed costs of these projects, by project. Nothing here; the bridges add theirs
|
|
895
|
+
* (`project_account`: posted costs on each project's cost centre, made when missing; `project_purchase`:
|
|
896
|
+
* confirmed purchase orders not billed yet). Called as the system by the refresh.
|
|
897
|
+
*/
|
|
898
|
+
moneyOf: method('project.project', async () => new Map()),
|
|
899
|
+
},
|
|
900
|
+
});
|
|
901
|
+
/** The layers between a project and its tasks (RFC 0026 §3.1): their place, people, dates, status and progress. */
|
|
902
|
+
const layerFields = {
|
|
903
|
+
name: f.char({ required: true }),
|
|
904
|
+
code: f.char({ label: 'Code' }),
|
|
905
|
+
sequence: f.integer({ required: true, default: 10 }),
|
|
906
|
+
manager: f.many2one('res.users', { ondelete: 'set null', label: 'Manager' }),
|
|
907
|
+
dateStart: f.date({ label: 'Start' }),
|
|
908
|
+
dateEnd: f.date({ label: 'End' }),
|
|
909
|
+
status: f.selection(STATUSES, { required: true, default: 'draft' }),
|
|
910
|
+
description: f.text(),
|
|
911
|
+
/** Set by hand when the tasks do not tell the whole story (empty: computed). */
|
|
912
|
+
progressOverride: f.decimal({ scale: 1, label: 'Progress set by hand %' }),
|
|
913
|
+
baselineStart: f.date({ label: 'Baseline start', groups: MANAGERS_WRITE }),
|
|
914
|
+
baselineEnd: f.date({ label: 'Baseline end', groups: MANAGERS_WRITE }),
|
|
915
|
+
};
|
|
916
|
+
export const ProjectPhase = defineModel('project.phase', {
|
|
917
|
+
label: 'Phase',
|
|
918
|
+
recName: 'name',
|
|
919
|
+
order: ['project asc', 'sequence asc', 'id asc'],
|
|
920
|
+
archivable: true,
|
|
921
|
+
companyScoped: { required: true },
|
|
922
|
+
hierarchy: { parent: 'parentPhase', path: 'parentPath' },
|
|
923
|
+
tracking: ['status', 'manager', 'dateEnd'],
|
|
924
|
+
access: { 'project.user': ['read', 'write', 'create'], 'project.manager': ALL },
|
|
925
|
+
fields: {
|
|
926
|
+
...layerFields,
|
|
927
|
+
project: f.many2one('project.project', {
|
|
928
|
+
required: true,
|
|
929
|
+
checkCompany: true,
|
|
930
|
+
ondelete: 'cascade',
|
|
931
|
+
}),
|
|
932
|
+
parentPhase: f.many2one('project.phase', { ondelete: 'restrict', label: 'Inside' }),
|
|
933
|
+
parentPath: f.char({ readonly: true, index: true }),
|
|
934
|
+
children: f.one2many('project.phase', 'parentPhase', { label: 'Sub-phases' }),
|
|
935
|
+
responsible: f.many2one('res.users', { ondelete: 'set null' }),
|
|
936
|
+
/** Its end has passed and it is not done or cancelled (set by the refresh, hourly). */
|
|
937
|
+
late: f.boolean({ default: false, label: 'Late' }),
|
|
938
|
+
priority: f.selection(PRIORITIES, { required: true, default: 'normal' }),
|
|
939
|
+
workPackages: f.one2many('project.work.package', 'phase', { label: 'Work packages' }),
|
|
940
|
+
tasks: f.one2many('project.task', 'phase'),
|
|
941
|
+
progress: f.decimal({
|
|
942
|
+
scale: 1,
|
|
943
|
+
store: true,
|
|
944
|
+
label: 'Progress %',
|
|
945
|
+
...compute('project.phase', 'progress', [
|
|
946
|
+
'progressOverride',
|
|
947
|
+
'tasks.progress',
|
|
948
|
+
'tasks.done',
|
|
949
|
+
'tasks.stage',
|
|
950
|
+
'tasks.plannedHours',
|
|
951
|
+
'tasks.active',
|
|
952
|
+
'tasks.parentTask',
|
|
953
|
+
], (phases) => {
|
|
954
|
+
for (const x of phases)
|
|
955
|
+
x.progress =
|
|
956
|
+
x.progressOverride ??
|
|
957
|
+
weighted([...x.tasks].filter((t) => t.parentTask?.id === undefined));
|
|
958
|
+
}),
|
|
959
|
+
}),
|
|
960
|
+
},
|
|
961
|
+
sqlConstraints: {
|
|
962
|
+
progressRange: {
|
|
963
|
+
check: orm.sql `progress_override IS NULL OR (progress_override >= 0 AND progress_override <= 100)`,
|
|
964
|
+
message: 'Progress is from 0 to 100.',
|
|
965
|
+
},
|
|
966
|
+
},
|
|
967
|
+
constraints: {
|
|
968
|
+
sameProject: {
|
|
969
|
+
...check('project.phase', ['parentPhase', 'project'], async (phases) => (await phases.load({ parentPhase: { project: true }, project: true }))
|
|
970
|
+
.filter((x) => x.parentPhase !== null &&
|
|
971
|
+
(x.parentPhase.id === x.id || x.parentPhase.project.id !== x.project.id))
|
|
972
|
+
.map((x) => x.id)),
|
|
973
|
+
message: 'A sub-phase is in the same project as the phase it is inside.',
|
|
974
|
+
},
|
|
975
|
+
dates: {
|
|
976
|
+
...check('project.phase', ['dateStart', 'dateEnd'], async (phases) => phases
|
|
977
|
+
.filter((x) => x.dateStart !== null && x.dateEnd !== null && String(x.dateEnd) < String(x.dateStart))
|
|
978
|
+
.map((x) => x.id)),
|
|
979
|
+
message: 'A phase ends on or after it starts.',
|
|
980
|
+
},
|
|
981
|
+
},
|
|
982
|
+
});
|
|
983
|
+
export const ProjectWorkPackage = defineModel('project.work.package', {
|
|
984
|
+
label: 'Work package',
|
|
985
|
+
recName: 'name',
|
|
986
|
+
order: ['project asc', 'sequence asc', 'id asc'],
|
|
987
|
+
archivable: true,
|
|
988
|
+
companyScoped: { required: true },
|
|
989
|
+
tracking: ['status', 'manager', 'dateEnd'],
|
|
990
|
+
access: { 'project.user': ['read', 'write', 'create'], 'project.manager': ALL },
|
|
991
|
+
fields: {
|
|
992
|
+
...layerFields,
|
|
993
|
+
project: f.many2one('project.project', {
|
|
994
|
+
required: true,
|
|
995
|
+
checkCompany: true,
|
|
996
|
+
ondelete: 'cascade',
|
|
997
|
+
}),
|
|
998
|
+
phase: f.many2one('project.phase', { ondelete: 'set null' }),
|
|
999
|
+
/** What it delivers. */
|
|
1000
|
+
deliverables: f.text(),
|
|
1001
|
+
tasks: f.one2many('project.task', 'workPackage'),
|
|
1002
|
+
progress: f.decimal({
|
|
1003
|
+
scale: 1,
|
|
1004
|
+
store: true,
|
|
1005
|
+
label: 'Progress %',
|
|
1006
|
+
...compute('project.work.package', 'progress', [
|
|
1007
|
+
'progressOverride',
|
|
1008
|
+
'tasks.progress',
|
|
1009
|
+
'tasks.done',
|
|
1010
|
+
'tasks.stage',
|
|
1011
|
+
'tasks.plannedHours',
|
|
1012
|
+
'tasks.active',
|
|
1013
|
+
'tasks.parentTask',
|
|
1014
|
+
], (packages) => {
|
|
1015
|
+
for (const w of packages)
|
|
1016
|
+
w.progress =
|
|
1017
|
+
w.progressOverride ??
|
|
1018
|
+
weighted([...w.tasks].filter((t) => t.parentTask?.id === undefined));
|
|
1019
|
+
}),
|
|
1020
|
+
}),
|
|
1021
|
+
},
|
|
1022
|
+
sqlConstraints: {
|
|
1023
|
+
progressRange: {
|
|
1024
|
+
check: orm.sql `progress_override IS NULL OR (progress_override >= 0 AND progress_override <= 100)`,
|
|
1025
|
+
message: 'Progress is from 0 to 100.',
|
|
1026
|
+
},
|
|
1027
|
+
},
|
|
1028
|
+
constraints: {
|
|
1029
|
+
samePhaseProject: {
|
|
1030
|
+
...check('project.work.package', ['phase', 'project'], async (packages) => (await packages.load({ phase: { project: true }, project: true }))
|
|
1031
|
+
.filter((w) => w.phase !== null && w.phase.project.id !== w.project.id)
|
|
1032
|
+
.map((w) => w.id)),
|
|
1033
|
+
message: 'A work package is in the same project as its phase.',
|
|
1034
|
+
},
|
|
1035
|
+
},
|
|
104
1036
|
});
|
|
105
1037
|
export const ProjectTask = defineModel('project.task', {
|
|
106
1038
|
label: 'Task',
|
|
107
1039
|
order: ['sequence asc', 'priority desc', 'id desc'],
|
|
108
1040
|
archivable: true,
|
|
109
1041
|
companyScoped: { required: true },
|
|
110
|
-
tracking: ['stage', 'assignee', 'deadline'],
|
|
1042
|
+
tracking: ['stage', 'assignee', 'deadline', 'phase', 'workPackage'],
|
|
111
1043
|
access: { 'project.user': WRITE, 'project.manager': ALL },
|
|
112
1044
|
rules: {
|
|
113
1045
|
all: {
|
|
@@ -124,7 +1056,27 @@ export const ProjectTask = defineModel('project.task', {
|
|
|
124
1056
|
ondelete: 'cascade',
|
|
125
1057
|
}),
|
|
126
1058
|
stage: f.many2one('project.task.stage', { ondelete: 'restrict' }),
|
|
1059
|
+
phase: f.many2one('project.phase', { ondelete: 'set null' }),
|
|
1060
|
+
workPackage: f.many2one('project.work.package', {
|
|
1061
|
+
ondelete: 'set null',
|
|
1062
|
+
label: 'Work package',
|
|
1063
|
+
}),
|
|
1064
|
+
category: f.many2one('project.task.category', { ondelete: 'set null' }),
|
|
1065
|
+
/** The task it is a sub-task of. */
|
|
1066
|
+
parentTask: f.many2one('project.task', { ondelete: 'cascade', label: 'Sub-task of' }),
|
|
1067
|
+
subtasks: f.one2many('project.task', 'parentTask', { label: 'Sub-tasks' }),
|
|
127
1068
|
assignee: f.many2one('res.users', { label: 'Assigned to' }),
|
|
1069
|
+
/** Everyone working on it (the first is `assignee`). */
|
|
1070
|
+
assignees: f.many2many('res.users', {
|
|
1071
|
+
relation: 'project_task_assignee_rel',
|
|
1072
|
+
label: 'Assignees',
|
|
1073
|
+
}),
|
|
1074
|
+
tags: f.many2many('project.tag', { relation: 'project_task_tag_rel' }),
|
|
1075
|
+
/** A point the project reaches (a handover, a go-live), shown as such on the timeline. */
|
|
1076
|
+
milestone: f.boolean({ default: false }),
|
|
1077
|
+
/** The tasks that must finish before this one starts. */
|
|
1078
|
+
predecessors: f.one2many('project.task.link', 'successor', { label: 'Waits for' }),
|
|
1079
|
+
successors: f.one2many('project.task.link', 'predecessor', { label: 'Followed by' }),
|
|
128
1080
|
partner: f.many2one('res.partner', { checkCompany: true, label: 'Customer' }),
|
|
129
1081
|
priority: f.selection({ normal: 'Normal', high: 'High' }, { required: true, default: 'normal' }),
|
|
130
1082
|
deadline: f.date(),
|
|
@@ -149,11 +1101,84 @@ export const ProjectTask = defineModel('project.task', {
|
|
|
149
1101
|
t.done = t.stage?.done === true;
|
|
150
1102
|
}),
|
|
151
1103
|
}),
|
|
1104
|
+
/** Done, as a percentage: 100 when done, else its sub-tasks' (weighted by planned hours), else 0. */
|
|
1105
|
+
progress: f.decimal({
|
|
1106
|
+
scale: 1,
|
|
1107
|
+
store: true,
|
|
1108
|
+
label: 'Progress %',
|
|
1109
|
+
...compute('project.task', 'progress', ['done', 'subtasks.done', 'subtasks.plannedHours', 'subtasks.active'], (tasks) => {
|
|
1110
|
+
for (const t of tasks) {
|
|
1111
|
+
if (t.done) {
|
|
1112
|
+
t.progress = hundred;
|
|
1113
|
+
continue;
|
|
1114
|
+
}
|
|
1115
|
+
const subs = [...t.subtasks].filter((x) => x.active !== false);
|
|
1116
|
+
t.progress =
|
|
1117
|
+
subs.length === 0
|
|
1118
|
+
? zero
|
|
1119
|
+
: weighted(subs.map((x) => ({ progress: x.done ? 100 : 0, plannedHours: x.plannedHours })));
|
|
1120
|
+
}
|
|
1121
|
+
}),
|
|
1122
|
+
}),
|
|
1123
|
+
/** A task it waits for is not done yet. */
|
|
1124
|
+
blocked: f.boolean({
|
|
1125
|
+
store: true,
|
|
1126
|
+
label: 'Waiting for another task',
|
|
1127
|
+
...compute('project.task', 'blocked', ['predecessors.predecessor.done', 'done'], (tasks) => {
|
|
1128
|
+
for (const t of tasks)
|
|
1129
|
+
t.blocked = !t.done && [...t.predecessors].some((l) => !l.predecessor.done);
|
|
1130
|
+
}),
|
|
1131
|
+
}),
|
|
152
1132
|
sequence: f.integer({ required: true, default: 10 }),
|
|
153
1133
|
description: f.text(),
|
|
154
1134
|
timesheets: f.one2many('project.timesheet', 'task'),
|
|
1135
|
+
/** When it starts (RFC 0026 step 5); it ends on its deadline. */
|
|
1136
|
+
dateStart: f.date({ label: 'Start' }),
|
|
1137
|
+
baselineStart: f.date({ label: 'Baseline start', groups: MANAGERS_WRITE }),
|
|
1138
|
+
baselineEnd: f.date({ label: 'Baseline end', groups: MANAGERS_WRITE }),
|
|
1139
|
+
/** Repeats: once done, the next one is made (the hourly refresh), until the date given. */
|
|
1140
|
+
repeatEvery: f.integer({
|
|
1141
|
+
label: 'Repeat every',
|
|
1142
|
+
help: 'Once done, the next one is made with its dates moved by this many days, weeks or months.',
|
|
1143
|
+
}),
|
|
1144
|
+
repeatUnit: f.selection({ day: 'Days', week: 'Weeks', month: 'Months' }, { default: 'week', label: 'Repeat unit' }),
|
|
1145
|
+
repeatUntil: f.date({ label: 'Repeat until' }),
|
|
1146
|
+
nextTask: f.many2one('project.task', { ondelete: 'set null', label: 'Next one' }),
|
|
1147
|
+
},
|
|
1148
|
+
sqlConstraints: {
|
|
1149
|
+
datesOrdered: {
|
|
1150
|
+
check: orm.sql `date_start IS NULL OR deadline IS NULL OR deadline >= date_start`,
|
|
1151
|
+
message: 'A task ends on or after it starts.',
|
|
1152
|
+
},
|
|
1153
|
+
repeatPositive: {
|
|
1154
|
+
check: orm.sql `repeat_every IS NULL OR repeat_every > 0`,
|
|
1155
|
+
message: 'A task repeats every one or more days, weeks or months.',
|
|
1156
|
+
},
|
|
1157
|
+
},
|
|
1158
|
+
constraints: {
|
|
1159
|
+
// Its phase, work package and parent task are in its own project; a task is not its own parent.
|
|
1160
|
+
sameProject: {
|
|
1161
|
+
...check('project.task', ['phase', 'workPackage', 'parentTask', 'project'], async (tasks) => (await tasks.load({
|
|
1162
|
+
phase: { project: true },
|
|
1163
|
+
workPackage: { project: true },
|
|
1164
|
+
parentTask: { project: true },
|
|
1165
|
+
project: true,
|
|
1166
|
+
}))
|
|
1167
|
+
.filter((t) => (t.phase !== null && t.phase.project.id !== t.project.id) ||
|
|
1168
|
+
(t.workPackage !== null && t.workPackage.project.id !== t.project.id) ||
|
|
1169
|
+
(t.parentTask !== null &&
|
|
1170
|
+
(t.parentTask.id === t.id || t.parentTask.project.id !== t.project.id)))
|
|
1171
|
+
.map((t) => t.id)),
|
|
1172
|
+
message: 'A task’s phase, work package and parent task are in its own project.',
|
|
1173
|
+
},
|
|
155
1174
|
},
|
|
156
1175
|
onchange: {
|
|
1176
|
+
// A work package sets the task's phase.
|
|
1177
|
+
workPackage: async (d) => {
|
|
1178
|
+
const w = await d.record('workPackage');
|
|
1179
|
+
if (w?.phase)
|
|
1180
|
+
d.set('phase', w.phase.id);
|
|
1181
|
+
},
|
|
157
1182
|
// A task of a project takes the project's customer, and the company's planned hours when it has none.
|
|
158
1183
|
project: async (d, env) => {
|
|
159
1184
|
const p = await d.record('project');
|
|
@@ -168,6 +1193,440 @@ export const ProjectTask = defineModel('project.task', {
|
|
|
168
1193
|
},
|
|
169
1194
|
},
|
|
170
1195
|
});
|
|
1196
|
+
/**
|
|
1197
|
+
* A finish-to-start dependency (RFC 0026): `successor` starts once `predecessor` is done, `lag` days later. Never a
|
|
1198
|
+
* task on itself, never twice, never in a circle.
|
|
1199
|
+
*/
|
|
1200
|
+
export const ProjectTaskLink = defineModel('project.task.link', {
|
|
1201
|
+
label: 'Task dependency',
|
|
1202
|
+
order: ['id asc'],
|
|
1203
|
+
access: { 'project.user': WRITE, 'project.manager': ALL },
|
|
1204
|
+
fields: {
|
|
1205
|
+
predecessor: f.many2one('project.task', {
|
|
1206
|
+
required: true,
|
|
1207
|
+
ondelete: 'cascade',
|
|
1208
|
+
label: 'Waits for',
|
|
1209
|
+
}),
|
|
1210
|
+
successor: f.many2one('project.task', { required: true, ondelete: 'cascade', label: 'Task' }),
|
|
1211
|
+
lag: f.integer({ required: true, default: 0, label: 'Lag (days)' }),
|
|
1212
|
+
},
|
|
1213
|
+
sqlConstraints: {
|
|
1214
|
+
once: { unique: ['predecessor', 'successor'], message: 'These tasks are linked already.' },
|
|
1215
|
+
notItself: {
|
|
1216
|
+
check: orm.sql `predecessor_id <> successor_id`,
|
|
1217
|
+
message: 'A task cannot wait for itself.',
|
|
1218
|
+
},
|
|
1219
|
+
},
|
|
1220
|
+
constraints: {
|
|
1221
|
+
noCircle: {
|
|
1222
|
+
...check('project.task.link', ['predecessor', 'successor'], async (links) => {
|
|
1223
|
+
const bad = [];
|
|
1224
|
+
for (const l of await links.load({})) {
|
|
1225
|
+
// Walking forward from the successor must never reach the predecessor.
|
|
1226
|
+
const seen = new Set();
|
|
1227
|
+
let frontier = [l.successor.id];
|
|
1228
|
+
let found = false;
|
|
1229
|
+
while (frontier.length > 0 && !found) {
|
|
1230
|
+
const next = await (await links.env
|
|
1231
|
+
.model('project.task.link')
|
|
1232
|
+
.search({ where: { predecessor: { in: frontier } } })).load({});
|
|
1233
|
+
frontier = [];
|
|
1234
|
+
for (const n of next) {
|
|
1235
|
+
const id = n.successor.id;
|
|
1236
|
+
if (id === l.predecessor.id)
|
|
1237
|
+
found = true;
|
|
1238
|
+
if (!seen.has(id)) {
|
|
1239
|
+
seen.add(id);
|
|
1240
|
+
frontier.push(id);
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
if (found)
|
|
1245
|
+
bad.push(l.id);
|
|
1246
|
+
}
|
|
1247
|
+
return bad;
|
|
1248
|
+
}),
|
|
1249
|
+
message: 'These dependencies would go round in a circle.',
|
|
1250
|
+
},
|
|
1251
|
+
},
|
|
1252
|
+
});
|
|
1253
|
+
/** The chatter's served models, set when the app's routes register (status reports post on their project). */
|
|
1254
|
+
let chatter;
|
|
1255
|
+
export function configureProjectChatter(context) {
|
|
1256
|
+
chatter = context;
|
|
1257
|
+
}
|
|
1258
|
+
/** Who reads and writes the control register: Projects users write, managers also delete. */
|
|
1259
|
+
const CONTROL_ACCESS = {
|
|
1260
|
+
'project.user': ['read', 'write', 'create'],
|
|
1261
|
+
'project.manager': ALL,
|
|
1262
|
+
};
|
|
1263
|
+
const RISK_LEVELS = { low: 'Low', medium: 'Medium', high: 'High', critical: 'Critical' };
|
|
1264
|
+
/** Probability × impact (each 1–5) as a level: up to 4 low, 9 medium, 15 high, above critical. */
|
|
1265
|
+
export function riskLevel(score) {
|
|
1266
|
+
if (score <= 4)
|
|
1267
|
+
return 'low';
|
|
1268
|
+
if (score <= 9)
|
|
1269
|
+
return 'medium';
|
|
1270
|
+
if (score <= 15)
|
|
1271
|
+
return 'high';
|
|
1272
|
+
return 'critical';
|
|
1273
|
+
}
|
|
1274
|
+
/** A risk of a project (RFC 0026 step 5): how likely, how bad, who owns it and what is done about it. */
|
|
1275
|
+
export const ProjectRisk = defineModel('project.risk', {
|
|
1276
|
+
label: 'Project risk',
|
|
1277
|
+
recName: 'name',
|
|
1278
|
+
order: ['score desc', 'id desc'],
|
|
1279
|
+
companyScoped: { required: true },
|
|
1280
|
+
tracking: ['status', 'probability', 'impact', 'owner'],
|
|
1281
|
+
access: CONTROL_ACCESS,
|
|
1282
|
+
fields: {
|
|
1283
|
+
project: f.many2one('project.project', {
|
|
1284
|
+
required: true,
|
|
1285
|
+
checkCompany: true,
|
|
1286
|
+
ondelete: 'cascade',
|
|
1287
|
+
}),
|
|
1288
|
+
name: f.char({ required: true, label: 'Risk' }),
|
|
1289
|
+
description: f.text(),
|
|
1290
|
+
category: f.selection({
|
|
1291
|
+
schedule: 'Schedule',
|
|
1292
|
+
cost: 'Cost',
|
|
1293
|
+
scope: 'Scope',
|
|
1294
|
+
quality: 'Quality',
|
|
1295
|
+
safety: 'Safety',
|
|
1296
|
+
supply: 'Supply',
|
|
1297
|
+
other: 'Other',
|
|
1298
|
+
}, { required: true, default: 'other' }),
|
|
1299
|
+
probability: f.integer({
|
|
1300
|
+
required: true,
|
|
1301
|
+
default: 3,
|
|
1302
|
+
help: 'How likely, from 1 (rare) to 5 (almost certain).',
|
|
1303
|
+
}),
|
|
1304
|
+
impact: f.integer({
|
|
1305
|
+
required: true,
|
|
1306
|
+
default: 3,
|
|
1307
|
+
help: 'How bad if it happens, from 1 (slight) to 5 (severe).',
|
|
1308
|
+
}),
|
|
1309
|
+
score: f.integer({
|
|
1310
|
+
store: true,
|
|
1311
|
+
label: 'Score',
|
|
1312
|
+
...compute('project.risk', 'score', ['probability', 'impact'], (risks) => {
|
|
1313
|
+
for (const r of risks)
|
|
1314
|
+
r.score = (r.probability ?? 0) * (r.impact ?? 0);
|
|
1315
|
+
}),
|
|
1316
|
+
}),
|
|
1317
|
+
level: f.selection(RISK_LEVELS, {
|
|
1318
|
+
store: true,
|
|
1319
|
+
label: 'Level',
|
|
1320
|
+
...compute('project.risk', 'level', ['probability', 'impact'], (risks) => {
|
|
1321
|
+
for (const r of risks)
|
|
1322
|
+
r.level = riskLevel((r.probability ?? 0) * (r.impact ?? 0));
|
|
1323
|
+
}),
|
|
1324
|
+
}),
|
|
1325
|
+
owner: f.many2one('res.users', {
|
|
1326
|
+
ondelete: 'set null',
|
|
1327
|
+
default: (env) => env.userId,
|
|
1328
|
+
}),
|
|
1329
|
+
mitigation: f.text({ help: 'What is done to make it less likely or less bad.' }),
|
|
1330
|
+
contingency: f.text({ help: 'What is done if it happens.' }),
|
|
1331
|
+
dueDate: f.date({ label: 'Review by' }),
|
|
1332
|
+
status: f.selection({ open: 'Open', mitigating: 'Being mitigated', occurred: 'Occurred', closed: 'Closed' }, { required: true, default: 'open' }),
|
|
1333
|
+
},
|
|
1334
|
+
sqlConstraints: {
|
|
1335
|
+
scale: {
|
|
1336
|
+
check: orm.sql `probability BETWEEN 1 AND 5 AND impact BETWEEN 1 AND 5`,
|
|
1337
|
+
message: 'Probability and impact are from 1 to 5.',
|
|
1338
|
+
},
|
|
1339
|
+
},
|
|
1340
|
+
});
|
|
1341
|
+
/** An issue of a project: something wrong now, how serious, who resolves it and how it was resolved. */
|
|
1342
|
+
export const ProjectIssue = defineModel('project.issue', {
|
|
1343
|
+
label: 'Project issue',
|
|
1344
|
+
recName: 'name',
|
|
1345
|
+
order: ['id desc'],
|
|
1346
|
+
companyScoped: { required: true },
|
|
1347
|
+
tracking: ['status', 'severity', 'owner'],
|
|
1348
|
+
access: CONTROL_ACCESS,
|
|
1349
|
+
fields: {
|
|
1350
|
+
project: f.many2one('project.project', {
|
|
1351
|
+
required: true,
|
|
1352
|
+
checkCompany: true,
|
|
1353
|
+
ondelete: 'cascade',
|
|
1354
|
+
}),
|
|
1355
|
+
name: f.char({ required: true, label: 'Issue' }),
|
|
1356
|
+
description: f.text(),
|
|
1357
|
+
severity: f.selection(RISK_LEVELS, { required: true, default: 'medium' }),
|
|
1358
|
+
raisedBy: f.many2one('res.users', {
|
|
1359
|
+
default: (env) => env.userId,
|
|
1360
|
+
ondelete: 'set null',
|
|
1361
|
+
label: 'Raised by',
|
|
1362
|
+
}),
|
|
1363
|
+
owner: f.many2one('res.users', { ondelete: 'set null' }),
|
|
1364
|
+
dueDate: f.date({ label: 'Resolve by' }),
|
|
1365
|
+
status: f.selection({ open: 'Open', in_progress: 'In progress', resolved: 'Resolved', closed: 'Closed' }, { required: true, default: 'open' }),
|
|
1366
|
+
resolution: f.text(),
|
|
1367
|
+
risk: f.many2one('project.risk', { ondelete: 'set null', label: 'From the risk' }),
|
|
1368
|
+
},
|
|
1369
|
+
constraints: {
|
|
1370
|
+
resolution: {
|
|
1371
|
+
...check('project.issue', ['status', 'resolution'], async (issues) => issues
|
|
1372
|
+
.filter((i) => ['resolved', 'closed'].includes(i.status) && (i.resolution ?? '').trim() === '')
|
|
1373
|
+
.map((i) => i.id)),
|
|
1374
|
+
message: 'A resolved issue says how it was resolved.',
|
|
1375
|
+
},
|
|
1376
|
+
},
|
|
1377
|
+
});
|
|
1378
|
+
const CHANGE_STATES = {
|
|
1379
|
+
draft: 'Draft',
|
|
1380
|
+
submitted: 'Waiting for approval',
|
|
1381
|
+
applied: 'Approved and applied',
|
|
1382
|
+
rejected: 'Rejected',
|
|
1383
|
+
cancelled: 'Cancelled',
|
|
1384
|
+
};
|
|
1385
|
+
/**
|
|
1386
|
+
* A change request (RFC 0026 step 5): a new deadline or budget for a project, with the reason and the impact, decided
|
|
1387
|
+
* through the approval engine; once approved it moves the project's dates and baseline (and its baseline budget) and
|
|
1388
|
+
* keeps what they were.
|
|
1389
|
+
*/
|
|
1390
|
+
export const ProjectChange = defineModel('project.change', {
|
|
1391
|
+
label: 'Change request',
|
|
1392
|
+
recName: 'title',
|
|
1393
|
+
order: ['id desc'],
|
|
1394
|
+
companyScoped: { required: true },
|
|
1395
|
+
tracking: ['state', 'newDateEnd', 'newBudget'],
|
|
1396
|
+
access: CONTROL_ACCESS,
|
|
1397
|
+
rules: {
|
|
1398
|
+
// Changed while a draft (or rejected); managers anywhere.
|
|
1399
|
+
read: { groups: ['project.user'], perms: ['read'], domain: {} },
|
|
1400
|
+
draft: {
|
|
1401
|
+
groups: ['project.user'],
|
|
1402
|
+
perms: ['write', 'create'],
|
|
1403
|
+
domain: { state: { in: ['draft', 'rejected'] } },
|
|
1404
|
+
},
|
|
1405
|
+
managers: {
|
|
1406
|
+
groups: ['project.manager'],
|
|
1407
|
+
perms: ['read', 'write', 'create', 'unlink'],
|
|
1408
|
+
domain: {},
|
|
1409
|
+
},
|
|
1410
|
+
},
|
|
1411
|
+
fields: {
|
|
1412
|
+
name: f.char({ readonly: true, sequence: 'project.change', label: 'Number' }),
|
|
1413
|
+
project: f.many2one('project.project', {
|
|
1414
|
+
required: true,
|
|
1415
|
+
checkCompany: true,
|
|
1416
|
+
ondelete: 'cascade',
|
|
1417
|
+
}),
|
|
1418
|
+
title: f.char({ required: true }),
|
|
1419
|
+
requester: f.many2one('res.users', {
|
|
1420
|
+
default: (env) => env.userId,
|
|
1421
|
+
ondelete: 'restrict',
|
|
1422
|
+
label: 'Asked by',
|
|
1423
|
+
}),
|
|
1424
|
+
/** The project's sponsor, who decides it first (a Projects manager when there is none). */
|
|
1425
|
+
sponsor: f.many2one('res.users', {
|
|
1426
|
+
store: true,
|
|
1427
|
+
label: 'Sponsor',
|
|
1428
|
+
...compute('project.change', 'sponsor', ['project.sponsor'], (changes) => {
|
|
1429
|
+
for (const c of changes)
|
|
1430
|
+
c.sponsor = c.project?.sponsor?.id ?? null;
|
|
1431
|
+
}),
|
|
1432
|
+
}),
|
|
1433
|
+
reason: f.text({ required: true }),
|
|
1434
|
+
impact: f.text({ help: 'What else it changes: scope, people, other projects.' }),
|
|
1435
|
+
newDateEnd: f.date({ label: 'New deadline' }),
|
|
1436
|
+
newBudget: f.decimal({ scale: 2, label: 'New baseline budget', groups: MANAGERS_ONLY }),
|
|
1437
|
+
previousDateEnd: f.date({ label: 'Deadline before' }),
|
|
1438
|
+
previousBaselineEnd: f.date({ label: 'Baseline end before' }),
|
|
1439
|
+
previousBudget: f.decimal({ scale: 2, label: 'Baseline budget before', groups: MANAGERS_ONLY }),
|
|
1440
|
+
appliedAt: f.datetime({ label: 'Applied on' }),
|
|
1441
|
+
state: f.state({
|
|
1442
|
+
states: CHANGE_STATES,
|
|
1443
|
+
initial: 'draft',
|
|
1444
|
+
help: 'Submitted for approval; once approved, applied to the project and its baseline.',
|
|
1445
|
+
transitions: {
|
|
1446
|
+
submit: {
|
|
1447
|
+
from: ['draft', 'rejected'],
|
|
1448
|
+
to: 'submitted',
|
|
1449
|
+
// The new budget is the managers' to see: read as the system (a transition is a write).
|
|
1450
|
+
guard: records('project.change', async (changes) => changes.env.asSuperuser({ reason: 'change request check' }, async (su) => [
|
|
1451
|
+
...(await su
|
|
1452
|
+
.model('project.change')
|
|
1453
|
+
.browse(changes.ids)
|
|
1454
|
+
.load(['newDateEnd', 'newBudget'])),
|
|
1455
|
+
]
|
|
1456
|
+
.filter((c) => c.newDateEnd === null && c.newBudget === null)
|
|
1457
|
+
.map((c) => c.id))),
|
|
1458
|
+
message: 'A change request asks for a new deadline, a new budget or both.',
|
|
1459
|
+
after: records('project.change', async (changes) => {
|
|
1460
|
+
await requestApprovals(changes);
|
|
1461
|
+
}),
|
|
1462
|
+
},
|
|
1463
|
+
approve: {
|
|
1464
|
+
from: ['submitted'],
|
|
1465
|
+
to: 'applied',
|
|
1466
|
+
guard: records('project.change', (changes) => approvalGuard(changes)),
|
|
1467
|
+
message: 'Waiting for approvals.',
|
|
1468
|
+
// Run as the system by the engine: the deadline, the baseline and its budget move, what they were kept.
|
|
1469
|
+
after: records('project.change', async (changes) => {
|
|
1470
|
+
const env = changes.env;
|
|
1471
|
+
await env.asSuperuser({ reason: 'change request applied' }, async (su) => {
|
|
1472
|
+
for (const c of await su
|
|
1473
|
+
.model('project.change')
|
|
1474
|
+
.browse(changes.ids)
|
|
1475
|
+
.load({ project: ['dateEnd', 'baselineEnd', 'baselineBudget'] })) {
|
|
1476
|
+
const project = c.project;
|
|
1477
|
+
const moves = {};
|
|
1478
|
+
if (c.newDateEnd !== null) {
|
|
1479
|
+
moves['dateEnd'] = c.newDateEnd;
|
|
1480
|
+
moves['baselineEnd'] = c.newDateEnd;
|
|
1481
|
+
}
|
|
1482
|
+
if (c.newBudget !== null)
|
|
1483
|
+
moves['baselineBudget'] = c.newBudget;
|
|
1484
|
+
await su
|
|
1485
|
+
.model('project.change')
|
|
1486
|
+
.browse([c.id])
|
|
1487
|
+
.write({
|
|
1488
|
+
previousDateEnd: project.dateEnd,
|
|
1489
|
+
previousBaselineEnd: project.baselineEnd,
|
|
1490
|
+
previousBudget: project.baselineBudget,
|
|
1491
|
+
appliedAt: new Date(),
|
|
1492
|
+
});
|
|
1493
|
+
await su
|
|
1494
|
+
.model('project.project')
|
|
1495
|
+
.browse([project.id])
|
|
1496
|
+
.write(moves);
|
|
1497
|
+
await su.emit('project.change.applied', { id: c.id });
|
|
1498
|
+
}
|
|
1499
|
+
});
|
|
1500
|
+
}),
|
|
1501
|
+
},
|
|
1502
|
+
reject: { from: ['submitted'], to: 'rejected' },
|
|
1503
|
+
redraft: { from: ['submitted', 'rejected'], to: 'draft' },
|
|
1504
|
+
cancel: { from: ['draft', 'submitted', 'rejected'], to: 'cancelled' },
|
|
1505
|
+
},
|
|
1506
|
+
}),
|
|
1507
|
+
},
|
|
1508
|
+
});
|
|
1509
|
+
/**
|
|
1510
|
+
* A weekly status report (RFC 0026 step 5): the project's health and progress when written, what was done, what is
|
|
1511
|
+
* next and the risks; sent, it is posted on the project for its followers, the sponsor among them.
|
|
1512
|
+
*/
|
|
1513
|
+
export const ProjectStatusReport = defineModel('project.status.report', {
|
|
1514
|
+
label: 'Status report',
|
|
1515
|
+
recName: 'name',
|
|
1516
|
+
order: ['date desc', 'id desc'],
|
|
1517
|
+
companyScoped: { required: true },
|
|
1518
|
+
access: CONTROL_ACCESS,
|
|
1519
|
+
rules: {
|
|
1520
|
+
read: { groups: ['project.user'], perms: ['read'], domain: {} },
|
|
1521
|
+
draftOwn: {
|
|
1522
|
+
groups: ['project.user'],
|
|
1523
|
+
perms: ['write', 'create'],
|
|
1524
|
+
domain: { author: { $env: 'uid' }, state: 'draft' },
|
|
1525
|
+
},
|
|
1526
|
+
managers: {
|
|
1527
|
+
groups: ['project.manager'],
|
|
1528
|
+
perms: ['read', 'write', 'create', 'unlink'],
|
|
1529
|
+
domain: {},
|
|
1530
|
+
},
|
|
1531
|
+
},
|
|
1532
|
+
fields: {
|
|
1533
|
+
project: f.many2one('project.project', {
|
|
1534
|
+
required: true,
|
|
1535
|
+
checkCompany: true,
|
|
1536
|
+
ondelete: 'cascade',
|
|
1537
|
+
}),
|
|
1538
|
+
date: f.date({ required: true, default: (env) => env.today(), label: 'Week of' }),
|
|
1539
|
+
name: f.char({
|
|
1540
|
+
store: true,
|
|
1541
|
+
label: 'Report',
|
|
1542
|
+
...compute('project.status.report', 'name', ['project.name', 'date'], (reports) => {
|
|
1543
|
+
for (const r of reports)
|
|
1544
|
+
r.name = `${r.project?.name ?? ''} — ${String(r.date ?? '').slice(0, 10)}`;
|
|
1545
|
+
}),
|
|
1546
|
+
}),
|
|
1547
|
+
author: f.many2one('res.users', {
|
|
1548
|
+
default: (env) => env.userId,
|
|
1549
|
+
ondelete: 'restrict',
|
|
1550
|
+
}),
|
|
1551
|
+
health: f.selection(HEALTH, { label: 'Health then' }),
|
|
1552
|
+
progress: f.decimal({ scale: 1, label: 'Progress then %' }),
|
|
1553
|
+
done: f.text({ label: 'Done this week' }),
|
|
1554
|
+
next: f.text({ label: 'Next week' }),
|
|
1555
|
+
risks: f.text({ label: 'Risks and issues' }),
|
|
1556
|
+
sentAt: f.datetime({ label: 'Sent' }),
|
|
1557
|
+
state: f.state({
|
|
1558
|
+
states: { draft: 'Draft', sent: 'Sent' },
|
|
1559
|
+
initial: 'draft',
|
|
1560
|
+
transitions: {
|
|
1561
|
+
send: {
|
|
1562
|
+
from: ['draft'],
|
|
1563
|
+
to: 'sent',
|
|
1564
|
+
guard: records('project.status.report', async (reports) => [...(await reports.load({}))]
|
|
1565
|
+
.filter((r) => (r.done ?? '').trim() === '' && (r.next ?? '').trim() === '')
|
|
1566
|
+
.map((r) => r.id)),
|
|
1567
|
+
message: 'A report says what was done or what is next.',
|
|
1568
|
+
after: records('project.status.report', async (reports) => {
|
|
1569
|
+
const env = reports.env;
|
|
1570
|
+
for (const r of await reports.load({ project: ['sponsor'] })) {
|
|
1571
|
+
await env
|
|
1572
|
+
.model('project.status.report')
|
|
1573
|
+
.browse([r.id])
|
|
1574
|
+
.write({ sentAt: new Date() });
|
|
1575
|
+
// Posted on the project for its followers; its sponsor follows it.
|
|
1576
|
+
if (chatter !== undefined) {
|
|
1577
|
+
const projectId = r.project.id;
|
|
1578
|
+
const sponsor = r.project.sponsor?.id ?? null;
|
|
1579
|
+
if (sponsor !== null)
|
|
1580
|
+
await setFollower(env, chatter, 'project.project', projectId, {
|
|
1581
|
+
user: sponsor,
|
|
1582
|
+
follow: true,
|
|
1583
|
+
});
|
|
1584
|
+
const parts = [
|
|
1585
|
+
`Status report, week of ${String(r.date ?? '').slice(0, 10)}`,
|
|
1586
|
+
`Health: ${r.health === null ? '—' : HEALTH[r.health]} · Progress: ${String(r.progress ?? 0)}%`,
|
|
1587
|
+
(r.done ?? '').trim() === '' ? '' : `Done: ${r.done}`,
|
|
1588
|
+
(r.next ?? '').trim() === '' ? '' : `Next: ${r.next}`,
|
|
1589
|
+
(r.risks ?? '').trim() === '' ? '' : `Risks and issues: ${r.risks}`,
|
|
1590
|
+
].filter((x) => x !== '');
|
|
1591
|
+
await postMessage(env, chatter, 'project.project', projectId, {
|
|
1592
|
+
kind: 'comment',
|
|
1593
|
+
text: parts.join('\n\n'),
|
|
1594
|
+
});
|
|
1595
|
+
}
|
|
1596
|
+
await reports.env.emit('project.status.report.sent', { id: r.id });
|
|
1597
|
+
}
|
|
1598
|
+
}),
|
|
1599
|
+
},
|
|
1600
|
+
},
|
|
1601
|
+
}),
|
|
1602
|
+
},
|
|
1603
|
+
onchange: {
|
|
1604
|
+
// The project's figures and its open risks and issues, filled in for the author to edit.
|
|
1605
|
+
project: onchange('project.status.report', async (d, env) => {
|
|
1606
|
+
const id = Number(d.get('project') ?? 0);
|
|
1607
|
+
if (!Number.isSafeInteger(id) || id <= 0)
|
|
1608
|
+
return;
|
|
1609
|
+
const [p] = await env
|
|
1610
|
+
.model('project.project')
|
|
1611
|
+
.browse([id])
|
|
1612
|
+
.load(['health', 'progress']);
|
|
1613
|
+
const risks = await env
|
|
1614
|
+
.model('project.risk')
|
|
1615
|
+
.search({ where: { project: id, status: { in: ['open', 'mitigating'] } } })
|
|
1616
|
+
.then((r) => r.load(['name', 'level']));
|
|
1617
|
+
const issues = await env
|
|
1618
|
+
.model('project.issue')
|
|
1619
|
+
.search({ where: { project: id, status: { in: ['open', 'in_progress'] } } })
|
|
1620
|
+
.then((r) => r.load(['name', 'severity']));
|
|
1621
|
+
d.set('health', p?.health ?? null);
|
|
1622
|
+
d.set('progress', p?.progress ?? null);
|
|
1623
|
+
d.set('risks', [
|
|
1624
|
+
...[...risks].map((x) => `Risk (${x.level}): ${x.name}`),
|
|
1625
|
+
...[...issues].map((x) => `Issue (${x.severity}): ${x.name}`),
|
|
1626
|
+
].join('\n'));
|
|
1627
|
+
}),
|
|
1628
|
+
},
|
|
1629
|
+
});
|
|
171
1630
|
export const ProjectTimesheet = defineModel('project.timesheet', {
|
|
172
1631
|
label: 'Timesheet',
|
|
173
1632
|
order: ['date desc', 'id desc'],
|
|
@@ -195,6 +1654,33 @@ export const ProjectTimesheet = defineModel('project.timesheet', {
|
|
|
195
1654
|
date: f.date({ required: true, default: (env) => env.today() }),
|
|
196
1655
|
hours: f.decimal({ scale: 2, required: true, default: '1' }),
|
|
197
1656
|
description: f.char(),
|
|
1657
|
+
/** The hours at the person's cost in the project on that day, else their role's default (managers only). */
|
|
1658
|
+
cost: f.decimal({
|
|
1659
|
+
scale: 2,
|
|
1660
|
+
store: true,
|
|
1661
|
+
label: 'Cost',
|
|
1662
|
+
groups: MANAGERS_SEE,
|
|
1663
|
+
...compute('project.timesheet', 'cost', [
|
|
1664
|
+
'hours',
|
|
1665
|
+
'user',
|
|
1666
|
+
'date',
|
|
1667
|
+
'project',
|
|
1668
|
+
'project.members.hourlyCost',
|
|
1669
|
+
'project.members.user',
|
|
1670
|
+
'project.members.dateFrom',
|
|
1671
|
+
'project.members.dateTo',
|
|
1672
|
+
'project.members.role.defaultHourlyCost',
|
|
1673
|
+
], (lines) => {
|
|
1674
|
+
for (const l of lines) {
|
|
1675
|
+
const day = String(l.date);
|
|
1676
|
+
const member = [...(l.project?.members ?? [])].find((m) => m.user.id === l.user.id &&
|
|
1677
|
+
(m.dateFrom === null || String(m.dateFrom) <= day) &&
|
|
1678
|
+
(m.dateTo === null || String(m.dateTo) >= day));
|
|
1679
|
+
const rate = member?.hourlyCost ?? member?.role?.defaultHourlyCost ?? null;
|
|
1680
|
+
l.cost = rate === null ? orm.toDecimal(0) : l.hours.times(rate).toDecimalPlaces(2);
|
|
1681
|
+
}
|
|
1682
|
+
}),
|
|
1683
|
+
}),
|
|
198
1684
|
},
|
|
199
1685
|
sqlConstraints: {
|
|
200
1686
|
hoursPositive: { check: orm.sql `hours > 0`, message: 'Hours are positive.' },
|
|
@@ -215,8 +1701,8 @@ export const ProjectTimesheet = defineModel('project.timesheet', {
|
|
|
215
1701
|
});
|
|
216
1702
|
export const projectShutro = defineModule({
|
|
217
1703
|
name: 'project',
|
|
218
|
-
version: '0.
|
|
219
|
-
depends: [contactsShutro, messagingShutro],
|
|
1704
|
+
version: '0.4.0',
|
|
1705
|
+
depends: [contactsShutro, messagingShutro, permissionsShutro],
|
|
220
1706
|
groups: [
|
|
221
1707
|
orm.defineGroup({
|
|
222
1708
|
code: 'project.user',
|
|
@@ -229,7 +1715,38 @@ export const projectShutro = defineModule({
|
|
|
229
1715
|
implies: ['project.user'],
|
|
230
1716
|
}),
|
|
231
1717
|
],
|
|
232
|
-
|
|
1718
|
+
permissions: [
|
|
1719
|
+
orm.definePermission({
|
|
1720
|
+
code: 'project.baseline',
|
|
1721
|
+
label: 'Take a plan’s baseline again',
|
|
1722
|
+
groups: ['project.manager'],
|
|
1723
|
+
}),
|
|
1724
|
+
],
|
|
1725
|
+
sequences: [
|
|
1726
|
+
orm.defineSequence({ code: 'project.project', prefix: 'PRJ-', padding: 4 }),
|
|
1727
|
+
orm.defineSequence({ code: 'project.plan', prefix: 'PLAN-', padding: 4 }),
|
|
1728
|
+
orm.defineSequence({ code: 'project.change', prefix: 'CR-', padding: 4 }),
|
|
1729
|
+
],
|
|
1730
|
+
models: [
|
|
1731
|
+
ProjectRisk,
|
|
1732
|
+
ProjectIssue,
|
|
1733
|
+
ProjectChange,
|
|
1734
|
+
ProjectStatusReport,
|
|
1735
|
+
ProjectBudgetLine,
|
|
1736
|
+
ProjectRole,
|
|
1737
|
+
ProjectMember,
|
|
1738
|
+
ProjectPlan,
|
|
1739
|
+
ProjectType,
|
|
1740
|
+
ProjectTaskCategory,
|
|
1741
|
+
ProjectTag,
|
|
1742
|
+
ProjectTaskStage,
|
|
1743
|
+
ProjectProject,
|
|
1744
|
+
ProjectPhase,
|
|
1745
|
+
ProjectWorkPackage,
|
|
1746
|
+
ProjectTask,
|
|
1747
|
+
ProjectTaskLink,
|
|
1748
|
+
ProjectTimesheet,
|
|
1749
|
+
],
|
|
233
1750
|
extensions: [CompanyProject],
|
|
234
1751
|
// The kanban every installation starts with (theirs to change afterwards).
|
|
235
1752
|
data: [
|