shutro-erp 0.3.5 → 0.3.6

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