shutro-erp 0.3.6 → 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 (623) hide show
  1. package/dist/account_asset/.kosh/bindings.js +1 -1
  2. package/dist/account_asset/.kosh/schema.d.ts +530 -8
  3. package/dist/account_asset/i18n/bn.d.ts +2 -0
  4. package/dist/account_asset/i18n/bn.js +97 -0
  5. package/dist/account_asset/index.js +2 -0
  6. package/dist/account_asset/module.d.ts +4899 -1445
  7. package/dist/account_budget/.kosh/bindings.js +1 -1
  8. package/dist/account_budget/.kosh/schema.d.ts +1130 -56
  9. package/dist/account_budget/i18n/bn.d.ts +2 -0
  10. package/dist/account_budget/i18n/bn.js +63 -0
  11. package/dist/account_budget/index.js +14 -0
  12. package/dist/account_budget/module.d.ts +17958 -4532
  13. package/dist/account_budget/module.js +15 -0
  14. package/dist/account_portal/.kosh/bindings.js +1 -1
  15. package/dist/account_portal/.kosh/schema.d.ts +530 -8
  16. package/dist/account_portal/i18n/bn.d.ts +2 -0
  17. package/dist/account_portal/i18n/bn.js +6 -0
  18. package/dist/account_portal/index.js +2 -0
  19. package/dist/account_portal/module.d.ts +5167 -1693
  20. package/dist/accounting/.kosh/bindings.js +1 -1
  21. package/dist/accounting/.kosh/schema.d.ts +530 -8
  22. package/dist/accounting/closing.js +1 -0
  23. package/dist/accounting/fiscal.d.ts +578 -0
  24. package/dist/accounting/fiscal.js +401 -0
  25. package/dist/accounting/followups.d.ts +618 -0
  26. package/dist/accounting/followups.js +420 -0
  27. package/dist/accounting/i18n/bn.d.ts +2 -0
  28. package/dist/accounting/i18n/bn.js +425 -0
  29. package/dist/accounting/index.d.ts +2 -0
  30. package/dist/accounting/index.js +649 -2
  31. package/dist/accounting/module.d.ts +4998 -1451
  32. package/dist/accounting/module.js +124 -18
  33. package/dist/accounting/statements.d.ts +878 -0
  34. package/dist/accounting/statements.js +728 -0
  35. package/dist/accounting/web/BankReconcilePage.d.ts +9 -0
  36. package/dist/accounting/web/BankReconcilePage.js +138 -0
  37. package/dist/accounting/web/FollowupsPage.d.ts +8 -0
  38. package/dist/accounting/web/FollowupsPage.js +54 -0
  39. package/dist/accounting/web/PostingPreview.d.ts +8 -0
  40. package/dist/accounting/web/PostingPreview.js +26 -0
  41. package/dist/accounting/web/index.d.ts +3 -0
  42. package/dist/accounting/web/index.js +9 -0
  43. package/dist/accounting/web/messages.js +140 -0
  44. package/dist/approvals/.kosh/bindings.js +1 -1
  45. package/dist/approvals/.kosh/schema.d.ts +272 -4
  46. package/dist/approvals/demo.d.ts +7 -0
  47. package/dist/approvals/demo.js +41 -0
  48. package/dist/approvals/i18n/bn.d.ts +2 -0
  49. package/dist/approvals/i18n/bn.js +133 -0
  50. package/dist/approvals/index.d.ts +19 -1
  51. package/dist/approvals/index.js +477 -7
  52. package/dist/approvals/mails.d.ts +7 -2
  53. package/dist/approvals/mails.js +8 -3
  54. package/dist/approvals/module.d.ts +2930 -1
  55. package/dist/approvals/module.js +468 -7
  56. package/dist/approvals/web/Config.d.ts +12 -0
  57. package/dist/approvals/web/Config.js +60 -0
  58. package/dist/approvals/web/Matrix.d.ts +9 -0
  59. package/dist/approvals/web/Matrix.js +26 -0
  60. package/dist/approvals/web/index.d.ts +3 -1
  61. package/dist/approvals/web/index.js +8 -1
  62. package/dist/approvals/web/messages.js +92 -0
  63. package/dist/approvals_purchase/.kosh/bindings.js +15 -0
  64. package/dist/approvals_purchase/.kosh/schema.d.ts +1997 -0
  65. package/dist/approvals_purchase/i18n/bn.d.ts +2 -0
  66. package/dist/approvals_purchase/i18n/bn.js +5 -0
  67. package/dist/approvals_purchase/index.d.ts +3 -0
  68. package/dist/approvals_purchase/index.js +47 -0
  69. package/dist/approvals_purchase/module.d.ts +12670 -0
  70. package/dist/approvals_purchase/module.js +84 -0
  71. package/dist/attendance/.kosh/bindings.js +1 -1
  72. package/dist/attendance/.kosh/schema.d.ts +59 -1
  73. package/dist/attendance/i18n/bn.d.ts +2 -0
  74. package/dist/attendance/i18n/bn.js +147 -0
  75. package/dist/attendance/index.js +3 -1
  76. package/dist/attendance/module.d.ts +452 -8
  77. package/dist/automation/engine.js +1 -1
  78. package/dist/calendar/.kosh/bindings.js +1 -1
  79. package/dist/calendar/.kosh/schema.d.ts +2 -1
  80. package/dist/calendar/i18n/bn.d.ts +2 -0
  81. package/dist/calendar/i18n/bn.js +80 -0
  82. package/dist/calendar/index.js +77 -0
  83. package/dist/calendar/module.d.ts +26 -0
  84. package/dist/calendar/module.js +14 -4
  85. package/dist/cli/apps.js +25 -5
  86. package/dist/cli/init.js +14 -4
  87. package/dist/cli/templates.d.ts +1 -1
  88. package/dist/cli/templates.js +34 -5
  89. package/dist/contacts/.kosh/bindings.js +1 -1
  90. package/dist/contacts/.kosh/schema.d.ts +2 -1
  91. package/dist/contacts/i18n/bn.d.ts +2 -0
  92. package/dist/contacts/i18n/bn.js +67 -0
  93. package/dist/contacts/index.js +3 -0
  94. package/dist/contacts/module.d.ts +42 -0
  95. package/dist/contacts/module.js +6 -1
  96. package/dist/crm/.kosh/bindings.js +1 -1
  97. package/dist/crm/.kosh/schema.d.ts +2 -1
  98. package/dist/crm/i18n/bn.d.ts +2 -0
  99. package/dist/crm/i18n/bn.js +80 -0
  100. package/dist/crm/index.js +22 -0
  101. package/dist/crm/module.d.ts +20 -0
  102. package/dist/crm/module.js +4 -2
  103. package/dist/documents/.kosh/bindings.js +1 -1
  104. package/dist/documents/.kosh/schema.d.ts +2 -1
  105. package/dist/documents/i18n/bn.d.ts +2 -0
  106. package/dist/documents/i18n/bn.js +83 -0
  107. package/dist/documents/index.js +128 -1
  108. package/dist/documents/module.d.ts +20 -0
  109. package/dist/documents/module.js +1 -0
  110. package/dist/event/.kosh/bindings.js +1 -1
  111. package/dist/event/.kosh/schema.d.ts +2 -1
  112. package/dist/event/i18n/bn.d.ts +2 -0
  113. package/dist/event/i18n/bn.js +101 -0
  114. package/dist/event/index.js +66 -0
  115. package/dist/event/module.d.ts +20 -0
  116. package/dist/event/module.js +8 -1
  117. package/dist/event_account/.kosh/bindings.js +1 -1
  118. package/dist/event_account/.kosh/schema.d.ts +530 -8
  119. package/dist/event_account/i18n/bn.d.ts +2 -0
  120. package/dist/event_account/i18n/bn.js +5 -0
  121. package/dist/event_account/index.js +2 -0
  122. package/dist/event_account/module.d.ts +4905 -1431
  123. package/dist/expense/.kosh/bindings.js +1 -1
  124. package/dist/expense/.kosh/schema.d.ts +323 -2
  125. package/dist/expense/i18n/bn.d.ts +2 -0
  126. package/dist/expense/i18n/bn.js +155 -0
  127. package/dist/expense/index.js +8 -1
  128. package/dist/expense/module.d.ts +2715 -694
  129. package/dist/expense/module.js +3 -1
  130. package/dist/expense_account/.kosh/bindings.js +1 -1
  131. package/dist/expense_account/.kosh/schema.d.ts +587 -8
  132. package/dist/expense_account/i18n/bn.d.ts +2 -0
  133. package/dist/expense_account/i18n/bn.js +15 -0
  134. package/dist/expense_account/index.js +2 -0
  135. package/dist/expense_account/module.d.ts +8094 -2619
  136. package/dist/helpdesk/.kosh/bindings.js +1 -1
  137. package/dist/helpdesk/.kosh/schema.d.ts +2 -1
  138. package/dist/helpdesk/i18n/bn.d.ts +2 -0
  139. package/dist/helpdesk/i18n/bn.js +127 -0
  140. package/dist/helpdesk/index.js +2 -0
  141. package/dist/helpdesk/module.d.ts +20 -0
  142. package/dist/hr/.kosh/bindings.js +1 -1
  143. package/dist/hr/.kosh/schema.d.ts +59 -1
  144. package/dist/hr/demo.js +11 -1
  145. package/dist/hr/i18n/bn.d.ts +2 -0
  146. package/dist/hr/i18n/bn.js +126 -0
  147. package/dist/hr/index.d.ts +1 -1
  148. package/dist/hr/index.js +158 -12
  149. package/dist/hr/module.d.ts +932 -59
  150. package/dist/hr/module.js +114 -17
  151. package/dist/{mrp → indent}/.kosh/bindings.js +2 -2
  152. package/dist/indent/.kosh/schema.d.ts +2084 -0
  153. package/dist/indent/demo.d.ts +7 -0
  154. package/dist/indent/demo.js +48 -0
  155. package/dist/indent/i18n/bn.d.ts +2 -0
  156. package/dist/indent/i18n/bn.js +143 -0
  157. package/dist/indent/index.d.ts +3 -0
  158. package/dist/indent/index.js +450 -0
  159. package/dist/{mrp → indent}/module.d.ts +7164 -3075
  160. package/dist/indent/module.js +792 -0
  161. package/dist/indent_purchase/.kosh/bindings.d.ts +22 -0
  162. package/dist/indent_purchase/.kosh/bindings.js +15 -0
  163. package/dist/indent_purchase/.kosh/schema.d.ts +2710 -0
  164. package/dist/indent_purchase/i18n/bn.d.ts +2 -0
  165. package/dist/indent_purchase/i18n/bn.js +13 -0
  166. package/dist/indent_purchase/index.d.ts +3 -0
  167. package/dist/indent_purchase/index.js +61 -0
  168. package/dist/indent_purchase/module.d.ts +27950 -0
  169. package/dist/indent_purchase/module.js +163 -0
  170. package/dist/l10n_bd/.kosh/bindings.js +1 -1
  171. package/dist/l10n_bd/.kosh/schema.d.ts +530 -8
  172. package/dist/l10n_bd/i18n/bn.d.ts +2 -0
  173. package/dist/l10n_bd/i18n/bn.js +69 -0
  174. package/dist/l10n_bd/index.js +2 -0
  175. package/dist/l10n_bd/module.d.ts +5167 -1693
  176. package/dist/l10n_bd_payroll/.kosh/bindings.js +1 -1
  177. package/dist/l10n_bd_payroll/.kosh/schema.d.ts +587 -8
  178. package/dist/l10n_bd_payroll/i18n/bn.d.ts +2 -0
  179. package/dist/l10n_bd_payroll/i18n/bn.js +4 -0
  180. package/dist/l10n_bd_payroll/index.js +2 -0
  181. package/dist/l10n_bd_payroll/module.d.ts +5910 -1548
  182. package/dist/l10n_bd_pos/.kosh/bindings.d.ts +22 -0
  183. package/dist/{mrp_account → l10n_bd_pos}/.kosh/bindings.js +2 -2
  184. package/dist/{mrp_account → l10n_bd_pos}/.kosh/schema.d.ts +693 -651
  185. package/dist/l10n_bd_pos/i18n/bn.d.ts +2 -0
  186. package/dist/l10n_bd_pos/i18n/bn.js +32 -0
  187. package/dist/l10n_bd_pos/index.d.ts +3 -0
  188. package/dist/l10n_bd_pos/index.js +111 -0
  189. package/dist/{mrp_account → l10n_bd_pos}/module.d.ts +7402 -7647
  190. package/dist/l10n_bd_pos/module.js +43 -0
  191. package/dist/l10n_in/.kosh/bindings.js +1 -1
  192. package/dist/l10n_in/.kosh/schema.d.ts +530 -8
  193. package/dist/l10n_in/i18n/bn.d.ts +2 -0
  194. package/dist/l10n_in/i18n/bn.js +95 -0
  195. package/dist/l10n_in/index.js +2 -0
  196. package/dist/l10n_in/module.d.ts +6703 -1652
  197. package/dist/l10n_in/module.js +25 -5
  198. package/dist/l10n_in/web/GstSettings.js +2 -2
  199. package/dist/mail/inbound/handle.js +2 -4
  200. package/dist/mail/inbound/models.js +5 -3
  201. package/dist/mail/index.d.ts +12 -1
  202. package/dist/mail/index.js +139 -2
  203. package/dist/mail/send.d.ts +56 -0
  204. package/dist/mail/send.js +394 -0
  205. package/dist/mail/web/SendButton.d.ts +9 -0
  206. package/dist/mail/web/SendButton.js +71 -0
  207. package/dist/mail/web/SendSelected.d.ts +8 -0
  208. package/dist/mail/web/SendSelected.js +33 -0
  209. package/dist/mail/web/index.d.ts +2 -0
  210. package/dist/mail/web/index.js +6 -0
  211. package/dist/mail/web/messages.js +36 -0
  212. package/dist/maintenance/.kosh/bindings.js +1 -1
  213. package/dist/maintenance/.kosh/schema.d.ts +2 -1
  214. package/dist/maintenance/i18n/bn.d.ts +2 -0
  215. package/dist/maintenance/i18n/bn.js +132 -0
  216. package/dist/maintenance/index.js +85 -0
  217. package/dist/maintenance/module.d.ts +25 -0
  218. package/dist/maintenance/module.js +27 -9
  219. package/dist/maintenance_stock/.kosh/bindings.js +1 -1
  220. package/dist/maintenance_stock/.kosh/schema.d.ts +298 -2
  221. package/dist/maintenance_stock/i18n/bn.d.ts +2 -0
  222. package/dist/maintenance_stock/i18n/bn.js +21 -0
  223. package/dist/maintenance_stock/index.js +2 -0
  224. package/dist/maintenance_stock/module.d.ts +5302 -1256
  225. package/dist/maintenance_stock/module.js +1 -0
  226. package/dist/modules-sdk/apps.d.ts +10 -0
  227. package/dist/modules-sdk/apps.js +32 -0
  228. package/dist/modules-sdk/index.d.ts +1 -1
  229. package/dist/modules-sdk/manifest.d.ts +68 -2
  230. package/dist/payment/.kosh/bindings.js +1 -1
  231. package/dist/payment/.kosh/schema.d.ts +530 -8
  232. package/dist/payment/i18n/bn.d.ts +2 -0
  233. package/dist/payment/i18n/bn.js +59 -0
  234. package/dist/payment/index.js +85 -0
  235. package/dist/payment/module.d.ts +5134 -1660
  236. package/dist/payment/module.js +17 -3
  237. package/dist/payment/settle.js +1 -0
  238. package/dist/payroll/.kosh/bindings.js +1 -1
  239. package/dist/payroll/.kosh/schema.d.ts +59 -1
  240. package/dist/payroll/i18n/bn.d.ts +2 -0
  241. package/dist/payroll/i18n/bn.js +206 -0
  242. package/dist/payroll/index.js +2 -0
  243. package/dist/payroll/module.d.ts +936 -48
  244. package/dist/payroll_account/.kosh/bindings.js +1 -1
  245. package/dist/payroll_account/.kosh/schema.d.ts +587 -8
  246. package/dist/payroll_account/i18n/bn.d.ts +2 -0
  247. package/dist/payroll_account/i18n/bn.js +10 -0
  248. package/dist/payroll_account/index.js +2 -0
  249. package/dist/payroll_account/module.d.ts +7534 -3192
  250. package/dist/permissions/approvals.d.ts +97 -4
  251. package/dist/permissions/approvals.js +780 -31
  252. package/dist/permissions/index.d.ts +1 -1
  253. package/dist/permissions/index.js +2 -2
  254. package/dist/permissions/models.js +177 -5
  255. package/dist/permissions/routes.d.ts +3 -0
  256. package/dist/permissions/routes.js +36 -2
  257. package/dist/permissions/web/ApprovalPanel.js +43 -5
  258. package/dist/permissions/web/messages.js +52 -0
  259. package/dist/portal/.kosh/bindings.js +1 -1
  260. package/dist/portal/.kosh/schema.d.ts +2 -1
  261. package/dist/portal/documents.d.ts +3 -0
  262. package/dist/portal/documents.js +21 -1
  263. package/dist/portal/i18n/bn.d.ts +2 -0
  264. package/dist/portal/i18n/bn.js +33 -0
  265. package/dist/portal/index.js +2 -0
  266. package/dist/portal/module.d.ts +20 -0
  267. package/dist/portal/routes-documents.js +76 -0
  268. package/dist/pos/.kosh/bindings.js +1 -1
  269. package/dist/pos/.kosh/schema.d.ts +531 -8
  270. package/dist/pos/i18n/bn.d.ts +2 -0
  271. package/dist/pos/i18n/bn.js +133 -0
  272. package/dist/pos/index.js +83 -1
  273. package/dist/pos/module.d.ts +6888 -1777
  274. package/dist/pos/module.js +49 -5
  275. package/dist/pos/routes.js +80 -7
  276. package/dist/pos/web/Register.js +2 -5
  277. package/dist/pos_stock/.kosh/bindings.js +1 -1
  278. package/dist/pos_stock/.kosh/schema.d.ts +626 -9
  279. package/dist/pos_stock/i18n/bn.d.ts +2 -0
  280. package/dist/pos_stock/i18n/bn.js +6 -0
  281. package/dist/pos_stock/index.js +2 -0
  282. package/dist/pos_stock/module.d.ts +10798 -3276
  283. package/dist/product/.kosh/bindings.js +1 -1
  284. package/dist/product/.kosh/schema.d.ts +202 -1
  285. package/dist/product/i18n/bn.d.ts +2 -0
  286. package/dist/product/i18n/bn.js +175 -0
  287. package/dist/product/index.d.ts +1 -0
  288. package/dist/product/index.js +288 -1
  289. package/dist/product/module.d.ts +1725 -0
  290. package/dist/product/module.js +66 -6
  291. package/dist/product/pricelist.d.ts +575 -0
  292. package/dist/product/pricelist.js +257 -0
  293. package/dist/product/variants.d.ts +908 -0
  294. package/dist/product/variants.js +251 -0
  295. package/dist/production/.kosh/bindings.d.ts +22 -0
  296. package/dist/production/.kosh/bindings.js +15 -0
  297. package/dist/production/.kosh/schema.d.ts +2345 -0
  298. package/dist/{mrp → production}/demo.d.ts +1 -1
  299. package/dist/{mrp → production}/demo.js +5 -5
  300. package/dist/production/depth.d.ts +790 -0
  301. package/dist/production/depth.js +262 -0
  302. package/dist/production/i18n/bn.d.ts +2 -0
  303. package/dist/production/i18n/bn.js +350 -0
  304. package/dist/production/index.d.ts +6 -0
  305. package/dist/production/index.js +1256 -0
  306. package/dist/production/module.d.ts +17327 -0
  307. package/dist/production/module.js +1333 -0
  308. package/dist/production/plan.d.ts +921 -0
  309. package/dist/production/plan.js +511 -0
  310. package/dist/production/schedule.d.ts +656 -0
  311. package/dist/production/schedule.js +309 -0
  312. package/dist/production_account/.kosh/bindings.d.ts +22 -0
  313. package/dist/production_account/.kosh/bindings.js +15 -0
  314. package/dist/production_account/.kosh/schema.d.ts +3496 -0
  315. package/dist/production_account/i18n/bn.d.ts +2 -0
  316. package/dist/production_account/i18n/bn.js +93 -0
  317. package/dist/production_account/index.d.ts +4 -0
  318. package/dist/production_account/index.js +246 -0
  319. package/dist/production_account/module.d.ts +1192 -0
  320. package/dist/{mrp_account → production_account}/module.js +72 -18
  321. package/dist/production_account/overhead.d.ts +1195 -0
  322. package/dist/production_account/overhead.js +554 -0
  323. package/dist/project/.kosh/bindings.js +1 -1
  324. package/dist/project/.kosh/schema.d.ts +65 -2
  325. package/dist/project/i18n/bn.d.ts +2 -0
  326. package/dist/project/i18n/bn.js +383 -0
  327. package/dist/project/index.js +123 -0
  328. package/dist/project/module.d.ts +20 -0
  329. package/dist/project/module.js +73 -14
  330. package/dist/project_account/.kosh/bindings.js +1 -1
  331. package/dist/project_account/.kosh/schema.d.ts +530 -8
  332. package/dist/project_account/i18n/bn.d.ts +2 -0
  333. package/dist/project_account/i18n/bn.js +6 -0
  334. package/dist/project_account/index.js +2 -0
  335. package/dist/project_account/module.d.ts +4926 -1452
  336. package/dist/project_portal/.kosh/bindings.js +1 -1
  337. package/dist/project_portal/.kosh/schema.d.ts +65 -2
  338. package/dist/project_portal/i18n/bn.d.ts +2 -0
  339. package/dist/project_portal/i18n/bn.js +6 -0
  340. package/dist/project_portal/index.js +2 -0
  341. package/dist/project_portal/module.d.ts +40 -0
  342. package/dist/project_purchase/.kosh/bindings.js +1 -1
  343. package/dist/project_purchase/.kosh/schema.d.ts +1126 -53
  344. package/dist/project_purchase/i18n/bn.d.ts +2 -0
  345. package/dist/project_purchase/i18n/bn.js +4 -0
  346. package/dist/project_purchase/index.js +2 -0
  347. package/dist/project_purchase/module.d.ts +17912 -4446
  348. package/dist/purchase/.kosh/bindings.js +1 -1
  349. package/dist/purchase/.kosh/schema.d.ts +813 -3
  350. package/dist/purchase/comparison.d.ts +993 -0
  351. package/dist/purchase/comparison.js +415 -0
  352. package/dist/purchase/extras.d.ts +1509 -0
  353. package/dist/purchase/extras.js +445 -0
  354. package/dist/purchase/i18n/bn.d.ts +2 -0
  355. package/dist/purchase/i18n/bn.js +413 -0
  356. package/dist/purchase/index.d.ts +5 -0
  357. package/dist/purchase/index.js +1280 -4
  358. package/dist/purchase/module.d.ts +7139 -398
  359. package/dist/purchase/module.js +295 -11
  360. package/dist/purchase/request.d.ts +696 -0
  361. package/dist/purchase/request.js +446 -0
  362. package/dist/purchase/tender.d.ts +1208 -0
  363. package/dist/purchase/tender.js +507 -0
  364. package/dist/purchase/vendorprice.d.ts +340 -0
  365. package/dist/purchase/vendorprice.js +153 -0
  366. package/dist/purchase_account/.kosh/bindings.js +1 -1
  367. package/dist/purchase_account/.kosh/schema.d.ts +1130 -57
  368. package/dist/purchase_account/i18n/bn.d.ts +2 -0
  369. package/dist/purchase_account/i18n/bn.js +22 -0
  370. package/dist/purchase_account/index.js +19 -0
  371. package/dist/purchase_account/module.d.ts +12276 -2247
  372. package/dist/purchase_account/module.js +241 -9
  373. package/dist/purchase_portal/.kosh/bindings.js +1 -1
  374. package/dist/purchase_portal/.kosh/schema.d.ts +813 -3
  375. package/dist/purchase_portal/i18n/bn.d.ts +2 -0
  376. package/dist/purchase_portal/i18n/bn.js +5 -0
  377. package/dist/purchase_portal/index.js +2 -0
  378. package/dist/purchase_portal/module.d.ts +6849 -353
  379. package/dist/purchase_stock/.kosh/bindings.js +1 -1
  380. package/dist/purchase_stock/.kosh/schema.d.ts +910 -4
  381. package/dist/purchase_stock/i18n/bn.d.ts +2 -0
  382. package/dist/purchase_stock/i18n/bn.js +19 -0
  383. package/dist/purchase_stock/index.d.ts +1 -1
  384. package/dist/purchase_stock/index.js +29 -0
  385. package/dist/purchase_stock/module.d.ts +12410 -1864
  386. package/dist/purchase_stock/module.js +81 -9
  387. package/dist/purchase_stock/scorecard.d.ts +22 -0
  388. package/dist/purchase_stock/scorecard.js +77 -0
  389. package/dist/purchase_stock/web/Scorecard.d.ts +7 -0
  390. package/dist/purchase_stock/web/Scorecard.js +22 -0
  391. package/dist/purchase_stock/web/index.d.ts +11 -0
  392. package/dist/purchase_stock/web/index.js +16 -0
  393. package/dist/purchase_stock/web/messages.d.ts +2 -0
  394. package/dist/purchase_stock/web/messages.js +27 -0
  395. package/dist/purchase_stock_account/.kosh/bindings.js +1 -1
  396. package/dist/purchase_stock_account/.kosh/schema.d.ts +1286 -58
  397. package/dist/purchase_stock_account/i18n/bn.d.ts +2 -0
  398. package/dist/purchase_stock_account/i18n/bn.js +8 -0
  399. package/dist/purchase_stock_account/index.js +18 -0
  400. package/dist/purchase_stock_account/module.d.ts +7 -37995
  401. package/dist/purchase_stock_account/module.js +24 -3
  402. package/dist/recruitment/.kosh/bindings.js +1 -1
  403. package/dist/recruitment/.kosh/schema.d.ts +59 -1
  404. package/dist/recruitment/i18n/bn.d.ts +2 -0
  405. package/dist/recruitment/i18n/bn.js +93 -0
  406. package/dist/recruitment/index.js +64 -0
  407. package/dist/recruitment/module.d.ts +458 -8
  408. package/dist/recruitment/module.js +11 -3
  409. package/dist/reporting/documents.d.ts +5 -0
  410. package/dist/reporting/documents.js +11 -1
  411. package/dist/reporting/index.d.ts +1 -1
  412. package/dist/reporting/index.js +1 -1
  413. package/dist/reporting/web/CalendarView.d.ts +18 -5
  414. package/dist/reporting/web/CalendarView.js +163 -29
  415. package/dist/reporting/web/messages.js +18 -0
  416. package/dist/sale_account/.kosh/bindings.js +1 -1
  417. package/dist/sale_account/.kosh/schema.d.ts +642 -16
  418. package/dist/sale_account/i18n/bn.d.ts +2 -0
  419. package/dist/sale_account/i18n/bn.js +48 -0
  420. package/dist/sale_account/index.d.ts +1 -1
  421. package/dist/sale_account/index.js +102 -4
  422. package/dist/sale_account/module.d.ts +8254 -2212
  423. package/dist/sale_account/module.js +535 -46
  424. package/dist/sale_crm/.kosh/bindings.d.ts +22 -0
  425. package/dist/sale_crm/.kosh/bindings.js +15 -0
  426. package/dist/{mrp → sale_crm}/.kosh/schema.d.ts +389 -567
  427. package/dist/sale_crm/i18n/bn.d.ts +2 -0
  428. package/dist/sale_crm/i18n/bn.js +9 -0
  429. package/dist/sale_crm/index.d.ts +7 -0
  430. package/dist/sale_crm/index.js +104 -0
  431. package/dist/sale_crm/module.d.ts +5609 -0
  432. package/dist/sale_crm/module.js +73 -0
  433. package/dist/sale_portal/.kosh/bindings.js +1 -1
  434. package/dist/sale_portal/.kosh/schema.d.ts +291 -4
  435. package/dist/sale_portal/i18n/bn.d.ts +2 -0
  436. package/dist/sale_portal/i18n/bn.js +7 -0
  437. package/dist/sale_portal/index.js +10 -0
  438. package/dist/sale_portal/module.d.ts +2564 -294
  439. package/dist/sale_project/.kosh/bindings.js +1 -1
  440. package/dist/sale_project/.kosh/schema.d.ts +354 -5
  441. package/dist/sale_project/i18n/bn.d.ts +2 -0
  442. package/dist/sale_project/i18n/bn.js +12 -0
  443. package/dist/sale_project/index.js +2 -0
  444. package/dist/sale_project/module.d.ts +6249 -2402
  445. package/dist/sale_stock/.kosh/bindings.js +1 -1
  446. package/dist/sale_stock/.kosh/schema.d.ts +387 -5
  447. package/dist/sale_stock/i18n/bn.d.ts +2 -0
  448. package/dist/sale_stock/i18n/bn.js +15 -0
  449. package/dist/sale_stock/index.js +2 -0
  450. package/dist/sale_stock/module.d.ts +8057 -1759
  451. package/dist/sale_stock/module.js +20 -3
  452. package/dist/sales/.kosh/bindings.js +1 -1
  453. package/dist/sales/.kosh/schema.d.ts +291 -4
  454. package/dist/sales/i18n/bn.d.ts +2 -0
  455. package/dist/sales/i18n/bn.js +128 -0
  456. package/dist/sales/index.js +120 -5
  457. package/dist/sales/module.d.ts +3567 -633
  458. package/dist/sales/module.js +306 -11
  459. package/dist/server/api/duplicate.d.ts +14 -0
  460. package/dist/server/api/duplicate.js +75 -0
  461. package/dist/server/api/explain.d.ts +70 -0
  462. package/dist/server/api/explain.js +111 -0
  463. package/dist/server/api/metadata.d.ts +2 -0
  464. package/dist/server/api/metadata.js +9 -0
  465. package/dist/server/api/openapi.js +15 -0
  466. package/dist/server/api/rest.d.ts +1 -0
  467. package/dist/server/api/rest.js +76 -12
  468. package/dist/server/api/views.js +1 -1
  469. package/dist/server/apps.d.ts +16 -1
  470. package/dist/server/apps.js +164 -2
  471. package/dist/server/auth/base.js +2 -0
  472. package/dist/server/auth/service.d.ts +8 -0
  473. package/dist/server/auth/service.js +19 -0
  474. package/dist/server/events.d.ts +13 -0
  475. package/dist/server/events.js +22 -0
  476. package/dist/server/i18n.d.ts +27 -0
  477. package/dist/server/i18n.js +143 -0
  478. package/dist/server/index.d.ts +2 -0
  479. package/dist/server/index.js +2 -0
  480. package/dist/server/server.js +6 -0
  481. package/dist/settings/company.d.ts +3 -1
  482. package/dist/settings/company.js +26 -5
  483. package/dist/settings/routes.d.ts +10 -0
  484. package/dist/settings/routes.js +19 -3
  485. package/dist/settings/web/AppsPage.d.ts +3 -2
  486. package/dist/settings/web/AppsPage.js +26 -8
  487. package/dist/settings/web/CompaniesPage.js +91 -6
  488. package/dist/settings/web/messages.js +64 -0
  489. package/dist/sign/.kosh/bindings.js +1 -1
  490. package/dist/sign/.kosh/schema.d.ts +2 -1
  491. package/dist/sign/i18n/bn.d.ts +2 -0
  492. package/dist/sign/i18n/bn.js +97 -0
  493. package/dist/sign/index.d.ts +15 -0
  494. package/dist/sign/index.js +129 -1
  495. package/dist/sign/module.d.ts +40 -0
  496. package/dist/sign/module.js +11 -2
  497. package/dist/stock/.kosh/bindings.js +1 -1
  498. package/dist/stock/.kosh/schema.d.ts +298 -2
  499. package/dist/stock/i18n/bn.d.ts +2 -0
  500. package/dist/stock/i18n/bn.js +311 -0
  501. package/dist/stock/index.d.ts +3 -1
  502. package/dist/stock/index.js +442 -3
  503. package/dist/stock/ledger.d.ts +78 -0
  504. package/dist/stock/ledger.js +582 -0
  505. package/dist/stock/module.d.ts +5280 -2044
  506. package/dist/stock/module.js +222 -23
  507. package/dist/stock/replenish.js +16 -1
  508. package/dist/stock/returns.d.ts +157 -0
  509. package/dist/stock/returns.js +163 -0
  510. package/dist/stock/web/Ledger.d.ts +9 -0
  511. package/dist/stock/web/Ledger.js +108 -0
  512. package/dist/stock/web/Operations.d.ts +8 -0
  513. package/dist/stock/web/Operations.js +33 -0
  514. package/dist/stock/web/ReturnButton.d.ts +7 -0
  515. package/dist/stock/web/ReturnButton.js +62 -0
  516. package/dist/stock/web/index.d.ts +3 -0
  517. package/dist/stock/web/index.js +9 -0
  518. package/dist/stock/web/messages.js +154 -0
  519. package/dist/stock_account/.kosh/bindings.js +1 -1
  520. package/dist/stock_account/.kosh/schema.d.ts +682 -9
  521. package/dist/stock_account/i18n/bn.d.ts +2 -0
  522. package/dist/stock_account/i18n/bn.js +73 -0
  523. package/dist/stock_account/index.d.ts +1 -0
  524. package/dist/stock_account/index.js +131 -3
  525. package/dist/stock_account/landed.d.ts +429 -0
  526. package/dist/stock_account/landed.js +335 -0
  527. package/dist/stock_account/module.d.ts +11247 -3363
  528. package/dist/stock_account/module.js +135 -6
  529. package/dist/stock_account/web/LandedPreview.d.ts +7 -0
  530. package/dist/stock_account/web/LandedPreview.js +23 -0
  531. package/dist/stock_account/web/index.d.ts +11 -0
  532. package/dist/stock_account/web/index.js +16 -0
  533. package/dist/stock_account/web/messages.d.ts +2 -0
  534. package/dist/stock_account/web/messages.js +27 -0
  535. package/dist/todo/i18n/bn.d.ts +2 -0
  536. package/dist/todo/i18n/bn.js +66 -0
  537. package/dist/todo/index.js +59 -0
  538. package/dist/todo/module.d.ts +5 -0
  539. package/dist/todo/module.js +5 -3
  540. package/dist/trade_finance/.kosh/bindings.js +1 -1
  541. package/dist/trade_finance/.kosh/schema.d.ts +2 -1
  542. package/dist/trade_finance/i18n/bn.d.ts +2 -0
  543. package/dist/trade_finance/i18n/bn.js +162 -0
  544. package/dist/trade_finance/index.js +93 -0
  545. package/dist/trade_finance/module.d.ts +26 -0
  546. package/dist/trade_finance/module.js +27 -8
  547. package/dist/trade_finance_account/.kosh/bindings.js +1 -1
  548. package/dist/trade_finance_account/.kosh/schema.d.ts +530 -8
  549. package/dist/trade_finance_account/i18n/bn.d.ts +2 -0
  550. package/dist/trade_finance_account/i18n/bn.js +27 -0
  551. package/dist/trade_finance_account/index.js +2 -0
  552. package/dist/trade_finance_account/module.d.ts +4906 -1432
  553. package/dist/trade_finance_account/module.js +1 -0
  554. package/dist/trade_finance_purchase/.kosh/bindings.js +1 -1
  555. package/dist/trade_finance_purchase/.kosh/schema.d.ts +813 -3
  556. package/dist/trade_finance_purchase/i18n/bn.d.ts +2 -0
  557. package/dist/trade_finance_purchase/i18n/bn.js +6 -0
  558. package/dist/trade_finance_purchase/index.js +2 -0
  559. package/dist/trade_finance_purchase/module.d.ts +6909 -413
  560. package/dist/trade_finance_sale/.kosh/bindings.js +1 -1
  561. package/dist/trade_finance_sale/.kosh/schema.d.ts +291 -4
  562. package/dist/trade_finance_sale/i18n/bn.d.ts +2 -0
  563. package/dist/trade_finance_sale/i18n/bn.js +6 -0
  564. package/dist/trade_finance_sale/index.js +2 -0
  565. package/dist/trade_finance_sale/module.d.ts +2601 -331
  566. package/dist/ui/api.d.ts +2 -0
  567. package/dist/ui/app.d.ts +9 -0
  568. package/dist/ui/app.js +24 -4
  569. package/dist/ui/components/AppIcon.js +2 -2
  570. package/dist/ui/components/Explain.d.ts +69 -0
  571. package/dist/ui/components/Explain.js +62 -0
  572. package/dist/ui/context.d.ts +8 -1
  573. package/dist/ui/context.js +34 -1
  574. package/dist/ui/i18n/messages.d.ts +36 -0
  575. package/dist/ui/i18n/messages.js +72 -0
  576. package/dist/ui/index.d.ts +2 -1
  577. package/dist/ui/index.js +1 -0
  578. package/dist/ui/registry.d.ts +14 -0
  579. package/dist/ui/registry.js +10 -0
  580. package/dist/ui/views/FormView.d.ts +6 -0
  581. package/dist/ui/views/FormView.js +47 -10
  582. package/dist/ui/views/ListView.d.ts +8 -0
  583. package/dist/ui/views/ListView.js +10 -3
  584. package/dist/ui/views/StatsRow.js +2 -1
  585. package/dist/views/extend.js +12 -0
  586. package/dist/views/types.d.ts +13 -2
  587. package/dist/views/validate.js +8 -1
  588. package/dist/website/.kosh/bindings.js +1 -1
  589. package/dist/website/.kosh/schema.d.ts +2 -1
  590. package/dist/website/i18n/bn.d.ts +2 -0
  591. package/dist/website/i18n/bn.js +113 -0
  592. package/dist/website/index.js +36 -0
  593. package/dist/website/module.d.ts +20 -0
  594. package/dist/website/module.js +29 -5
  595. package/dist/website_crm/.kosh/bindings.js +1 -1
  596. package/dist/website_crm/.kosh/schema.d.ts +2 -1
  597. package/dist/website_crm/i18n/bn.d.ts +2 -0
  598. package/dist/website_crm/i18n/bn.js +5 -0
  599. package/dist/website_crm/index.js +2 -0
  600. package/dist/website_crm/module.d.ts +40 -0
  601. package/dist/website_sale/.kosh/bindings.js +1 -1
  602. package/dist/website_sale/.kosh/schema.d.ts +642 -17
  603. package/dist/website_sale/i18n/bn.d.ts +2 -0
  604. package/dist/website_sale/i18n/bn.js +132 -0
  605. package/dist/website_sale/index.js +83 -1
  606. package/dist/website_sale/module.d.ts +18705 -5550
  607. package/dist/website_sale/module.js +32 -10
  608. package/dist/website_sale/shop.js +22 -9
  609. package/dist/website_sale_stock/.kosh/bindings.js +1 -1
  610. package/dist/website_sale_stock/.kosh/schema.d.ts +737 -18
  611. package/dist/website_sale_stock/i18n/bn.d.ts +2 -0
  612. package/dist/website_sale_stock/i18n/bn.js +4 -0
  613. package/dist/website_sale_stock/index.js +2 -0
  614. package/dist/website_sale_stock/module.d.ts +10 -24997
  615. package/dist/website_sale_stock/module.js +7 -3
  616. package/package.json +36 -8
  617. package/dist/mrp/index.d.ts +0 -3
  618. package/dist/mrp/index.js +0 -393
  619. package/dist/mrp/module.js +0 -757
  620. package/dist/mrp_account/index.d.ts +0 -3
  621. package/dist/mrp_account/index.js +0 -22
  622. /package/dist/{mrp → approvals_purchase}/.kosh/bindings.d.ts +0 -0
  623. /package/dist/{mrp_account → indent}/.kosh/bindings.d.ts +0 -0
@@ -1,7 +1,14 @@
1
1
  import { audit, HttpProblem } from '../server/index.js';
2
2
  const ref = (env, name) => env.model(name);
3
3
  /** Many-to-many fields read here: loaded by the object spec (defaults plus the relation), read as ids. */
4
- const MANY = new Set(['approvers', 'approverUsers']);
4
+ const MANY = new Set([
5
+ 'approvers',
6
+ 'backups',
7
+ 'approverUsers',
8
+ 'extraUsers',
9
+ 'backupUsers',
10
+ 'companies',
11
+ ]);
5
12
  const specOf = (keys) => keys.some((k) => MANY.has(k))
6
13
  ? Object.fromEntries(keys.filter((k) => MANY.has(k)).map((k) => [k, true]))
7
14
  : keys;
@@ -11,6 +18,9 @@ async function find(env, model, where, keys, order) {
11
18
  }
12
19
  const REQUEST_KEYS = [
13
20
  'dueAt',
21
+ 'backups',
22
+ 'startedAt',
23
+ 'explanation',
14
24
  'escalatedTo',
15
25
  'approvers',
16
26
  'required',
@@ -40,6 +50,7 @@ function info(row) {
40
50
  company: row['company'] ?? null,
41
51
  approverRole: String(row['approverRole'] ?? ''),
42
52
  approvers: Array.isArray(row['approvers']) ? row['approvers'] : [],
53
+ backups: Array.isArray(row['backups']) ? row['backups'] : [],
43
54
  required: Number(row['required'] ?? 1),
44
55
  approvals: Number(row['approvals'] ?? 0),
45
56
  round: Number(row['round'] ?? 1),
@@ -51,6 +62,12 @@ function info(row) {
51
62
  escalatedTo: typeof row['escalatedTo'] === 'string' && row['escalatedTo'] !== ''
52
63
  ? row['escalatedTo']
53
64
  : null,
65
+ startedAt: row['startedAt'] instanceof Date
66
+ ? row['startedAt'].toISOString()
67
+ : typeof row['startedAt'] === 'string'
68
+ ? row['startedAt']
69
+ : null,
70
+ explanation: row['explanation'] ?? null,
54
71
  state: row['state'],
55
72
  requestedBy: row['requestedBy'] ?? null,
56
73
  decidedBy: row['decidedBy'] ?? null,
@@ -112,36 +129,210 @@ async function userIn(su, model, id, field) {
112
129
  }
113
130
  }
114
131
  /**
115
- * Who decides a level, and how many approvals it needs: named people, a manager or a field's person (the role when
116
- * there is nobody), a role's holders when all or a number of them must approve; never the requester.
132
+ * The user in a field of the requester's employee: the field's employee's user (`unitHead`, `coach`), or the user
133
+ * itself (`expenseApprover`); null when there is none or Workforce is not installed.
134
+ */
135
+ async function employeePerson(su, requester, field) {
136
+ let me;
137
+ try {
138
+ [me] = await find(su, 'hr.employee', { user: requester }, [field], ['id asc']);
139
+ }
140
+ catch {
141
+ return null;
142
+ }
143
+ const value = me?.[field];
144
+ if (me === undefined || typeof value !== 'number')
145
+ return null;
146
+ try {
147
+ // A field to an employee: that employee's user.
148
+ const [row] = (await ref(su, 'hr.employee')
149
+ .browse([me['id']])
150
+ .load({ [field]: { user: true } }));
151
+ return row?.[field]?.user?.id ?? null;
152
+ }
153
+ catch {
154
+ // A field to a user: the user.
155
+ return value;
156
+ }
157
+ }
158
+ /** The requester's employee in Workforce, with its department (null when Workforce is not installed or none). */
159
+ async function employeeOf(su, requester) {
160
+ try {
161
+ const [me] = await find(su, 'hr.employee', { user: requester }, ['name', 'department'], ['id asc']);
162
+ if (me === undefined)
163
+ return null;
164
+ return {
165
+ name: String(me['name']),
166
+ department: typeof me['department'] === 'number' ? me['department'] : null,
167
+ };
168
+ }
169
+ catch {
170
+ return null;
171
+ }
172
+ }
173
+ /** The head of a department and its approvers, with its name. */
174
+ async function departmentOf(su, id) {
175
+ const set = await ref(su, 'hr.department').search({ where: { id } });
176
+ const [row] = set.length === 0
177
+ ? []
178
+ : (await set.load({ approvalUsers: true })).read(['name', 'head', 'approvalUsers']);
179
+ const headEmployee = typeof row?.['head'] === 'number' ? row['head'] : null;
180
+ const [head] = headEmployee === null ? [] : await find(su, 'hr.employee', { id: headEmployee }, ['user']);
181
+ return {
182
+ name: String(row?.['name'] ?? id),
183
+ head: typeof head?.['user'] === 'number' ? head['user'] : null,
184
+ approvers: Array.isArray(row?.['approvalUsers']) ? row['approvalUsers'] : [],
185
+ };
186
+ }
187
+ /** The people the record names for a level (its model's `approvalUsers(key)`), or null when it names none. */
188
+ async function recordApprovers(su, model, id, key) {
189
+ const record = ref(su, model).browse([id]);
190
+ if (typeof record.approvalUsers !== 'function')
191
+ return null;
192
+ return [...((await record.approvalUsers(key)).get(id) ?? [])];
193
+ }
194
+ /** People's names, for explanations. */
195
+ async function namesOf(su, ids) {
196
+ if (ids.length === 0)
197
+ return 'nobody';
198
+ const rows = await find(su, 'res.users', { id: { in: [...ids] } }, ['name']);
199
+ const by = new Map(rows.map((r) => [r['id'], String(r['name'])]));
200
+ return ids.map((u) => by.get(u) ?? `#${u}`).join(', ');
201
+ }
202
+ /**
203
+ * Who decides a level, how many approvals it needs and how they were found (RFC 0027 §2.1): named people, the
204
+ * requester's manager, their department's head or approvers, a field's person, the people the record names — plus
205
+ * the level's extra people —; a role's holders for a role, and when all or a number of them must approve. Never the
206
+ * requester. `nobody` when no one can decide (the policy's `whenNobody` decides what then).
117
207
  */
118
208
  async function approversOf(su, level, target, requester) {
119
209
  const role = typeof level['approverRole'] === 'string' ? level['approverRole'] : '';
210
+ const notes = [];
120
211
  let people = [];
121
- const kind = level['approverKind'] ?? 'role';
122
- if (kind === 'users')
212
+ const kind = String(level['approverKind'] ?? 'role');
213
+ const who = await namesOf(su, [requester]);
214
+ if (kind === 'users') {
123
215
  people = level['approverUsers'] ?? [];
216
+ notes.push(`Named people: ${await namesOf(su, people)}.`);
217
+ }
124
218
  else if (kind === 'manager') {
125
219
  const manager = await managerOf(su, requester);
126
220
  if (manager !== null)
127
221
  people = [manager];
222
+ notes.push(manager === null
223
+ ? `${who} has no manager with a user in Workforce.`
224
+ : `${who}’s manager: ${await namesOf(su, people)}.`);
225
+ }
226
+ else if (kind === 'department_head' || kind === 'department_approvers') {
227
+ const employee = await employeeOf(su, requester);
228
+ if (employee === null)
229
+ notes.push(`${who} is not an employee in Workforce.`);
230
+ else if (employee.department === null)
231
+ notes.push(`${employee.name} has no department.`);
232
+ else {
233
+ const department = await departmentOf(su, employee.department);
234
+ if (kind === 'department_head') {
235
+ if (department.head !== null)
236
+ people = [department.head];
237
+ notes.push(department.head === null
238
+ ? `${department.name} has no head with a user.`
239
+ : `${department.name}’s head: ${await namesOf(su, people)}.`);
240
+ }
241
+ else {
242
+ people = department.approvers;
243
+ notes.push(`${department.name}’s approvers: ${await namesOf(su, people)}.`);
244
+ }
245
+ }
246
+ }
247
+ else if (kind === 'employee') {
248
+ // A person on the requester's employee record (RFC 0032 §3.2): their unit head, coach, expense approver…
249
+ const field = String(level['approverField'] ?? '');
250
+ const person = await employeePerson(su, requester, field);
251
+ if (person !== null)
252
+ people = [person];
253
+ notes.push(person === null
254
+ ? `${who} has no ${field} with a user in Workforce.`
255
+ : `${who}’s ${field}: ${await namesOf(su, people)}.`);
128
256
  }
129
257
  else if (kind === 'field') {
130
- const person = await userIn(su, target.model, target.id, String(level['approverField'] ?? ''));
258
+ const field = String(level['approverField'] ?? '');
259
+ const person = await userIn(su, target.model, target.id, field);
131
260
  if (person !== null)
132
261
  people = [person];
262
+ notes.push(`The record’s ${field}: ${await namesOf(su, people)}.`);
263
+ }
264
+ else if (kind === 'record') {
265
+ const named = await recordApprovers(su, target.model, target.id, String(level['approverField'] ?? ''));
266
+ people = named ?? [];
267
+ notes.push(named === null
268
+ ? `${target.model} names no approvers.`
269
+ : `Named by the record: ${await namesOf(su, people)}.`);
133
270
  }
271
+ // A role decides a role level, and stands in when nobody else was found.
272
+ const roleHolders = role === '' ? [] : (await holders(su, role, target.company)).filter((u) => u !== requester);
273
+ const extra = level['extraUsers'] ?? [];
274
+ if (extra.length > 0) {
275
+ // A role with extra people: its holders by name, beside them.
276
+ if (kind === 'role') {
277
+ people = roleHolders;
278
+ notes.push(`Role ${role}: ${await namesOf(su, roleHolders)}.`);
279
+ }
280
+ people = [...new Set([...people, ...extra])];
281
+ notes.push(`Also: ${await namesOf(su, extra)}.`);
282
+ }
283
+ if (people.includes(requester))
284
+ notes.push(`${who} asked, so does not decide.`);
134
285
  people = people.filter((u) => u !== requester);
135
286
  const quorum = level['quorum'] ?? 'any';
136
- if (people.length === 0 && quorum !== 'any' && role !== '')
137
- people = (await holders(su, role, target.company)).filter((u) => u !== requester);
287
+ const byRole = people.length === 0 && role !== '';
288
+ if (byRole) {
289
+ notes.push(kind === 'role'
290
+ ? `Role ${role}: ${roleHolders.length} ${roleHolders.length === 1 ? 'person' : 'people'} in the company.`
291
+ : `Instead, role ${role}: ${roleHolders.length} ${roleHolders.length === 1 ? 'person' : 'people'} in the company.`);
292
+ if (roleHolders.length === 0)
293
+ notes.push('Nobody holds it yet: administrators decide.');
294
+ if (quorum !== 'any')
295
+ people = roleHolders;
296
+ }
138
297
  const count = Math.max(1, Number(level['quorumCount'] ?? 1));
139
298
  const required = quorum === 'all'
140
299
  ? Math.max(1, people.length)
141
300
  : quorum === 'count'
142
301
  ? Math.min(count, Math.max(1, people.length))
143
302
  : 1;
144
- return { role: people.length > 0 && kind === 'users' ? '' : role, approvers: people, required };
303
+ return {
304
+ role: byRole ? role : '',
305
+ approvers: people,
306
+ required,
307
+ explanation: notes,
308
+ // A role always has someone: administrators decide a role nobody holds yet.
309
+ nobody: people.length === 0 && !byRole,
310
+ };
311
+ }
312
+ /** A company's segment (RFC 0027 §2.3), or null. */
313
+ async function segmentOf(su, company) {
314
+ if (company === null)
315
+ return null;
316
+ const segments = await find(su, 'res.segment', { active: true }, ['companies']);
317
+ const mine = segments.find((s) => (s['companies'] ?? []).includes(company));
318
+ return mine === undefined ? null : mine['id'];
319
+ }
320
+ /** Whether a policy covers a company: its company, its companies and its segment's together; none: every company. */
321
+ function covers(policy, company, segment) {
322
+ const one = typeof policy['company'] === 'number' ? policy['company'] : null;
323
+ const many = Array.isArray(policy['companies']) ? policy['companies'] : [];
324
+ const group = typeof policy['segment'] === 'number' ? policy['segment'] : null;
325
+ if (one === null && many.length === 0 && group === null)
326
+ return true;
327
+ if (company === null)
328
+ return false;
329
+ return one === company || many.includes(company) || (group !== null && group === segment);
330
+ }
331
+ /** The active policies of a model that cover a company, in order. */
332
+ async function policiesFor(su, model, company, keys) {
333
+ const segment = await segmentOf(su, company);
334
+ const rows = await find(su, 'approval.policy', { targetModel: model }, [...keys, 'company', 'companies', 'segment'], ['sequence asc', 'id asc']);
335
+ return rows.filter((p) => covers(p, company, segment));
145
336
  }
146
337
  /** The record's company, when the model has one. */
147
338
  async function companyOf(su, model, id) {
@@ -158,6 +349,199 @@ async function companyOf(su, model, id) {
158
349
  * and levels; earlier pending requests of the records are cancelled (a resubmission starts a new round). Returns
159
350
  * the number of requests created.
160
351
  */
352
+ /** Whether record `id` of `model` meets `condition` (a Shutro domain; empty: always). */
353
+ async function matches(su, model, id, condition) {
354
+ if (condition === null || Object.keys(condition).length === 0)
355
+ return true;
356
+ return ((await ref(su, model).search({ where: { and: [{ id }, condition] }, limit: 1 })).length > 0);
357
+ }
358
+ /**
359
+ * Which of the last `limit` records of the policy's model each of its levels would have asked about (RFC 0025 step
360
+ * 4: the editor's preview), as the system; the policy's own condition first.
361
+ */
362
+ export async function previewPolicy(su, policyId, limit = 50) {
363
+ const [policy] = await find(su, 'approval.policy', { id: policyId }, [
364
+ 'targetModel',
365
+ 'condition',
366
+ 'company',
367
+ 'companies',
368
+ 'segment',
369
+ ]);
370
+ if (policy === undefined)
371
+ throw new HttpProblem(404, 'missing-record', 'Not found', 'No such policy.');
372
+ const model = String(policy['targetModel']);
373
+ const recent = await ref(su, model).search({
374
+ where: policy['condition'] ?? {},
375
+ order: ['id desc'],
376
+ limit,
377
+ });
378
+ const ids = recent.ids;
379
+ const levels = await find(su, 'approval.level', { policy: policyId }, ['name', 'condition'], ['sequence asc', 'id asc']);
380
+ const out = [];
381
+ for (const level of levels) {
382
+ const condition = level['condition'];
383
+ const matched = ids.length === 0
384
+ ? []
385
+ : condition === null || Object.keys(condition).length === 0
386
+ ? ids
387
+ : (await ref(su, model).search({ where: { and: [{ id: { in: ids } }, condition] } }))
388
+ .ids;
389
+ out.push({ id: level['id'], name: String(level['name']), matched });
390
+ }
391
+ // The companies it covers: its company, its companies and its segment's, or every company.
392
+ const ids2 = new Set(Array.isArray(policy['companies']) ? policy['companies'] : []);
393
+ if (typeof policy['company'] === 'number')
394
+ ids2.add(policy['company']);
395
+ if (typeof policy['segment'] === 'number')
396
+ for (const s of await find(su, 'res.segment', { id: policy['segment'] }, ['companies']))
397
+ for (const c of s['companies'] ?? [])
398
+ ids2.add(c);
399
+ const all = ids2.size === 0 &&
400
+ typeof policy['segment'] !== 'number' &&
401
+ typeof policy['company'] !== 'number';
402
+ const names = ids2.size === 0
403
+ ? []
404
+ : (await find(su, 'res.company', { id: { in: [...ids2] } }, ['name'], ['name asc'])).map((c) => String(c['name']));
405
+ return { records: ids.length, levels: out, covers: { all, companies: names } };
406
+ }
407
+ /** Writes steps to the matrix log (RFC 0027 §2.5). */
408
+ async function logEvents(su, events) {
409
+ if (events.length === 0)
410
+ return;
411
+ const at = new Date();
412
+ await ref(su, 'approval.event').create(events.map((e) => ({
413
+ request: e.request,
414
+ kind: e.kind,
415
+ user: e.user ?? null,
416
+ note: e.note === undefined || e.note === '' ? null : e.note,
417
+ at: e.at ?? at,
418
+ })));
419
+ }
420
+ /**
421
+ * Starts the levels whose turn it is and that have not started: `startedAt` now, the deadline counted from it
422
+ * (RFC 0027 §2.5), logged.
423
+ */
424
+ async function startTurn(su, model, id, now) {
425
+ const turn = (await turnOf(su, model, id)).filter((r) => r.startedAt === null);
426
+ if (turn.length === 0)
427
+ return;
428
+ const rows = await find(su, 'approval.request', { id: { in: turn.map((r) => r.id) } }, ['level']);
429
+ const levelIds = rows.map((r) => r['level']).filter((l) => typeof l === 'number');
430
+ const levels = levelIds.length === 0
431
+ ? []
432
+ : await find(su, 'approval.level', { id: { in: levelIds } }, ['deadlineHours']);
433
+ const hours = new Map(levels.map((l) => [l['id'], Number(l['deadlineHours'] ?? 0)]));
434
+ for (const row of rows) {
435
+ const h = typeof row['level'] === 'number' ? (hours.get(row['level']) ?? 0) : 0;
436
+ await ref(su, 'approval.request')
437
+ .browse([row['id']])
438
+ .write({
439
+ startedAt: now,
440
+ ...(h > 0 ? { dueAt: new Date(now.getTime() + h * 3_600_000) } : {}),
441
+ });
442
+ }
443
+ await logEvents(su, turn.map((r) => ({ request: r.id, kind: 'started', at: now })));
444
+ await skipDuplicates(su, model, id, turn, now);
445
+ }
446
+ /**
447
+ * Skip approvers who already approved (RFC 0027 §2.2): for the levels just started, under a policy that asks for it,
448
+ * the approval of someone who approved an earlier level of this round counts here too — a vote marked `auto` —;
449
+ * a level that reaches its quorum this way is approved and the next turn starts (the record approved when it was
450
+ * the last).
451
+ */
452
+ async function skipDuplicates(su, model, id, started, now) {
453
+ const policyIds = [
454
+ ...new Set(started.map((r) => r.policy).filter((p) => p !== null)),
455
+ ];
456
+ if (policyIds.length === 0)
457
+ return;
458
+ const skipping = new Set((await find(su, 'approval.policy', { id: { in: policyIds }, skipDuplicates: true }, ['id'])).map((p) => p['id']));
459
+ if (skipping.size === 0)
460
+ return;
461
+ const round = await currentRequests(su, model, id);
462
+ const earlier = round.filter((r) => r.state === 'approved');
463
+ if (earlier.length === 0)
464
+ return;
465
+ const votes = await find(su, 'approval.vote', { request: { in: earlier.map((r) => r.id) }, decision: 'approve' }, ['request', 'user']);
466
+ let approvedAny = false;
467
+ for (const request of started) {
468
+ if (request.policy === null || !skipping.has(request.policy))
469
+ continue;
470
+ const already = new Set((await find(su, 'approval.vote', { request: request.id }, ['user'])).map((v) => v['user']));
471
+ const allowed = new Set([
472
+ ...request.approvers,
473
+ ...request.backups,
474
+ ...(request.approvers.length === 0 && request.approverRole !== ''
475
+ ? await holders(su, request.approverRole, request.company)
476
+ : []),
477
+ ]);
478
+ let approvals = request.approvals;
479
+ let last = null;
480
+ for (const v of votes) {
481
+ const user = v['user'];
482
+ if (approvals >= request.required)
483
+ break;
484
+ if (user === request.requestedBy || already.has(user) || !allowed.has(user))
485
+ continue;
486
+ const level = earlier.find((r) => r.id === v['request'])?.levelName ?? '';
487
+ await ref(su, 'approval.vote').create([
488
+ {
489
+ request: request.id,
490
+ user,
491
+ decision: 'approve',
492
+ comment: `Approved already at ${level}.`,
493
+ at: now,
494
+ auto: true,
495
+ },
496
+ ]);
497
+ await logEvents(su, [
498
+ { request: request.id, kind: 'auto', user, note: `Approved already at ${level}.`, at: now },
499
+ ]);
500
+ already.add(user);
501
+ approvals += 1;
502
+ last = user;
503
+ }
504
+ if (approvals >= request.required && last !== null) {
505
+ await ref(su, 'approval.request')
506
+ .browse([request.id])
507
+ .write({ state: 'approved', decidedBy: last, decidedAt: now });
508
+ approvedAny = true;
509
+ }
510
+ }
511
+ if (!approvedAny)
512
+ return;
513
+ const after = await currentRequests(su, model, id);
514
+ if (after.every((r) => r.state === 'approved')) {
515
+ await su.emit('approval.decided', { model, id, state: 'approved', final: true });
516
+ for (const pid of new Set(after.map((r) => r.policy).filter((p) => p !== null))) {
517
+ const [policy] = await find(su, 'approval.policy', { id: pid }, ['onApproved']);
518
+ await runTransition(su, { model, id }, policy?.['onApproved']);
519
+ }
520
+ return;
521
+ }
522
+ await su.emit('approval.decided', { model, id, state: 'approved', final: false });
523
+ await startTurn(su, model, id, now);
524
+ }
525
+ /** The matrix log of a record's requests, when `env` may read the record. */
526
+ export async function eventsOf(env, model, id) {
527
+ await ref(env, model).browse([id]).load(['id']); // as the caller: ACL and rules decide
528
+ return env.asSuperuser({ reason: 'approval log' }, async (su) => {
529
+ const requests = await find(su, 'approval.request', { targetModel: model, targetId: id }, [
530
+ 'id',
531
+ ]);
532
+ if (requests.length === 0)
533
+ return [];
534
+ const rows = await find(su, 'approval.event', { request: { in: requests.map((r) => r['id']) } }, ['request', 'at', 'user', 'kind', 'note'], ['at asc', 'id asc']);
535
+ return rows.map((r) => ({
536
+ id: r['id'],
537
+ request: r['request'],
538
+ at: r['at'] instanceof Date ? r['at'].toISOString() : String(r['at']),
539
+ user: r['user'] ?? null,
540
+ kind: String(r['kind']),
541
+ note: r['note'] ?? null,
542
+ }));
543
+ });
544
+ }
161
545
  export async function requestApprovals(records) {
162
546
  const { env, modelName, ids } = records;
163
547
  const requester = env.uid;
@@ -167,14 +551,28 @@ export async function requestApprovals(records) {
167
551
  const stale = await ref(su, 'approval.request').search({
168
552
  where: { targetModel: modelName, targetId: id, state: 'pending' },
169
553
  });
170
- if (stale.length > 0)
554
+ if (stale.length > 0) {
171
555
  await stale.write({ state: 'cancelled' });
556
+ await logEvents(su, stale.ids.map((request) => ({
557
+ request,
558
+ kind: 'cancelled',
559
+ user: requester,
560
+ note: 'Submitted again.',
561
+ })));
562
+ }
172
563
  const [last] = await find(su, 'approval.request', { targetModel: modelName, targetId: id }, ['round'], ['round desc']);
173
564
  const round = Number(last?.['round'] ?? 0) + 1;
174
565
  const company = (await companyOf(su, modelName, id)) ?? env.companyId;
175
- const policies = await find(su, 'approval.policy', { targetModel: modelName, or: [{ company: null }, { company }] }, ['name'], ['sequence asc', 'id asc']);
566
+ const policies = await policiesFor(su, modelName, company, [
567
+ 'name',
568
+ 'condition',
569
+ 'whenNobody',
570
+ ]);
176
571
  const rows = [];
177
572
  for (const policy of policies) {
573
+ // A policy for some records only (a request category's): the record must match its condition.
574
+ if (!(await matches(su, modelName, id, policy['condition'])))
575
+ continue;
178
576
  const levels = await find(su, 'approval.level', { policy: policy['id'] }, [
179
577
  'sequence',
180
578
  'name',
@@ -182,20 +580,21 @@ export async function requestApprovals(records) {
182
580
  'approverKind',
183
581
  'approverRole',
184
582
  'approverUsers',
583
+ 'extraUsers',
584
+ 'backupUsers',
185
585
  'approverField',
186
586
  'quorum',
187
587
  'quorumCount',
188
588
  'deadlineHours',
189
589
  ], ['sequence asc', 'id asc']);
190
590
  for (const level of levels) {
191
- const condition = level['condition'];
192
- const applies = condition === null ||
193
- Object.keys(condition).length === 0 ||
194
- (await ref(su, modelName).search({ where: { and: [{ id }, condition] }, limit: 1 }))
195
- .length > 0;
196
- if (!applies)
591
+ if (!(await matches(su, modelName, id, level['condition'])))
197
592
  continue;
198
593
  const who = await approversOf(su, level, { model: modelName, id, company }, requester);
594
+ const whenNobody = String(policy['whenNobody'] ?? 'refuse');
595
+ if (who.nobody && whenNobody === 'refuse')
596
+ refuse(422, 'no-approver', 'Nobody to approve', `Nobody can decide the level “${String(level['name'])}” of “${String(policy['name'])}”. ${who.explanation.join(' ')}`, 'Set who approves (Workforce: the manager or the department), or change the approval policy.');
597
+ const skipped = who.nobody && whenNobody === 'skip';
199
598
  rows.push({
200
599
  policy: policy['id'],
201
600
  level: level['id'],
@@ -206,19 +605,44 @@ export async function requestApprovals(records) {
206
605
  company,
207
606
  approverRole: who.role,
208
607
  approvers: { set: who.approvers },
608
+ backups: {
609
+ set: (level['backupUsers'] ?? []).filter((u) => u !== requester),
610
+ },
209
611
  required: who.required,
210
612
  round,
211
- ...(typeof level['deadlineHours'] === 'number' && level['deadlineHours'] > 0
212
- ? { dueAt: new Date(Date.now() + level['deadlineHours'] * 3_600_000) }
213
- : {}),
613
+ explanation: [
614
+ ...who.explanation,
615
+ ...((level['backupUsers'] ?? []).length > 0
616
+ ? [
617
+ `Backups: ${await namesOf(su, level['backupUsers'].filter((u) => u !== requester))}.`,
618
+ ]
619
+ : []),
620
+ ...(skipped ? ['Nobody could decide: the level is skipped.'] : []),
621
+ ].join(' '),
622
+ ...(skipped ? { state: 'approved', decidedAt: new Date() } : {}),
214
623
  requestedBy: requester,
215
624
  });
216
625
  }
217
626
  }
218
627
  if (rows.length === 0)
219
628
  continue;
220
- await ref(su, 'approval.request').create(rows);
629
+ const made = await ref(su, 'approval.request').create(rows);
221
630
  created += rows.length;
631
+ const now = new Date();
632
+ await logEvents(su, made.ids.map((request, i) => ({
633
+ request,
634
+ kind: rows[i]?.['state'] === 'approved' ? 'skipped' : 'created',
635
+ user: requester,
636
+ note: String(rows[i]?.['explanation'] ?? ''),
637
+ at: now,
638
+ })));
639
+ await startTurn(su, modelName, id, now);
640
+ // Every level skipped (nobody to ask, the policy says skip): approved now.
641
+ if (rows.every((r) => r['state'] === 'approved'))
642
+ for (const pid of new Set(rows.map((r) => r['policy']))) {
643
+ const [policy] = await find(su, 'approval.policy', { id: pid }, ['onApproved']);
644
+ await runTransition(su, { model: modelName, id }, policy?.['onApproved']);
645
+ }
222
646
  await audit(su, {
223
647
  kind: 'approval.requested',
224
648
  summary: `${rows.length} approval(s) requested for ${modelName} ${id}.`,
@@ -294,7 +718,7 @@ const holds = (roles, role, company) => role !== '' &&
294
718
  * the escalation role once it is late —, or for someone away who delegated to them (`{ for: <user> }`); null when not.
295
719
  */
296
720
  function approverFor(who, request) {
297
- if (request.approvers.includes(who.uid))
721
+ if (request.approvers.includes(who.uid) || request.backups.includes(who.uid))
298
722
  return { for: null };
299
723
  if (request.approvers.length === 0 && (who.administrator || who.groups.has(request.approverRole)))
300
724
  return { for: null };
@@ -303,7 +727,7 @@ function approverFor(who, request) {
303
727
  for (const d of who.delegators) {
304
728
  if (d.user === request.requestedBy)
305
729
  continue;
306
- if (request.approvers.includes(d.user))
730
+ if (request.approvers.includes(d.user) || request.backups.includes(d.user))
307
731
  return { for: d.user };
308
732
  if (request.approvers.length === 0 && holds(d.roles, request.approverRole, request.company))
309
733
  return { for: d.user };
@@ -401,6 +825,9 @@ export async function decide(env, requestId, decision, comment, from) {
401
825
  at: new Date(),
402
826
  onBehalfOf,
403
827
  ip: from?.ip ?? null,
828
+ asBackup: onBehalfOf === null &&
829
+ request.backups.includes(actor) &&
830
+ !request.approvers.includes(actor),
404
831
  },
405
832
  ]);
406
833
  const approvals = request.approvals + (decision === 'approve' ? 1 : 0);
@@ -420,12 +847,29 @@ export async function decide(env, requestId, decision, comment, from) {
420
847
  ? {}
421
848
  : { decidedBy: actor, decidedAt: new Date(), comment: text === '' ? null : text }),
422
849
  });
850
+ await logEvents(su, [
851
+ {
852
+ request: request.id,
853
+ kind: state === 'pending' ? 'voted' : state,
854
+ user: actor,
855
+ note: [onBehalfOf === null ? '' : `For user ${onBehalfOf}.`, text]
856
+ .filter((x) => x !== '')
857
+ .join(' ') || null,
858
+ },
859
+ ]);
423
860
  if (state === 'rejected' || state === 'sent_back') {
424
861
  const open = round
425
862
  .filter((r) => r.id !== request.id && r.state === 'pending')
426
863
  .map((r) => r.id);
427
- if (open.length > 0)
864
+ if (open.length > 0) {
428
865
  await ref(su, 'approval.request').browse(open).write({ state: 'cancelled' });
866
+ await logEvents(su, open.map((id) => ({
867
+ request: id,
868
+ kind: 'cancelled',
869
+ user: actor,
870
+ note: `${request.levelName} ${state === 'rejected' ? 'rejected' : 'sent back'}.`,
871
+ })));
872
+ }
429
873
  }
430
874
  const done = state === 'rejected' ||
431
875
  state === 'sent_back' ||
@@ -446,6 +890,8 @@ export async function decide(env, requestId, decision, comment, from) {
446
890
  ip: from?.ip ?? null,
447
891
  },
448
892
  });
893
+ if (state === 'approved' && !done)
894
+ await startTurn(su, target.model, target.id, new Date());
449
895
  await su.emit('approval.decided', { ...target, request: request.id, state, final: done });
450
896
  if (done && policy !== undefined) {
451
897
  const key = state === 'approved' ? 'onApproved' : state === 'rejected' ? 'onRejected' : 'onSentBack';
@@ -481,6 +927,7 @@ export async function withdraw(env, model, id) {
481
927
  await ref(su, 'approval.request')
482
928
  .browse(pending.map((r) => r.id))
483
929
  .write({ state: 'cancelled' });
930
+ await logEvents(su, pending.map((r) => ({ request: r.id, kind: 'cancelled', user: actor, note: 'Withdrawn.' })));
484
931
  await audit(su, {
485
932
  kind: 'approval.withdrawn',
486
933
  summary: `Approvals withdrawn for ${model} ${id}.`,
@@ -501,6 +948,179 @@ export async function withdraw(env, model, id) {
501
948
  return pending.length;
502
949
  });
503
950
  }
951
+ const managesApprovals = (env) => env.administrator || env.groups.has('approvals.manager');
952
+ /**
953
+ * Reassigns a pending request (RFC 0027 §2.6), for Approvals managers: its approvers replaced by `users`, or `users`
954
+ * added to them (a role's holders named first); never the requester; a reason required; logged, audited and
955
+ * announced (`approval.reassigned`, with the people added). @throws {HttpProblem} 403 / 404 / 409 / 422
956
+ */
957
+ export async function reassign(env, requestId, users, mode, reason) {
958
+ if (!managesApprovals(env))
959
+ refuse(403, 'access', 'Approvals managers', 'Reassigning needs the Approvals manager role.');
960
+ const text = reason.trim();
961
+ if (text === '')
962
+ refuse(422, 'reason-required', 'Say why', 'A reassignment says why.');
963
+ const actor = env.uid;
964
+ return env.asSuperuser({ reason: `reassign approval ${requestId}` }, async (su) => {
965
+ const [request] = await loadRequests(su, { id: requestId }, ['id asc']);
966
+ if (request === undefined)
967
+ refuse(404, 'missing-record', 'Not found', `No approval request ${requestId}.`);
968
+ if (request.state !== 'pending')
969
+ refuse(409, 'approval-decided', 'Already decided', `This request is ${request.state}.`);
970
+ const chosen = [...new Set(users)];
971
+ if (chosen.length === 0)
972
+ refuse(422, 'validation', 'Nobody chosen', 'Choose who decides.');
973
+ if (request.requestedBy !== null && chosen.includes(request.requestedBy))
974
+ refuse(422, 'separation-of-duties', 'Separation of duties', 'The requester cannot approve their own record.');
975
+ const found = await find(su, 'res.users', { id: { in: chosen } }, ['id']);
976
+ if (found.length !== chosen.length)
977
+ refuse(422, 'validation', 'Unknown people', 'Every person must be a user.');
978
+ const before = request.approvers.length > 0
979
+ ? [...request.approvers]
980
+ : request.approverRole === ''
981
+ ? []
982
+ : (await holders(su, request.approverRole, request.company)).filter((u) => u !== request.requestedBy);
983
+ const next = mode === 'replace' ? chosen : [...new Set([...before, ...chosen])];
984
+ const required = Math.min(request.required, Math.max(1, next.length));
985
+ await ref(su, 'approval.request')
986
+ .browse([request.id])
987
+ .write({ approvers: { set: next }, required });
988
+ const note = `${await namesOf(su, before)} → ${await namesOf(su, next)}. ${text}`;
989
+ await logEvents(su, [{ request: request.id, kind: 'reassigned', user: actor, note }]);
990
+ await audit(su, {
991
+ kind: 'approval.reassigned',
992
+ summary: `${request.levelName} reassigned for ${request.targetModel} ${request.targetId}.`,
993
+ actor,
994
+ company: request.company,
995
+ targetModel: request.targetModel,
996
+ targetId: request.targetId,
997
+ details: { request: request.id, before, after: next, reason: text },
998
+ });
999
+ const added = next.filter((u) => !before.includes(u));
1000
+ if (added.length > 0 && request.startedAt !== null)
1001
+ await su.emit('approval.reassigned', { request: request.id, users: added });
1002
+ const [after] = await loadRequests(su, { id: request.id }, ['id asc']);
1003
+ return after;
1004
+ });
1005
+ }
1006
+ /**
1007
+ * Tells a current request's backups that it waits (RFC 0027 §2.6): for Approvals managers and the request's
1008
+ * approvers; logged and announced (`approval.backups`). @throws {HttpProblem} 403 / 404 / 409
1009
+ */
1010
+ export async function notifyBackups(env, requestId) {
1011
+ const actor = env.uid;
1012
+ return env.asSuperuser({ reason: `notify backups of approval ${requestId}` }, async (su) => {
1013
+ const [request] = await loadRequests(su, { id: requestId }, ['id asc']);
1014
+ if (request === undefined)
1015
+ refuse(404, 'missing-record', 'Not found', `No approval request ${requestId}.`);
1016
+ if (!managesApprovals(env) && !request.approvers.includes(actor))
1017
+ refuse(403, 'access', 'Not an approver', 'Approvers and Approvals managers tell backups.');
1018
+ if (request.state !== 'pending' || request.startedAt === null)
1019
+ refuse(409, 'approval-not-current', 'Not waiting', 'Only a request whose turn it is.');
1020
+ if (request.backups.length === 0)
1021
+ refuse(409, 'no-backups', 'No backups', 'This level has no backups.');
1022
+ await logEvents(su, [
1023
+ {
1024
+ request: request.id,
1025
+ kind: 'notified',
1026
+ user: actor,
1027
+ note: await namesOf(su, request.backups),
1028
+ },
1029
+ ]);
1030
+ await su.emit('approval.backups', { request: request.id, users: [...request.backups] });
1031
+ return request.backups.length;
1032
+ });
1033
+ }
1034
+ /** The fields of a level that decide its approvers: a change to one is synced to its waiting requests. */
1035
+ export const APPROVER_KEYS = [
1036
+ 'approverKind',
1037
+ 'approverRole',
1038
+ 'approverUsers',
1039
+ 'extraUsers',
1040
+ 'backupUsers',
1041
+ 'approverField',
1042
+ 'quorum',
1043
+ 'quorumCount',
1044
+ ];
1045
+ /**
1046
+ * Keeps waiting requests in step with their levels (RFC 0027 §2.6), as the system: each pending request of these
1047
+ * levels gets its approvers found again — added to the ones it has, or replacing them, as its policy says (`mode`
1048
+ * overrides it, for "Sync now") — logged when they change. Returns what changed, with the people added to requests
1049
+ * whose turn it is (to tell, when the policy says so).
1050
+ */
1051
+ export async function syncLevels(su, levelIds, mode) {
1052
+ const out = [];
1053
+ if (levelIds.length === 0)
1054
+ return out;
1055
+ const levels = await find(su, 'approval.level', { id: { in: [...levelIds] } }, [
1056
+ 'policy',
1057
+ 'name',
1058
+ 'approverKind',
1059
+ 'approverRole',
1060
+ 'approverUsers',
1061
+ 'extraUsers',
1062
+ 'backupUsers',
1063
+ 'approverField',
1064
+ 'quorum',
1065
+ 'quorumCount',
1066
+ ]);
1067
+ for (const level of levels) {
1068
+ const [policy] = await find(su, 'approval.policy', { id: level['policy'] }, [
1069
+ 'syncPending',
1070
+ 'notifyOnSync',
1071
+ ]);
1072
+ const how = mode ?? String(policy?.['syncPending'] ?? 'add');
1073
+ if (how === 'off')
1074
+ continue;
1075
+ const pending = await loadRequests(su, { level: level['id'], state: 'pending' }, ['id asc']);
1076
+ for (const request of pending) {
1077
+ const requester = request.requestedBy ?? 0;
1078
+ const who = await approversOf(su, level, { model: request.targetModel, id: request.targetId, company: request.company }, requester);
1079
+ const levelBackups = (level['backupUsers'] ?? []).filter((u) => u !== requester);
1080
+ const approvers = how === 'add'
1081
+ ? [...new Set([...request.approvers, ...who.approvers])]
1082
+ : who.approvers.length > 0 || who.role !== ''
1083
+ ? who.approvers
1084
+ : request.approvers;
1085
+ const backups = how === 'add' ? [...new Set([...request.backups, ...levelBackups])] : levelBackups;
1086
+ const same = (a, b) => a.length === b.length && a.every((x) => b.includes(x));
1087
+ const role = how === 'replace' ? who.role : request.approverRole;
1088
+ if (same(approvers, request.approvers) &&
1089
+ same(backups, request.backups) &&
1090
+ role === request.approverRole)
1091
+ continue;
1092
+ await ref(su, 'approval.request')
1093
+ .browse([request.id])
1094
+ .write({
1095
+ approvers: { set: approvers },
1096
+ backups: { set: backups },
1097
+ approverRole: role,
1098
+ required: how === 'replace' ? who.required : request.required,
1099
+ explanation: who.explanation.join(' '),
1100
+ });
1101
+ await logEvents(su, [
1102
+ {
1103
+ request: request.id,
1104
+ kind: 'synced',
1105
+ note: `${how === 'add' ? 'Added' : 'Replaced'}: ${await namesOf(su, approvers)}${backups.length > 0 ? `; backups: ${await namesOf(su, backups)}` : ''}.`,
1106
+ },
1107
+ ]);
1108
+ const [after] = await loadRequests(su, { id: request.id }, ['id asc']);
1109
+ if (after === undefined)
1110
+ continue;
1111
+ const added = [
1112
+ ...approvers.filter((u) => !request.approvers.includes(u)),
1113
+ ...backups.filter((u) => !request.backups.includes(u)),
1114
+ ];
1115
+ out.push({
1116
+ request: after,
1117
+ added: request.startedAt === null ? [] : added,
1118
+ notify: policy?.['notifyOnSync'] !== false,
1119
+ });
1120
+ }
1121
+ }
1122
+ return out;
1123
+ }
504
1124
  /** `purchase.order` as people say it: "Purchase order". */
505
1125
  const labelOf = (model) => {
506
1126
  const words = model.replace(/[._]/g, ' ');
@@ -698,10 +1318,122 @@ export async function approvalStats(env) {
698
1318
  };
699
1319
  });
700
1320
  }
1321
+ /** The periods of the matrix dashboard, in days (`all`: no limit). */
1322
+ const MATRIX_DAYS = { '7d': 7, '30d': 30, '3m': 91, '6m': 182, '1y': 365, all: null };
1323
+ export const MATRIX_PERIODS = Object.keys(MATRIX_DAYS);
1324
+ /**
1325
+ * The matrix dashboard (RFC 0027 §2.7): the requests of the period the user decides — directly (named, or holding
1326
+ * the role) or delegated (as a backup or for someone away) — a card per policy with what waits, what was approved
1327
+ * and rejected; for Approvals managers also the hours each level takes to decide and the bottlenecks (who has the
1328
+ * most requests late).
1329
+ */
1330
+ export async function approvalMatrix(env, period, mode) {
1331
+ const manages = managesApprovals(env);
1332
+ return env.asSuperuser({ reason: 'approval matrix' }, async (su) => {
1333
+ const who = await whoIs(su, env);
1334
+ const days = MATRIX_DAYS[period];
1335
+ const since = days === null ? null : new Date(Date.now() - days * 86_400_000);
1336
+ const rows = await loadRequests(su, {
1337
+ state: { ne: 'cancelled' },
1338
+ ...(since === null ? {} : { createdAt: { gte: since } }),
1339
+ }, ['id asc']);
1340
+ const mine = (r) => r.approvers.includes(who.uid) ||
1341
+ (r.approvers.length === 0 &&
1342
+ r.approverRole !== '' &&
1343
+ (r.company === null || r.company === who.companyId) &&
1344
+ who.groups.has(r.approverRole));
1345
+ const forOthers = (r) => !mine(r) &&
1346
+ (r.backups.includes(who.uid) ||
1347
+ who.delegators.some((d) => d.user !== r.requestedBy &&
1348
+ (r.approvers.includes(d.user) ||
1349
+ (r.approvers.length === 0 && holds(d.roles, r.approverRole, r.company)))));
1350
+ const waitingNow = (r) => r.state === 'pending' && r.startedAt !== null;
1351
+ const scoped = rows.filter((r) => r.requestedBy !== who.uid);
1352
+ const direct = scoped.filter(mine);
1353
+ const delegated = scoped.filter(forOthers);
1354
+ const names = new Map((await find(
1355
+ // Archived policies keep their requests' history.
1356
+ su.withFlags({ activeTest: false }), 'approval.policy', {
1357
+ id: {
1358
+ in: [...new Set(rows.map((r) => r.policy).filter((p) => p !== null))],
1359
+ },
1360
+ }, ['name'])).map((p) => [p['id'], String(p['name'])]));
1361
+ const cards = new Map();
1362
+ for (const r of mode === 'direct' ? direct : delegated) {
1363
+ const key = String(r.policy ?? 0);
1364
+ const card = cards.get(key) ?? {
1365
+ policy: r.policy,
1366
+ title: (r.policy === null ? undefined : names.get(r.policy)) ?? r.targetModel,
1367
+ waiting: 0,
1368
+ approved: 0,
1369
+ rejected: 0,
1370
+ total: 0,
1371
+ };
1372
+ if (r.state === 'pending' && !waitingNow(r))
1373
+ continue; // a later level: not anyone's turn yet
1374
+ cards.set(key, {
1375
+ ...card,
1376
+ waiting: card.waiting + (waitingNow(r) ? 1 : 0),
1377
+ approved: card.approved + (r.state === 'approved' ? 1 : 0),
1378
+ rejected: card.rejected + (r.state === 'rejected' || r.state === 'sent_back' ? 1 : 0),
1379
+ total: card.total + 1,
1380
+ });
1381
+ }
1382
+ let levels = null;
1383
+ let bottlenecks = null;
1384
+ if (manages) {
1385
+ const byLevel = new Map();
1386
+ for (const r of rows) {
1387
+ if (r.decidedAt === null || r.startedAt === null || r.state === 'pending')
1388
+ continue;
1389
+ const policy = (r.policy === null ? undefined : names.get(r.policy)) ?? r.targetModel;
1390
+ const key = `${policy}\u0000${r.levelName}`;
1391
+ const entry = byLevel.get(key) ?? { policy, level: r.levelName, hours: [] };
1392
+ entry.hours.push((Date.parse(r.decidedAt) - Date.parse(r.startedAt)) / 3_600_000);
1393
+ byLevel.set(key, entry);
1394
+ }
1395
+ levels = [...byLevel.values()]
1396
+ .map((e) => ({
1397
+ policy: e.policy,
1398
+ level: e.level,
1399
+ decided: e.hours.length,
1400
+ averageHours: Math.round((e.hours.reduce((a, b) => a + b, 0) / e.hours.length) * 10) / 10,
1401
+ }))
1402
+ .sort((a, b) => b.averageHours - a.averageHours);
1403
+ const load = new Map();
1404
+ const now = Date.now();
1405
+ for (const r of rows.filter(waitingNow))
1406
+ for (const u of r.approvers) {
1407
+ const entry = load.get(u) ?? { late: 0, waiting: 0 };
1408
+ entry.waiting += 1;
1409
+ if (r.dueAt !== null && Date.parse(r.dueAt) <= now)
1410
+ entry.late += 1;
1411
+ load.set(u, entry);
1412
+ }
1413
+ const people = new Map(load.size === 0
1414
+ ? []
1415
+ : (await find(su, 'res.users', { id: { in: [...load.keys()] } }, ['name'])).map((u) => [
1416
+ u['id'],
1417
+ String(u['name']),
1418
+ ]));
1419
+ bottlenecks = [...load.entries()]
1420
+ .map(([user, l]) => ({ user, name: people.get(user) ?? `#${user}`, ...l }))
1421
+ .sort((a, b) => b.late - a.late || b.waiting - a.waiting)
1422
+ .slice(0, 10);
1423
+ }
1424
+ return {
1425
+ cards: [...cards.values()].sort((a, b) => a.title.localeCompare(b.title)),
1426
+ direct: direct.filter(waitingNow).length,
1427
+ delegated: delegated.filter(waitingNow).length,
1428
+ levels,
1429
+ bottlenecks,
1430
+ };
1431
+ });
1432
+ }
701
1433
  /** The people who may decide `request` now: its approvers (or its role's holders), the escalation role's once late,
702
1434
  * and whoever they delegated to today; never the requester. */
703
1435
  export async function decidersOf(su, request, today) {
704
- const people = new Set(request.approvers);
1436
+ const people = new Set([...request.approvers, ...request.backups]);
705
1437
  if (request.approvers.length === 0 && request.approverRole !== '')
706
1438
  for (const u of await holders(su, request.approverRole, request.company))
707
1439
  people.add(u);
@@ -733,11 +1465,12 @@ export async function sweepDeadlines(su, now = new Date()) {
733
1465
  'remindedAt',
734
1466
  'escalatedTo',
735
1467
  'createdAt',
1468
+ 'startedAt',
736
1469
  'level',
737
1470
  ]);
738
1471
  for (const row of rows) {
739
1472
  const due = new Date(row['dueAt']).getTime();
740
- const start = new Date(row['createdAt']).getTime();
1473
+ const start = new Date((row['startedAt'] ?? row['createdAt'])).getTime();
741
1474
  const [request] = await loadRequests(su, { id: row['id'] }, ['id asc']);
742
1475
  if (request === undefined)
743
1476
  continue;
@@ -763,6 +1496,7 @@ export async function sweepDeadlines(su, now = new Date()) {
763
1496
  targetId: request.targetId,
764
1497
  details: { request: request.id, role },
765
1498
  });
1499
+ await logEvents(su, [{ request: request.id, kind: 'escalated', note: role, at: now }]);
766
1500
  await su.emit('approval.escalated', { request: request.id, role });
767
1501
  out.push({ kind: 'escalated', request: { ...request, escalatedTo: role } });
768
1502
  continue;
@@ -770,16 +1504,19 @@ export async function sweepDeadlines(su, now = new Date()) {
770
1504
  }
771
1505
  if ((row['remindedAt'] ?? null) === null && now.getTime() >= start + (due - start) / 2) {
772
1506
  await ref(su, 'approval.request').browse([request.id]).write({ remindedAt: now });
1507
+ await logEvents(su, [{ request: request.id, kind: 'reminded', at: now }]);
773
1508
  out.push({ kind: 'reminded', request });
774
1509
  }
775
1510
  }
776
1511
  return out;
777
1512
  }
778
1513
  /** One-time links deciding `request` from an email, one per person who may decide it now (7 days). */
779
- export async function issueLinks(su, request, today, newToken, digestOf) {
1514
+ export async function issueLinks(su, request, today, newToken, digestOf, only) {
780
1515
  const out = [];
781
1516
  const expiresAt = new Date(Date.now() + 7 * 86_400_000);
782
1517
  for (const user of await decidersOf(su, request, today)) {
1518
+ if (only !== undefined && !only.includes(user))
1519
+ continue;
783
1520
  const token = newToken();
784
1521
  await ref(su, 'approval.link').create([
785
1522
  { request: request.id, user, tokenHash: digestOf(token), expiresAt },
@@ -859,12 +1596,24 @@ export async function installApprovalPolicies(env, modules) {
859
1596
  name: level.name,
860
1597
  condition: level.condition ?? null,
861
1598
  approverRole: level.approverRole,
862
- approverKind: level.approver === 'manager'
863
- ? 'manager'
1599
+ approverKind: typeof level.approver === 'string'
1600
+ ? level.approver
864
1601
  : typeof level.approver === 'object'
865
- ? 'field'
1602
+ ? 'record' in level.approver
1603
+ ? 'record'
1604
+ : 'employee' in level.approver
1605
+ ? 'employee'
1606
+ : 'field'
866
1607
  : 'role',
867
- ...(typeof level.approver === 'object' ? { approverField: level.approver.field } : {}),
1608
+ ...(typeof level.approver === 'object'
1609
+ ? {
1610
+ approverField: 'record' in level.approver
1611
+ ? level.approver.record
1612
+ : 'employee' in level.approver
1613
+ ? level.approver.employee
1614
+ : level.approver.field,
1615
+ }
1616
+ : {}),
868
1617
  quorum: level.quorum === undefined || level.quorum === 'any'
869
1618
  ? 'any'
870
1619
  : level.quorum === 'all'