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.
Files changed (384) hide show
  1. package/dist/account_asset/.kosh/bindings.d.ts +22 -0
  2. package/dist/account_asset/.kosh/bindings.js +15 -0
  3. package/dist/account_asset/.kosh/schema.d.ts +1755 -0
  4. package/dist/account_asset/board.d.ts +61 -0
  5. package/dist/account_asset/board.js +86 -0
  6. package/dist/account_asset/books.d.ts +42 -0
  7. package/dist/account_asset/books.js +398 -0
  8. package/dist/account_asset/demo.d.ts +7 -0
  9. package/dist/account_asset/demo.js +72 -0
  10. package/dist/account_asset/index.d.ts +9 -0
  11. package/dist/account_asset/index.js +335 -0
  12. package/dist/account_asset/module.d.ts +7622 -0
  13. package/dist/account_asset/module.js +230 -0
  14. package/dist/account_budget/.kosh/bindings.d.ts +22 -0
  15. package/dist/account_budget/.kosh/bindings.js +15 -0
  16. package/dist/account_budget/.kosh/schema.d.ts +1767 -0
  17. package/dist/account_budget/budget.d.ts +5 -0
  18. package/dist/account_budget/budget.js +142 -0
  19. package/dist/account_budget/demo.d.ts +6 -0
  20. package/dist/account_budget/demo.js +30 -0
  21. package/dist/account_budget/index.d.ts +4 -0
  22. package/dist/account_budget/index.js +223 -0
  23. package/dist/account_budget/module.d.ts +13863 -0
  24. package/dist/account_budget/module.js +167 -0
  25. package/dist/account_portal/.kosh/bindings.js +1 -1
  26. package/dist/account_portal/.kosh/schema.d.ts +123 -4
  27. package/dist/account_portal/module.d.ts +146 -0
  28. package/dist/accounting/.kosh/bindings.js +1 -1
  29. package/dist/accounting/.kosh/schema.d.ts +123 -4
  30. package/dist/accounting/index.d.ts +1 -1
  31. package/dist/accounting/index.js +131 -3
  32. package/dist/accounting/module.d.ts +296 -0
  33. package/dist/accounting/module.js +32 -1
  34. package/dist/approvals/.kosh/bindings.d.ts +22 -0
  35. package/dist/approvals/.kosh/bindings.js +15 -0
  36. package/dist/approvals/.kosh/schema.d.ts +611 -0
  37. package/dist/approvals/index.d.ts +18 -0
  38. package/dist/approvals/index.js +302 -0
  39. package/dist/approvals/mails.d.ts +25 -0
  40. package/dist/approvals/mails.js +82 -0
  41. package/dist/approvals/module.d.ts +1 -0
  42. package/dist/approvals/module.js +12 -0
  43. package/dist/approvals/web/History.d.ts +4 -0
  44. package/dist/approvals/web/History.js +13 -0
  45. package/dist/approvals/web/Inbox.d.ts +9 -0
  46. package/dist/approvals/web/Inbox.js +114 -0
  47. package/dist/approvals/web/Mine.d.ts +7 -0
  48. package/dist/approvals/web/Mine.js +27 -0
  49. package/dist/approvals/web/index.d.ts +13 -0
  50. package/dist/approvals/web/index.js +22 -0
  51. package/dist/approvals/web/messages.d.ts +2 -0
  52. package/dist/approvals/web/messages.js +119 -0
  53. package/dist/approvals/web/shared.d.ts +59 -0
  54. package/dist/approvals/web/shared.js +46 -0
  55. package/dist/attendance/.kosh/bindings.d.ts +22 -0
  56. package/dist/attendance/.kosh/bindings.js +15 -0
  57. package/dist/attendance/.kosh/schema.d.ts +1048 -0
  58. package/dist/attendance/days.d.ts +96 -0
  59. package/dist/attendance/days.js +430 -0
  60. package/dist/attendance/demo.d.ts +7 -0
  61. package/dist/attendance/demo.js +58 -0
  62. package/dist/attendance/index.d.ts +4 -0
  63. package/dist/attendance/index.js +513 -0
  64. package/dist/attendance/module.d.ts +3969 -0
  65. package/dist/attendance/module.js +412 -0
  66. package/dist/attendance/routes.d.ts +16 -0
  67. package/dist/attendance/routes.js +266 -0
  68. package/dist/attendance/web/Check.d.ts +8 -0
  69. package/dist/attendance/web/Check.js +40 -0
  70. package/dist/attendance/web/Kiosk.d.ts +8 -0
  71. package/dist/attendance/web/Kiosk.js +62 -0
  72. package/dist/attendance/web/PinButton.d.ts +7 -0
  73. package/dist/attendance/web/PinButton.js +29 -0
  74. package/dist/attendance/web/index.d.ts +13 -0
  75. package/dist/attendance/web/index.js +22 -0
  76. package/dist/attendance/web/messages.d.ts +2 -0
  77. package/dist/attendance/web/messages.js +61 -0
  78. package/dist/cli/init.js +32 -0
  79. package/dist/cli/templates.d.ts +1 -1
  80. package/dist/cli/templates.js +89 -0
  81. package/dist/contacts/module.js +13 -2
  82. package/dist/crm/.kosh/bindings.js +1 -1
  83. package/dist/crm/.kosh/schema.d.ts +2 -1
  84. package/dist/crm/module.d.ts +2 -0
  85. package/dist/crm/module.js +2 -1
  86. package/dist/documents/.kosh/bindings.d.ts +22 -0
  87. package/dist/documents/.kosh/bindings.js +15 -0
  88. package/dist/documents/.kosh/schema.d.ts +862 -0
  89. package/dist/documents/demo.d.ts +6 -0
  90. package/dist/documents/demo.js +22 -0
  91. package/dist/documents/documents.d.ts +98 -0
  92. package/dist/documents/documents.js +318 -0
  93. package/dist/documents/index.d.ts +30 -0
  94. package/dist/documents/index.js +442 -0
  95. package/dist/documents/module.d.ts +2781 -0
  96. package/dist/documents/module.js +214 -0
  97. package/dist/documents/web/Versions.d.ts +7 -0
  98. package/dist/documents/web/Versions.js +45 -0
  99. package/dist/documents/web/index.d.ts +11 -0
  100. package/dist/documents/web/index.js +16 -0
  101. package/dist/documents/web/messages.d.ts +2 -0
  102. package/dist/documents/web/messages.js +21 -0
  103. package/dist/event_account/.kosh/bindings.js +1 -1
  104. package/dist/event_account/.kosh/schema.d.ts +123 -4
  105. package/dist/event_account/module.d.ts +146 -0
  106. package/dist/expense/.kosh/bindings.d.ts +22 -0
  107. package/dist/expense/.kosh/bindings.js +15 -0
  108. package/dist/expense/.kosh/schema.d.ts +1299 -0
  109. package/dist/expense/demo.d.ts +7 -0
  110. package/dist/expense/demo.js +69 -0
  111. package/dist/expense/index.d.ts +3 -0
  112. package/dist/expense/index.js +495 -0
  113. package/dist/expense/module.d.ts +4747 -0
  114. package/dist/expense/module.js +647 -0
  115. package/dist/expense_account/.kosh/bindings.d.ts +22 -0
  116. package/dist/expense_account/.kosh/bindings.js +15 -0
  117. package/dist/expense_account/.kosh/schema.d.ts +1936 -0
  118. package/dist/expense_account/index.d.ts +3 -0
  119. package/dist/expense_account/index.js +74 -0
  120. package/dist/expense_account/module.d.ts +9153 -0
  121. package/dist/expense_account/module.js +318 -0
  122. package/dist/helpdesk/.kosh/bindings.d.ts +22 -0
  123. package/dist/helpdesk/.kosh/bindings.js +15 -0
  124. package/dist/helpdesk/.kosh/schema.d.ts +885 -0
  125. package/dist/helpdesk/demo.d.ts +7 -0
  126. package/dist/helpdesk/demo.js +65 -0
  127. package/dist/helpdesk/hours.d.ts +17 -0
  128. package/dist/helpdesk/hours.js +94 -0
  129. package/dist/helpdesk/index.d.ts +10 -0
  130. package/dist/helpdesk/index.js +461 -0
  131. package/dist/helpdesk/module.d.ts +2940 -0
  132. package/dist/helpdesk/module.js +326 -0
  133. package/dist/helpdesk/routes.d.ts +12 -0
  134. package/dist/helpdesk/routes.js +132 -0
  135. package/dist/helpdesk/sla.d.ts +37 -0
  136. package/dist/helpdesk/sla.js +226 -0
  137. package/dist/hr/index.d.ts +1 -1
  138. package/dist/hr/index.js +1 -1
  139. package/dist/l10n_bd/.kosh/bindings.js +1 -1
  140. package/dist/l10n_bd/.kosh/schema.d.ts +123 -4
  141. package/dist/l10n_bd/module.d.ts +146 -0
  142. package/dist/l10n_bd_payroll/.kosh/bindings.d.ts +22 -0
  143. package/dist/l10n_bd_payroll/.kosh/bindings.js +15 -0
  144. package/dist/l10n_bd_payroll/.kosh/schema.d.ts +2312 -0
  145. package/dist/l10n_bd_payroll/data.d.ts +47 -0
  146. package/dist/l10n_bd_payroll/data.js +347 -0
  147. package/dist/l10n_bd_payroll/index.d.ts +4 -0
  148. package/dist/l10n_bd_payroll/index.js +24 -0
  149. package/dist/l10n_bd_payroll/module.d.ts +12932 -0
  150. package/dist/l10n_bd_payroll/module.js +86 -0
  151. package/dist/l10n_in/.kosh/bindings.js +1 -1
  152. package/dist/l10n_in/.kosh/schema.d.ts +123 -4
  153. package/dist/l10n_in/module.d.ts +147 -0
  154. package/dist/mail/inbound/handle.js +6 -1
  155. package/dist/maintenance_stock/.kosh/bindings.js +1 -1
  156. package/dist/maintenance_stock/.kosh/schema.d.ts +77 -2
  157. package/dist/maintenance_stock/module.d.ts +788 -66
  158. package/dist/modules-sdk/index.d.ts +1 -1
  159. package/dist/modules-sdk/manifest.d.ts +39 -2
  160. package/dist/mrp/.kosh/bindings.js +1 -1
  161. package/dist/mrp/.kosh/schema.d.ts +77 -2
  162. package/dist/mrp/index.d.ts +1 -1
  163. package/dist/mrp/index.js +2 -1
  164. package/dist/mrp/module.d.ts +781 -54
  165. package/dist/mrp/module.js +64 -2
  166. package/dist/mrp_account/.kosh/bindings.js +1 -1
  167. package/dist/mrp_account/.kosh/schema.d.ts +198 -5
  168. package/dist/mrp_account/module.d.ts +4852 -2541
  169. package/dist/payment/.kosh/bindings.js +1 -1
  170. package/dist/payment/.kosh/schema.d.ts +125 -5
  171. package/dist/payment/index.d.ts +41 -0
  172. package/dist/payment/index.js +131 -74
  173. package/dist/payment/module.d.ts +210 -6
  174. package/dist/payment/module.js +19 -3
  175. package/dist/payment/settle.js +10 -1
  176. package/dist/payroll/.kosh/bindings.d.ts +22 -0
  177. package/dist/payroll/.kosh/bindings.js +15 -0
  178. package/dist/payroll/.kosh/schema.d.ts +1390 -0
  179. package/dist/payroll/compute.d.ts +35 -0
  180. package/dist/payroll/compute.js +398 -0
  181. package/dist/payroll/demo.d.ts +55 -0
  182. package/dist/payroll/demo.js +121 -0
  183. package/dist/payroll/formula.d.ts +62 -0
  184. package/dist/payroll/formula.js +320 -0
  185. package/dist/payroll/index.d.ts +230 -0
  186. package/dist/payroll/index.js +713 -0
  187. package/dist/payroll/module.d.ts +9390 -0
  188. package/dist/payroll/module.js +606 -0
  189. package/dist/payroll/routes.d.ts +10 -0
  190. package/dist/payroll/routes.js +112 -0
  191. package/dist/payroll_account/.kosh/bindings.d.ts +22 -0
  192. package/dist/payroll_account/.kosh/bindings.js +15 -0
  193. package/dist/payroll_account/.kosh/schema.d.ts +2308 -0
  194. package/dist/payroll_account/index.d.ts +3 -0
  195. package/dist/payroll_account/index.js +40 -0
  196. package/dist/payroll_account/module.d.ts +12396 -0
  197. package/dist/payroll_account/module.js +256 -0
  198. package/dist/permissions/approvals.d.ts +131 -9
  199. package/dist/permissions/approvals.js +662 -57
  200. package/dist/permissions/index.d.ts +1 -1
  201. package/dist/permissions/index.js +2 -2
  202. package/dist/permissions/models.js +191 -12
  203. package/dist/permissions/routes.d.ts +6 -1
  204. package/dist/permissions/routes.js +37 -4
  205. package/dist/permissions/web/ApprovalPanel.d.ts +5 -4
  206. package/dist/permissions/web/ApprovalPanel.js +39 -13
  207. package/dist/permissions/web/index.d.ts +6 -4
  208. package/dist/permissions/web/index.js +13 -4
  209. package/dist/permissions/web/messages.d.ts +2 -0
  210. package/dist/permissions/web/messages.js +43 -0
  211. package/dist/portal/index.js +1 -1
  212. package/dist/pos/.kosh/bindings.js +1 -1
  213. package/dist/pos/.kosh/schema.d.ts +123 -4
  214. package/dist/pos/index.js +4 -0
  215. package/dist/pos/module.d.ts +147 -0
  216. package/dist/pos_stock/.kosh/bindings.js +1 -1
  217. package/dist/pos_stock/.kosh/schema.d.ts +198 -5
  218. package/dist/pos_stock/module.d.ts +914 -46
  219. package/dist/product/.kosh/bindings.js +1 -1
  220. package/dist/product/.kosh/schema.d.ts +2 -1
  221. package/dist/product/index.js +13 -0
  222. package/dist/product/module.d.ts +2 -0
  223. package/dist/product/module.js +2 -1
  224. package/dist/project/.kosh/bindings.js +1 -1
  225. package/dist/project/.kosh/schema.d.ts +672 -1
  226. package/dist/project/index.d.ts +3 -1
  227. package/dist/project/index.js +901 -14
  228. package/dist/project/module.d.ts +9892 -390
  229. package/dist/project/module.js +1530 -13
  230. package/dist/project/routes.d.ts +44 -0
  231. package/dist/project/routes.js +314 -0
  232. package/dist/project/schedule.d.ts +72 -0
  233. package/dist/project/schedule.js +192 -0
  234. package/dist/project/web/Refresh.d.ts +8 -0
  235. package/dist/project/web/Refresh.js +29 -0
  236. package/dist/project/web/Schedule.d.ts +9 -0
  237. package/dist/project/web/Schedule.js +93 -0
  238. package/dist/project/web/Structure.d.ts +8 -0
  239. package/dist/project/web/Structure.js +86 -0
  240. package/dist/project/web/Workload.d.ts +8 -0
  241. package/dist/project/web/Workload.js +43 -0
  242. package/dist/project/web/index.d.ts +14 -0
  243. package/dist/project/web/index.js +25 -0
  244. package/dist/project/web/messages.d.ts +2 -0
  245. package/dist/project/web/messages.js +85 -0
  246. package/dist/project_account/.kosh/bindings.d.ts +22 -0
  247. package/dist/project_account/.kosh/bindings.js +15 -0
  248. package/dist/project_account/.kosh/schema.d.ts +2241 -0
  249. package/dist/project_account/index.d.ts +3 -0
  250. package/dist/project_account/index.js +27 -0
  251. package/dist/project_account/module.d.ts +11539 -0
  252. package/dist/project_account/module.js +76 -0
  253. package/dist/project_portal/.kosh/bindings.js +1 -1
  254. package/dist/project_portal/.kosh/schema.d.ts +672 -1
  255. package/dist/project_portal/module.d.ts +4798 -129
  256. package/dist/project_purchase/.kosh/bindings.d.ts +22 -0
  257. package/dist/project_purchase/.kosh/bindings.js +15 -0
  258. package/dist/project_purchase/.kosh/schema.d.ts +2320 -0
  259. package/dist/project_purchase/index.d.ts +9 -0
  260. package/dist/project_purchase/index.js +22 -0
  261. package/dist/project_purchase/module.d.ts +25147 -0
  262. package/dist/project_purchase/module.js +56 -0
  263. package/dist/purchase/.kosh/bindings.js +1 -1
  264. package/dist/purchase/.kosh/schema.d.ts +97 -4
  265. package/dist/purchase/module.d.ts +13 -2
  266. package/dist/purchase/module.js +10 -1
  267. package/dist/purchase_account/.kosh/bindings.js +1 -1
  268. package/dist/purchase_account/.kosh/schema.d.ts +126 -6
  269. package/dist/purchase_account/index.js +1 -0
  270. package/dist/purchase_account/module.d.ts +176 -1
  271. package/dist/purchase_account/module.js +13 -2
  272. package/dist/purchase_portal/.kosh/bindings.js +1 -1
  273. package/dist/purchase_portal/.kosh/schema.d.ts +97 -4
  274. package/dist/purchase_portal/module.d.ts +6 -1
  275. package/dist/purchase_stock/.kosh/bindings.js +1 -1
  276. package/dist/purchase_stock/.kosh/schema.d.ts +174 -5
  277. package/dist/purchase_stock/index.d.ts +1 -1
  278. package/dist/purchase_stock/index.js +9 -2
  279. package/dist/purchase_stock/module.d.ts +840 -68
  280. package/dist/purchase_stock/module.js +242 -6
  281. package/dist/purchase_stock_account/.kosh/bindings.js +1 -1
  282. package/dist/purchase_stock_account/.kosh/schema.d.ts +203 -7
  283. package/dist/purchase_stock_account/module.d.ts +8163 -5514
  284. package/dist/purchase_stock_account/module.js +11 -5
  285. package/dist/recruitment/.kosh/bindings.js +1 -1
  286. package/dist/recruitment/.kosh/schema.d.ts +2 -1
  287. package/dist/recruitment/module.d.ts +2 -0
  288. package/dist/recruitment/module.js +2 -1
  289. package/dist/sale_account/.kosh/bindings.js +1 -1
  290. package/dist/sale_account/.kosh/schema.d.ts +123 -4
  291. package/dist/sale_account/module.d.ts +147 -0
  292. package/dist/sale_portal/.kosh/bindings.js +1 -1
  293. package/dist/sale_portal/.kosh/schema.d.ts +2 -1
  294. package/dist/sale_portal/module.d.ts +1 -0
  295. package/dist/sale_project/.kosh/bindings.js +1 -1
  296. package/dist/sale_project/.kosh/schema.d.ts +673 -1
  297. package/dist/sale_project/module.d.ts +4800 -129
  298. package/dist/sale_project/module.js +17 -1
  299. package/dist/sale_stock/.kosh/bindings.js +1 -1
  300. package/dist/sale_stock/.kosh/schema.d.ts +77 -2
  301. package/dist/sale_stock/module.d.ts +789 -66
  302. package/dist/sales/.kosh/bindings.js +1 -1
  303. package/dist/sales/.kosh/schema.d.ts +2 -1
  304. package/dist/sales/index.js +4 -0
  305. package/dist/sales/module.d.ts +1 -0
  306. package/dist/sign/.kosh/bindings.d.ts +22 -0
  307. package/dist/sign/.kosh/bindings.js +15 -0
  308. package/dist/sign/.kosh/schema.d.ts +990 -0
  309. package/dist/sign/index.d.ts +14 -0
  310. package/dist/sign/index.js +350 -0
  311. package/dist/sign/module.d.ts +4478 -0
  312. package/dist/sign/module.js +158 -0
  313. package/dist/sign/sign.d.ts +82 -0
  314. package/dist/sign/sign.js +474 -0
  315. package/dist/stock/.kosh/bindings.js +1 -1
  316. package/dist/stock/.kosh/schema.d.ts +77 -2
  317. package/dist/stock/demo.d.ts +2 -1
  318. package/dist/stock/demo.js +28 -1
  319. package/dist/stock/index.d.ts +2 -1
  320. package/dist/stock/index.js +233 -4
  321. package/dist/stock/module.d.ts +2363 -837
  322. package/dist/stock/module.js +569 -14
  323. package/dist/stock/replenish.d.ts +38 -0
  324. package/dist/stock/replenish.js +176 -0
  325. package/dist/stock/web/Replenishment.d.ts +8 -0
  326. package/dist/stock/web/Replenishment.js +84 -0
  327. package/dist/stock/web/index.d.ts +4 -2
  328. package/dist/stock/web/index.js +6 -2
  329. package/dist/stock/web/messages.js +52 -0
  330. package/dist/stock_account/.kosh/bindings.js +1 -1
  331. package/dist/stock_account/.kosh/schema.d.ts +198 -5
  332. package/dist/stock_account/index.js +10 -0
  333. package/dist/stock_account/module.d.ts +969 -101
  334. package/dist/trade_finance/index.js +2 -0
  335. package/dist/trade_finance/module.js +2 -0
  336. package/dist/trade_finance_account/.kosh/bindings.js +1 -1
  337. package/dist/trade_finance_account/.kosh/schema.d.ts +123 -4
  338. package/dist/trade_finance_account/index.js +1 -0
  339. package/dist/trade_finance_account/module.d.ts +146 -0
  340. package/dist/trade_finance_account/module.js +1 -0
  341. package/dist/trade_finance_purchase/.kosh/bindings.js +1 -1
  342. package/dist/trade_finance_purchase/.kosh/schema.d.ts +97 -4
  343. package/dist/trade_finance_purchase/module.d.ts +6 -1
  344. package/dist/trade_finance_sale/.kosh/bindings.js +1 -1
  345. package/dist/trade_finance_sale/.kosh/schema.d.ts +2 -1
  346. package/dist/trade_finance_sale/module.d.ts +1 -0
  347. package/dist/ui/app.js +24 -12
  348. package/dist/ui/components/AppIcon.js +8 -1
  349. package/dist/ui/i18n/messages.d.ts +4 -0
  350. package/dist/ui/i18n/messages.js +8 -0
  351. package/dist/ui/views/FormView.js +4 -2
  352. package/dist/ui/views/KanbanView.js +9 -1
  353. package/dist/ui/views/ListView.js +4 -2
  354. package/dist/ui/views/widgets.js +7 -1
  355. package/dist/website/index.js +1 -0
  356. package/dist/website/module.js +1 -0
  357. package/dist/website_crm/.kosh/bindings.js +1 -1
  358. package/dist/website_crm/.kosh/schema.d.ts +2 -1
  359. package/dist/website_crm/module.d.ts +1 -0
  360. package/dist/website_sale/.kosh/bindings.d.ts +22 -0
  361. package/dist/website_sale/.kosh/bindings.js +15 -0
  362. package/dist/website_sale/.kosh/schema.d.ts +2284 -0
  363. package/dist/website_sale/couriers.d.ts +50 -0
  364. package/dist/website_sale/couriers.js +59 -0
  365. package/dist/website_sale/demo.d.ts +7 -0
  366. package/dist/website_sale/demo.js +90 -0
  367. package/dist/website_sale/growth.d.ts +31 -0
  368. package/dist/website_sale/growth.js +163 -0
  369. package/dist/website_sale/index.d.ts +49 -0
  370. package/dist/website_sale/index.js +538 -0
  371. package/dist/website_sale/module.d.ts +22027 -0
  372. package/dist/website_sale/module.js +397 -0
  373. package/dist/website_sale/shipments.d.ts +16 -0
  374. package/dist/website_sale/shipments.js +113 -0
  375. package/dist/website_sale/shop.d.ts +81 -0
  376. package/dist/website_sale/shop.js +649 -0
  377. package/dist/website_sale_stock/.kosh/bindings.d.ts +22 -0
  378. package/dist/website_sale_stock/.kosh/bindings.js +15 -0
  379. package/dist/website_sale_stock/.kosh/schema.d.ts +2681 -0
  380. package/dist/website_sale_stock/index.d.ts +9 -0
  381. package/dist/website_sale_stock/index.js +20 -0
  382. package/dist/website_sale_stock/module.d.ts +24998 -0
  383. package/dist/website_sale_stock/module.js +28 -0
  384. package/package.json +83 -2
@@ -1,13 +1,17 @@
1
1
  /**
2
- * The data layer of Projects (RFC 0008): projects with their customer and manager, tasks on a kanban of shared
3
- * stages (a stage may close the task), assignees, priorities, deadlines, planned hours, and timesheets whose hours
4
- * add up on the task. Users see every project and task of their companies and their own timesheets; managers
5
- * see everything.
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
- const [company] = (await env
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([env.companyId])
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
- tracking: ['manager', 'partner', 'dateEnd'],
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.2.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
- models: [ProjectTaskStage, ProjectProject, ProjectTask, ProjectTimesheet],
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: [