shutro-erp 0.3.4 → 0.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/account_portal/.kosh/bindings.js +1 -1
- package/dist/account_portal/.kosh/schema.d.ts +54 -3
- package/dist/account_portal/module.d.ts +359 -6
- package/dist/accounting/.kosh/bindings.js +1 -1
- package/dist/accounting/.kosh/schema.d.ts +54 -3
- package/dist/accounting/index.js +214 -2
- package/dist/accounting/module.d.ts +217 -5
- package/dist/accounting/module.js +23 -2
- package/dist/calendar/.kosh/bindings.d.ts +22 -0
- package/dist/calendar/.kosh/bindings.js +15 -0
- package/dist/calendar/.kosh/schema.d.ts +795 -0
- package/dist/calendar/demo.d.ts +6 -0
- package/dist/calendar/demo.js +24 -0
- package/dist/calendar/index.d.ts +3 -0
- package/dist/calendar/index.js +291 -0
- package/dist/calendar/module.d.ts +1705 -0
- package/dist/calendar/module.js +180 -0
- package/dist/cli/init.js +48 -0
- package/dist/cli/templates.d.ts +1 -1
- package/dist/cli/templates.js +137 -0
- package/dist/contacts/.kosh/bindings.js +1 -1
- package/dist/contacts/.kosh/schema.d.ts +78 -3
- package/dist/contacts/demo.js +30 -1
- package/dist/contacts/index.d.ts +1 -1
- package/dist/contacts/index.js +155 -9
- package/dist/contacts/module.d.ts +338 -2
- package/dist/contacts/module.js +82 -5
- package/dist/crm/.kosh/bindings.d.ts +22 -0
- package/dist/crm/.kosh/bindings.js +15 -0
- package/dist/crm/.kosh/schema.d.ts +799 -0
- package/dist/crm/demo.d.ts +6 -0
- package/dist/crm/demo.js +45 -0
- package/dist/crm/index.d.ts +3 -0
- package/dist/crm/index.js +361 -0
- package/dist/crm/module.d.ts +1843 -0
- package/dist/crm/module.js +207 -0
- package/dist/event/.kosh/bindings.d.ts +22 -0
- package/dist/event/.kosh/bindings.js +15 -0
- package/dist/event/.kosh/schema.d.ts +843 -0
- package/dist/event/demo.d.ts +6 -0
- package/dist/event/demo.js +65 -0
- package/dist/event/index.d.ts +14 -0
- package/dist/event/index.js +333 -0
- package/dist/event/mails.d.ts +15 -0
- package/dist/event/mails.js +112 -0
- package/dist/event/module.d.ts +2800 -0
- package/dist/event/module.js +323 -0
- package/dist/event/web/CheckInBox.d.ts +9 -0
- package/dist/event/web/CheckInBox.js +87 -0
- package/dist/event/web/index.d.ts +11 -0
- package/dist/event/web/index.js +16 -0
- package/dist/event/web/messages.d.ts +2 -0
- package/dist/event/web/messages.js +23 -0
- package/dist/event_account/.kosh/bindings.d.ts +22 -0
- package/dist/event_account/.kosh/bindings.js +15 -0
- package/dist/event_account/.kosh/schema.d.ts +1639 -0
- package/dist/event_account/index.d.ts +3 -0
- package/dist/event_account/index.js +28 -0
- package/dist/event_account/module.d.ts +6968 -0
- package/dist/event_account/module.js +90 -0
- package/dist/hr/.kosh/bindings.d.ts +22 -0
- package/dist/hr/.kosh/bindings.js +15 -0
- package/dist/hr/.kosh/schema.d.ts +878 -0
- package/dist/hr/demo.d.ts +6 -0
- package/dist/hr/demo.js +60 -0
- package/dist/hr/index.d.ts +3 -0
- package/dist/hr/index.js +483 -0
- package/dist/hr/module.d.ts +2611 -0
- package/dist/hr/module.js +350 -0
- package/dist/l10n_bd/.kosh/bindings.js +1 -1
- package/dist/l10n_bd/.kosh/schema.d.ts +54 -3
- package/dist/l10n_bd/module.d.ts +359 -6
- package/dist/l10n_in/.kosh/bindings.js +1 -1
- package/dist/l10n_in/.kosh/schema.d.ts +54 -3
- package/dist/l10n_in/module.d.ts +380 -7
- package/dist/maintenance/.kosh/bindings.d.ts +22 -0
- package/dist/maintenance/.kosh/bindings.js +15 -0
- package/dist/maintenance/.kosh/schema.d.ts +911 -0
- package/dist/maintenance/demo.d.ts +6 -0
- package/dist/maintenance/demo.js +60 -0
- package/dist/maintenance/index.d.ts +3 -0
- package/dist/maintenance/index.js +422 -0
- package/dist/maintenance/module.d.ts +3545 -0
- package/dist/maintenance/module.js +446 -0
- package/dist/maintenance_stock/.kosh/bindings.d.ts +22 -0
- package/dist/maintenance_stock/.kosh/bindings.js +15 -0
- package/dist/maintenance_stock/.kosh/schema.d.ts +1371 -0
- package/dist/maintenance_stock/index.d.ts +9 -0
- package/dist/maintenance_stock/index.js +52 -0
- package/dist/maintenance_stock/module.d.ts +6953 -0
- package/dist/maintenance_stock/module.js +192 -0
- package/dist/modules-sdk/index.d.ts +1 -1
- package/dist/modules-sdk/manifest.d.ts +70 -0
- package/dist/modules-sdk/manifest.js +35 -1
- package/dist/mrp/.kosh/bindings.d.ts +22 -0
- package/dist/mrp/.kosh/bindings.js +15 -0
- package/dist/mrp/.kosh/schema.d.ts +1378 -0
- package/dist/mrp/demo.d.ts +7 -0
- package/dist/mrp/demo.js +42 -0
- package/dist/mrp/index.d.ts +3 -0
- package/dist/mrp/index.js +392 -0
- package/dist/mrp/module.d.ts +8608 -0
- package/dist/mrp/module.js +695 -0
- package/dist/mrp_account/.kosh/bindings.d.ts +22 -0
- package/dist/mrp_account/.kosh/bindings.js +15 -0
- package/dist/mrp_account/.kosh/schema.d.ts +2106 -0
- package/dist/mrp_account/index.d.ts +3 -0
- package/dist/mrp_account/index.js +22 -0
- package/dist/mrp_account/module.d.ts +20840 -0
- package/dist/mrp_account/module.js +123 -0
- package/dist/payment/.kosh/bindings.js +1 -1
- package/dist/payment/.kosh/schema.d.ts +54 -3
- package/dist/payment/module.d.ts +359 -6
- package/dist/portal/.kosh/bindings.js +1 -1
- package/dist/portal/.kosh/schema.d.ts +78 -3
- package/dist/portal/module.d.ts +166 -2
- package/dist/pos/.kosh/bindings.d.ts +22 -0
- package/dist/pos/.kosh/bindings.js +15 -0
- package/dist/pos/.kosh/schema.d.ts +1690 -0
- package/dist/pos/accounting.d.ts +10 -0
- package/dist/pos/accounting.js +149 -0
- package/dist/pos/demo.d.ts +6 -0
- package/dist/pos/demo.js +90 -0
- package/dist/pos/index.d.ts +9 -0
- package/dist/pos/index.js +349 -0
- package/dist/pos/module.d.ts +9636 -0
- package/dist/pos/module.js +423 -0
- package/dist/pos/routes.d.ts +39 -0
- package/dist/pos/routes.js +368 -0
- package/dist/pos/web/Register.d.ts +9 -0
- package/dist/pos/web/Register.js +252 -0
- package/dist/pos/web/index.d.ts +11 -0
- package/dist/pos/web/index.js +16 -0
- package/dist/pos/web/messages.d.ts +2 -0
- package/dist/pos/web/messages.js +87 -0
- package/dist/pos_stock/.kosh/bindings.d.ts +22 -0
- package/dist/pos_stock/.kosh/bindings.js +15 -0
- package/dist/pos_stock/.kosh/schema.d.ts +2015 -0
- package/dist/pos_stock/index.d.ts +9 -0
- package/dist/pos_stock/index.js +35 -0
- package/dist/pos_stock/module.d.ts +11935 -0
- package/dist/pos_stock/module.js +115 -0
- package/dist/product/.kosh/bindings.js +1 -1
- package/dist/product/.kosh/schema.d.ts +51 -1
- package/dist/product/index.js +81 -4
- package/dist/product/module.d.ts +43 -1
- package/dist/product/module.js +9 -2
- package/dist/project/.kosh/bindings.js +1 -1
- package/dist/project/.kosh/schema.d.ts +54 -3
- package/dist/project/index.js +147 -7
- package/dist/project/module.d.ts +210 -2
- package/dist/project/module.js +52 -5
- package/dist/project_portal/.kosh/bindings.js +1 -1
- package/dist/project_portal/.kosh/schema.d.ts +54 -3
- package/dist/project_portal/module.d.ts +353 -4
- package/dist/purchase/.kosh/bindings.js +1 -1
- package/dist/purchase/.kosh/schema.d.ts +54 -3
- package/dist/purchase/index.js +163 -3
- package/dist/purchase/module.d.ts +215 -3
- package/dist/purchase/module.js +21 -3
- package/dist/purchase_account/.kosh/bindings.js +1 -1
- package/dist/purchase_account/.kosh/schema.d.ts +55 -3
- package/dist/purchase_account/index.js +26 -1
- package/dist/purchase_account/module.d.ts +393 -7
- package/dist/purchase_portal/.kosh/bindings.js +1 -1
- package/dist/purchase_portal/.kosh/schema.d.ts +54 -3
- package/dist/purchase_portal/module.d.ts +366 -5
- package/dist/purchase_stock/.kosh/bindings.d.ts +22 -0
- package/dist/purchase_stock/.kosh/bindings.js +15 -0
- package/dist/purchase_stock/.kosh/schema.d.ts +1310 -0
- package/dist/purchase_stock/index.d.ts +7 -0
- package/dist/purchase_stock/index.js +91 -0
- package/dist/purchase_stock/module.d.ts +6763 -0
- package/dist/purchase_stock/module.js +144 -0
- package/dist/purchase_stock_account/.kosh/bindings.d.ts +22 -0
- package/dist/purchase_stock_account/.kosh/bindings.js +15 -0
- package/dist/purchase_stock_account/.kosh/schema.d.ts +1959 -0
- package/dist/purchase_stock_account/index.d.ts +3 -0
- package/dist/purchase_stock_account/index.js +24 -0
- package/dist/purchase_stock_account/module.d.ts +35366 -0
- package/dist/purchase_stock_account/module.js +162 -0
- package/dist/recruitment/.kosh/bindings.d.ts +22 -0
- package/dist/recruitment/.kosh/bindings.js +15 -0
- package/dist/recruitment/.kosh/schema.d.ts +990 -0
- package/dist/recruitment/demo.d.ts +6 -0
- package/dist/recruitment/demo.js +45 -0
- package/dist/recruitment/index.d.ts +3 -0
- package/dist/recruitment/index.js +385 -0
- package/dist/recruitment/module.d.ts +3247 -0
- package/dist/recruitment/module.js +193 -0
- package/dist/recruitment/web/HireButton.d.ts +9 -0
- package/dist/recruitment/web/HireButton.js +32 -0
- package/dist/recruitment/web/index.d.ts +11 -0
- package/dist/recruitment/web/index.js +16 -0
- package/dist/recruitment/web/messages.d.ts +2 -0
- package/dist/recruitment/web/messages.js +19 -0
- package/dist/reporting/documents.d.ts +5 -0
- package/dist/reporting/documents.js +28 -1
- package/dist/reporting/index.d.ts +1 -1
- package/dist/reporting/index.js +4 -2
- package/dist/reporting/models.js +9 -0
- package/dist/reporting/overviews.d.ts +16 -0
- package/dist/reporting/overviews.js +56 -0
- package/dist/reporting/routes-documents.js +1 -1
- package/dist/reporting/routes.js +138 -80
- package/dist/reporting/web/DashboardView.d.ts +11 -0
- package/dist/reporting/web/DashboardView.js +2 -2
- package/dist/reporting/web/DesignerView.js +5 -0
- package/dist/reporting/web/Overview.d.ts +9 -0
- package/dist/reporting/web/Overview.js +53 -0
- package/dist/reporting/web/index.d.ts +1 -0
- package/dist/reporting/web/index.js +4 -1
- package/dist/reporting/web/messages.js +9 -0
- package/dist/sale_account/.kosh/bindings.js +1 -1
- package/dist/sale_account/.kosh/schema.d.ts +59 -3
- package/dist/sale_account/index.js +22 -1
- package/dist/sale_account/module.d.ts +461 -7
- package/dist/sale_portal/.kosh/bindings.js +1 -1
- package/dist/sale_portal/.kosh/schema.d.ts +58 -3
- package/dist/sale_portal/module.d.ts +434 -5
- package/dist/sale_project/.kosh/bindings.js +1 -1
- package/dist/sale_project/.kosh/schema.d.ts +60 -3
- package/dist/sale_project/module.d.ts +476 -6
- package/dist/sale_stock/.kosh/bindings.d.ts +22 -0
- package/dist/sale_stock/.kosh/bindings.js +15 -0
- package/dist/sale_stock/.kosh/schema.d.ts +1228 -0
- package/dist/sale_stock/index.d.ts +7 -0
- package/dist/sale_stock/index.js +84 -0
- package/dist/sale_stock/module.d.ts +6791 -0
- package/dist/sale_stock/module.js +146 -0
- package/dist/sales/.kosh/bindings.js +1 -1
- package/dist/sales/.kosh/schema.d.ts +58 -3
- package/dist/sales/index.js +191 -4
- package/dist/sales/module.d.ts +355 -3
- package/dist/sales/module.js +78 -3
- package/dist/server/api/metadata.d.ts +5 -0
- package/dist/server/api/metadata.js +5 -0
- package/dist/server/api/rest.js +64 -1
- package/dist/server/api/views.js +1 -0
- package/dist/server/app-settings.d.ts +7 -0
- package/dist/server/app-settings.js +10 -0
- package/dist/server/auth/base.js +9 -0
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +1 -0
- package/dist/settings/app-settings.d.ts +2 -0
- package/dist/settings/app-settings.js +518 -0
- package/dist/settings/index.d.ts +1 -0
- package/dist/settings/index.js +15 -0
- package/dist/settings/people.js +29 -5
- package/dist/settings/web/AppSettingsPage.d.ts +11 -0
- package/dist/settings/web/AppSettingsPage.js +210 -0
- package/dist/settings/web/OverviewPage.js +16 -3
- package/dist/settings/web/PeoplePage.js +20 -6
- package/dist/settings/web/index.d.ts +1 -0
- package/dist/settings/web/index.js +3 -0
- package/dist/settings/web/messages.js +118 -2
- package/dist/stock/.kosh/bindings.d.ts +22 -0
- package/dist/stock/.kosh/bindings.js +15 -0
- package/dist/stock/.kosh/schema.d.ts +1147 -0
- package/dist/stock/demo.d.ts +7 -0
- package/dist/stock/demo.js +55 -0
- package/dist/stock/index.d.ts +3 -0
- package/dist/stock/index.js +674 -0
- package/dist/stock/module.d.ts +6330 -0
- package/dist/stock/module.js +1325 -0
- package/dist/stock/web/ScanBox.d.ts +8 -0
- package/dist/stock/web/ScanBox.js +90 -0
- package/dist/stock/web/index.d.ts +11 -0
- package/dist/stock/web/index.js +16 -0
- package/dist/stock/web/messages.d.ts +2 -0
- package/dist/stock/web/messages.js +25 -0
- package/dist/stock_account/.kosh/bindings.d.ts +22 -0
- package/dist/stock_account/.kosh/bindings.js +15 -0
- package/dist/stock_account/.kosh/schema.d.ts +1875 -0
- package/dist/stock_account/index.d.ts +3 -0
- package/dist/stock_account/index.js +130 -0
- package/dist/stock_account/module.d.ts +10655 -0
- package/dist/stock_account/module.js +339 -0
- package/dist/storage/attachments.d.ts +5 -0
- package/dist/storage/attachments.js +12 -1
- package/dist/storage/models.js +3 -0
- package/dist/storage/routes.js +13 -2
- package/dist/todo/.kosh/bindings.d.ts +22 -0
- package/dist/todo/.kosh/bindings.js +15 -0
- package/dist/todo/.kosh/schema.d.ts +732 -0
- package/dist/todo/demo.d.ts +6 -0
- package/dist/todo/demo.js +35 -0
- package/dist/todo/index.d.ts +3 -0
- package/dist/todo/index.js +187 -0
- package/dist/todo/module.d.ts +1180 -0
- package/dist/todo/module.js +130 -0
- package/dist/trade_finance/.kosh/bindings.d.ts +22 -0
- package/dist/trade_finance/.kosh/bindings.js +15 -0
- package/dist/trade_finance/.kosh/schema.d.ts +884 -0
- package/dist/trade_finance/demo.d.ts +6 -0
- package/dist/trade_finance/demo.js +63 -0
- package/dist/trade_finance/index.d.ts +3 -0
- package/dist/trade_finance/index.js +413 -0
- package/dist/trade_finance/module.d.ts +3779 -0
- package/dist/trade_finance/module.js +505 -0
- package/dist/trade_finance_account/.kosh/bindings.d.ts +22 -0
- package/dist/trade_finance_account/.kosh/bindings.js +15 -0
- package/dist/trade_finance_account/.kosh/schema.d.ts +1711 -0
- package/dist/trade_finance_account/index.d.ts +3 -0
- package/dist/trade_finance_account/index.js +92 -0
- package/dist/trade_finance_account/module.d.ts +7928 -0
- package/dist/trade_finance_account/module.js +244 -0
- package/dist/trade_finance_purchase/.kosh/bindings.d.ts +22 -0
- package/dist/trade_finance_purchase/.kosh/bindings.js +15 -0
- package/dist/trade_finance_purchase/.kosh/schema.d.ts +1153 -0
- package/dist/trade_finance_purchase/index.d.ts +8 -0
- package/dist/trade_finance_purchase/index.js +50 -0
- package/dist/trade_finance_purchase/module.d.ts +4252 -0
- package/dist/trade_finance_purchase/module.js +44 -0
- package/dist/trade_finance_sale/.kosh/bindings.d.ts +22 -0
- package/dist/trade_finance_sale/.kosh/bindings.js +15 -0
- package/dist/trade_finance_sale/.kosh/schema.d.ts +1071 -0
- package/dist/trade_finance_sale/index.d.ts +8 -0
- package/dist/trade_finance_sale/index.js +50 -0
- package/dist/trade_finance_sale/module.d.ts +4280 -0
- package/dist/trade_finance_sale/module.js +44 -0
- package/dist/ui/api.d.ts +2 -1
- package/dist/ui/api.js +2 -1
- package/dist/ui/app.js +2 -1
- package/dist/ui/components/AppIcon.js +23 -1
- package/dist/ui/components/primitives.d.ts +2 -0
- package/dist/ui/components/primitives.js +7 -1
- package/dist/ui/i18n/messages.d.ts +23 -0
- package/dist/ui/i18n/messages.js +38 -0
- package/dist/ui/index.d.ts +1 -0
- package/dist/ui/index.js +1 -0
- package/dist/ui/offline/queue.d.ts +5 -1
- package/dist/ui/offline/queue.js +2 -0
- package/dist/ui/registry.d.ts +5 -0
- package/dist/ui/shell/Home.js +55 -4
- package/dist/ui/views/FormView.js +98 -17
- package/dist/ui/views/KanbanView.d.ts +2 -0
- package/dist/ui/views/KanbanView.js +32 -3
- package/dist/ui/views/ListView.js +108 -11
- package/dist/ui/views/StatsRow.d.ts +14 -0
- package/dist/ui/views/StatsRow.js +88 -0
- package/dist/ui/views/widgets.d.ts +2 -1
- package/dist/ui/views/widgets.js +194 -1
- package/dist/views/builders.d.ts +19 -2
- package/dist/views/builders.js +21 -1
- package/dist/views/condition.d.ts +6 -0
- package/dist/views/condition.js +20 -0
- package/dist/views/documents.d.ts +8 -0
- package/dist/views/extend.d.ts +2 -2
- package/dist/views/extend.js +11 -1
- package/dist/views/index.d.ts +2 -2
- package/dist/views/index.js +2 -2
- package/dist/views/types.d.ts +52 -5
- package/dist/views/validate.js +39 -1
- package/dist/website/.kosh/bindings.d.ts +22 -0
- package/dist/website/.kosh/bindings.js +15 -0
- package/dist/website/.kosh/schema.d.ts +881 -0
- package/dist/website/demo.d.ts +6 -0
- package/dist/website/demo.js +115 -0
- package/dist/website/index.d.ts +9 -0
- package/dist/website/index.js +250 -0
- package/dist/website/module.d.ts +2987 -0
- package/dist/website/module.js +281 -0
- package/dist/website/render.d.ts +45 -0
- package/dist/website/render.js +102 -0
- package/dist/website/routes.d.ts +12 -0
- package/dist/website/routes.js +246 -0
- package/dist/website/web/PagePreview.d.ts +7 -0
- package/dist/website/web/PagePreview.js +33 -0
- package/dist/website/web/index.d.ts +11 -0
- package/dist/website/web/index.js +16 -0
- package/dist/website/web/messages.d.ts +2 -0
- package/dist/website/web/messages.js +15 -0
- package/dist/website_crm/.kosh/bindings.d.ts +22 -0
- package/dist/website_crm/.kosh/bindings.js +15 -0
- package/dist/website_crm/.kosh/schema.d.ts +966 -0
- package/dist/website_crm/index.d.ts +3 -0
- package/dist/website_crm/index.js +39 -0
- package/dist/website_crm/module.d.ts +2955 -0
- package/dist/website_crm/module.js +44 -0
- package/package.json +118 -1
|
@@ -0,0 +1,1325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The data layer of Stockroom (RFC 0021): warehouses, a tree of locations, what is in them (quants), and the
|
|
3
|
+
* **Transfer** — one document for receipts, deliveries and internal moves — with its lines and the reservations that
|
|
4
|
+
* hold stock for them. Quantities are exact: a line may be in any unit of its product's unit category and is converted
|
|
5
|
+
* with Shutro's `Quantity`; quants are kept in the product's unit. Stock never goes below zero: a transfer out of an
|
|
6
|
+
* internal location is validated only when all of it is reserved, and reservations take only what is available.
|
|
7
|
+
* Quants and reservations are written only here, in audited superuser scopes, never by users.
|
|
8
|
+
*/
|
|
9
|
+
import { contactsShutro } from '../contacts/index.js';
|
|
10
|
+
import { messagingShutro } from '../messaging/index.js';
|
|
11
|
+
import { productShutro } from '../product/index.js';
|
|
12
|
+
import { installationSetting } from '../server/index.js';
|
|
13
|
+
import { orm } from '../server/orm.js';
|
|
14
|
+
import { check, compute, defineModel, defineModule, extendModel, f, method, onchange, record, records, } from './.kosh/bindings.js';
|
|
15
|
+
const READ = ['read'];
|
|
16
|
+
const ALL = ['read', 'write', 'create', 'unlink'];
|
|
17
|
+
const zero = orm.toDecimal(0);
|
|
18
|
+
const asUom = (u) => orm.defineUom({
|
|
19
|
+
id: u.id,
|
|
20
|
+
name: typeof u.name === 'string' ? u.name : String(u.id),
|
|
21
|
+
category: u.category,
|
|
22
|
+
ratio: u.ratio,
|
|
23
|
+
rounding: u.rounding,
|
|
24
|
+
});
|
|
25
|
+
/**
|
|
26
|
+
* `quantity` of `unit` in `productUnit`, exactly (a dozen is 12 units). The same unit, or a product without a unit,
|
|
27
|
+
* keeps the quantity. @throws {IncompatibleUomCategoryError} across categories (refused earlier by the line's check).
|
|
28
|
+
*/
|
|
29
|
+
export function inProductUnit(quantity, unit, productUnit) {
|
|
30
|
+
if (unit === null || productUnit === null || unit.id === productUnit.id)
|
|
31
|
+
return quantity;
|
|
32
|
+
return orm.Quantity.of(quantity, asUom(unit)).to(asUom(productUnit)).value;
|
|
33
|
+
}
|
|
34
|
+
/** Whether transfers reserve when they are confirmed (App settings › Stockroom). */
|
|
35
|
+
export async function reserveOnConfirm(env) {
|
|
36
|
+
return (await installationSetting(env, 'stock.reserveOnConfirm', true)) !== false;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The default locations of a kind of transfer in the company's first warehouse: a receipt comes from the vendors
|
|
40
|
+
* into stock, a delivery goes from stock to the customers, an internal move stays in stock.
|
|
41
|
+
*/
|
|
42
|
+
export async function defaultLocations(env, kind, warehouse,
|
|
43
|
+
/** The document's company (an order's), else the user's current one. */
|
|
44
|
+
forCompany) {
|
|
45
|
+
const company = forCompany ?? env.companyId;
|
|
46
|
+
const wh = warehouse ??
|
|
47
|
+
(await env.model('stock.warehouse').search({ where: { company }, limit: 1 })).ids[0] ??
|
|
48
|
+
null;
|
|
49
|
+
const stock = wh === null
|
|
50
|
+
? null
|
|
51
|
+
: ((await env.model('stock.location').search({
|
|
52
|
+
where: { warehouse: wh, usage: 'internal', parent: { isNull: true } },
|
|
53
|
+
limit: 1,
|
|
54
|
+
})).ids[0] ?? null);
|
|
55
|
+
const shared = async (usage) => (await env.model('stock.location').search({ where: { usage }, limit: 1 })).ids[0] ?? null;
|
|
56
|
+
if (kind === 'receipt')
|
|
57
|
+
return { warehouse: wh, source: await shared('vendor'), destination: stock };
|
|
58
|
+
if (kind === 'delivery')
|
|
59
|
+
return { warehouse: wh, source: stock, destination: await shared('customer') };
|
|
60
|
+
return { warehouse: wh, source: stock, destination: stock };
|
|
61
|
+
}
|
|
62
|
+
export const StockWarehouse = defineModel('stock.warehouse', {
|
|
63
|
+
label: 'Warehouse',
|
|
64
|
+
recName: 'name',
|
|
65
|
+
order: ['name asc'],
|
|
66
|
+
archivable: true,
|
|
67
|
+
companyScoped: { required: true },
|
|
68
|
+
access: { 'stock.user': READ, 'stock.manager': [...ALL, 'export'] },
|
|
69
|
+
fields: {
|
|
70
|
+
name: f.char({ required: true }),
|
|
71
|
+
/** The short code that starts its locations' names (`WH/Stock`). */
|
|
72
|
+
code: f.char({ required: true, label: 'Short code' }),
|
|
73
|
+
locations: f.one2many('stock.location', 'warehouse'),
|
|
74
|
+
address: f.many2one('res.partner', { ondelete: 'set null' }),
|
|
75
|
+
},
|
|
76
|
+
sqlConstraints: {
|
|
77
|
+
oneCode: { unique: ['company', 'code'], message: 'Another warehouse has this short code.' },
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
export const StockLocation = defineModel('stock.location', {
|
|
81
|
+
label: 'Location',
|
|
82
|
+
recName: 'fullName',
|
|
83
|
+
order: ['fullName asc'],
|
|
84
|
+
archivable: true,
|
|
85
|
+
// Vendors, customers and the adjustment location are shared by the companies.
|
|
86
|
+
companyScoped: { required: false },
|
|
87
|
+
hierarchy: { parent: 'parent', path: 'parentPath' },
|
|
88
|
+
access: { 'stock.user': READ, 'stock.manager': [...ALL, 'export', 'import'] },
|
|
89
|
+
fields: {
|
|
90
|
+
name: f.char({ required: true, translate: true }),
|
|
91
|
+
parent: f.many2one('stock.location', { ondelete: 'restrict', label: 'Inside' }),
|
|
92
|
+
parentPath: f.char({ readonly: true, index: true }),
|
|
93
|
+
children: f.one2many('stock.location', 'parent'),
|
|
94
|
+
warehouse: f.many2one('stock.warehouse', { ondelete: 'cascade' }),
|
|
95
|
+
usage: f.selection({
|
|
96
|
+
internal: 'Internal (holds stock)',
|
|
97
|
+
vendor: 'Vendors',
|
|
98
|
+
customer: 'Customers',
|
|
99
|
+
adjustment: 'Inventory adjustment',
|
|
100
|
+
production: 'Production',
|
|
101
|
+
transit: 'In transit',
|
|
102
|
+
}, { required: true, default: 'internal' }),
|
|
103
|
+
/**
|
|
104
|
+
* `WH/Stock/Shelf 1`: the warehouse code and the names from the top (four levels by name; deeper ones start
|
|
105
|
+
* with `…`). Shutro refuses a computed field that depends on itself, so it reads the stored names.
|
|
106
|
+
*/
|
|
107
|
+
fullName: f.char({
|
|
108
|
+
store: true,
|
|
109
|
+
label: 'Location',
|
|
110
|
+
...compute('stock.location', 'fullName', [
|
|
111
|
+
'name',
|
|
112
|
+
'warehouse.code',
|
|
113
|
+
'parent.name',
|
|
114
|
+
'parent.parent.name',
|
|
115
|
+
'parent.parent.parent.name',
|
|
116
|
+
'parent.parent.parent.parent',
|
|
117
|
+
], (locations) => {
|
|
118
|
+
for (const l of locations) {
|
|
119
|
+
const names = [l.name];
|
|
120
|
+
let up = l.parent;
|
|
121
|
+
for (let depth = 0; up !== null && depth < 3; depth += 1) {
|
|
122
|
+
names.unshift(up.name);
|
|
123
|
+
up = up.parent;
|
|
124
|
+
}
|
|
125
|
+
const top = up !== null ? '…' : l.warehouse !== null ? l.warehouse.code : null;
|
|
126
|
+
l.fullName = [...(top === null ? [] : [top]), ...names].join('/');
|
|
127
|
+
}
|
|
128
|
+
}),
|
|
129
|
+
}),
|
|
130
|
+
},
|
|
131
|
+
constraints: {
|
|
132
|
+
// Stock is held in a warehouse's internal locations; a child stays in its parent's warehouse.
|
|
133
|
+
internalInWarehouse: {
|
|
134
|
+
...check('stock.location', ['usage', 'warehouse', 'parent'], async (locations) => (await locations.load({ warehouse: true, parent: { warehouse: true } }))
|
|
135
|
+
.filter((l) => (l.usage === 'internal' && l.warehouse === null) ||
|
|
136
|
+
(l.parent !== null && l.parent.warehouse?.id !== l.warehouse?.id))
|
|
137
|
+
.map((l) => l.id)),
|
|
138
|
+
message: 'An internal location belongs to a warehouse, the same as the location it is inside.',
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
export const StockLot = defineModel('stock.lot', {
|
|
143
|
+
label: 'Lot / serial number',
|
|
144
|
+
recName: 'name',
|
|
145
|
+
order: ['expiryDate asc', 'name asc'],
|
|
146
|
+
companyScoped: { required: true },
|
|
147
|
+
access: {
|
|
148
|
+
'stock.user': ['read', 'write', 'create'],
|
|
149
|
+
'stock.manager': [...ALL, 'export', 'import'],
|
|
150
|
+
},
|
|
151
|
+
fields: {
|
|
152
|
+
name: f.char({ required: true, label: 'Lot / serial number' }),
|
|
153
|
+
product: f.many2one('product.product', { required: true, ondelete: 'restrict' }),
|
|
154
|
+
/** Reservations take the lots that expire first. */
|
|
155
|
+
expiryDate: f.date({ label: 'Expires on' }),
|
|
156
|
+
quants: f.one2many('stock.quant', 'lot'),
|
|
157
|
+
qtyOnHand: f.decimal({
|
|
158
|
+
scale: 4,
|
|
159
|
+
store: true,
|
|
160
|
+
label: 'On hand',
|
|
161
|
+
...compute('stock.lot', 'qtyOnHand', ['quants.quantity'], (lots) => {
|
|
162
|
+
for (const l of lots)
|
|
163
|
+
l.qtyOnHand = [...l.quants].reduce((s, q) => s.plus(q.quantity), zero);
|
|
164
|
+
}),
|
|
165
|
+
}),
|
|
166
|
+
/** Every transfer that moved it: where it came from and where it went. */
|
|
167
|
+
history: f.one2many('stock.lot.line', 'lot'),
|
|
168
|
+
note: f.text(),
|
|
169
|
+
},
|
|
170
|
+
sqlConstraints: {
|
|
171
|
+
onePerName: {
|
|
172
|
+
unique: ['company', 'product', 'name'],
|
|
173
|
+
message: 'This product already has a lot or serial number with this name.',
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
export const StockQuant = defineModel('stock.quant', {
|
|
178
|
+
label: 'Stock',
|
|
179
|
+
order: ['id asc'],
|
|
180
|
+
companyScoped: { required: true },
|
|
181
|
+
// Read-only to everyone: quantities change only through validated transfers.
|
|
182
|
+
access: { 'stock.user': READ, 'stock.manager': ['read', 'export'] },
|
|
183
|
+
fields: {
|
|
184
|
+
product: f.many2one('product.product', { required: true, ondelete: 'restrict' }),
|
|
185
|
+
location: f.many2one('stock.location', { required: true, ondelete: 'restrict' }),
|
|
186
|
+
/** The lot or serial number, for a tracked product. */
|
|
187
|
+
lot: f.many2one('stock.lot', { ondelete: 'restrict' }),
|
|
188
|
+
/** On hand, in the product's unit. */
|
|
189
|
+
quantity: f.decimal({ scale: 4, required: true, default: '0', label: 'On hand' }),
|
|
190
|
+
reservations: f.one2many('stock.reservation', 'quant'),
|
|
191
|
+
reserved: f.decimal({
|
|
192
|
+
scale: 4,
|
|
193
|
+
store: true,
|
|
194
|
+
label: 'Reserved',
|
|
195
|
+
...compute('stock.quant', 'reserved', ['reservations.quantity'], (quants) => {
|
|
196
|
+
for (const q of quants)
|
|
197
|
+
q.reserved = [...q.reservations].reduce((s, r) => s.plus(r.quantity), zero);
|
|
198
|
+
}),
|
|
199
|
+
}),
|
|
200
|
+
available: f.decimal({
|
|
201
|
+
scale: 4,
|
|
202
|
+
store: true,
|
|
203
|
+
label: 'Available',
|
|
204
|
+
...compute('stock.quant', 'available', ['quantity', 'reserved'], (quants) => {
|
|
205
|
+
for (const q of quants)
|
|
206
|
+
q.available = q.quantity.minus(q.reserved ?? zero);
|
|
207
|
+
}),
|
|
208
|
+
}),
|
|
209
|
+
},
|
|
210
|
+
sqlConstraints: {
|
|
211
|
+
onePerPlace: {
|
|
212
|
+
unique: ['product', 'location'],
|
|
213
|
+
where: orm.sql `lot_id IS NULL`,
|
|
214
|
+
message: 'A product has one stock line per location.',
|
|
215
|
+
},
|
|
216
|
+
onePerLot: {
|
|
217
|
+
unique: ['product', 'location', 'lot'],
|
|
218
|
+
where: orm.sql `lot_id IS NOT NULL`,
|
|
219
|
+
message: 'A lot has one stock line per location.',
|
|
220
|
+
},
|
|
221
|
+
notNegative: {
|
|
222
|
+
check: orm.sql `quantity >= 0`,
|
|
223
|
+
message: 'Stock cannot go below zero.',
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
export const StockReservation = defineModel('stock.reservation', {
|
|
228
|
+
label: 'Reservation',
|
|
229
|
+
recName: 'label',
|
|
230
|
+
order: ['id asc'],
|
|
231
|
+
access: { 'stock.user': READ, 'stock.manager': READ },
|
|
232
|
+
fields: {
|
|
233
|
+
move: f.many2one('stock.move', { required: true, ondelete: 'cascade' }),
|
|
234
|
+
quant: f.many2one('stock.quant', { required: true, ondelete: 'cascade' }),
|
|
235
|
+
quantity: f.decimal({ scale: 4, required: true }),
|
|
236
|
+
/** "2 Units reserved for TR/2026/00042 (SO/2026/00017)"; "1 Units of SN-0042 reserved for …" with a lot. */
|
|
237
|
+
label: f.char({
|
|
238
|
+
store: true,
|
|
239
|
+
label: 'Reserved for',
|
|
240
|
+
...compute('stock.reservation', 'label', [
|
|
241
|
+
'quantity',
|
|
242
|
+
'move.picking.name',
|
|
243
|
+
'move.picking.origin',
|
|
244
|
+
'quant.product.uom.name',
|
|
245
|
+
'quant.lot.name',
|
|
246
|
+
], (reservations) => {
|
|
247
|
+
for (const r of reservations) {
|
|
248
|
+
const p = r.move.picking;
|
|
249
|
+
const unit = r.quant.product.uom?.name ?? '';
|
|
250
|
+
const lot = r.quant.lot?.name;
|
|
251
|
+
r.label = `${r.quantity.toString()}${unit === '' ? '' : ` ${unit}`}${lot ? ` of ${lot}` : ''} reserved for ${p.name}${p.origin ? ` (${p.origin})` : ''}`;
|
|
252
|
+
}
|
|
253
|
+
}),
|
|
254
|
+
}),
|
|
255
|
+
},
|
|
256
|
+
sqlConstraints: {
|
|
257
|
+
positive: { check: orm.sql `quantity > 0`, message: 'A reservation holds a positive quantity.' },
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
export const StockMove = defineModel('stock.move', {
|
|
261
|
+
label: 'Transfer line',
|
|
262
|
+
order: ['id asc'],
|
|
263
|
+
access: {
|
|
264
|
+
'stock.user': ['read', 'write', 'create', 'unlink'],
|
|
265
|
+
'stock.manager': [...ALL, 'export'],
|
|
266
|
+
},
|
|
267
|
+
fields: {
|
|
268
|
+
picking: f.many2one('stock.picking', {
|
|
269
|
+
required: true,
|
|
270
|
+
ondelete: 'cascade',
|
|
271
|
+
label: 'Transfer',
|
|
272
|
+
}),
|
|
273
|
+
product: f.many2one('product.product', { required: true, ondelete: 'restrict' }),
|
|
274
|
+
name: f.char({ label: 'Description' }),
|
|
275
|
+
quantity: f.decimal({ scale: 4, required: true, default: '1', label: 'Quantity' }),
|
|
276
|
+
uom: f.many2one('uom.uom', { label: 'Unit' }),
|
|
277
|
+
/** The quantity in the product's unit (what quants count). */
|
|
278
|
+
productQty: f.decimal({
|
|
279
|
+
scale: 4,
|
|
280
|
+
store: true,
|
|
281
|
+
label: 'In the product’s unit',
|
|
282
|
+
...compute('stock.move', 'productQty', [
|
|
283
|
+
'quantity',
|
|
284
|
+
'uom.name',
|
|
285
|
+
'uom.ratio',
|
|
286
|
+
'uom.category',
|
|
287
|
+
'uom.rounding',
|
|
288
|
+
'product.uom.name',
|
|
289
|
+
'product.uom.ratio',
|
|
290
|
+
'product.uom.category',
|
|
291
|
+
'product.uom.rounding',
|
|
292
|
+
], (moves) => {
|
|
293
|
+
for (const m of moves) {
|
|
294
|
+
const unit = m.uom ?? null;
|
|
295
|
+
const productUnit = m.product.uom ?? null;
|
|
296
|
+
m.productQty =
|
|
297
|
+
unit !== null && productUnit !== null && unit.category !== productUnit.category
|
|
298
|
+
? m.quantity // refused by `sameUnitCategory`
|
|
299
|
+
: inProductUnit(m.quantity, unit, productUnit);
|
|
300
|
+
}
|
|
301
|
+
}),
|
|
302
|
+
}),
|
|
303
|
+
/**
|
|
304
|
+
* What is actually moved, in the line's unit, set while the transfer waits (empty: all of it). Less than the
|
|
305
|
+
* quantity validates part of the line; the rest goes to a backorder.
|
|
306
|
+
*/
|
|
307
|
+
quantityDone: f.decimal({ scale: 4, label: 'Done' }),
|
|
308
|
+
/** The done quantity in the product's unit. */
|
|
309
|
+
doneQty: f.decimal({
|
|
310
|
+
scale: 4,
|
|
311
|
+
store: true,
|
|
312
|
+
label: 'Done in the product’s unit',
|
|
313
|
+
...compute('stock.move', 'doneQty', [
|
|
314
|
+
'quantity',
|
|
315
|
+
'quantityDone',
|
|
316
|
+
'uom.name',
|
|
317
|
+
'uom.ratio',
|
|
318
|
+
'uom.category',
|
|
319
|
+
'uom.rounding',
|
|
320
|
+
'product.uom.name',
|
|
321
|
+
'product.uom.ratio',
|
|
322
|
+
'product.uom.category',
|
|
323
|
+
'product.uom.rounding',
|
|
324
|
+
], (moves) => {
|
|
325
|
+
for (const m of moves) {
|
|
326
|
+
const done = m.quantityDone ?? m.quantity;
|
|
327
|
+
const unit = m.uom ?? null;
|
|
328
|
+
const productUnit = m.product.uom ?? null;
|
|
329
|
+
m.doneQty =
|
|
330
|
+
unit !== null && productUnit !== null && unit.category !== productUnit.category
|
|
331
|
+
? done
|
|
332
|
+
: inProductUnit(done, unit, productUnit);
|
|
333
|
+
}
|
|
334
|
+
}),
|
|
335
|
+
}),
|
|
336
|
+
/** Take this lot only (a customer's batch, a count's loss); otherwise the lots that expire first. */
|
|
337
|
+
restrictLot: f.many2one('stock.lot', { ondelete: 'set null', label: 'Lot to take' }),
|
|
338
|
+
reservations: f.one2many('stock.reservation', 'move'),
|
|
339
|
+
reserved: f.decimal({
|
|
340
|
+
scale: 4,
|
|
341
|
+
store: true,
|
|
342
|
+
label: 'Reserved',
|
|
343
|
+
...compute('stock.move', 'reserved', ['reservations.quantity'], (moves) => {
|
|
344
|
+
for (const m of moves)
|
|
345
|
+
m.reserved = [...m.reservations].reduce((s, r) => s.plus(r.quantity), zero);
|
|
346
|
+
}),
|
|
347
|
+
}),
|
|
348
|
+
},
|
|
349
|
+
methods: {
|
|
350
|
+
/**
|
|
351
|
+
* What a backorder's line copies from each of these lines: the product, description and unit. A bridge adds
|
|
352
|
+
* its link by overriding it (`sale_stock`: the order line), so the rest of a line stays tied to its origin.
|
|
353
|
+
*/
|
|
354
|
+
backorderValues: method('stock.move', async function () {
|
|
355
|
+
const out = new Map();
|
|
356
|
+
for (const m of await this.load({}))
|
|
357
|
+
out.set(m.id, {
|
|
358
|
+
product: m.product.id,
|
|
359
|
+
name: m.name,
|
|
360
|
+
uom: m.uom?.id ?? null,
|
|
361
|
+
restrictLot: m.restrictLot?.id ?? null,
|
|
362
|
+
});
|
|
363
|
+
return out;
|
|
364
|
+
}),
|
|
365
|
+
},
|
|
366
|
+
onchange: {
|
|
367
|
+
// The product's unit and name by default.
|
|
368
|
+
product: onchange('stock.move', async (d) => {
|
|
369
|
+
const p = await d.record('product');
|
|
370
|
+
if (p === null || p === undefined)
|
|
371
|
+
return;
|
|
372
|
+
if (p.uom)
|
|
373
|
+
d.set('uom', p.uom.id);
|
|
374
|
+
if ((d.get('name') ?? '') === '')
|
|
375
|
+
d.set('name', p.name);
|
|
376
|
+
}),
|
|
377
|
+
},
|
|
378
|
+
constraints: {
|
|
379
|
+
positiveQuantity: {
|
|
380
|
+
...check('stock.move', ['quantity'], async (moves) => [...moves].filter((m) => m.quantity.lte(0)).map((m) => m.id)),
|
|
381
|
+
message: 'A line moves a positive quantity.',
|
|
382
|
+
},
|
|
383
|
+
doneNotNegative: {
|
|
384
|
+
...check('stock.move', ['quantityDone'], async (moves) => [...moves].filter((m) => m.quantityDone?.lt(0) === true).map((m) => m.id)),
|
|
385
|
+
message: 'A done quantity cannot be negative.',
|
|
386
|
+
},
|
|
387
|
+
goodsOnly: {
|
|
388
|
+
...check('stock.move', ['product'], async (moves) => (await moves.load({ product: true }))
|
|
389
|
+
.filter((m) => m.product.kind !== 'goods')
|
|
390
|
+
.map((m) => m.id)),
|
|
391
|
+
message: 'Services are not kept in stock: only goods are transferred.',
|
|
392
|
+
},
|
|
393
|
+
sameUnitCategory: {
|
|
394
|
+
...check('stock.move', ['uom', 'product'], async (moves) => (await moves.load({ uom: true, product: { uom: true } }))
|
|
395
|
+
.filter((m) => m.uom !== null && m.product.uom !== null && m.uom.category !== m.product.uom.category)
|
|
396
|
+
.map((m) => m.id)),
|
|
397
|
+
message: 'The line’s unit must measure the same thing as the product’s unit (a dozen of units, grams of kg).',
|
|
398
|
+
},
|
|
399
|
+
// Lines change while the transfer is a draft (reservations would no longer match).
|
|
400
|
+
draftOnly: {
|
|
401
|
+
...check('stock.move', ['product', 'quantity', 'uom', 'picking'], async (moves) => (await moves.load({ picking: true }))
|
|
402
|
+
.filter((m) => m.picking.state !== 'draft')
|
|
403
|
+
.map((m) => m.id)),
|
|
404
|
+
message: 'Lines change while the transfer is a draft: cancel it and set it back to draft first.',
|
|
405
|
+
},
|
|
406
|
+
},
|
|
407
|
+
});
|
|
408
|
+
/** Holds what is available in the transfers' source locations (and inside them) for their lines. */
|
|
409
|
+
async function reserve(ids, env) {
|
|
410
|
+
await env.asSuperuser({ reason: 'stock reservation' }, async (su) => {
|
|
411
|
+
const pickings = await su
|
|
412
|
+
.model('stock.picking')
|
|
413
|
+
.browse(ids)
|
|
414
|
+
.load({ source: true, moves: { product: true } });
|
|
415
|
+
const taken = new Map();
|
|
416
|
+
for (const p of pickings) {
|
|
417
|
+
if (p.source.usage !== 'internal')
|
|
418
|
+
continue;
|
|
419
|
+
for (const m of p.moves) {
|
|
420
|
+
let need = (m.productQty ?? zero).minus(m.reserved ?? zero);
|
|
421
|
+
if (need.lte(0))
|
|
422
|
+
continue;
|
|
423
|
+
const found = await (await su.model('stock.quant').search({
|
|
424
|
+
where: {
|
|
425
|
+
product: m.product.id,
|
|
426
|
+
location: { childOf: p.source.id },
|
|
427
|
+
...(m.restrictLot === null ? {} : { lot: m.restrictLot.id }),
|
|
428
|
+
},
|
|
429
|
+
})).load({ lot: true });
|
|
430
|
+
// First expiry first out; lots without a date last; then the oldest stock.
|
|
431
|
+
const expiry = (d) => d instanceof Date
|
|
432
|
+
? d.getTime()
|
|
433
|
+
: typeof d === 'string' && d !== ''
|
|
434
|
+
? Date.parse(d)
|
|
435
|
+
: Number.POSITIVE_INFINITY;
|
|
436
|
+
const quants = [...found].sort((a, b) => expiry(a.lot?.expiryDate) - expiry(b.lot?.expiryDate) || a.id - b.id);
|
|
437
|
+
for (const q of quants) {
|
|
438
|
+
const free = q.quantity.minus(q.reserved ?? zero).minus(taken.get(q.id) ?? zero);
|
|
439
|
+
if (free.lte(0))
|
|
440
|
+
continue;
|
|
441
|
+
const take = free.lt(need) ? free : need;
|
|
442
|
+
await su.model('stock.reservation').create([{ move: m.id, quant: q.id, quantity: take }]);
|
|
443
|
+
taken.set(q.id, (taken.get(q.id) ?? zero).plus(take));
|
|
444
|
+
need = need.minus(take);
|
|
445
|
+
if (need.lte(0))
|
|
446
|
+
break;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
/** Releases what the transfers hold. */
|
|
453
|
+
async function unreserve(ids, env) {
|
|
454
|
+
await env.asSuperuser({ reason: 'stock unreservation' }, async (su) => {
|
|
455
|
+
const moves = await su.model('stock.move').search({ where: { picking: { in: [...ids] } } });
|
|
456
|
+
if (moves.length === 0)
|
|
457
|
+
return;
|
|
458
|
+
const held = await su
|
|
459
|
+
.model('stock.reservation')
|
|
460
|
+
.search({ where: { move: { in: [...moves.ids] } } });
|
|
461
|
+
if (held.length > 0)
|
|
462
|
+
await held.unlink();
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Moves the stock of validated transfers: what each line has done (all of it unless less is set) leaves its
|
|
467
|
+
* reservations and enters the destination's quant; the reservations are released. Lots travel with the stock: out of
|
|
468
|
+
* stock, the reserved quants' lots move and are recorded on the transfer (what left, which lot); into stock from
|
|
469
|
+
* outside (receipts, adjustment gains), a tracked product's lots and serial numbers are listed on the transfer — as
|
|
470
|
+
* many as are done, one per serial number, a serial number never received twice — and new ones are made. Lines done
|
|
471
|
+
* in part leave the rest to a backorder — a new transfer with the same partner, places and source document, and every
|
|
472
|
+
* other field of its transfer and lines that `backorderValues` gives (a bridge's order and order lines too) —
|
|
473
|
+
* confirmed at once.
|
|
474
|
+
*/
|
|
475
|
+
async function applyMoves(ids, env) {
|
|
476
|
+
await env.asSuperuser({ reason: 'stock transfer validated' }, async (su) => {
|
|
477
|
+
const pickings = await su
|
|
478
|
+
.model('stock.picking')
|
|
479
|
+
.browse(ids)
|
|
480
|
+
.load({
|
|
481
|
+
source: true,
|
|
482
|
+
destination: true,
|
|
483
|
+
company: true,
|
|
484
|
+
moves: { product: true, reservations: { quant: true } },
|
|
485
|
+
lotLines: { lot: true, product: true },
|
|
486
|
+
});
|
|
487
|
+
const out = new Map();
|
|
488
|
+
const into = new Map();
|
|
489
|
+
const add = (place) => {
|
|
490
|
+
const key = `${place.product}:${place.location}:${place.lot ?? ''}`;
|
|
491
|
+
into.set(key, { ...place, quantity: (into.get(key)?.quantity ?? zero).plus(place.quantity) });
|
|
492
|
+
};
|
|
493
|
+
const used = [];
|
|
494
|
+
const shipped = [];
|
|
495
|
+
const backorders = [];
|
|
496
|
+
const refuse = (problem, hint) => {
|
|
497
|
+
throw new orm.ValidationError('stock.picking', ['lotLines'], problem, hint);
|
|
498
|
+
};
|
|
499
|
+
for (const p of pickings) {
|
|
500
|
+
const rest = new Map();
|
|
501
|
+
const company = p.company.id;
|
|
502
|
+
if (p.source.usage === 'internal') {
|
|
503
|
+
for (const m of p.moves) {
|
|
504
|
+
let left = m.doneQty ?? zero;
|
|
505
|
+
for (const r of m.reservations) {
|
|
506
|
+
const take = left.lt(r.quantity) ? left : r.quantity;
|
|
507
|
+
used.push(r.id);
|
|
508
|
+
left = left.minus(take);
|
|
509
|
+
if (take.lte(0))
|
|
510
|
+
continue;
|
|
511
|
+
out.set(r.quant.id, (out.get(r.quant.id) ?? zero).plus(take));
|
|
512
|
+
const lot = r.quant.lot?.id ?? null;
|
|
513
|
+
if (p.destination.usage === 'internal')
|
|
514
|
+
add({
|
|
515
|
+
product: m.product.id,
|
|
516
|
+
location: p.destination.id,
|
|
517
|
+
company,
|
|
518
|
+
lot,
|
|
519
|
+
quantity: take,
|
|
520
|
+
});
|
|
521
|
+
if (lot !== null)
|
|
522
|
+
shipped.push({ picking: p.id, product: m.product.id, lot, quantity: take });
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
else if (p.destination.usage === 'internal') {
|
|
527
|
+
const tracked = new Map();
|
|
528
|
+
for (const m of p.moves) {
|
|
529
|
+
const done = m.doneQty ?? zero;
|
|
530
|
+
if (m.product.tracking === 'none' || m.product.tracking === null) {
|
|
531
|
+
if (done.gt(0))
|
|
532
|
+
add({
|
|
533
|
+
product: m.product.id,
|
|
534
|
+
location: p.destination.id,
|
|
535
|
+
company,
|
|
536
|
+
lot: null,
|
|
537
|
+
quantity: done,
|
|
538
|
+
});
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
const t = tracked.get(m.product.id);
|
|
542
|
+
tracked.set(m.product.id, {
|
|
543
|
+
name: m.product.name,
|
|
544
|
+
serial: m.product.tracking === 'serial',
|
|
545
|
+
done: (t?.done ?? zero).plus(done),
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
for (const [product, t] of tracked) {
|
|
549
|
+
const lines = [...p.lotLines].filter((l) => l.product.id === product);
|
|
550
|
+
const given = lines.reduce((s, l) => s.plus(l.quantity), zero);
|
|
551
|
+
if (!given.eq(t.done))
|
|
552
|
+
refuse(`${t.name}: ${given.toString()} of ${t.done.toString()} have a lot or serial number.`, 'List them in Lots and serial numbers: one line per lot, one per serial number.');
|
|
553
|
+
const names = new Set();
|
|
554
|
+
for (const l of lines) {
|
|
555
|
+
const name = (l.lot?.name ?? l.lotName ?? '').trim();
|
|
556
|
+
if (t.serial && !l.quantity.eq(1))
|
|
557
|
+
refuse(`${t.name} is tracked by serial number: one line per unit.`, `${name} has ${l.quantity.toString()}.`);
|
|
558
|
+
if (t.serial && names.has(name))
|
|
559
|
+
refuse(`${t.name}: the serial number ${name} is listed twice.`, 'Each unit has its own serial number.');
|
|
560
|
+
names.add(name);
|
|
561
|
+
let lot = l.lot?.id ?? null;
|
|
562
|
+
if (lot === null) {
|
|
563
|
+
const existing = await su.model('stock.lot').search({
|
|
564
|
+
where: { company, product: orm.asId(product, 'product.product'), name },
|
|
565
|
+
limit: 1,
|
|
566
|
+
});
|
|
567
|
+
lot =
|
|
568
|
+
existing.ids[0] ??
|
|
569
|
+
(await su
|
|
570
|
+
.model('stock.lot')
|
|
571
|
+
.create([{ company, product: orm.asId(product, 'product.product'), name }])).ids[0];
|
|
572
|
+
await l.write({ lot });
|
|
573
|
+
}
|
|
574
|
+
if (t.serial) {
|
|
575
|
+
const [held] = await su.model('stock.lot').browse([lot]).load(['qtyOnHand']);
|
|
576
|
+
if ((held?.qtyOnHand ?? zero).gt(0))
|
|
577
|
+
refuse(`${t.name}: the serial number ${name} is already in stock.`, 'A serial number is received once.');
|
|
578
|
+
}
|
|
579
|
+
add({
|
|
580
|
+
product: orm.asId(product, 'product.product'),
|
|
581
|
+
location: p.destination.id,
|
|
582
|
+
company,
|
|
583
|
+
lot,
|
|
584
|
+
quantity: l.quantity,
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
for (const m of p.moves) {
|
|
590
|
+
const lineDone = m.quantityDone ?? m.quantity;
|
|
591
|
+
if (lineDone.lt(m.quantity))
|
|
592
|
+
rest.set(m.id, m.quantity.minus(lineDone));
|
|
593
|
+
if (m.quantityDone === null)
|
|
594
|
+
await m.write({ quantityDone: m.quantity });
|
|
595
|
+
}
|
|
596
|
+
if (rest.size > 0)
|
|
597
|
+
backorders.push({ picking: p.id, rest });
|
|
598
|
+
}
|
|
599
|
+
if (used.length > 0)
|
|
600
|
+
await su.model('stock.reservation').browse(used).unlink();
|
|
601
|
+
for (const [quant, quantity] of out) {
|
|
602
|
+
const [q] = await su.model('stock.quant').browse([quant]).load(['quantity']);
|
|
603
|
+
if (q !== undefined)
|
|
604
|
+
await q.write({ quantity: q.quantity.minus(quantity) });
|
|
605
|
+
}
|
|
606
|
+
for (const { product, location, company, lot, quantity } of into.values()) {
|
|
607
|
+
const found = await su.model('stock.quant').search({
|
|
608
|
+
where: { product, location, lot: lot === null ? { isNull: true } : lot },
|
|
609
|
+
limit: 1,
|
|
610
|
+
});
|
|
611
|
+
const [q] = await found.load(['quantity']);
|
|
612
|
+
if (q !== undefined)
|
|
613
|
+
await q.write({ quantity: q.quantity.plus(quantity) });
|
|
614
|
+
else
|
|
615
|
+
await su.model('stock.quant').create([{ product, location, company, lot, quantity }]);
|
|
616
|
+
}
|
|
617
|
+
// What left, lot by lot (what was listed on an outgoing transfer is replaced by what actually moved).
|
|
618
|
+
const outgoing = [...pickings].filter((p) => p.source.usage === 'internal').map((p) => p.id);
|
|
619
|
+
if (outgoing.length > 0) {
|
|
620
|
+
const listed = await su
|
|
621
|
+
.model('stock.lot.line')
|
|
622
|
+
.search({ where: { picking: { in: outgoing } } });
|
|
623
|
+
if (listed.length > 0)
|
|
624
|
+
await listed.unlink();
|
|
625
|
+
}
|
|
626
|
+
if (shipped.length > 0)
|
|
627
|
+
await su.model('stock.lot.line').create(shipped);
|
|
628
|
+
await su.model('stock.picking').browse(ids).write({ doneDate: su.today() });
|
|
629
|
+
// What follows a validation in other apps (`stock_account` values the stock).
|
|
630
|
+
await su.model('stock.picking').browse(ids).validated();
|
|
631
|
+
for (const { picking, rest } of backorders) {
|
|
632
|
+
const from = (await su.model('stock.picking').browse([picking]).backorderValues()).get(picking);
|
|
633
|
+
const lines = await su
|
|
634
|
+
.model('stock.move')
|
|
635
|
+
.browse([...rest.keys()])
|
|
636
|
+
.backorderValues();
|
|
637
|
+
const made = await su.model('stock.picking').create([
|
|
638
|
+
{
|
|
639
|
+
...from,
|
|
640
|
+
backorderOf: picking,
|
|
641
|
+
moves: {
|
|
642
|
+
create: [...rest].map(([id, quantity]) => ({ ...lines.get(id), quantity })),
|
|
643
|
+
},
|
|
644
|
+
},
|
|
645
|
+
]);
|
|
646
|
+
await su
|
|
647
|
+
.model('stock.picking')
|
|
648
|
+
.browse(made.ids)
|
|
649
|
+
.confirm();
|
|
650
|
+
}
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* The lots and serial numbers of a transfer: listed by hand on what comes into stock (an existing lot, or the name of
|
|
655
|
+
* a new one), recorded by the validation on what leaves it. The lot's history.
|
|
656
|
+
*/
|
|
657
|
+
export const StockLotLine = defineModel('stock.lot.line', {
|
|
658
|
+
label: 'Lot line',
|
|
659
|
+
order: ['id asc'],
|
|
660
|
+
access: {
|
|
661
|
+
'stock.user': ['read', 'write', 'create', 'unlink'],
|
|
662
|
+
'stock.manager': [...ALL, 'export', 'import'],
|
|
663
|
+
},
|
|
664
|
+
fields: {
|
|
665
|
+
picking: f.many2one('stock.picking', {
|
|
666
|
+
required: true,
|
|
667
|
+
ondelete: 'cascade',
|
|
668
|
+
label: 'Transfer',
|
|
669
|
+
}),
|
|
670
|
+
product: f.many2one('product.product', { required: true, ondelete: 'restrict' }),
|
|
671
|
+
lot: f.many2one('stock.lot', { ondelete: 'restrict', label: 'Lot / serial number' }),
|
|
672
|
+
/** A new lot or serial number, made when the transfer is validated. */
|
|
673
|
+
lotName: f.char({ label: 'New lot / serial number' }),
|
|
674
|
+
quantity: f.decimal({ scale: 4, required: true, default: '1' }),
|
|
675
|
+
},
|
|
676
|
+
sqlConstraints: {
|
|
677
|
+
positive: { check: orm.sql `quantity > 0`, message: 'A lot line has a positive quantity.' },
|
|
678
|
+
},
|
|
679
|
+
constraints: {
|
|
680
|
+
lotGiven: {
|
|
681
|
+
...check('stock.lot.line', ['lot', 'lotName'], async (lines) => [...lines]
|
|
682
|
+
.filter((l) => l.lot === null && (l.lotName ?? '').trim() === '')
|
|
683
|
+
.map((l) => l.id)),
|
|
684
|
+
message: 'Give the lot or serial number: an existing one, or the name of a new one.',
|
|
685
|
+
},
|
|
686
|
+
sameProduct: {
|
|
687
|
+
...check('stock.lot.line', ['lot', 'product'], async (lines) => (await lines.load({ lot: true }))
|
|
688
|
+
.filter((l) => l.lot !== null && l.lot.product.id !== l.product.id)
|
|
689
|
+
.map((l) => l.id)),
|
|
690
|
+
message: 'This lot or serial number belongs to another product.',
|
|
691
|
+
},
|
|
692
|
+
},
|
|
693
|
+
});
|
|
694
|
+
export const StockPicking = defineModel('stock.picking', {
|
|
695
|
+
label: 'Transfer',
|
|
696
|
+
recName: 'name',
|
|
697
|
+
order: ['scheduledDate desc', 'id desc'],
|
|
698
|
+
companyScoped: { required: true },
|
|
699
|
+
tracking: ['state', 'partner', 'scheduledDate'],
|
|
700
|
+
access: {
|
|
701
|
+
'stock.user': ['read', 'write', 'create'],
|
|
702
|
+
'stock.manager': [...ALL, 'export', 'import'],
|
|
703
|
+
},
|
|
704
|
+
fields: {
|
|
705
|
+
name: f.char({
|
|
706
|
+
required: true,
|
|
707
|
+
readonly: true,
|
|
708
|
+
sequence: { code: 'stock.picking', dateField: 'scheduledDate' },
|
|
709
|
+
label: 'Reference',
|
|
710
|
+
}),
|
|
711
|
+
kind: f.selection({
|
|
712
|
+
receipt: 'Receipt',
|
|
713
|
+
delivery: 'Delivery',
|
|
714
|
+
internal: 'Internal move',
|
|
715
|
+
adjustment: 'Inventory adjustment',
|
|
716
|
+
production: 'Production',
|
|
717
|
+
}, { required: true, default: 'receipt', label: 'Type' }),
|
|
718
|
+
partner: f.many2one('res.partner', { ondelete: 'set null', label: 'Contact' }),
|
|
719
|
+
warehouse: f.many2one('stock.warehouse', { ondelete: 'restrict' }),
|
|
720
|
+
source: f.many2one('stock.location', { required: true, ondelete: 'restrict', label: 'From' }),
|
|
721
|
+
destination: f.many2one('stock.location', {
|
|
722
|
+
required: true,
|
|
723
|
+
ondelete: 'restrict',
|
|
724
|
+
label: 'To',
|
|
725
|
+
}),
|
|
726
|
+
scheduledDate: f.date({
|
|
727
|
+
required: true,
|
|
728
|
+
default: (env) => env.today(),
|
|
729
|
+
label: 'Scheduled',
|
|
730
|
+
}),
|
|
731
|
+
doneDate: f.date({ label: 'Done on' }),
|
|
732
|
+
/** The document it comes from (`SO/2026/00017`). */
|
|
733
|
+
origin: f.char({ label: 'Source document' }),
|
|
734
|
+
note: f.text(),
|
|
735
|
+
moves: f.one2many('stock.move', 'picking', { label: 'Lines' }),
|
|
736
|
+
lotLines: f.one2many('stock.lot.line', 'picking', { label: 'Lots and serial numbers' }),
|
|
737
|
+
/** The count this adjustment applies. */
|
|
738
|
+
count: f.many2one('stock.count', { ondelete: 'set null', label: 'Count' }),
|
|
739
|
+
/** The transfer this one takes the rest of. */
|
|
740
|
+
backorderOf: f.many2one('stock.picking', { ondelete: 'set null', label: 'Backorder of' }),
|
|
741
|
+
backorders: f.one2many('stock.picking', 'backorderOf'),
|
|
742
|
+
/** How much of what leaves an internal location is reserved; receipts are always ready. */
|
|
743
|
+
availability: f.selection({ none: 'Nothing reserved', partial: 'Partly reserved', full: 'Everything reserved' }, {
|
|
744
|
+
store: true,
|
|
745
|
+
label: 'Availability',
|
|
746
|
+
...compute('stock.picking', 'availability', ['source.usage', 'moves.productQty', 'moves.reserved'], (pickings) => {
|
|
747
|
+
for (const p of pickings) {
|
|
748
|
+
const moves = [...p.moves];
|
|
749
|
+
if (p.source.usage !== 'internal' || moves.length === 0) {
|
|
750
|
+
p.availability = 'full';
|
|
751
|
+
continue;
|
|
752
|
+
}
|
|
753
|
+
const full = moves.every((m) => (m.reserved ?? zero).gte(m.productQty ?? zero));
|
|
754
|
+
const some = moves.some((m) => (m.reserved ?? zero).gt(0));
|
|
755
|
+
p.availability = full ? 'full' : some ? 'partial' : 'none';
|
|
756
|
+
}
|
|
757
|
+
}),
|
|
758
|
+
}),
|
|
759
|
+
/** The one chip: Draft, Waiting, Ready, Done or Cancelled. */
|
|
760
|
+
status: f.selection({ draft: 'Draft', waiting: 'Waiting', ready: 'Ready', done: 'Done', cancelled: 'Cancelled' }, {
|
|
761
|
+
store: true,
|
|
762
|
+
label: 'Status',
|
|
763
|
+
...compute('stock.picking', 'status', ['state', 'availability'], (pickings) => {
|
|
764
|
+
for (const p of pickings)
|
|
765
|
+
p.status =
|
|
766
|
+
p.state === 'confirmed'
|
|
767
|
+
? p.availability === 'full'
|
|
768
|
+
? 'ready'
|
|
769
|
+
: 'waiting'
|
|
770
|
+
: p.state === 'cancel'
|
|
771
|
+
? 'cancelled'
|
|
772
|
+
: p.state;
|
|
773
|
+
}),
|
|
774
|
+
}),
|
|
775
|
+
state: f.state({
|
|
776
|
+
states: { draft: 'Draft', confirmed: 'Confirmed', done: 'Done', cancel: 'Cancelled' },
|
|
777
|
+
initial: 'draft',
|
|
778
|
+
transitions: {
|
|
779
|
+
confirm: {
|
|
780
|
+
from: ['draft'],
|
|
781
|
+
to: 'confirmed',
|
|
782
|
+
guard: records('stock.picking', async (pickings) => {
|
|
783
|
+
const loaded = await pickings.load({ moves: true });
|
|
784
|
+
return loaded.filter((p) => p.moves.length === 0);
|
|
785
|
+
}),
|
|
786
|
+
message: 'Add at least one line before confirming the transfer.',
|
|
787
|
+
// The lines' checks run while the transfer is still a draft (created and confirmed at once).
|
|
788
|
+
before: records('stock.picking', async (pickings) => {
|
|
789
|
+
await pickings.env.flush();
|
|
790
|
+
}),
|
|
791
|
+
after: records('stock.picking', async (pickings) => {
|
|
792
|
+
if (await reserveOnConfirm(pickings.env))
|
|
793
|
+
await reserve(pickings.ids, pickings.env);
|
|
794
|
+
}),
|
|
795
|
+
},
|
|
796
|
+
/** "Check availability": reserve what has come in since. */
|
|
797
|
+
reserve: {
|
|
798
|
+
from: ['confirmed'],
|
|
799
|
+
to: 'confirmed',
|
|
800
|
+
after: records('stock.picking', async (pickings) => {
|
|
801
|
+
await reserve(pickings.ids, pickings.env);
|
|
802
|
+
}),
|
|
803
|
+
},
|
|
804
|
+
unreserve: {
|
|
805
|
+
from: ['confirmed'],
|
|
806
|
+
to: 'confirmed',
|
|
807
|
+
after: records('stock.picking', async (pickings) => {
|
|
808
|
+
await unreserve(pickings.ids, pickings.env);
|
|
809
|
+
}),
|
|
810
|
+
},
|
|
811
|
+
validate: {
|
|
812
|
+
from: ['confirmed'],
|
|
813
|
+
to: 'done',
|
|
814
|
+
// What is done must be reserved (out of stock), and something must be done.
|
|
815
|
+
guard: records('stock.picking', async (pickings) => {
|
|
816
|
+
const loaded = await pickings.load({ source: true, moves: true });
|
|
817
|
+
return loaded.filter((p) => [...p.moves].every((m) => (m.doneQty ?? zero).lte(0)) ||
|
|
818
|
+
(p.source.usage === 'internal' &&
|
|
819
|
+
[...p.moves].some((m) => (m.reserved ?? zero).lt(m.doneQty ?? zero))));
|
|
820
|
+
}),
|
|
821
|
+
message: 'Not everything done is reserved: check availability, wait for the stock, or lower the done quantities.',
|
|
822
|
+
before: records('stock.picking', async (pickings) => {
|
|
823
|
+
await applyMoves(pickings.ids, pickings.env);
|
|
824
|
+
}),
|
|
825
|
+
},
|
|
826
|
+
cancel: {
|
|
827
|
+
from: ['draft', 'confirmed'],
|
|
828
|
+
to: 'cancel',
|
|
829
|
+
after: records('stock.picking', async (pickings) => {
|
|
830
|
+
await unreserve(pickings.ids, pickings.env);
|
|
831
|
+
}),
|
|
832
|
+
},
|
|
833
|
+
redraft: { from: ['cancel'], to: 'draft' },
|
|
834
|
+
},
|
|
835
|
+
extensible: true,
|
|
836
|
+
}),
|
|
837
|
+
},
|
|
838
|
+
methods: {
|
|
839
|
+
/**
|
|
840
|
+
* Runs once these transfers are validated — their stock moved, their done quantities written — in the
|
|
841
|
+
* validation's audited superuser scope, before any backorder is made. Nothing here; a bridge overrides it
|
|
842
|
+
* (`stock_account`: the stock's value posted to Accounting).
|
|
843
|
+
*/
|
|
844
|
+
validated: method('stock.picking', async () => undefined),
|
|
845
|
+
/**
|
|
846
|
+
* What a backorder copies from each of these transfers: the type, contact, places, company, source document,
|
|
847
|
+
* date and note. A bridge adds its link by overriding it (`sale_stock`: the sales order).
|
|
848
|
+
*/
|
|
849
|
+
backorderValues: method('stock.picking', async function () {
|
|
850
|
+
const out = new Map();
|
|
851
|
+
for (const p of await this.load({}))
|
|
852
|
+
out.set(p.id, {
|
|
853
|
+
kind: p.kind,
|
|
854
|
+
partner: p.partner?.id ?? null,
|
|
855
|
+
warehouse: p.warehouse?.id ?? null,
|
|
856
|
+
source: p.source.id,
|
|
857
|
+
destination: p.destination.id,
|
|
858
|
+
company: p.company.id,
|
|
859
|
+
origin: p.origin,
|
|
860
|
+
scheduledDate: p.scheduledDate,
|
|
861
|
+
note: p.note,
|
|
862
|
+
});
|
|
863
|
+
return out;
|
|
864
|
+
}),
|
|
865
|
+
},
|
|
866
|
+
onchange: {
|
|
867
|
+
// A new type or warehouse proposes its locations.
|
|
868
|
+
kind: onchange('stock.picking', async (d, env) => {
|
|
869
|
+
const wh = d.get('warehouse');
|
|
870
|
+
const l = await defaultLocations(env, String(d.get('kind')), wh ?? null);
|
|
871
|
+
d.set('warehouse', l.warehouse);
|
|
872
|
+
d.set('source', l.source);
|
|
873
|
+
d.set('destination', l.destination);
|
|
874
|
+
}),
|
|
875
|
+
warehouse: onchange('stock.picking', async (d, env) => {
|
|
876
|
+
const wh = d.get('warehouse');
|
|
877
|
+
const l = await defaultLocations(env, String(d.get('kind')), wh ?? null);
|
|
878
|
+
d.set('source', l.source);
|
|
879
|
+
d.set('destination', l.destination);
|
|
880
|
+
}),
|
|
881
|
+
},
|
|
882
|
+
constraints: {
|
|
883
|
+
differentPlaces: {
|
|
884
|
+
...check('stock.picking', ['source', 'destination', 'kind'], async (pickings) => (await pickings.load({ source: true, destination: true }))
|
|
885
|
+
.filter((p) => p.source.id === p.destination.id ||
|
|
886
|
+
(p.kind === 'receipt' && p.destination.usage !== 'internal') ||
|
|
887
|
+
(p.kind === 'delivery' && p.source.usage !== 'internal') ||
|
|
888
|
+
(p.kind === 'internal' &&
|
|
889
|
+
(p.source.usage !== 'internal' || p.destination.usage !== 'internal')) ||
|
|
890
|
+
(p.kind === 'adjustment' &&
|
|
891
|
+
!((p.source.usage === 'adjustment' && p.destination.usage === 'internal') ||
|
|
892
|
+
(p.source.usage === 'internal' && p.destination.usage === 'adjustment'))) ||
|
|
893
|
+
(p.kind === 'production' &&
|
|
894
|
+
!((p.source.usage === 'production' && p.destination.usage === 'internal') ||
|
|
895
|
+
(p.source.usage === 'internal' && p.destination.usage === 'production'))))
|
|
896
|
+
.map((p) => p.id)),
|
|
897
|
+
message: 'A transfer goes between two places: a receipt into stock, a delivery out of stock, an internal move between stock locations, an adjustment between stock and the adjustment location.',
|
|
898
|
+
},
|
|
899
|
+
},
|
|
900
|
+
});
|
|
901
|
+
/**
|
|
902
|
+
* What `product` has in `location` and the locations inside it, in the product's unit: all of it, or that of `lot`
|
|
903
|
+
* (`null`: the stock without a lot).
|
|
904
|
+
*/
|
|
905
|
+
export async function onHandIn(env, product, location, lot) {
|
|
906
|
+
const quants = await (await env.model('stock.quant').search({
|
|
907
|
+
where: {
|
|
908
|
+
product,
|
|
909
|
+
location: { childOf: location },
|
|
910
|
+
...(lot === undefined ? {} : { lot: lot === null ? { isNull: true } : lot }),
|
|
911
|
+
},
|
|
912
|
+
})).load(['quantity']);
|
|
913
|
+
return [...quants].reduce((s, q) => s.plus(q.quantity), zero);
|
|
914
|
+
}
|
|
915
|
+
export const StockCountLine = defineModel('stock.count.line', {
|
|
916
|
+
label: 'Count line',
|
|
917
|
+
order: ['id asc'],
|
|
918
|
+
access: {
|
|
919
|
+
'stock.user': ['read', 'write', 'create', 'unlink'],
|
|
920
|
+
'stock.manager': [...ALL, 'export', 'import'],
|
|
921
|
+
},
|
|
922
|
+
fields: {
|
|
923
|
+
count: f.many2one('stock.count', { required: true, ondelete: 'cascade' }),
|
|
924
|
+
product: f.many2one('product.product', { required: true, ondelete: 'restrict' }),
|
|
925
|
+
/** The lot or serial number counted, for a tracked product. */
|
|
926
|
+
lot: f.many2one('stock.lot', { ondelete: 'restrict', label: 'Lot / serial number' }),
|
|
927
|
+
/** On hand when the count started, in the product's unit. */
|
|
928
|
+
expected: f.decimal({ scale: 4, label: 'Expected' }),
|
|
929
|
+
/** What was found (empty: not counted yet, left as it is). */
|
|
930
|
+
counted: f.decimal({ scale: 4, label: 'Counted' }),
|
|
931
|
+
difference: f.decimal({
|
|
932
|
+
scale: 4,
|
|
933
|
+
store: true,
|
|
934
|
+
label: 'Difference',
|
|
935
|
+
...compute('stock.count.line', 'difference', ['expected', 'counted'], (lines) => {
|
|
936
|
+
for (const l of lines)
|
|
937
|
+
l.difference = l.counted === null ? null : l.counted.minus(l.expected ?? zero);
|
|
938
|
+
}),
|
|
939
|
+
}),
|
|
940
|
+
},
|
|
941
|
+
sqlConstraints: {
|
|
942
|
+
onePerProduct: {
|
|
943
|
+
unique: ['count', 'product'],
|
|
944
|
+
where: orm.sql `lot_id IS NULL`,
|
|
945
|
+
message: 'A product is counted once per count.',
|
|
946
|
+
},
|
|
947
|
+
onePerLot: {
|
|
948
|
+
unique: ['count', 'product', 'lot'],
|
|
949
|
+
where: orm.sql `lot_id IS NOT NULL`,
|
|
950
|
+
message: 'A lot is counted once per count.',
|
|
951
|
+
},
|
|
952
|
+
notNegative: {
|
|
953
|
+
check: orm.sql `counted IS NULL OR counted >= 0`,
|
|
954
|
+
message: 'A counted quantity cannot be negative.',
|
|
955
|
+
},
|
|
956
|
+
},
|
|
957
|
+
constraints: {
|
|
958
|
+
goodsOnly: {
|
|
959
|
+
...check('stock.count.line', ['product'], async (lines) => (await lines.load({ product: true }))
|
|
960
|
+
.filter((l) => l.product.kind !== 'goods')
|
|
961
|
+
.map((l) => l.id)),
|
|
962
|
+
message: 'Services are not counted: only goods are kept in stock.',
|
|
963
|
+
},
|
|
964
|
+
},
|
|
965
|
+
});
|
|
966
|
+
/**
|
|
967
|
+
* Applies counts: what was found against what is on hand now, in one adjustment transfer for the gains (from the
|
|
968
|
+
* adjustment location into the counted location) and one for the losses (out of it, reserved first), both validated.
|
|
969
|
+
* Refused when a loss is held by other transfers' reservations (they are named).
|
|
970
|
+
*/
|
|
971
|
+
async function applyCounts(ids, env) {
|
|
972
|
+
await env.asSuperuser({ reason: 'stock count applied' }, async (su) => {
|
|
973
|
+
const counts = await su
|
|
974
|
+
.model('stock.count')
|
|
975
|
+
.browse(ids)
|
|
976
|
+
.load({ location: true, company: true, lines: { product: true } });
|
|
977
|
+
const adjustment = (await su
|
|
978
|
+
.model('stock.location')
|
|
979
|
+
.search({ where: { usage: 'adjustment', name: 'Inventory adjustment' }, limit: 1 })).ids[0];
|
|
980
|
+
if (adjustment === undefined)
|
|
981
|
+
throw new orm.ValidationError('stock.count', ['state'], 'There is no inventory adjustment location.', 'Upgrade the Stockroom app to restore it.');
|
|
982
|
+
for (const c of counts) {
|
|
983
|
+
const gains = [];
|
|
984
|
+
const losses = [];
|
|
985
|
+
for (const l of c.lines) {
|
|
986
|
+
if (l.counted === null)
|
|
987
|
+
continue;
|
|
988
|
+
const lot = l.lot?.id ?? null;
|
|
989
|
+
const tracked = l.product.tracking !== 'none' && l.product.tracking !== null;
|
|
990
|
+
const diff = l.counted.minus(await onHandIn(su, l.product.id, c.location.id, lot));
|
|
991
|
+
if (diff.gt(0) && tracked && lot === null)
|
|
992
|
+
throw new orm.ValidationError('stock.count', ['lines'], `${l.product.name} is tracked: the stock found needs its lot or serial number.`, 'Choose the lot on its line, or scan the lot or serial number.');
|
|
993
|
+
if (diff.gt(0))
|
|
994
|
+
gains.push({ product: l.product.id, lot, quantity: diff });
|
|
995
|
+
if (diff.lt(0))
|
|
996
|
+
losses.push({ product: l.product.id, lot, quantity: diff.negated() });
|
|
997
|
+
}
|
|
998
|
+
for (const [lines, source, destination] of [
|
|
999
|
+
[gains, adjustment, c.location.id],
|
|
1000
|
+
[losses, c.location.id, adjustment],
|
|
1001
|
+
]) {
|
|
1002
|
+
if (lines.length === 0)
|
|
1003
|
+
continue;
|
|
1004
|
+
const made = await su.model('stock.picking').create([
|
|
1005
|
+
{
|
|
1006
|
+
kind: 'adjustment',
|
|
1007
|
+
company: c.company.id,
|
|
1008
|
+
source,
|
|
1009
|
+
destination,
|
|
1010
|
+
origin: c.name,
|
|
1011
|
+
count: c.id,
|
|
1012
|
+
moves: {
|
|
1013
|
+
create: lines.map((l) => ({
|
|
1014
|
+
product: l.product,
|
|
1015
|
+
quantity: l.quantity,
|
|
1016
|
+
restrictLot: source === adjustment ? null : l.lot,
|
|
1017
|
+
})),
|
|
1018
|
+
},
|
|
1019
|
+
// Found stock enters with its lot.
|
|
1020
|
+
lotLines: {
|
|
1021
|
+
create: source === adjustment
|
|
1022
|
+
? lines
|
|
1023
|
+
.filter((l) => l.lot !== null)
|
|
1024
|
+
.map((l) => ({ product: l.product, lot: l.lot, quantity: l.quantity }))
|
|
1025
|
+
: [],
|
|
1026
|
+
},
|
|
1027
|
+
},
|
|
1028
|
+
]);
|
|
1029
|
+
await made.confirm();
|
|
1030
|
+
if (source !== adjustment)
|
|
1031
|
+
await made.reserve();
|
|
1032
|
+
const [loaded] = await made.load(['availability']);
|
|
1033
|
+
if (loaded?.availability !== 'full') {
|
|
1034
|
+
const held = await (await su.model('stock.reservation').search({
|
|
1035
|
+
where: { 'quant.location': { childOf: c.location.id } },
|
|
1036
|
+
limit: 3,
|
|
1037
|
+
})).load(['label']);
|
|
1038
|
+
throw new orm.ValidationError('stock.count', ['state'], `Some of what is missing is reserved: ${[...held].map((r) => r.label).join('; ')}.`, 'Unreserve or cancel those transfers, then apply the count again.');
|
|
1039
|
+
}
|
|
1040
|
+
await made.validate();
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
});
|
|
1044
|
+
}
|
|
1045
|
+
export const StockCount = defineModel('stock.count', {
|
|
1046
|
+
label: 'Count',
|
|
1047
|
+
recName: 'name',
|
|
1048
|
+
order: ['date desc', 'id desc'],
|
|
1049
|
+
companyScoped: { required: true },
|
|
1050
|
+
tracking: ['state'],
|
|
1051
|
+
access: {
|
|
1052
|
+
'stock.user': ['read', 'write', 'create'],
|
|
1053
|
+
'stock.manager': [...ALL, 'export'],
|
|
1054
|
+
},
|
|
1055
|
+
fields: {
|
|
1056
|
+
name: f.char({
|
|
1057
|
+
required: true,
|
|
1058
|
+
readonly: true,
|
|
1059
|
+
sequence: { code: 'stock.count', dateField: 'date' },
|
|
1060
|
+
label: 'Reference',
|
|
1061
|
+
}),
|
|
1062
|
+
location: f.many2one('stock.location', { required: true, ondelete: 'restrict' }),
|
|
1063
|
+
date: f.date({ required: true, default: (env) => env.today() }),
|
|
1064
|
+
user: f.many2one('res.users', {
|
|
1065
|
+
default: (env) => env.userId,
|
|
1066
|
+
label: 'Counted by',
|
|
1067
|
+
}),
|
|
1068
|
+
lines: f.one2many('stock.count.line', 'count'),
|
|
1069
|
+
adjustments: f.one2many('stock.picking', 'count'),
|
|
1070
|
+
note: f.text(),
|
|
1071
|
+
state: f.state({
|
|
1072
|
+
states: { draft: 'Draft', counting: 'Counting', done: 'Applied', cancel: 'Cancelled' },
|
|
1073
|
+
initial: 'draft',
|
|
1074
|
+
transitions: {
|
|
1075
|
+
/** Lists what the location holds (and inside it), with what is expected, for counting. */
|
|
1076
|
+
start: {
|
|
1077
|
+
from: ['draft'],
|
|
1078
|
+
to: 'counting',
|
|
1079
|
+
after: records('stock.count', async (counts) => {
|
|
1080
|
+
const env = counts.env;
|
|
1081
|
+
await env.asSuperuser({ reason: 'stock count started' }, async (su) => {
|
|
1082
|
+
for (const c of await su
|
|
1083
|
+
.model('stock.count')
|
|
1084
|
+
.browse(counts.ids)
|
|
1085
|
+
.load({ lines: true })) {
|
|
1086
|
+
// By product and lot. Indexed first: creating a line unloads the count's lines.
|
|
1087
|
+
const key = (product, lot) => `${product}:${lot ?? ''}`;
|
|
1088
|
+
const existing = new Map([...c.lines].map((l) => [key(l.product.id, l.lot?.id ?? null), l]));
|
|
1089
|
+
const listed = new Map([...c.lines].map((l) => [
|
|
1090
|
+
key(l.product.id, l.lot?.id ?? null),
|
|
1091
|
+
{ product: l.product.id, lot: l.lot?.id ?? null },
|
|
1092
|
+
]));
|
|
1093
|
+
const quants = await (await su
|
|
1094
|
+
.model('stock.quant')
|
|
1095
|
+
.search({ where: { location: { childOf: c.location.id } } })).load({ product: true, lot: true });
|
|
1096
|
+
for (const q of quants)
|
|
1097
|
+
listed.set(key(q.product.id, q.lot?.id ?? null), {
|
|
1098
|
+
product: q.product.id,
|
|
1099
|
+
lot: q.lot?.id ?? null,
|
|
1100
|
+
});
|
|
1101
|
+
for (const [k, { product, lot }] of listed) {
|
|
1102
|
+
const expected = await onHandIn(su, product, c.location.id, lot);
|
|
1103
|
+
const line = existing.get(k);
|
|
1104
|
+
if (line !== undefined)
|
|
1105
|
+
await line.write({ expected });
|
|
1106
|
+
else
|
|
1107
|
+
await su
|
|
1108
|
+
.model('stock.count.line')
|
|
1109
|
+
.create([{ count: c.id, product, lot, expected }]);
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
});
|
|
1113
|
+
}),
|
|
1114
|
+
},
|
|
1115
|
+
apply: {
|
|
1116
|
+
from: ['counting'],
|
|
1117
|
+
to: 'done',
|
|
1118
|
+
requires: 'stock.count_apply',
|
|
1119
|
+
before: records('stock.count', async (counts) => {
|
|
1120
|
+
await applyCounts(counts.ids, counts.env);
|
|
1121
|
+
}),
|
|
1122
|
+
},
|
|
1123
|
+
cancel: { from: ['draft', 'counting'], to: 'cancel' },
|
|
1124
|
+
redraft: { from: ['cancel'], to: 'draft' },
|
|
1125
|
+
},
|
|
1126
|
+
}),
|
|
1127
|
+
},
|
|
1128
|
+
constraints: {
|
|
1129
|
+
internalLocation: {
|
|
1130
|
+
...check('stock.count', ['location'], async (counts) => (await counts.load({ location: true }))
|
|
1131
|
+
.filter((c) => c.location.usage !== 'internal')
|
|
1132
|
+
.map((c) => c.id)),
|
|
1133
|
+
message: 'Stock is counted in an internal location.',
|
|
1134
|
+
},
|
|
1135
|
+
},
|
|
1136
|
+
});
|
|
1137
|
+
export const SCRAP_REASONS = {
|
|
1138
|
+
damaged: 'Damaged',
|
|
1139
|
+
defect: 'Defective',
|
|
1140
|
+
expired: 'Expired',
|
|
1141
|
+
spill: 'Spilt or broken in handling',
|
|
1142
|
+
other: 'Other',
|
|
1143
|
+
};
|
|
1144
|
+
/**
|
|
1145
|
+
* Scrap: stock thrown away, with its reason. Validated, it leaves its location for the Scrap location in a validated
|
|
1146
|
+
* adjustment transfer (reserved first: what other transfers hold is not scrapped), so it is valued as a loss.
|
|
1147
|
+
*/
|
|
1148
|
+
export const StockScrap = defineModel('stock.scrap', {
|
|
1149
|
+
label: 'Scrap',
|
|
1150
|
+
recName: 'name',
|
|
1151
|
+
order: ['date desc', 'id desc'],
|
|
1152
|
+
companyScoped: { required: true },
|
|
1153
|
+
tracking: ['state', 'quantity', 'reason'],
|
|
1154
|
+
access: {
|
|
1155
|
+
'stock.user': ['read', 'write', 'create'],
|
|
1156
|
+
'stock.manager': [...ALL, 'export'],
|
|
1157
|
+
},
|
|
1158
|
+
fields: {
|
|
1159
|
+
name: f.char({
|
|
1160
|
+
required: true,
|
|
1161
|
+
readonly: true,
|
|
1162
|
+
sequence: { code: 'stock.scrap', dateField: 'date' },
|
|
1163
|
+
label: 'Reference',
|
|
1164
|
+
}),
|
|
1165
|
+
product: f.many2one('product.product', { required: true, ondelete: 'restrict' }),
|
|
1166
|
+
/** In the product's unit. */
|
|
1167
|
+
quantity: f.decimal({ scale: 4, required: true, default: '1' }),
|
|
1168
|
+
/** Where it is thrown away from (empty: the main warehouse's stock). */
|
|
1169
|
+
location: f.many2one('stock.location', { ondelete: 'restrict', label: 'From' }),
|
|
1170
|
+
lot: f.many2one('stock.lot', { ondelete: 'restrict', label: 'Lot / serial number' }),
|
|
1171
|
+
reason: f.selection(SCRAP_REASONS, { required: true, default: 'damaged' }),
|
|
1172
|
+
note: f.text(),
|
|
1173
|
+
date: f.date({ required: true, default: (env) => env.today() }),
|
|
1174
|
+
picking: f.many2one('stock.picking', { ondelete: 'set null', label: 'Transfer' }),
|
|
1175
|
+
state: f.state({
|
|
1176
|
+
states: { draft: 'Draft', done: 'Scrapped', cancel: 'Cancelled' },
|
|
1177
|
+
initial: 'draft',
|
|
1178
|
+
transitions: {
|
|
1179
|
+
validate: {
|
|
1180
|
+
from: ['draft'],
|
|
1181
|
+
to: 'done',
|
|
1182
|
+
before: records('stock.scrap', async (scraps) => {
|
|
1183
|
+
await scrapStock(scraps.ids, scraps.env);
|
|
1184
|
+
}),
|
|
1185
|
+
},
|
|
1186
|
+
cancel: { from: ['draft'], to: 'cancel' },
|
|
1187
|
+
},
|
|
1188
|
+
}),
|
|
1189
|
+
},
|
|
1190
|
+
sqlConstraints: {
|
|
1191
|
+
positive: { check: orm.sql `quantity > 0`, message: 'Scrap a positive quantity.' },
|
|
1192
|
+
},
|
|
1193
|
+
});
|
|
1194
|
+
/** Moves the scraps' stock to the Scrap location through validated adjustment transfers. */
|
|
1195
|
+
async function scrapStock(ids, env) {
|
|
1196
|
+
const scrapped = (await env
|
|
1197
|
+
.model('stock.location')
|
|
1198
|
+
.search({ where: { usage: 'adjustment', name: 'Scrap' }, limit: 1 })).ids[0];
|
|
1199
|
+
if (scrapped === undefined)
|
|
1200
|
+
throw new orm.ValidationError('stock.scrap', ['state'], 'There is no Scrap location.', 'Upgrade the Stockroom app to restore it.');
|
|
1201
|
+
for (const sc of await env.model('stock.scrap').browse(ids).load({ product: true })) {
|
|
1202
|
+
const from = sc.location?.id ?? (await defaultLocations(env, 'internal', null, sc.company.id)).source;
|
|
1203
|
+
if (from === null)
|
|
1204
|
+
throw new orm.ValidationError('stock.scrap', ['location'], 'There is no stock location.', 'Choose where from.');
|
|
1205
|
+
const made = await env.model('stock.picking').create([
|
|
1206
|
+
{
|
|
1207
|
+
kind: 'adjustment',
|
|
1208
|
+
company: sc.company.id,
|
|
1209
|
+
source: from,
|
|
1210
|
+
destination: scrapped,
|
|
1211
|
+
origin: sc.name,
|
|
1212
|
+
note: sc.note,
|
|
1213
|
+
moves: {
|
|
1214
|
+
create: [
|
|
1215
|
+
{ product: sc.product.id, quantity: sc.quantity, restrictLot: sc.lot?.id ?? null },
|
|
1216
|
+
],
|
|
1217
|
+
},
|
|
1218
|
+
},
|
|
1219
|
+
]);
|
|
1220
|
+
await made.confirm();
|
|
1221
|
+
await made.reserve();
|
|
1222
|
+
const [loaded] = await made.load(['availability']);
|
|
1223
|
+
if (loaded?.availability !== 'full')
|
|
1224
|
+
throw new orm.ValidationError('stock.scrap', ['quantity'], `Not ${sc.quantity.toString()} of ${sc.product.name} is available to scrap there.`, 'Scrap less, from another location, or unreserve what other transfers hold.');
|
|
1225
|
+
await made.validate();
|
|
1226
|
+
await env
|
|
1227
|
+
.model('stock.scrap')
|
|
1228
|
+
.browse([sc.id])
|
|
1229
|
+
.write({ picking: made.ids[0] ?? null });
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
/** What a product has in stock, across the internal locations. */
|
|
1233
|
+
export const ProductStock = extendModel('product.product', {
|
|
1234
|
+
fields: {
|
|
1235
|
+
/** By lot (a batch, an expiry date) or by serial number (one unit each); lots travel with the stock. */
|
|
1236
|
+
tracking: f.selection({ none: 'Not tracked', lot: 'By lot', serial: 'By serial number' }, { required: true, default: 'none', label: 'Tracking' }),
|
|
1237
|
+
lots: f.one2many('stock.lot', 'product'),
|
|
1238
|
+
quants: f.one2many('stock.quant', 'product'),
|
|
1239
|
+
qtyOnHand: f.decimal({
|
|
1240
|
+
scale: 4,
|
|
1241
|
+
store: true,
|
|
1242
|
+
label: 'On hand',
|
|
1243
|
+
...compute('product.product', 'qtyOnHand', ['quants.quantity'], (products) => {
|
|
1244
|
+
for (const p of products)
|
|
1245
|
+
p.qtyOnHand = [...p.quants].reduce((s, q) => s.plus(q.quantity), zero);
|
|
1246
|
+
}),
|
|
1247
|
+
}),
|
|
1248
|
+
qtyReserved: f.decimal({
|
|
1249
|
+
scale: 4,
|
|
1250
|
+
store: true,
|
|
1251
|
+
label: 'Reserved',
|
|
1252
|
+
...compute('product.product', 'qtyReserved', ['quants.reserved'], (products) => {
|
|
1253
|
+
for (const p of products)
|
|
1254
|
+
p.qtyReserved = [...p.quants].reduce((s, q) => s.plus(q.reserved ?? zero), zero);
|
|
1255
|
+
}),
|
|
1256
|
+
}),
|
|
1257
|
+
qtyAvailable: f.decimal({
|
|
1258
|
+
scale: 4,
|
|
1259
|
+
store: true,
|
|
1260
|
+
label: 'Available',
|
|
1261
|
+
...compute('product.product', 'qtyAvailable', ['qtyOnHand', 'qtyReserved'], (products) => {
|
|
1262
|
+
for (const p of products)
|
|
1263
|
+
p.qtyAvailable = (p.qtyOnHand ?? zero).minus(p.qtyReserved ?? zero);
|
|
1264
|
+
}),
|
|
1265
|
+
}),
|
|
1266
|
+
},
|
|
1267
|
+
});
|
|
1268
|
+
const shared = (id, name, usage) => record('stock.location', id, { name, usage, company: null }, { noupdate: true });
|
|
1269
|
+
export const stockShutro = defineModule({
|
|
1270
|
+
name: 'stock',
|
|
1271
|
+
version: '0.1.0',
|
|
1272
|
+
depends: [productShutro, contactsShutro, messagingShutro],
|
|
1273
|
+
groups: [
|
|
1274
|
+
orm.defineGroup({ code: 'stock.user', label: 'Stockroom user', implies: ['product.user'] }),
|
|
1275
|
+
orm.defineGroup({ code: 'stock.manager', label: 'Stockroom manager', implies: ['stock.user'] }),
|
|
1276
|
+
],
|
|
1277
|
+
sequences: [
|
|
1278
|
+
orm.defineSequence({
|
|
1279
|
+
code: 'stock.picking',
|
|
1280
|
+
prefix: 'TR/{year}/',
|
|
1281
|
+
padding: 5,
|
|
1282
|
+
reset: 'yearly',
|
|
1283
|
+
}),
|
|
1284
|
+
orm.defineSequence({
|
|
1285
|
+
code: 'stock.scrap',
|
|
1286
|
+
prefix: 'SC/{year}/',
|
|
1287
|
+
padding: 5,
|
|
1288
|
+
reset: 'yearly',
|
|
1289
|
+
}),
|
|
1290
|
+
orm.defineSequence({
|
|
1291
|
+
code: 'stock.count',
|
|
1292
|
+
prefix: 'CNT/{year}/',
|
|
1293
|
+
padding: 5,
|
|
1294
|
+
reset: 'yearly',
|
|
1295
|
+
}),
|
|
1296
|
+
],
|
|
1297
|
+
permissions: [
|
|
1298
|
+
orm.definePermission({
|
|
1299
|
+
code: 'stock.count_apply',
|
|
1300
|
+
label: 'Apply stock counts',
|
|
1301
|
+
groups: ['stock.manager'],
|
|
1302
|
+
}),
|
|
1303
|
+
],
|
|
1304
|
+
models: [
|
|
1305
|
+
StockWarehouse,
|
|
1306
|
+
StockLocation,
|
|
1307
|
+
StockLot,
|
|
1308
|
+
StockQuant,
|
|
1309
|
+
StockReservation,
|
|
1310
|
+
StockMove,
|
|
1311
|
+
StockPicking,
|
|
1312
|
+
StockLotLine,
|
|
1313
|
+
StockCount,
|
|
1314
|
+
StockCountLine,
|
|
1315
|
+
StockScrap,
|
|
1316
|
+
],
|
|
1317
|
+
extensions: [ProductStock],
|
|
1318
|
+
data: [
|
|
1319
|
+
shared('location_vendors', 'Vendors', 'vendor'),
|
|
1320
|
+
shared('location_customers', 'Customers', 'customer'),
|
|
1321
|
+
shared('location_adjustment', 'Inventory adjustment', 'adjustment'),
|
|
1322
|
+
shared('location_production', 'Production', 'production'),
|
|
1323
|
+
shared('location_scrap', 'Scrap', 'adjustment'),
|
|
1324
|
+
],
|
|
1325
|
+
});
|