shutro-erp 0.3.5 → 0.3.7

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