@fayz-ai/plugin-inventory 0.18.15 → 0.19.0
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/InventoryGeneralSettings-GTSBWOTS.js +3 -0
- package/dist/InventoryGeneralSettings-GTSBWOTS.js.map +1 -0
- package/dist/InventoryPage-WHNVIP4B.js +6673 -0
- package/dist/InventoryPage-WHNVIP4B.js.map +1 -0
- package/dist/UnitStockLocationsWidget-ZHB4HLRG.js +49 -0
- package/dist/UnitStockLocationsWidget-ZHB4HLRG.js.map +1 -0
- package/dist/chunk-MJZZ4EZB.js +82 -0
- package/dist/chunk-MJZZ4EZB.js.map +1 -0
- package/dist/chunk-UZLEFDCX.js +56 -0
- package/dist/chunk-UZLEFDCX.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2322 -18498
- package/dist/index.js.map +1 -1
- package/dist/migrations/index.d.ts +1 -0
- package/dist/migrations/index.d.ts.map +1 -1
- package/dist/migrations/main.browser.js +38 -0
- package/dist/migrations/main.browser.js.map +1 -0
- package/dist/migrations/main.js +9675 -0
- package/dist/migrations/main.js.map +1 -0
- package/package.json +11 -5
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/migrations/index.ts"],"names":[],"mappings":";AAIO,IAAM,sBAAA,GAAyB,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;AAAA;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AA2+G/B,IAAM,sDAAA,GAAyD,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAiR/D,IAAM,uCAAA,GAA0C,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAwRhD,IAAM,+CAAA,GAAkD,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAqKxD,IAAM,4CAAA,GAA+C,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAwLrD,IAAM,mDAAA,GAAsD,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AA4E5D,IAAM,4CAAA,GAA+C,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAyUrD,IAAM,kCAAA,GAAqC,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAiM3C,IAAM,qDAAA,GAAwD,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AA4B9D,IAAM,uDAAA,GAA0D,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AA8KhE,IAAM,0CAAA,GAA6C,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AA8anD,IAAM,wCAAA,GAA2C,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAwgBjD,IAAM,sEAAA,GAAyE,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAqR/E,IAAM,gDAAA,GAAmD,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAqQzD,IAAM,yCAAA,GAA4C,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AA+PlD,IAAM,6CAAA,GAAgD,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AA6PtD,IAAM,4CAAA,GAA+C,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAsQrD,IAAM,8CAAA,GAAiD,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AA8KvD,IAAM,qCAAA,GAAwC,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAuP9C,IAAM,oCAAA,GAAuC,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AA4L7C,IAAM,sDAAA,GAAyD,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AA0T/D,IAAM,sCAAA,GAAyC,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAwI/C,IAAM,+CAAA,GAAkD,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAmFxD,IAAM,gDAAA,GAAmD,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAsBzD,IAAM,kDAAA,GAAqD,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAyD3D,IAAM,yCAAA,GAA4C,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAkTlD,IAAM,2CAAA,GAA8C,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AA0IpD,IAAM,mDAAA,GAAsD,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAsC5D,IAAM,yCAAA,GAA4C,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AA4ClD,IAAM,gDAAA,GAAmD,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAuCzD,IAAM,sCAAA,GAAyC,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AA+C/C,IAAM,gDAAA,GAAmD,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAwTzD,IAAM,UAAA,GAAiD;AAAA,EAC5D,EAAE,EAAA,EAAI,cAAA,EAAgB,GAAA,EAAK,sBAAA,EAAuB;AAAA,EAClD,EAAE,EAAA,EAAI,8CAAA,EAAgD,GAAA,EAAK,sDAAA,EAAuD;AAAA,EAClH,EAAE,EAAA,EAAI,+BAAA,EAAiC,GAAA,EAAK,uCAAA,EAAwC;AAAA,EACpF,EAAE,EAAA,EAAI,uCAAA,EAAyC,GAAA,EAAK,+CAAA,EAAgD;AAAA,EACpG,EAAE,EAAA,EAAI,oCAAA,EAAsC,GAAA,EAAK,4CAAA,EAA6C;AAAA,EAC9F,EAAE,EAAA,EAAI,2CAAA,EAA6C,GAAA,EAAK,mDAAA,EAAoD;AAAA,EAC5G,EAAE,EAAA,EAAI,oCAAA,EAAsC,GAAA,EAAK,4CAAA,EAA6C;AAAA,EAC9F,EAAE,EAAA,EAAI,0BAAA,EAA4B,GAAA,EAAK,kCAAA,EAAmC;AAAA,EAC1E,EAAE,EAAA,EAAI,6CAAA,EAA+C,GAAA,EAAK,qDAAA,EAAsD;AAAA,EAChH,EAAE,EAAA,EAAI,+CAAA,EAAiD,GAAA,EAAK,uDAAA,EAAwD;AAAA,EACpH,EAAE,EAAA,EAAI,kCAAA,EAAoC,GAAA,EAAK,0CAAA,EAA2C;AAAA,EAC1F,EAAE,EAAA,EAAI,gCAAA,EAAkC,GAAA,EAAK,wCAAA,EAAyC;AAAA,EACtF,EAAE,EAAA,EAAI,8DAAA,EAAgE,GAAA,EAAK,sEAAA,EAAuE;AAAA,EAClJ,EAAE,EAAA,EAAI,wCAAA,EAA0C,GAAA,EAAK,gDAAA,EAAiD;AAAA,EACtG,EAAE,EAAA,EAAI,iCAAA,EAAmC,GAAA,EAAK,yCAAA,EAA0C;AAAA,EACxF,EAAE,EAAA,EAAI,qCAAA,EAAuC,GAAA,EAAK,6CAAA,EAA8C;AAAA,EAChG,EAAE,EAAA,EAAI,oCAAA,EAAsC,GAAA,EAAK,4CAAA,EAA6C;AAAA,EAC9F,EAAE,EAAA,EAAI,sCAAA,EAAwC,GAAA,EAAK,8CAAA,EAA+C;AAAA,EAClG,EAAE,EAAA,EAAI,6BAAA,EAA+B,GAAA,EAAK,qCAAA,EAAsC;AAAA,EAChF,EAAE,EAAA,EAAI,4BAAA,EAA8B,GAAA,EAAK,oCAAA,EAAqC;AAAA,EAC9E,EAAE,EAAA,EAAI,8CAAA,EAAgD,GAAA,EAAK,sDAAA,EAAuD;AAAA,EAClH,EAAE,EAAA,EAAI,8BAAA,EAAgC,GAAA,EAAK,sCAAA,EAAuC;AAAA,EAClF,EAAE,EAAA,EAAI,uCAAA,EAAyC,GAAA,EAAK,+CAAA,EAAgD;AAAA,EACpG,EAAE,EAAA,EAAI,wCAAA,EAA0C,GAAA,EAAK,gDAAA,EAAiD;AAAA,EACtG,EAAE,EAAA,EAAI,0CAAA,EAA4C,GAAA,EAAK,kDAAA,EAAmD;AAAA,EAC1G,EAAE,EAAA,EAAI,iCAAA,EAAmC,GAAA,EAAK,yCAAA,EAA0C;AAAA,EACxF,EAAE,EAAA,EAAI,mCAAA,EAAqC,GAAA,EAAK,2CAAA,EAA4C;AAAA,EAC5F,EAAE,EAAA,EAAI,2CAAA,EAA6C,GAAA,EAAK,mDAAA,EAAoD;AAAA,EAC5G,EAAE,EAAA,EAAI,iCAAA,EAAmC,GAAA,EAAK,yCAAA,EAA0C;AAAA,EACxF,EAAE,EAAA,EAAI,wCAAA,EAA0C,GAAA,EAAK,gDAAA,EAAiD;AAAA,EACtG,EAAE,EAAA,EAAI,8BAAA,EAAgC,GAAA,EAAK,sCAAA,EAAuC;AAAA,EAClF,EAAE,EAAA,EAAI,wCAAA,EAA0C,GAAA,EAAK,gDAAA;AACvD","file":"main.js","sourcesContent":["// AUTO-GENERATED from 000_baseline.sql, 001_the_usage_that_left_stock_can_be_audited.sql, 002_a_auditoria_fecha_em_lote.sql, 003_stock_count_closes_through_ledger.sql, 004_a_posicao_de_estoque_atravessa.sql, 005_a_posicao_e_consequencia_do_movimento.sql, 006_a_producao_tem_ordem_e_estacao.sql, 007_a_producao_atravessa.sql, 008_o_grupo_da_receita_tambem_e_de_uma_casa.sql, 009_a_direcao_do_movimento_nao_esta_no_numero.sql, 010_a_entrada_de_notas_atravessa.sql, 011_o_consumo_tem_duas_origens.sql, 012_o_consumo_clinico_nao_precisa_da_tabela_de_itens_inteira.sql, 013_a_fila_de_auditoria_tem_uma_janela.sql, 014_a_sessao_se_busca_uma_a_uma.sql, 015_lateral_sem_cerca_nao_e_lateral.sql, 016_a_data_do_consumo_e_uma_coluna.sql, 017_aprovar_e_baixar_sao_dois_gestos.sql, 018_o_nome_nao_sai_da_vista.sql, 019_sem_saldo_nao_se_baixa.sql, 020_o_produto_e_varias_coisas_ao_mesmo_tempo.sql, 021_o_razao_diz_de_onde_veio.sql, 022_receber_estoque_volta_a_funcionar.sql, 023_a_receita_se_escreve_com_permissao.sql, 024_a_categoria_do_produto_e_do_catalogo.sql, 025_a_ficha_tecnica_por_estacao.sql, 026_stock_cost_knows_its_currency.sql, 027_a_receipt_names_the_line_it_came_from.sql, 028_a_receipt_keeps_the_variant.sql, 029_a_reversal_undoes_the_same_variant.sql, 030_the_minimum_can_be_saved.sql, 031_the_bell_rings_when_stock_runs_low.sql — regenerate with scripts/embed-migrations.mjs\n// SQL files are the source of truth; this inline copy lets the manifest declare\n// migrations as data. Do not edit by hand — run the embed script instead.\n\nexport const MIGRATION_000_BASELINE = `-- ============================================================================\n-- plugins/plugin-inventory/src/migrations/000_baseline.sql — what installing this provisions, as one file.\n--\n-- GENERATED by scripts/emit-unit-baselines.mjs (#338). Do not edit by hand:\n-- change the schema, regenerate, and let scripts/check-chain-equivalence.mjs\n-- prove the result still matches.\n--\n-- This replaced 28 migration files. They are kept, unapplied, in\n-- migrations.archive/ — a year of decisions is worth reading even when it is no\n-- longer worth replaying.\n--\n-- Object order is fixed rather than dependency-sorted: schemas, types,\n-- sequences, tables, defaults, functions, constraints, views, indexes, foreign\n-- keys, triggers, row security, policies, grants. Dependencies BETWEEN units\n-- are carried by the order packages/db/chain.json declares.\n-- ============================================================================\n\n-- A LANGUAGE sql function binds its body at CREATE time, and 570 functions\n-- sorted by name are not sorted by who calls whom. This is what lets them load\n-- in any order; every body is still compiled on first call, so a genuinely\n-- broken reference surfaces then rather than never. Session-scoped.\nSET check_function_bodies = false;\n\n\n\n-- ── default privileges: reset ───────────────────────────────────────────\n-- Cleared BEFORE anything is created, so every object below gets the ACL the\n-- dump describes rather than the image's defaults on top of it. Restored at\n-- the end of this file.\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON SEQUENCES FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON SEQUENCES FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON SEQUENCES FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON SEQUENCES FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON TABLES FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON TABLES FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON TABLES FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON TABLES FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEND $dp$;\n\n\n-- ── table ───────────────────────────────────────────────────────────────\n\nCREATE TABLE public.plg_inventory_count_items (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n session_id uuid NOT NULL,\n product_id uuid NOT NULL,\n system_quantity numeric(14,4) DEFAULT 0 NOT NULL,\n counted_quantity numeric(14,4),\n unit_cost numeric(14,2) DEFAULT 0 NOT NULL,\n notes text,\n counted_at timestamp with time zone,\n counted_by uuid,\n movement_id uuid,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n unit_id uuid,\n owner_id uuid\n);\n\nALTER TABLE ONLY public.plg_inventory_count_items FORCE ROW LEVEL SECURITY;\n\nCREATE TABLE public.plg_inventory_count_sessions (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n reference text,\n status text DEFAULT 'open'::text NOT NULL,\n stock_location_id uuid NOT NULL,\n category_id uuid,\n blind boolean DEFAULT true NOT NULL,\n notes text,\n opened_at timestamp with time zone DEFAULT now() NOT NULL,\n opened_by uuid,\n closed_at timestamp with time zone,\n closed_by uuid,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n unit_id uuid,\n owner_id uuid,\n CONSTRAINT plg_inventory_count_sessions_status_check CHECK ((status = ANY (ARRAY['open'::text, 'counting'::text, 'closed'::text, 'cancelled'::text])))\n);\n\nALTER TABLE ONLY public.plg_inventory_count_sessions FORCE ROW LEVEL SECURITY;\n\nCREATE TABLE public.plg_inventory_fulfillment_items (\n item_id uuid NOT NULL,\n parent_kind text DEFAULT 'fulfillment'::text NOT NULL,\n tenant_id uuid NOT NULL,\n unit_id uuid,\n batch_number text,\n expiration_date date,\n delivered_by uuid,\n stock_location_id uuid,\n movement_id uuid,\n owner_id uuid,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n CONSTRAINT plg_inventory_fulfillment_items_parent_kind_check CHECK ((parent_kind = 'fulfillment'::text))\n);\n\nALTER TABLE ONLY public.plg_inventory_fulfillment_items FORCE ROW LEVEL SECURITY;\n\nCREATE TABLE public.plg_inventory_measurement_units (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n name text NOT NULL,\n abbreviation text NOT NULL,\n is_active boolean DEFAULT true NOT NULL,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n unit_id uuid,\n owner_id uuid,\n code text,\n kind text DEFAULT 'unit'::text NOT NULL,\n is_base boolean DEFAULT false NOT NULL,\n metadata jsonb DEFAULT '{}'::jsonb NOT NULL,\n CONSTRAINT plg_inventory_measurement_units_kind_check CHECK ((kind = ANY (ARRAY['unit'::text, 'mass'::text, 'volume'::text, 'length'::text])))\n);\n\nALTER TABLE ONLY public.plg_inventory_measurement_units FORCE ROW LEVEL SECURITY;\n\nCREATE TABLE public.plg_inventory_operations (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n kind text NOT NULL,\n idempotency_key text NOT NULL,\n status text DEFAULT 'pending'::text NOT NULL,\n actor_id uuid,\n request jsonb DEFAULT '{}'::jsonb NOT NULL,\n result jsonb,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n unit_id uuid,\n owner_id uuid,\n CONSTRAINT plg_inventory_operations_kind_check CHECK ((kind = ANY (ARRAY['receive'::text, 'transfer'::text, 'adjust'::text, 'reserve'::text, 'confirm'::text, 'reverse'::text]))),\n CONSTRAINT plg_inventory_operations_status_check CHECK ((status = ANY (ARRAY['pending'::text, 'done'::text])))\n);\n\nALTER TABLE ONLY public.plg_inventory_operations FORCE ROW LEVEL SECURITY;\n\nCREATE TABLE public.plg_inventory_product_categories (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n name text NOT NULL,\n parent_id uuid,\n is_active boolean DEFAULT true NOT NULL,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n unit_id uuid,\n owner_id uuid\n);\n\nALTER TABLE ONLY public.plg_inventory_product_categories FORCE ROW LEVEL SECURITY;\n\nCREATE TABLE public.plg_inventory_product_details (\n product_id uuid NOT NULL,\n tenant_id uuid NOT NULL,\n category_id uuid,\n measurement_unit_id uuid,\n purchase_unit_id uuid,\n conversion_factor numeric(14,4) DEFAULT 1 NOT NULL,\n supplier_id uuid,\n default_location_id uuid,\n purpose text,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL\n);\n\nCREATE TABLE public.plg_inventory_product_settings (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n product_id uuid NOT NULL,\n min_quantity numeric(16,4) DEFAULT 0 NOT NULL,\n max_quantity numeric(16,4),\n measurement_unit_id uuid,\n default_location_id uuid,\n track_batches boolean DEFAULT false NOT NULL,\n metadata jsonb DEFAULT '{}'::jsonb NOT NULL,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n unit_id uuid,\n owner_id uuid,\n CONSTRAINT plg_inventory_product_settings_max_quantity_check CHECK (((max_quantity IS NULL) OR (max_quantity >= (0)::numeric))),\n CONSTRAINT plg_inventory_product_settings_min_quantity_check CHECK ((min_quantity >= (0)::numeric))\n);\n\nALTER TABLE ONLY public.plg_inventory_product_settings FORCE ROW LEVEL SECURITY;\n\nCREATE TABLE public.plg_inventory_recipe_groups (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n recipe_id uuid NOT NULL,\n name text NOT NULL,\n preparation_notes text,\n display_order integer DEFAULT 0 NOT NULL,\n estimated_minutes integer,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL\n);\n\nCREATE TABLE public.plg_inventory_recipe_ingredients (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n recipe_id uuid NOT NULL,\n tenant_id uuid NOT NULL,\n product_id uuid NOT NULL,\n quantity numeric(14,4) NOT NULL,\n unit_id uuid,\n display_order integer DEFAULT 0,\n notes text,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n group_id uuid\n);\n\nCREATE TABLE public.plg_inventory_recipes (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n name text NOT NULL,\n description text,\n product_id uuid,\n yield_quantity numeric(14,4) DEFAULT 1,\n yield_unit_id uuid,\n preparation_time_minutes integer,\n instructions text,\n is_active boolean DEFAULT true NOT NULL,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n metadata jsonb,\n unit_id uuid,\n owner_id uuid\n);\n\nALTER TABLE ONLY public.plg_inventory_recipes FORCE ROW LEVEL SECURITY;\n\nCREATE TABLE public.plg_inventory_reservations (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n idempotency_key text NOT NULL,\n line_no integer DEFAULT 1 NOT NULL,\n source_type text NOT NULL,\n source_id uuid NOT NULL,\n product_id uuid NOT NULL,\n stock_location_id uuid NOT NULL,\n batch_number text,\n expiration_date date,\n quantity numeric(16,4) NOT NULL,\n measurement_unit_id uuid,\n status text DEFAULT 'reserved'::text NOT NULL,\n movement_id uuid,\n reversal_movement_id uuid,\n reason text,\n confirmed_at timestamp with time zone,\n confirmed_by uuid,\n reversed_at timestamp with time zone,\n reversed_by uuid,\n metadata jsonb DEFAULT '{}'::jsonb NOT NULL,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n unit_id uuid NOT NULL,\n owner_id uuid,\n CONSTRAINT plg_inventory_reservations_quantity_check CHECK ((quantity > (0)::numeric)),\n CONSTRAINT plg_inventory_reservations_status_check CHECK ((status = ANY (ARRAY['reserved'::text, 'confirmed'::text, 'reversed'::text])))\n);\n\nALTER TABLE ONLY public.plg_inventory_reservations FORCE ROW LEVEL SECURITY;\n\nCREATE TABLE public.plg_inventory_stock_locations (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n name text NOT NULL,\n description text,\n is_active boolean DEFAULT true NOT NULL,\n unit_id uuid NOT NULL,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n owner_id uuid,\n code text,\n metadata jsonb DEFAULT '{}'::jsonb NOT NULL\n);\n\nALTER TABLE ONLY public.plg_inventory_stock_locations FORCE ROW LEVEL SECURITY;\n\nCREATE TABLE public.plg_inventory_stock_movements (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n product_id uuid NOT NULL,\n quantity numeric(14,4) NOT NULL,\n kind text NOT NULL,\n unit_cost numeric(16,4) DEFAULT 0 NOT NULL,\n total_cost numeric(16,4) DEFAULT 0 NOT NULL,\n source_location_id uuid NOT NULL,\n destination_location_id uuid,\n supplier_id uuid,\n document_number text,\n reason text,\n notes text,\n movement_date date DEFAULT CURRENT_DATE NOT NULL,\n user_id uuid,\n batch_number text,\n expiration_date date,\n metadata jsonb DEFAULT '{}'::jsonb,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n unit_id uuid NOT NULL,\n owner_id uuid,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n stock_location_id uuid GENERATED ALWAYS AS (source_location_id) STORED,\n movement_type text GENERATED ALWAYS AS (kind) STORED,\n document_type text,\n reverses_movement_id uuid,\n idempotency_key text,\n line_no integer DEFAULT 1 NOT NULL,\n operation_id uuid,\n source_item_type text,\n source_item_id uuid,\n measurement_unit_id uuid,\n CONSTRAINT plg_inventory_stock_movements_cost_check CHECK (((unit_cost >= (0)::numeric) AND (total_cost >= (0)::numeric))),\n CONSTRAINT plg_inventory_stock_movements_kind_check CHECK ((kind = ANY (ARRAY['in'::text, 'out'::text, 'transfer'::text, 'adjust'::text, 'reverse'::text]))),\n CONSTRAINT plg_inventory_stock_movements_quantity_check CHECK ((quantity <> (0)::numeric)),\n CONSTRAINT plg_inventory_stock_movements_reverse_check CHECK (((kind = 'reverse'::text) = (reverses_movement_id IS NOT NULL))),\n CONSTRAINT plg_inventory_stock_movements_sign_check CHECK ((((kind = 'in'::text) AND (quantity > (0)::numeric)) OR ((kind = 'out'::text) AND (quantity < (0)::numeric)) OR ((kind = 'transfer'::text) AND (quantity < (0)::numeric)) OR (kind = ANY (ARRAY['adjust'::text, 'reverse'::text])))),\n CONSTRAINT plg_inventory_stock_movements_transfer_check CHECK ((((kind = 'transfer'::text) AND (destination_location_id IS NOT NULL) AND (destination_location_id <> source_location_id)) OR ((kind = ANY (ARRAY['in'::text, 'out'::text, 'adjust'::text])) AND (destination_location_id IS NULL)) OR (kind = 'reverse'::text)))\n);\n\nALTER TABLE ONLY public.plg_inventory_stock_movements FORCE ROW LEVEL SECURITY;\n\nCREATE TABLE public.plg_inventory_stock_positions (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n product_id uuid NOT NULL,\n quantity numeric(14,4) NOT NULL,\n unit_cost numeric(16,4) DEFAULT 0 NOT NULL,\n stock_location_id uuid NOT NULL,\n batch_number text,\n expiration_date date,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n measurement_unit_id uuid,\n unit_type text DEFAULT 'base'::text NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n unit_id uuid NOT NULL,\n owner_id uuid,\n CONSTRAINT plg_inventory_stock_positions_quantity_check CHECK ((quantity >= (0)::numeric))\n);\n\nALTER TABLE ONLY public.plg_inventory_stock_positions FORCE ROW LEVEL SECURITY;\n\nCREATE TABLE public.plg_inventory_unit_conversions (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n from_unit_id uuid NOT NULL,\n to_unit_id uuid NOT NULL,\n factor numeric(20,8) NOT NULL,\n product_id uuid,\n notes text,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n unit_id uuid,\n owner_id uuid,\n CONSTRAINT plg_inventory_unit_conversions_check CHECK ((from_unit_id <> to_unit_id)),\n CONSTRAINT plg_inventory_unit_conversions_factor_check CHECK ((factor > (0)::numeric))\n);\n\nALTER TABLE ONLY public.plg_inventory_unit_conversions FORCE ROW LEVEL SECURITY;\n\n\n-- ── function ────────────────────────────────────────────────────────────\n\nCREATE FUNCTION app.inventory_apply_to_position(p_tenant uuid, p_product uuid, p_location uuid, p_batch text, p_expiry date, p_effect numeric, p_unit_cost numeric, p_measurement_unit uuid, OUT o_quantity numeric, OUT o_unit_cost numeric) RETURNS record\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE\n v_unit uuid;\n v_pos record;\n v_new_qty numeric;\n v_new_cost numeric;\nBEGIN\n SELECT l.unit_id INTO v_unit FROM public.plg_inventory_stock_locations l WHERE l.id = p_location AND l.tenant_id = p_tenant;\n SELECT p.id, p.quantity, p.unit_cost INTO v_pos\n FROM public.plg_inventory_stock_positions p\n WHERE p.tenant_id = p_tenant AND p.product_id = p_product AND p.stock_location_id = p_location\n AND coalesce(p.batch_number, '') = coalesce(p_batch, '')\n AND coalesce(p.expiration_date, 'infinity'::date) = coalesce(p_expiry, 'infinity'::date)\n FOR UPDATE;\n IF v_pos.id IS NULL THEN\n IF p_effect < 0 THEN\n RAISE EXCEPTION 'inventory: insufficient stock (no position for product % at location %)', p_product, p_location USING ERRCODE = '23514';\n END IF;\n INSERT INTO public.plg_inventory_stock_positions\n (tenant_id, unit_id, product_id, stock_location_id, batch_number, expiration_date, quantity, unit_cost, measurement_unit_id)\n VALUES (p_tenant, v_unit, p_product, p_location, p_batch, p_expiry, p_effect, coalesce(p_unit_cost, 0), p_measurement_unit)\n RETURNING plg_inventory_stock_positions.quantity, plg_inventory_stock_positions.unit_cost INTO o_quantity, o_unit_cost;\n RETURN;\n END IF;\n v_new_qty := v_pos.quantity + p_effect;\n IF v_new_qty < 0 THEN\n RAISE EXCEPTION 'inventory: insufficient stock (product % at location %: % on hand, % requested)', p_product, p_location, v_pos.quantity, -p_effect USING ERRCODE = '23514';\n END IF;\n IF p_effect > 0 AND v_new_qty > 0 THEN\n v_new_cost := (v_pos.quantity * v_pos.unit_cost + p_effect * coalesce(p_unit_cost, v_pos.unit_cost)) / v_new_qty;\n ELSE\n v_new_cost := v_pos.unit_cost;\n END IF;\n UPDATE public.plg_inventory_stock_positions p\n SET quantity = v_new_qty, unit_cost = round(v_new_cost, 4), unit_id = v_unit,\n measurement_unit_id = coalesce(p.measurement_unit_id, p_measurement_unit)\n WHERE p.id = v_pos.id\n RETURNING p.quantity, p.unit_cost INTO o_quantity, o_unit_cost;\nEND $$;\n\nCREATE FUNCTION app.inventory_authorize_location(p_tenant uuid, p_location uuid, p_perm text) RETURNS uuid\n LANGUAGE plpgsql STABLE SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE v_loc record;\nBEGIN\n IF p_location IS NULL THEN\n RAISE EXCEPTION 'inventory: a stock location is required' USING ERRCODE = '22023';\n END IF;\n SELECT id, unit_id, is_active INTO v_loc FROM public.plg_inventory_stock_locations WHERE id = p_location AND tenant_id = p_tenant;\n -- unknown, another tenant's, or outside the caller's units: one answer, no leak\n IF v_loc.id IS NULL OR NOT app.has_unit(v_loc.unit_id) THEN\n RAISE EXCEPTION 'inventory: no access to stock location %', p_location USING ERRCODE = '42501';\n END IF;\n IF NOT app.has_permission(p_perm, v_loc.unit_id) THEN\n RAISE EXCEPTION 'inventory: % required at the unit of stock location %', p_perm, p_location USING ERRCODE = '42501';\n END IF;\n RETURN v_loc.unit_id;\nEND $$;\n\nCREATE FUNCTION app.inventory_available(p_tenant uuid, p_product uuid, p_location uuid, p_batch text, p_expiry date) RETURNS numeric\n LANGUAGE sql STABLE SECURITY DEFINER\n SET search_path TO ''\n AS $$\n SELECT coalesce((SELECT p.quantity FROM public.plg_inventory_stock_positions p\n WHERE p.tenant_id = p_tenant AND p.product_id = p_product AND p.stock_location_id = p_location\n AND coalesce(p.batch_number, '') = coalesce(p_batch, '')\n AND coalesce(p.expiration_date, 'infinity'::date) = coalesce(p_expiry, 'infinity'::date)), 0)\n - coalesce((SELECT sum(r.quantity) FROM public.plg_inventory_reservations r\n WHERE r.tenant_id = p_tenant AND r.product_id = p_product AND r.stock_location_id = p_location\n AND coalesce(r.batch_number, '') = coalesce(p_batch, '')\n AND coalesce(r.expiration_date, 'infinity'::date) = coalesce(p_expiry, 'infinity'::date)\n AND r.status = 'reserved'), 0);\n$$;\n\nCREATE FUNCTION app.inventory_begin_operation(p_tenant uuid, p_kind text, p_key text, p_request jsonb, OUT op_id uuid, OUT existing jsonb) RETURNS record\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nBEGIN\n -- from here to inventory_finish_operation the ledger tables accept writes\n PERFORM set_config('fayz.inventory_internal', 'on', true);\n INSERT INTO public.plg_inventory_operations (tenant_id, kind, idempotency_key, actor_id, request)\n VALUES (p_tenant, p_kind, p_key, auth.uid(), coalesce(p_request, '{}'::jsonb))\n ON CONFLICT (tenant_id, kind, idempotency_key) DO NOTHING\n RETURNING id INTO op_id;\n IF op_id IS NULL THEN\n SELECT result INTO existing FROM public.plg_inventory_operations\n WHERE tenant_id = p_tenant AND kind = p_kind AND idempotency_key = p_key;\n IF existing IS NULL THEN\n -- a visible row without a result can only be a call that failed after\n -- committing nothing — impossible in one transaction — or a concurrent\n -- call still in flight after the wait; refuse rather than double-apply\n RAISE EXCEPTION 'inventory: operation % is still in progress', p_key USING ERRCODE = '55P03';\n END IF;\n PERFORM set_config('fayz.inventory_internal', 'off', true);\n END IF;\nEND $$;\n\nCREATE FUNCTION app.inventory_check_lines(p_lines jsonb) RETURNS void\n LANGUAGE plpgsql IMMUTABLE\n AS $$\nBEGIN\n IF p_lines IS NULL OR jsonb_typeof(p_lines) <> 'array' OR jsonb_array_length(p_lines) = 0 THEN\n RAISE EXCEPTION 'inventory: lines must be a non-empty JSON array' USING ERRCODE = '22023';\n END IF;\nEND $$;\n\nCREATE FUNCTION app.inventory_consume_order_item(p_tenant uuid, p_order uuid, p_order_item uuid, p_unit uuid, p_product uuid, p_quantity numeric, p_actor uuid DEFAULT NULL::uuid) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE\n v_key text := 'order_item:' || p_order_item::text;\n v_op record;\n v_reservation record;\n v_recipe record;\n v_requirement jsonb;\n v_requirements jsonb := '[]'::jsonb;\n v_parsed record;\n v_position record;\n v_location uuid;\n v_remaining numeric;\n v_available numeric;\n v_take numeric;\n v_reservation_cost numeric;\n v_movement uuid;\n v_line_no integer := 0;\n v_movements jsonb := '[]'::jsonb;\n v_mode text := 'direct_product';\nBEGIN\n IF p_tenant IS NULL OR p_order IS NULL OR p_order_item IS NULL OR p_product IS NULL\n OR coalesce(p_quantity, 0) <= 0 THEN\n RAISE EXCEPTION 'inventory: invalid order item consumption request' USING ERRCODE = '22023';\n END IF;\n\n -- Compatibility with the former browser bridge. If it already reserved the\n -- recipe lines, finish those exact lines instead of resolving the recipe a\n -- second time. The confirm operation key is shared by both implementations.\n IF EXISTS (\n SELECT 1 FROM public.plg_inventory_reservations r\n WHERE r.tenant_id = p_tenant AND r.idempotency_key = v_key\n ) THEN\n SELECT * INTO v_op FROM app.inventory_begin_operation(\n p_tenant, 'confirm', v_key,\n jsonb_build_object('source', 'order.completed', 'order_id', p_order, 'actor_id', p_actor)\n );\n IF v_op.existing IS NOT NULL THEN\n RETURN v_op.existing;\n END IF;\n\n FOR v_reservation IN\n SELECT r.* FROM public.plg_inventory_reservations r\n WHERE r.tenant_id = p_tenant AND r.idempotency_key = v_key\n ORDER BY r.line_no FOR UPDATE\n LOOP\n IF v_reservation.status = 'reserved' THEN\n SELECT p.unit_cost INTO v_reservation_cost\n FROM public.plg_inventory_stock_positions p\n WHERE p.tenant_id = p_tenant\n AND p.product_id = v_reservation.product_id\n AND p.stock_location_id = v_reservation.stock_location_id\n AND coalesce(p.batch_number, '') = coalesce(v_reservation.batch_number, '')\n AND coalesce(p.expiration_date, 'infinity'::date) = coalesce(v_reservation.expiration_date, 'infinity'::date);\n\n INSERT INTO public.plg_inventory_stock_movements\n (tenant_id, product_id, kind, quantity, unit_cost, source_location_id,\n batch_number, expiration_date, measurement_unit_id, document_type,\n document_number, source_item_type, source_item_id, idempotency_key,\n line_no, operation_id, user_id, metadata)\n VALUES\n (p_tenant, v_reservation.product_id, 'out', -v_reservation.quantity,\n coalesce(v_reservation_cost, 0), v_reservation.stock_location_id,\n v_reservation.batch_number, v_reservation.expiration_date,\n v_reservation.measurement_unit_id, 'consumption', p_order::text,\n 'order_item', p_order_item, v_key, v_reservation.line_no, v_op.op_id,\n p_actor, coalesce(v_reservation.metadata, '{}'::jsonb)\n || jsonb_build_object('source', 'order.completed', 'reservation_id', v_reservation.id))\n RETURNING id INTO v_movement;\n\n UPDATE public.plg_inventory_reservations r\n SET status = 'confirmed', confirmed_at = now(), confirmed_by = p_actor,\n movement_id = v_movement\n WHERE r.id = v_reservation.id AND r.status = 'reserved';\n v_movements := v_movements || app.inventory_movement_json(v_movement);\n END IF;\n END LOOP;\n\n RETURN app.inventory_finish_operation(\n p_tenant, v_op.op_id, 'confirm', v_key,\n jsonb_build_object('operation_id', v_op.op_id, 'kind', 'confirm',\n 'idempotency_key', v_key, 'source_type', 'order_item',\n 'source_id', p_order_item, 'status', 'confirmed',\n 'mode', 'legacy_reservation', 'movements', v_movements),\n jsonb_build_object('source', 'order.completed', 'order_id', p_order,\n 'order_item_id', p_order_item, 'actor_id', p_actor)\n );\n END IF;\n\n SELECT r.id, r.yield_quantity INTO v_recipe\n FROM public.plg_inventory_recipes r\n WHERE r.tenant_id = p_tenant\n AND r.product_id = p_product\n AND r.is_active\n ORDER BY r.updated_at DESC, r.id\n LIMIT 1;\n\n IF v_recipe.id IS NOT NULL THEN\n IF coalesce(v_recipe.yield_quantity, 0) <= 0 THEN\n RAISE EXCEPTION 'inventory: recipe % has an invalid yield quantity', v_recipe.id\n USING ERRCODE = '22023';\n END IF;\n\n SELECT coalesce(jsonb_agg(jsonb_build_object(\n 'product_id', x.product_id,\n 'quantity', round(x.quantity * p_quantity / v_recipe.yield_quantity, 4),\n 'measurement_unit_id', x.unit_id,\n 'recipe_id', v_recipe.id\n ) ORDER BY x.first_order), '[]'::jsonb)\n INTO v_requirements\n FROM (\n SELECT i.product_id, i.unit_id, sum(i.quantity) AS quantity,\n min(i.display_order) AS first_order\n FROM public.plg_inventory_recipe_ingredients i\n WHERE i.tenant_id = p_tenant AND i.recipe_id = v_recipe.id\n GROUP BY i.product_id, i.unit_id\n ) x;\n\n IF jsonb_array_length(v_requirements) = 0 THEN\n RAISE EXCEPTION 'inventory: recipe % has no ingredients', v_recipe.id\n USING ERRCODE = '22023';\n END IF;\n v_mode := 'recipe';\n ELSE\n v_requirements := jsonb_build_array(jsonb_build_object(\n 'product_id', p_product,\n 'quantity', round(p_quantity, 4),\n 'measurement_unit_id', NULL\n ));\n END IF;\n\n SELECT * INTO v_op FROM app.inventory_begin_operation(\n p_tenant, 'confirm', v_key,\n jsonb_build_object('source', 'order.completed', 'order_id', p_order,\n 'order_item_id', p_order_item, 'product_id', p_product,\n 'quantity', p_quantity, 'mode', v_mode, 'actor_id', p_actor)\n );\n IF v_op.existing IS NOT NULL THEN\n RETURN v_op.existing;\n END IF;\n\n FOR v_requirement IN SELECT value FROM jsonb_array_elements(v_requirements)\n LOOP\n SELECT * INTO v_parsed FROM app.inventory_parse_line(\n p_tenant,\n jsonb_strip_nulls(jsonb_build_object(\n 'product_id', v_requirement ->> 'product_id',\n 'quantity', v_requirement ->> 'quantity',\n 'measurement_unit_id', v_requirement ->> 'measurement_unit_id'\n )),\n 'positive'\n );\n\n v_remaining := round(v_parsed.quantity, 4);\n IF v_remaining <= 0 THEN\n RAISE EXCEPTION 'inventory: order item % produced a zero stock requirement', p_order_item\n USING ERRCODE = '22023';\n END IF;\n\n v_location := NULL;\n SELECT candidate.location_id INTO v_location\n FROM (\n SELECT s.default_location_id AS location_id,\n CASE WHEN p_unit IS NOT NULL AND s.unit_id = p_unit THEN 1 ELSE 2 END AS priority\n FROM public.plg_inventory_product_settings s\n WHERE s.tenant_id = p_tenant\n AND s.product_id = v_parsed.product_id\n AND s.default_location_id IS NOT NULL\n AND (s.unit_id IS NULL OR s.unit_id = p_unit)\n UNION ALL\n SELECT d.default_location_id, 3\n FROM public.plg_inventory_product_details d\n WHERE d.tenant_id = p_tenant\n AND d.product_id = v_parsed.product_id\n AND d.default_location_id IS NOT NULL\n UNION ALL\n SELECT l.id, 4\n FROM public.plg_inventory_stock_locations l\n WHERE l.tenant_id = p_tenant AND l.is_active\n ) candidate\n JOIN public.plg_inventory_stock_locations l\n ON l.id = candidate.location_id\n AND l.tenant_id = p_tenant\n AND l.is_active\n WHERE p_unit IS NULL OR l.unit_id = p_unit\n ORDER BY candidate.priority, l.created_at, l.id\n LIMIT 1;\n\n IF v_location IS NULL THEN\n RAISE EXCEPTION 'inventory: product % has no active stock location for order unit %',\n v_parsed.product_id, p_unit USING ERRCODE = '22023';\n END IF;\n\n FOR v_position IN\n SELECT p.*\n FROM public.plg_inventory_stock_positions p\n WHERE p.tenant_id = p_tenant\n AND p.product_id = v_parsed.product_id\n AND p.stock_location_id = v_location\n AND p.quantity > 0\n ORDER BY p.expiration_date NULLS LAST, p.created_at, p.id\n FOR UPDATE\n LOOP\n v_available := app.inventory_available(\n p_tenant, v_position.product_id, v_position.stock_location_id,\n v_position.batch_number, v_position.expiration_date\n );\n CONTINUE WHEN v_available <= 0;\n v_take := least(v_remaining, v_available);\n v_line_no := v_line_no + 1;\n\n INSERT INTO public.plg_inventory_stock_movements\n (tenant_id, product_id, kind, quantity, unit_cost, source_location_id,\n batch_number, expiration_date, measurement_unit_id, document_type,\n document_number, source_item_type, source_item_id, idempotency_key,\n line_no, operation_id, user_id, metadata)\n VALUES\n (p_tenant, v_parsed.product_id, 'out', -v_take,\n coalesce(v_position.unit_cost, 0), v_location,\n v_position.batch_number, v_position.expiration_date,\n coalesce(v_position.measurement_unit_id, v_parsed.measurement_unit_id),\n 'consumption', p_order::text, 'order_item', p_order_item, v_key,\n v_line_no, v_op.op_id, p_actor,\n jsonb_strip_nulls(jsonb_build_object(\n 'source', 'order.completed',\n 'mode', v_mode,\n 'recipe_id', v_requirement ->> 'recipe_id',\n 'declared', v_parsed.declared\n )))\n RETURNING id INTO v_movement;\n\n v_movements := v_movements || app.inventory_movement_json(v_movement);\n v_remaining := round(v_remaining - v_take, 4);\n EXIT WHEN v_remaining <= 0;\n END LOOP;\n\n IF v_remaining > 0 THEN\n RAISE EXCEPTION 'inventory: insufficient stock for product % at location % (% missing)',\n v_parsed.product_id, v_location, v_remaining USING ERRCODE = '23514';\n END IF;\n END LOOP;\n\n RETURN app.inventory_finish_operation(\n p_tenant, v_op.op_id, 'confirm', v_key,\n jsonb_build_object('operation_id', v_op.op_id, 'kind', 'confirm',\n 'idempotency_key', v_key, 'source_type', 'order_item',\n 'source_id', p_order_item, 'status', 'confirmed', 'mode', v_mode,\n 'recipe_id', v_recipe.id, 'movements', v_movements),\n jsonb_build_object('source', 'order.completed', 'order_id', p_order,\n 'order_item_id', p_order_item, 'actor_id', p_actor,\n 'movement_count', v_line_no, 'mode', v_mode)\n );\nEND $$;\n\nCREATE FUNCTION app.inventory_default_variant(p_tenant uuid, p_product uuid) RETURNS uuid\n LANGUAGE sql STABLE SECURITY DEFINER\n SET search_path TO ''\n AS $$\n SELECT v.id\n FROM public.product_variants v\n WHERE v.tenant_id = p_tenant\n AND v.product_id = p_product\n AND v.is_active\n ORDER BY (v.is_default IS TRUE) DESC, v.sort_order, v.created_at\n LIMIT 1\n$$;\n\nCREATE FUNCTION app.inventory_finish_operation(p_tenant uuid, p_op uuid, p_kind text, p_key text, p_result jsonb, p_audit jsonb) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nBEGIN\n UPDATE public.plg_inventory_operations SET status = 'done', result = p_result WHERE id = p_op;\n PERFORM set_config('fayz.inventory_internal', 'off', true);\n INSERT INTO public.audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)\n VALUES (p_tenant, auth.uid(), 'inventory.' || p_kind, 'inventory.operation', p_op::text,\n jsonb_build_object('idempotency_key', p_key) || coalesce(p_audit, '{}'::jsonb));\n RETURN p_result;\nEND $$;\n\nCREATE FUNCTION app.inventory_internal_only() RETURNS trigger\n LANGUAGE plpgsql\n AS $$\nBEGIN\n IF coalesce(current_setting('fayz.inventory_internal', true), 'off') <> 'on' THEN\n RAISE EXCEPTION 'inventory: % is written only through the inventory RPCs (inventory_receive / _transfer / _adjust / _reserve / _confirm / _reverse)', TG_TABLE_NAME\n USING ERRCODE = '55000';\n END IF;\n IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;\n RETURN NEW;\nEND $$;\n\nCREATE FUNCTION app.inventory_locations_before_update() RETURNS trigger\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nBEGIN\n IF NEW.unit_id IS DISTINCT FROM OLD.unit_id\n AND EXISTS (SELECT 1 FROM public.plg_inventory_stock_movements m\n WHERE m.tenant_id = OLD.tenant_id\n AND (m.source_location_id = OLD.id OR m.destination_location_id = OLD.id)) THEN\n RAISE EXCEPTION 'inventory: a stock location with movements cannot change unit (deactivate it and create a new one)'\n USING ERRCODE = '55000';\n END IF;\n IF NEW.tenant_id IS DISTINCT FROM OLD.tenant_id THEN\n RAISE EXCEPTION 'inventory: a stock location cannot change tenant' USING ERRCODE = '55000';\n END IF;\n RETURN NEW;\nEND $$;\n\nCREATE FUNCTION app.inventory_movement_json(p_id uuid) RETURNS jsonb\n LANGUAGE sql STABLE SECURITY DEFINER\n SET search_path TO ''\n AS $$\n SELECT jsonb_build_object(\n 'id', m.id, 'line_no', m.line_no, 'kind', m.kind, 'product_id', m.product_id, 'quantity', m.quantity,\n 'unit_cost', m.unit_cost, 'total_cost', m.total_cost,\n 'source_location_id', m.source_location_id, 'destination_location_id', m.destination_location_id,\n 'batch_number', m.batch_number, 'expiration_date', m.expiration_date,\n 'measurement_unit_id', m.measurement_unit_id, 'reverses_movement_id', m.reverses_movement_id,\n 'source_item_type', m.source_item_type, 'source_item_id', m.source_item_id)\n FROM public.plg_inventory_stock_movements m WHERE m.id = p_id;\n$$;\n\nCREATE FUNCTION app.inventory_movement_source_required() RETURNS trigger\n LANGUAGE plpgsql\n AS $$\nBEGIN\n IF NEW.source_location_id IS NULL THEN\n RAISE EXCEPTION 'inventory: a stock movement must name the location it moves from'\n USING ERRCODE = '23514';\n END IF;\n RETURN NEW;\nEND $$;\n\nCREATE FUNCTION app.inventory_movements_after_insert() RETURNS trigger\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE\n v_prev text := coalesce(current_setting('fayz.inventory_internal', true), 'off');\n r record;\nBEGIN\n PERFORM set_config('fayz.inventory_internal', 'on', true);\n SELECT * INTO r FROM app.inventory_apply_to_position(\n NEW.tenant_id, NEW.product_id, NEW.source_location_id, NEW.batch_number, NEW.expiration_date,\n NEW.quantity, NEW.unit_cost, NEW.measurement_unit_id);\n PERFORM app.inventory_sync_archetype_balance(NEW.tenant_id, NEW.product_id, NEW.source_location_id, NEW.quantity);\n\n IF NEW.destination_location_id IS NOT NULL THEN\n SELECT * INTO r FROM app.inventory_apply_to_position(\n NEW.tenant_id, NEW.product_id, NEW.destination_location_id, NEW.batch_number, NEW.expiration_date,\n -NEW.quantity, NEW.unit_cost, NEW.measurement_unit_id);\n PERFORM app.inventory_sync_archetype_balance(NEW.tenant_id, NEW.product_id, NEW.destination_location_id, -NEW.quantity);\n END IF;\n\n PERFORM set_config('fayz.inventory_internal', v_prev, true);\n RETURN NULL;\nEND $$;\n\nCREATE FUNCTION app.inventory_movements_append_only() RETURNS trigger\n LANGUAGE plpgsql\n AS $$\nDECLARE\n v_old jsonb;\n v_new jsonb;\n k text;\nBEGIN\n IF TG_OP = 'UPDATE' THEN\n v_old := to_jsonb(OLD);\n v_new := to_jsonb(NEW);\n FOREACH k IN ARRAY ARRAY['unit_id', 'owner_id', 'updated_at'] LOOP\n v_old := v_old - k;\n v_new := v_new - k;\n END LOOP;\n -- GENERATED columns read NULL in NEW inside a BEFORE trigger: Postgres\n -- computes them after these run. This table has two — the compat mirrors\n -- 107 left behind for stock_location_id and movement_type — so comparing\n -- them would report a change on every row and refuse every backfill.\n -- They are derived from the columns already being compared anyway.\n FOR k IN\n SELECT a.attname FROM pg_attribute a\n WHERE a.attrelid = TG_RELID AND a.attnum > 0\n AND NOT a.attisdropped AND a.attgenerated <> ''\n LOOP\n v_old := v_old - k;\n v_new := v_new - k;\n END LOOP;\n IF v_old = v_new\n AND (OLD.unit_id IS NULL OR NEW.unit_id IS NOT DISTINCT FROM OLD.unit_id)\n AND (OLD.owner_id IS NULL OR NEW.owner_id IS NOT DISTINCT FROM OLD.owner_id) THEN\n RETURN NEW;\n END IF;\n END IF;\n RAISE EXCEPTION 'inventory: stock movements are append-only (write a reversal instead)' USING ERRCODE = '55000';\nEND $$;\n\nCREATE FUNCTION app.inventory_movements_before_insert() RETURNS trigger\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE\n v_src record;\n v_dst record;\nBEGIN\n IF NEW.tenant_id IS NULL THEN\n RAISE EXCEPTION 'inventory: movement without tenant' USING ERRCODE = '22023';\n END IF;\n IF NEW.source_location_id IS NULL THEN\n RAISE EXCEPTION 'inventory: movement without a source location' USING ERRCODE = '22023';\n END IF;\n SELECT id, tenant_id, unit_id, is_active INTO v_src FROM public.plg_inventory_stock_locations WHERE id = NEW.source_location_id;\n IF v_src.id IS NULL OR v_src.tenant_id <> NEW.tenant_id THEN\n RAISE EXCEPTION 'inventory: source location % does not belong to the tenant', NEW.source_location_id USING ERRCODE = '22023';\n END IF;\n IF NEW.destination_location_id IS NOT NULL THEN\n SELECT id, tenant_id, unit_id, is_active INTO v_dst FROM public.plg_inventory_stock_locations WHERE id = NEW.destination_location_id;\n IF v_dst.id IS NULL OR v_dst.tenant_id <> NEW.tenant_id THEN\n RAISE EXCEPTION 'inventory: destination location % does not belong to the tenant', NEW.destination_location_id USING ERRCODE = '22023';\n END IF;\n END IF;\n IF NOT EXISTS (SELECT 1 FROM public.products p WHERE p.id = NEW.product_id AND p.tenant_id = NEW.tenant_id) THEN\n RAISE EXCEPTION 'inventory: product % does not belong to the tenant', NEW.product_id USING ERRCODE = '22023';\n END IF;\n -- the unit of the ledger row is the unit of the location whose stock moves first\n NEW.unit_id := v_src.unit_id;\n NEW.unit_cost := coalesce(NEW.unit_cost, 0);\n NEW.total_cost := abs(NEW.quantity) * NEW.unit_cost;\n NEW.user_id := coalesce(NEW.user_id, auth.uid());\n NEW.movement_date := coalesce(NEW.movement_date, current_date);\n NEW.metadata := coalesce(NEW.metadata, '{}'::jsonb);\n NEW.batch_number := nullif(btrim(NEW.batch_number), '');\n RETURN NEW;\nEND $$;\n\nCREATE FUNCTION app.inventory_parse_line(p_tenant uuid, p_line jsonb, p_mode text DEFAULT 'positive'::text, OUT product_id uuid, OUT quantity numeric, OUT unit_cost numeric, OUT batch_number text, OUT expiration_date date, OUT measurement_unit_id uuid, OUT declared jsonb, OUT notes text) RETURNS record\n LANGUAGE plpgsql STABLE SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE\n v_product uuid;\n v_declared_unit uuid;\n v_stock_unit uuid;\n v_declared_qty numeric;\n v_qty numeric;\n v_cost numeric;\n v_batch text;\n v_expiry date;\nBEGIN\n BEGIN\n v_product := (p_line->>'product_id')::uuid;\n v_declared_qty := coalesce((p_line->>'quantity')::numeric, (p_line->>'delta')::numeric);\n v_cost := (p_line->>'unit_cost')::numeric;\n v_batch := nullif(btrim(coalesce(p_line->>'batch_number', p_line->>'batch', '')), '');\n v_expiry := coalesce((p_line->>'expiration_date')::date, (p_line->>'expiry')::date);\n v_declared_unit := (p_line->>'measurement_unit_id')::uuid;\n EXCEPTION WHEN OTHERS THEN\n RAISE EXCEPTION 'inventory: invalid line %: %', p_line, SQLERRM USING ERRCODE = '22023';\n END;\n IF v_product IS NULL THEN\n RAISE EXCEPTION 'inventory: line without product_id: %', p_line USING ERRCODE = '22023';\n END IF;\n IF NOT EXISTS (SELECT 1 FROM public.products p WHERE p.id = v_product AND p.tenant_id = p_tenant) THEN\n RAISE EXCEPTION 'inventory: product % not found in this tenant', v_product USING ERRCODE = '22023';\n END IF;\n IF v_declared_qty IS NULL\n OR (p_mode = 'positive' AND v_declared_qty <= 0)\n OR (p_mode = 'signed' AND v_declared_qty = 0)\n OR (p_mode = 'absolute' AND v_declared_qty < 0) THEN\n RAISE EXCEPTION 'inventory: quantity must be % (line %)',\n CASE p_mode WHEN 'positive' THEN 'positive' WHEN 'signed' THEN 'non-zero' ELSE 'zero or positive' END, p_line USING ERRCODE = '22023';\n END IF;\n IF v_cost IS NOT NULL AND v_cost < 0 THEN\n RAISE EXCEPTION 'inventory: unit_cost cannot be negative (line %)', p_line USING ERRCODE = '22023';\n END IF;\n SELECT s.measurement_unit_id INTO v_stock_unit\n FROM public.plg_inventory_product_settings s\n WHERE s.tenant_id = p_tenant AND s.product_id = v_product AND s.unit_id IS NULL;\n v_qty := v_declared_qty;\n declared := '{}'::jsonb;\n IF v_declared_unit IS NOT NULL AND v_stock_unit IS NOT NULL AND v_declared_unit <> v_stock_unit THEN\n v_qty := public.inventory_convert(p_tenant, v_product, v_declared_qty, v_declared_unit, v_stock_unit);\n -- the cost was per declared unit; the ledger keeps cost per stock unit\n IF v_cost IS NOT NULL AND v_qty <> 0 THEN\n v_cost := round(v_cost * v_declared_qty / v_qty, 4);\n END IF;\n declared := jsonb_build_object('declared_quantity', v_declared_qty, 'declared_unit_id', v_declared_unit,\n 'declared_unit_cost', (p_line->>'unit_cost')::numeric);\n END IF;\n product_id := v_product;\n quantity := v_qty;\n unit_cost := v_cost;\n batch_number := v_batch;\n expiration_date := v_expiry;\n measurement_unit_id := coalesce(v_stock_unit, v_declared_unit);\n notes := p_line->>'notes';\nEND $$;\n\nCREATE FUNCTION app.inventory_position_json(p_tenant uuid, p_product uuid, p_location uuid, p_batch text, p_expiry date) RETURNS jsonb\n LANGUAGE sql STABLE SECURITY DEFINER\n SET search_path TO ''\n AS $$\n SELECT coalesce((\n SELECT jsonb_build_object('product_id', p.product_id, 'stock_location_id', p.stock_location_id, 'unit_id', p.unit_id,\n 'batch_number', p.batch_number, 'expiration_date', p.expiration_date,\n 'quantity', p.quantity, 'unit_cost', p.unit_cost)\n FROM public.plg_inventory_stock_positions p\n WHERE p.tenant_id = p_tenant AND p.product_id = p_product AND p.stock_location_id = p_location\n AND coalesce(p.batch_number, '') = coalesce(p_batch, '')\n AND coalesce(p.expiration_date, 'infinity'::date) = coalesce(p_expiry, 'infinity'::date)),\n jsonb_build_object('product_id', p_product, 'stock_location_id', p_location, 'batch_number', p_batch, 'expiration_date', p_expiry, 'quantity', 0, 'unit_cost', 0));\n$$;\n\nCREATE FUNCTION app.inventory_product_label(p_product uuid) RETURNS TABLE(name text, sku text)\n LANGUAGE sql STABLE SECURITY DEFINER\n SET search_path TO ''\n AS $$\n SELECT p.name, p.sku FROM public.products p\n WHERE p.id = p_product AND p.tenant_id = app.current_tenant_id();\n$$;\n\nCREATE FUNCTION app.inventory_require_tenant() RETURNS uuid\n LANGUAGE plpgsql STABLE SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE v_tenant uuid := app.current_tenant_id();\nBEGIN\n IF v_tenant IS NULL THEN\n RAISE EXCEPTION 'inventory: no tenant in session' USING ERRCODE = '42501';\n END IF;\n RETURN v_tenant;\nEND $$;\n\nCREATE FUNCTION app.inventory_reservation_json(p_id uuid) RETURNS jsonb\n LANGUAGE sql STABLE SECURITY DEFINER\n SET search_path TO ''\n AS $$\n SELECT jsonb_build_object(\n 'id', r.id, 'line_no', r.line_no, 'status', r.status, 'source_type', r.source_type, 'source_id', r.source_id,\n 'product_id', r.product_id, 'stock_location_id', r.stock_location_id, 'quantity', r.quantity,\n 'batch_number', r.batch_number, 'expiration_date', r.expiration_date,\n 'movement_id', r.movement_id, 'reversal_movement_id', r.reversal_movement_id)\n FROM public.plg_inventory_reservations r WHERE r.id = p_id;\n$$;\n\nCREATE FUNCTION app.inventory_reservation_one_per_source() RETURNS trigger\n LANGUAGE plpgsql\n SET search_path TO ''\n AS $$\nDECLARE v_existing record;\nBEGIN\n IF NEW.status = 'reversed' THEN\n RETURN NEW;\n END IF;\n SELECT r.id, r.idempotency_key, r.status INTO v_existing\n FROM public.plg_inventory_reservations r\n WHERE r.tenant_id = NEW.tenant_id\n AND r.source_type = NEW.source_type\n AND r.source_id = NEW.source_id\n AND r.line_no = NEW.line_no\n AND r.status <> 'reversed'\n AND r.id <> NEW.id\n LIMIT 1;\n IF FOUND THEN\n RAISE EXCEPTION\n 'inventory: % % line % is already consumed by reservation % (%) — reverse it before reserving again',\n NEW.source_type, NEW.source_id, NEW.line_no, v_existing.id, v_existing.status\n USING ERRCODE = '55000',\n HINT = 'inventory_reserve is idempotent by source item, not by idempotency key: a second key does not buy a second consumption (#203).';\n END IF;\n RETURN NEW;\nEND $$;\n\nCREATE FUNCTION app.inventory_sync_archetype_balance(p_tenant uuid, p_product uuid, p_location uuid, p_delta numeric) RETURNS void\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE\n v_variant uuid := app.inventory_default_variant(p_tenant, p_product);\n v_unit uuid;\nBEGIN\n IF v_variant IS NULL OR coalesce(p_delta, 0) = 0 THEN\n RETURN;\n END IF;\n SELECT l.unit_id INTO v_unit\n FROM public.plg_inventory_stock_locations l\n WHERE l.id = p_location AND l.tenant_id = p_tenant;\n\n PERFORM public.stock_apply(p_tenant, v_variant, v_unit, p_delta, 0, 'inventory');\nEND $$;\n\nCREATE FUNCTION app.trg_inventory_seed_units_for_tenant() RETURNS trigger\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nBEGIN\n PERFORM public.inventory_seed_measurement_units(NEW.id);\n RETURN NULL;\nEND $$;\n\nCREATE FUNCTION public.agent_inventory_upsert_recipe(p_tenant_id uuid, p_actor_user_id uuid, p_payload jsonb) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO 'public'\n AS $$\nDECLARE\n v_denial jsonb;\n v_used int;\n v_recipe_id uuid;\n v_is_update boolean;\n v_name text;\n v_description text;\n v_instructions text;\n v_yield numeric;\n v_prep int;\n v_product_id uuid;\n v_product_hint text;\n v_item jsonb;\n v_iname text;\n v_qty numeric;\n v_pid uuid;\n v_order int := 0;\n v_resolved jsonb := '[]'::jsonb;\n v_unmatched jsonb := '[]'::jsonb;\n v_metadata jsonb;\n v_final_id uuid;\n v_final_name text;\nBEGIN\n -- ── payload ──────────────────────────────────────────────────────────────\n BEGIN\n v_recipe_id := NULLIF(p_payload->>'recipe_id','')::uuid;\n EXCEPTION WHEN OTHERS THEN\n RETURN jsonb_build_object('ok', false, 'error', 'invalid recipe_id');\n END;\n v_is_update := v_recipe_id IS NOT NULL;\n v_name := btrim(p_payload->>'name');\n v_description := left(p_payload->>'description', 1000);\n v_instructions := left(p_payload->>'instructions', 8000);\n v_yield := COALESCE(NULLIF(p_payload->>'yield_quantity','')::numeric, 1);\n v_prep := NULLIF(p_payload->>'preparation_time_minutes','')::int;\n v_product_hint := btrim(COALESCE(p_payload->>'product_name',''));\n BEGIN\n v_product_id := NULLIF(p_payload->>'product_id','')::uuid;\n EXCEPTION WHEN OTHERS THEN\n v_product_id := NULL;\n END;\n\n IF NOT v_is_update AND (v_name IS NULL OR v_name = '') THEN\n RETURN jsonb_build_object('ok', false, 'error', 'name is required to create a recipe');\n END IF;\n IF v_yield <= 0 THEN\n v_yield := 1;\n END IF;\n\n -- ── authorization: role → plan → recipes cap ─────────────────────────────\n SELECT count(*) INTO v_used FROM plg_inventory_recipes\n WHERE tenant_id = p_tenant_id AND is_active = true;\n v_denial := agent_guard(p_tenant_id, p_actor_user_id, 'inventory.recipes',\n CASE WHEN v_is_update THEN 'update' ELSE 'create' END,\n 'recipes', v_used, CASE WHEN v_is_update THEN 0 ELSE 1 END);\n IF v_denial IS NOT NULL THEN\n RETURN jsonb_build_object('ok', false, 'denial', v_denial);\n END IF;\n\n IF v_is_update THEN\n PERFORM 1 FROM plg_inventory_recipes\n WHERE id = v_recipe_id AND tenant_id = p_tenant_id;\n IF NOT FOUND THEN\n RETURN jsonb_build_object('ok', false, 'error', 'unknown recipe for this tenant');\n END IF;\n END IF;\n\n -- ── produced product: id must be this tenant's, else resolve by name ──────\n IF v_product_id IS NOT NULL THEN\n PERFORM 1 FROM products WHERE id = v_product_id AND tenant_id = p_tenant_id;\n IF NOT FOUND THEN v_product_id := NULL; END IF;\n END IF;\n IF v_product_id IS NULL AND v_product_hint <> '' THEN\n SELECT p.id INTO v_product_id FROM products p\n WHERE p.tenant_id = p_tenant_id\n AND lower(btrim(p.name)) = lower(v_product_hint)\n ORDER BY p.is_active DESC LIMIT 1;\n IF v_product_id IS NULL THEN\n -- Containment rather than ILIKE: the name is user text and % / _ in it\n -- would be read as wildcards.\n SELECT p.id INTO v_product_id FROM products p\n WHERE p.tenant_id = p_tenant_id\n AND position(lower(v_product_hint) in lower(p.name)) > 0\n ORDER BY length(p.name) LIMIT 1;\n END IF;\n END IF;\n\n -- ── ingredients: name → products.id, unresolved ones set aside ───────────\n FOR v_item IN SELECT value FROM jsonb_array_elements(COALESCE(p_payload->'ingredients','[]'::jsonb))\n LOOP\n v_iname := btrim(COALESCE(v_item->>'name', v_item->>'product_name', ''));\n v_qty := COALESCE(NULLIF(v_item->>'quantity','')::numeric, 0);\n BEGIN\n v_pid := NULLIF(v_item->>'product_id','')::uuid;\n EXCEPTION WHEN OTHERS THEN\n v_pid := NULL;\n END;\n\n IF v_pid IS NOT NULL THEN\n PERFORM 1 FROM products WHERE id = v_pid AND tenant_id = p_tenant_id;\n IF NOT FOUND THEN v_pid := NULL; END IF;\n END IF;\n\n IF v_pid IS NULL AND v_iname <> '' THEN\n SELECT p.id INTO v_pid FROM products p\n WHERE p.tenant_id = p_tenant_id\n AND lower(btrim(p.name)) = lower(v_iname)\n ORDER BY p.is_active DESC LIMIT 1;\n IF v_pid IS NULL THEN\n SELECT p.id INTO v_pid FROM products p\n WHERE p.tenant_id = p_tenant_id\n AND position(lower(v_iname) in lower(p.name)) > 0\n ORDER BY length(p.name) LIMIT 1;\n END IF;\n END IF;\n\n IF v_pid IS NULL OR v_qty <= 0 THEN\n v_unmatched := v_unmatched || jsonb_build_object(\n 'name', v_iname, 'quantity', v_qty, 'notes', NULLIF(v_item->>'notes',''));\n ELSE\n v_resolved := v_resolved || jsonb_build_object(\n 'product_id', v_pid, 'quantity', v_qty,\n 'notes', NULLIF(v_item->>'notes',''), 'display_order', v_order);\n v_order := v_order + 1;\n END IF;\n END LOOP;\n\n v_metadata := jsonb_strip_nulls(jsonb_build_object(\n 'unmatchedIngredients',\n CASE WHEN jsonb_array_length(v_unmatched) > 0 THEN v_unmatched ELSE NULL END,\n 'productHint',\n CASE WHEN v_product_id IS NULL AND v_product_hint <> '' THEN v_product_hint ELSE NULL END\n ));\n\n -- ── write + audit ────────────────────────────────────────────────────────\n IF v_is_update THEN\n UPDATE plg_inventory_recipes SET\n name = COALESCE(NULLIF(v_name,''), name),\n description = COALESCE(v_description, description),\n product_id = COALESCE(v_product_id, product_id),\n yield_quantity = COALESCE(NULLIF(NULLIF(p_payload->>'yield_quantity','')::numeric, 0), yield_quantity),\n preparation_time_minutes = COALESCE(v_prep, preparation_time_minutes),\n instructions = COALESCE(v_instructions, instructions),\n -- An edit that says nothing about ingredients must not erase the gaps the\n -- user still has to resolve.\n metadata = CASE WHEN p_payload ? 'ingredients' OR v_product_hint <> ''\n THEN v_metadata ELSE metadata END,\n updated_at = now()\n WHERE id = v_recipe_id AND tenant_id = p_tenant_id\n RETURNING id, name INTO v_final_id, v_final_name;\n ELSE\n INSERT INTO plg_inventory_recipes (\n tenant_id, name, description, product_id, yield_quantity,\n preparation_time_minutes, instructions, metadata\n ) VALUES (\n p_tenant_id, v_name, v_description, v_product_id, v_yield,\n v_prep, v_instructions, v_metadata\n )\n RETURNING id, name INTO v_final_id, v_final_name;\n END IF;\n\n -- \\`ingredients\\` REPLACES the list; omitting the key leaves the recipe's own.\n IF p_payload ? 'ingredients' THEN\n DELETE FROM plg_inventory_recipe_ingredients\n WHERE recipe_id = v_final_id AND tenant_id = p_tenant_id;\n INSERT INTO plg_inventory_recipe_ingredients (\n tenant_id, recipe_id, product_id, quantity, notes, display_order\n )\n SELECT p_tenant_id, v_final_id, (e->>'product_id')::uuid,\n (e->>'quantity')::numeric, NULLIF(e->>'notes',''), (e->>'display_order')::int\n FROM jsonb_array_elements(v_resolved) e;\n END IF;\n\n INSERT INTO audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)\n VALUES (p_tenant_id, p_actor_user_id,\n CASE WHEN v_is_update THEN 'agent.updateRecipe' ELSE 'agent.createRecipe' END,\n 'inventory_recipe', v_final_id::text,\n jsonb_build_object('payload', p_payload));\n\n RETURN jsonb_build_object(\n 'ok', true,\n 'id', v_final_id,\n 'record', jsonb_build_object(\n 'ref', jsonb_build_object('id', v_final_id, 'resource', 'plg_inventory_recipes',\n 'archetype', 'inventory:recipe'),\n 'name', v_final_name,\n 'productId', v_product_id,\n 'ingredientCount', jsonb_array_length(v_resolved),\n 'unmatchedIngredients', v_unmatched,\n 'productHint', CASE WHEN v_product_id IS NULL THEN NULLIF(v_product_hint,'') ELSE NULL END\n )\n );\nEND;\n$$;\n\nCREATE FUNCTION public.app_product_stock_owner(p_tenant uuid) RETURNS text\n LANGUAGE sql STABLE SECURITY DEFINER\n SET search_path TO ''\n AS $$ SELECT app.product_stock_owner(p_tenant) $$;\n\nCREATE FUNCTION public.inventory_adjust(p_location uuid, p_lines jsonb, p_reason text, p_idempotency_key text DEFAULT NULL::text) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE\n v_tenant uuid := app.inventory_require_tenant();\n v_key text := coalesce(nullif(btrim(p_idempotency_key), ''), 'adjust:' || gen_random_uuid()::text);\n v_reason text := nullif(btrim(p_reason), '');\n v_op record;\n v_unit uuid;\n v_line jsonb;\n v_ln record;\n v_no integer := 0;\n v_id uuid;\n v_delta numeric;\n v_current numeric;\n v_target numeric;\n v_cost numeric;\n v_movements jsonb := '[]'::jsonb;\n v_positions jsonb := '[]'::jsonb;\nBEGIN\n IF v_reason IS NULL THEN\n RAISE EXCEPTION 'inventory: an adjustment needs a reason' USING ERRCODE = '22023';\n END IF;\n PERFORM app.inventory_check_lines(p_lines);\n v_unit := app.inventory_authorize_location(v_tenant, p_location, 'inventory.edit');\n\n SELECT * INTO v_op FROM app.inventory_begin_operation(v_tenant, 'adjust', v_key,\n jsonb_build_object('location', p_location, 'lines', p_lines, 'reason', v_reason));\n IF v_op.existing IS NOT NULL THEN RETURN v_op.existing; END IF;\n\n FOR v_line IN SELECT * FROM jsonb_array_elements(p_lines) LOOP\n v_no := v_no + 1;\n IF v_line ? 'set_quantity' THEN\n -- absolute: turn into a delta against the current position (in the stock unit)\n SELECT * INTO v_ln FROM app.inventory_parse_line(v_tenant, (v_line - 'set_quantity') || jsonb_build_object('quantity', v_line->'set_quantity'), 'absolute');\n v_target := v_ln.quantity;\n IF v_target < 0 THEN\n RAISE EXCEPTION 'inventory: set_quantity cannot be negative (line %)', v_line USING ERRCODE = '22023';\n END IF;\n SELECT coalesce(p.quantity, 0) INTO v_current FROM public.plg_inventory_stock_positions p\n WHERE p.tenant_id = v_tenant AND p.product_id = v_ln.product_id AND p.stock_location_id = p_location\n AND coalesce(p.batch_number, '') = coalesce(v_ln.batch_number, '')\n AND coalesce(p.expiration_date, 'infinity'::date) = coalesce(v_ln.expiration_date, 'infinity'::date);\n v_delta := v_target - coalesce(v_current, 0);\n IF v_delta = 0 THEN\n v_positions := v_positions || app.inventory_position_json(v_tenant, v_ln.product_id, p_location, v_ln.batch_number, v_ln.expiration_date);\n CONTINUE;\n END IF;\n ELSE\n SELECT * INTO v_ln FROM app.inventory_parse_line(v_tenant, v_line, 'signed');\n v_delta := v_ln.quantity;\n END IF;\n IF v_delta < 0 THEN\n IF app.inventory_available(v_tenant, v_ln.product_id, p_location, v_ln.batch_number, v_ln.expiration_date) < -v_delta THEN\n RAISE EXCEPTION 'inventory: adjustment would take product % at location % below its available quantity',\n v_ln.product_id, p_location USING ERRCODE = '23514';\n END IF;\n END IF;\n SELECT p.unit_cost INTO v_cost FROM public.plg_inventory_stock_positions p\n WHERE p.tenant_id = v_tenant AND p.product_id = v_ln.product_id AND p.stock_location_id = p_location\n AND coalesce(p.batch_number, '') = coalesce(v_ln.batch_number, '')\n AND coalesce(p.expiration_date, 'infinity'::date) = coalesce(v_ln.expiration_date, 'infinity'::date);\n INSERT INTO public.plg_inventory_stock_movements\n (tenant_id, product_id, kind, quantity, unit_cost, source_location_id, batch_number, expiration_date, measurement_unit_id,\n document_type, reason, notes, idempotency_key, line_no, operation_id, metadata)\n VALUES\n (v_tenant, v_ln.product_id, 'adjust', v_delta, coalesce(v_ln.unit_cost, v_cost, 0), p_location, v_ln.batch_number, v_ln.expiration_date, v_ln.measurement_unit_id,\n 'adjustment', v_reason, v_ln.notes, v_key, v_no, v_op.op_id, v_ln.declared)\n RETURNING id INTO v_id;\n v_movements := v_movements || app.inventory_movement_json(v_id);\n v_positions := v_positions || app.inventory_position_json(v_tenant, v_ln.product_id, p_location, v_ln.batch_number, v_ln.expiration_date);\n END LOOP;\n\n RETURN app.inventory_finish_operation(v_tenant, v_op.op_id, 'adjust', v_key,\n jsonb_build_object('operation_id', v_op.op_id, 'kind', 'adjust', 'idempotency_key', v_key,\n 'location_id', p_location, 'unit_id', v_unit, 'reason', v_reason, 'movements', v_movements, 'positions', v_positions),\n jsonb_build_object('location_id', p_location, 'unit_id', v_unit, 'reason', v_reason, 'lines', v_no));\nEND $$;\n\nCREATE FUNCTION public.inventory_close_count_session(p_session_id uuid, p_reason text DEFAULT NULL::text) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO 'public'\n AS $$\nDECLARE\n v_session plg_inventory_count_sessions%ROWTYPE;\n v_item plg_inventory_count_items%ROWTYPE;\n v_delta numeric;\n v_movement_id uuid;\n v_reason text;\n v_emitted int := 0;\nBEGIN\n -- The lock is what makes two simultaneous closes serialise instead of both\n -- reading \\`counting\\` and both emitting.\n SELECT * INTO v_session FROM plg_inventory_count_sessions\n WHERE id = p_session_id FOR UPDATE;\n IF NOT FOUND THEN\n RETURN jsonb_build_object('ok', false, 'error', 'unknown count session');\n END IF;\n IF v_session.tenant_id NOT IN (SELECT public.user_tenant_ids()) THEN\n RAISE EXCEPTION 'inventory_close_count_session: not a member of this tenant';\n END IF;\n\n IF v_session.status = 'closed' THEN\n RETURN jsonb_build_object('ok', true, 'session_id', p_session_id,\n 'status', 'closed', 'already_closed', true, 'adjustments_created', 0);\n END IF;\n IF v_session.status = 'cancelled' THEN\n RETURN jsonb_build_object('ok', false, 'error', 'this count was cancelled');\n END IF;\n\n v_reason := COALESCE(NULLIF(btrim(COALESCE(p_reason, '')), ''), 'Stock count');\n\n FOR v_item IN\n SELECT * FROM plg_inventory_count_items\n WHERE session_id = p_session_id\n AND counted_quantity IS NOT NULL\n AND counted_quantity <> system_quantity\n AND movement_id IS NULL\n ORDER BY id\n FOR UPDATE\n LOOP\n v_delta := v_item.counted_quantity - v_item.system_quantity;\n\n INSERT INTO plg_inventory_stock_movements (\n tenant_id, product_id, quantity, movement_type, unit_cost, total_cost,\n stock_location_id, reason, movement_date, user_id, metadata\n ) VALUES (\n v_session.tenant_id, v_item.product_id, v_item.counted_quantity, 'adjustment',\n v_item.unit_cost, v_item.unit_cost * v_item.counted_quantity,\n v_session.stock_location_id, v_reason, CURRENT_DATE, auth.uid(),\n jsonb_build_object(\n 'countSessionId', p_session_id,\n 'countItemId', v_item.id,\n 'systemQuantity', v_item.system_quantity,\n 'countedQuantity', v_item.counted_quantity,\n 'variance', v_delta\n )\n )\n RETURNING id INTO v_movement_id;\n\n UPDATE plg_inventory_count_items\n SET movement_id = v_movement_id, updated_at = now()\n WHERE id = v_item.id;\n\n -- The variance lands on the untracked slot; batch slots keep their lots.\n INSERT INTO plg_inventory_stock_positions (\n tenant_id, product_id, stock_location_id, quantity, unit_cost\n ) VALUES (\n v_session.tenant_id, v_item.product_id, v_session.stock_location_id,\n v_delta, v_item.unit_cost\n )\n ON CONFLICT (tenant_id, product_id, stock_location_id, batch_number, expiration_date)\n DO UPDATE SET\n quantity = plg_inventory_stock_positions.quantity + EXCLUDED.quantity,\n updated_at = now();\n\n v_emitted := v_emitted + 1;\n END LOOP;\n\n UPDATE plg_inventory_count_sessions\n SET status = 'closed', closed_at = now(), closed_by = auth.uid(), updated_at = now()\n WHERE id = p_session_id;\n\n RETURN jsonb_build_object('ok', true, 'session_id', p_session_id,\n 'status', 'closed', 'already_closed', false, 'adjustments_created', v_emitted);\nEND;\n$$;\n\nCREATE FUNCTION public.inventory_confirm(p_idempotency_key text) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE\n v_tenant uuid := app.inventory_require_tenant();\n v_key text := nullif(btrim(p_idempotency_key), '');\n v_op record;\n r record;\n v_unit uuid;\n v_id uuid;\n v_cost numeric;\n v_n integer := 0;\n v_movements jsonb := '[]'::jsonb;\n v_reservations jsonb := '[]'::jsonb;\n v_positions jsonb := '[]'::jsonb;\n v_source_type text; v_source_id uuid;\nBEGIN\n IF v_key IS NULL THEN\n RAISE EXCEPTION 'inventory: idempotency_key is required' USING ERRCODE = '22023';\n END IF;\n IF NOT EXISTS (SELECT 1 FROM public.plg_inventory_reservations x WHERE x.tenant_id = v_tenant AND x.idempotency_key = v_key) THEN\n RAISE EXCEPTION 'inventory: no reservation with key % in this tenant', v_key USING ERRCODE = '22023';\n END IF;\n -- authorization first, before the operation row exists\n FOR r IN SELECT DISTINCT stock_location_id FROM public.plg_inventory_reservations x WHERE x.tenant_id = v_tenant AND x.idempotency_key = v_key LOOP\n PERFORM app.inventory_authorize_location(v_tenant, r.stock_location_id, 'inventory.edit');\n END LOOP;\n\n SELECT * INTO v_op FROM app.inventory_begin_operation(v_tenant, 'confirm', v_key, jsonb_build_object('idempotency_key', v_key));\n IF v_op.existing IS NOT NULL THEN RETURN v_op.existing; END IF;\n\n FOR r IN SELECT * FROM public.plg_inventory_reservations x\n WHERE x.tenant_id = v_tenant AND x.idempotency_key = v_key ORDER BY x.line_no FOR UPDATE LOOP\n v_source_type := r.source_type; v_source_id := r.source_id;\n IF r.status = 'reserved' THEN\n -- CAS: only the row still reserved becomes a movement\n UPDATE public.plg_inventory_reservations x\n SET status = 'confirmed', confirmed_at = now(), confirmed_by = auth.uid()\n WHERE x.id = r.id AND x.status = 'reserved';\n IF FOUND THEN\n SELECT p.unit_cost INTO v_cost FROM public.plg_inventory_stock_positions p\n WHERE p.tenant_id = v_tenant AND p.product_id = r.product_id AND p.stock_location_id = r.stock_location_id\n AND coalesce(p.batch_number, '') = coalesce(r.batch_number, '')\n AND coalesce(p.expiration_date, 'infinity'::date) = coalesce(r.expiration_date, 'infinity'::date);\n INSERT INTO public.plg_inventory_stock_movements\n (tenant_id, product_id, kind, quantity, unit_cost, source_location_id, batch_number, expiration_date, measurement_unit_id,\n document_type, document_number, source_item_type, source_item_id, idempotency_key, line_no, operation_id, metadata)\n VALUES\n (v_tenant, r.product_id, 'out', -r.quantity, coalesce(v_cost, 0), r.stock_location_id, r.batch_number, r.expiration_date, r.measurement_unit_id,\n 'consumption', r.source_type || ':' || r.source_id::text, r.source_type, r.source_id, v_key, r.line_no, v_op.op_id,\n coalesce(r.metadata, '{}'::jsonb) || jsonb_build_object('reservation_id', r.id))\n RETURNING id INTO v_id;\n UPDATE public.plg_inventory_reservations x SET movement_id = v_id WHERE x.id = r.id;\n v_movements := v_movements || app.inventory_movement_json(v_id);\n v_n := v_n + 1;\n END IF;\n ELSIF r.status = 'reversed' AND r.movement_id IS NULL THEN\n RAISE EXCEPTION 'inventory: reservation % line % was reversed before confirmation', v_key, r.line_no USING ERRCODE = '55000';\n END IF;\n v_reservations := v_reservations || app.inventory_reservation_json(r.id);\n v_positions := v_positions || app.inventory_position_json(v_tenant, r.product_id, r.stock_location_id, r.batch_number, r.expiration_date);\n END LOOP;\n\n RETURN app.inventory_finish_operation(v_tenant, v_op.op_id, 'confirm', v_key,\n jsonb_build_object('operation_id', v_op.op_id, 'kind', 'confirm', 'idempotency_key', v_key,\n 'source_type', v_source_type, 'source_id', v_source_id, 'status', 'confirmed',\n 'movements', v_movements, 'reservations', v_reservations, 'positions', v_positions),\n jsonb_build_object('source_type', v_source_type, 'source_id', v_source_id, 'confirmed_lines', v_n));\nEND $$;\n\nCREATE FUNCTION public.inventory_convert(p_tenant uuid, p_product uuid, p_qty numeric, p_from_unit uuid, p_to_unit uuid) RETURNS numeric\n LANGUAGE plpgsql STABLE SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE\n v_factor numeric;\n v_kind_from text; v_kind_to text;\n v_base uuid;\n v_f1 numeric; v_f2 numeric;\nBEGIN\n IF p_tenant IS NULL OR p_from_unit IS NULL OR p_to_unit IS NULL THEN\n RAISE EXCEPTION 'inventory_convert: tenant, from_unit and to_unit are required' USING ERRCODE = '22023';\n END IF;\n -- a signed-in session converts only within its own tenant\n IF auth.uid() IS NOT NULL AND p_tenant IS DISTINCT FROM app.current_tenant_id() THEN\n RAISE EXCEPTION 'inventory_convert: not your tenant' USING ERRCODE = '42501';\n END IF;\n IF p_from_unit = p_to_unit THEN RETURN p_qty; END IF;\n\n -- direct (product-specific first, then tenant-wide)\n SELECT c.factor INTO v_factor FROM public.plg_inventory_unit_conversions c\n WHERE c.tenant_id = p_tenant AND c.from_unit_id = p_from_unit AND c.to_unit_id = p_to_unit\n AND (c.product_id = p_product OR c.product_id IS NULL)\n ORDER BY (c.product_id IS NOT NULL) DESC LIMIT 1;\n IF v_factor IS NOT NULL THEN RETURN p_qty * v_factor; END IF;\n -- inverse\n SELECT c.factor INTO v_factor FROM public.plg_inventory_unit_conversions c\n WHERE c.tenant_id = p_tenant AND c.from_unit_id = p_to_unit AND c.to_unit_id = p_from_unit\n AND (c.product_id = p_product OR c.product_id IS NULL)\n ORDER BY (c.product_id IS NOT NULL) DESC LIMIT 1;\n IF v_factor IS NOT NULL THEN RETURN p_qty / v_factor; END IF;\n\n -- one hop through the base unit of the (shared) kind\n SELECT kind INTO v_kind_from FROM public.plg_inventory_measurement_units WHERE id = p_from_unit AND tenant_id = p_tenant;\n SELECT kind INTO v_kind_to FROM public.plg_inventory_measurement_units WHERE id = p_to_unit AND tenant_id = p_tenant;\n IF v_kind_from IS NULL OR v_kind_to IS NULL OR v_kind_from <> v_kind_to THEN\n RAISE EXCEPTION 'inventory_convert: no conversion from % to % (tenant %, product %)', p_from_unit, p_to_unit, p_tenant, p_product USING ERRCODE = '22023';\n END IF;\n SELECT id INTO v_base FROM public.plg_inventory_measurement_units WHERE tenant_id = p_tenant AND kind = v_kind_from AND is_base;\n IF v_base IS NULL OR v_base IN (p_from_unit, p_to_unit) THEN\n RAISE EXCEPTION 'inventory_convert: no conversion from % to % (tenant %, product %)', p_from_unit, p_to_unit, p_tenant, p_product USING ERRCODE = '22023';\n END IF;\n -- from → base\n SELECT CASE WHEN c.from_unit_id = p_from_unit THEN c.factor ELSE 1 / c.factor END INTO v_f1\n FROM public.plg_inventory_unit_conversions c\n WHERE c.tenant_id = p_tenant AND (c.product_id = p_product OR c.product_id IS NULL)\n AND ((c.from_unit_id = p_from_unit AND c.to_unit_id = v_base) OR (c.from_unit_id = v_base AND c.to_unit_id = p_from_unit))\n ORDER BY (c.product_id IS NOT NULL) DESC LIMIT 1;\n -- base → to\n SELECT CASE WHEN c.from_unit_id = v_base THEN c.factor ELSE 1 / c.factor END INTO v_f2\n FROM public.plg_inventory_unit_conversions c\n WHERE c.tenant_id = p_tenant AND (c.product_id = p_product OR c.product_id IS NULL)\n AND ((c.from_unit_id = v_base AND c.to_unit_id = p_to_unit) OR (c.from_unit_id = p_to_unit AND c.to_unit_id = v_base))\n ORDER BY (c.product_id IS NOT NULL) DESC LIMIT 1;\n IF v_f1 IS NULL OR v_f2 IS NULL THEN\n RAISE EXCEPTION 'inventory_convert: no conversion from % to % (tenant %, product %)', p_from_unit, p_to_unit, p_tenant, p_product USING ERRCODE = '22023';\n END IF;\n RETURN p_qty * v_f1 * v_f2;\nEND $$;\n\nCREATE FUNCTION public.inventory_open_count_session(p_stock_location_id uuid, p_category_id uuid DEFAULT NULL::uuid, p_reference text DEFAULT NULL::text, p_blind boolean DEFAULT true, p_notes text DEFAULT NULL::text) RETURNS uuid\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO 'public'\n AS $$\nDECLARE\n v_tenant uuid;\n v_session_id uuid;\nBEGIN\n SELECT tenant_id INTO v_tenant\n FROM plg_inventory_stock_locations WHERE id = p_stock_location_id;\n IF v_tenant IS NULL THEN\n RAISE EXCEPTION 'inventory_open_count_session: unknown stock location %', p_stock_location_id;\n END IF;\n IF v_tenant NOT IN (SELECT public.user_tenant_ids()) THEN\n RAISE EXCEPTION 'inventory_open_count_session: not a member of this tenant';\n END IF;\n\n INSERT INTO plg_inventory_count_sessions (\n tenant_id, reference, status, stock_location_id, category_id, blind, notes, opened_by\n ) VALUES (\n v_tenant, NULLIF(btrim(COALESCE(p_reference, '')), ''), 'open',\n p_stock_location_id, p_category_id, COALESCE(p_blind, true),\n NULLIF(btrim(COALESCE(p_notes, '')), ''), auth.uid()\n )\n RETURNING id INTO v_session_id;\n\n -- Assets are patrimony, not stock, so they are never on a count sheet.\n INSERT INTO plg_inventory_count_items (\n tenant_id, session_id, product_id, system_quantity, unit_cost\n )\n SELECT\n v_tenant, v_session_id, p.id,\n COALESCE(pos.quantity, 0),\n COALESCE(pos.unit_cost, p.cost, 0)\n FROM products p\n LEFT JOIN plg_inventory_product_details d ON d.product_id = p.id\n LEFT JOIN LATERAL (\n SELECT sum(sp.quantity) AS quantity, max(sp.unit_cost) AS unit_cost\n FROM plg_inventory_stock_positions sp\n WHERE sp.tenant_id = v_tenant\n AND sp.product_id = p.id\n AND sp.stock_location_id = p_stock_location_id\n ) pos ON true\n WHERE p.tenant_id = v_tenant\n AND p.is_active = true\n AND COALESCE(p.metadata->>'productType', 'sale') <> 'asset'\n AND (p_category_id IS NULL OR d.category_id = p_category_id);\n\n RETURN v_session_id;\nEND;\n$$;\n\nCREATE FUNCTION public.inventory_receive(p_location uuid, p_lines jsonb, p_document jsonb DEFAULT '{}'::jsonb, p_idempotency_key text DEFAULT NULL::text) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE\n v_tenant uuid := app.inventory_require_tenant();\n v_key text := coalesce(nullif(btrim(p_idempotency_key), ''), 'receive:' || gen_random_uuid()::text);\n v_op record;\n v_unit uuid;\n v_line jsonb;\n v_ln record;\n v_no integer := 0;\n v_id uuid;\n v_movements jsonb := '[]'::jsonb;\n v_positions jsonb := '[]'::jsonb;\n v_doc jsonb := coalesce(p_document, '{}'::jsonb);\n v_supplier uuid;\n v_date date;\nBEGIN\n PERFORM app.inventory_check_lines(p_lines);\n v_unit := app.inventory_authorize_location(v_tenant, p_location, 'inventory.create');\n BEGIN\n v_supplier := (v_doc->>'supplier_id')::uuid;\n v_date := (v_doc->>'date')::date;\n EXCEPTION WHEN OTHERS THEN\n RAISE EXCEPTION 'inventory: invalid document: %', SQLERRM USING ERRCODE = '22023';\n END;\n IF v_supplier IS NOT NULL AND NOT EXISTS (SELECT 1 FROM public.people s WHERE s.id = v_supplier AND s.tenant_id = v_tenant) THEN\n RAISE EXCEPTION 'inventory: supplier % not found in this tenant', v_supplier USING ERRCODE = '22023';\n END IF;\n\n SELECT * INTO v_op FROM app.inventory_begin_operation(v_tenant, 'receive', v_key,\n jsonb_build_object('location', p_location, 'lines', p_lines, 'document', v_doc));\n IF v_op.existing IS NOT NULL THEN RETURN v_op.existing; END IF;\n\n FOR v_line IN SELECT * FROM jsonb_array_elements(p_lines) LOOP\n v_no := v_no + 1;\n SELECT * INTO v_ln FROM app.inventory_parse_line(v_tenant, v_line, 'positive');\n INSERT INTO public.plg_inventory_stock_movements\n (tenant_id, product_id, kind, quantity, unit_cost, source_location_id, batch_number, expiration_date, measurement_unit_id,\n supplier_id, document_type, document_number, notes, movement_date, idempotency_key, line_no, operation_id, metadata)\n VALUES\n (v_tenant, v_ln.product_id, 'in', v_ln.quantity, coalesce(v_ln.unit_cost, 0), p_location, v_ln.batch_number, v_ln.expiration_date, v_ln.measurement_unit_id,\n v_supplier, coalesce(v_doc->>'type', 'manual'), v_doc->>'ref', coalesce(v_ln.notes, v_doc->>'notes'), coalesce(v_date, current_date),\n v_key, v_no, v_op.op_id, v_ln.declared)\n RETURNING id INTO v_id;\n v_movements := v_movements || app.inventory_movement_json(v_id);\n v_positions := v_positions || app.inventory_position_json(v_tenant, v_ln.product_id, p_location, v_ln.batch_number, v_ln.expiration_date);\n END LOOP;\n\n RETURN app.inventory_finish_operation(v_tenant, v_op.op_id, 'receive', v_key,\n jsonb_build_object('operation_id', v_op.op_id, 'kind', 'receive', 'idempotency_key', v_key,\n 'location_id', p_location, 'unit_id', v_unit, 'movements', v_movements, 'positions', v_positions),\n jsonb_build_object('location_id', p_location, 'unit_id', v_unit, 'lines', v_no, 'document_type', coalesce(v_doc->>'type', 'manual')));\nEND $$;\n\nCREATE FUNCTION public.inventory_register_product_image(p_product uuid, p_storage_path text, p_public_url text, p_file_name text, p_file_size integer, p_mime_type text) RETURNS uuid\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE\n v_tenant uuid := app.current_tenant_id();\n v_document uuid;\n v_prefix text;\nBEGIN\n IF v_tenant IS NULL THEN\n RAISE EXCEPTION 'inventory: no tenant in session' USING ERRCODE = '42501';\n END IF;\n IF NOT app.has_permission_anywhere('inventory.edit') THEN\n RAISE EXCEPTION 'inventory: inventory.edit required to register a product image' USING ERRCODE = '42501';\n END IF;\n IF NOT EXISTS (\n SELECT 1 FROM public.products p\n WHERE p.id = p_product AND p.tenant_id = v_tenant AND p.kind = 'good'\n ) THEN\n RAISE EXCEPTION 'inventory: product not found in this tenant' USING ERRCODE = '22023';\n END IF;\n\n v_prefix := v_tenant::text || '/products/' || p_product::text || '/';\n IF nullif(btrim(p_storage_path), '') IS NULL OR p_storage_path NOT LIKE v_prefix || '%' THEN\n RAISE EXCEPTION 'inventory: product image path is outside its tenant/product prefix' USING ERRCODE = '22023';\n END IF;\n IF nullif(btrim(p_public_url), '') IS NULL THEN\n RAISE EXCEPTION 'inventory: product image URL is required' USING ERRCODE = '22023';\n END IF;\n IF p_file_size IS NULL OR p_file_size <= 0 OR p_file_size > 8388608 THEN\n RAISE EXCEPTION 'inventory: product image must be between 1 byte and 8 MB' USING ERRCODE = '22023';\n END IF;\n IF coalesce(p_mime_type, '') <> ALL (ARRAY['image/jpeg','image/png','image/webp','image/heic','image/heif']) THEN\n RAISE EXCEPTION 'inventory: unsupported product image type' USING ERRCODE = '22023';\n END IF;\n\n UPDATE public.documents\n SET is_active = false, status = 'archived', updated_at = now()\n WHERE tenant_id = v_tenant\n AND kind = 'inventory_product_image'\n AND subject_type = 'product'\n AND subject_id = p_product\n AND is_active;\n\n INSERT INTO public.documents (\n tenant_id, kind, title, status, file_url, file_name, file_size, mime_type,\n storage_provider, storage_bucket, storage_path, subject_type, subject_id,\n metadata, created_by, updated_by\n ) VALUES (\n v_tenant, 'inventory_product_image', coalesce(nullif(btrim(p_file_name), ''), 'Product image'),\n 'active', p_public_url, p_file_name, p_file_size, p_mime_type,\n 'supabase', 'inventory-images', p_storage_path, 'product', p_product,\n jsonb_build_object('plugin', 'inventory'), auth.uid(), auth.uid()\n ) RETURNING id INTO v_document;\n\n -- Compatibility projection for readers that have not moved to documents yet.\n UPDATE public.products\n SET image_url = p_public_url, updated_at = now()\n WHERE id = p_product AND tenant_id = v_tenant;\n\n INSERT INTO public.audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)\n VALUES (\n v_tenant, auth.uid(), 'inventory.productImage.registered', 'document', v_document::text,\n jsonb_build_object('product_id', p_product, 'storage_path', p_storage_path)\n );\n\n RETURN v_document;\nEND $$;\n\nCREATE FUNCTION public.inventory_reserve(p_source_type text, p_source_id uuid, p_lines jsonb, p_idempotency_key text DEFAULT NULL::text) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE\n v_tenant uuid := app.inventory_require_tenant();\n v_source_type text := nullif(btrim(p_source_type), '');\n v_key text;\n v_op record;\n v_line jsonb;\n v_ln record;\n v_loc uuid;\n v_unit uuid;\n v_no integer := 0;\n v_id uuid;\n v_avail numeric;\n v_reservations jsonb := '[]'::jsonb;\n v_positions jsonb := '[]'::jsonb;\nBEGIN\n IF v_source_type IS NULL OR p_source_id IS NULL THEN\n RAISE EXCEPTION 'inventory: a reservation needs source_type and source_id' USING ERRCODE = '22023';\n END IF;\n v_key := coalesce(nullif(btrim(p_idempotency_key), ''), v_source_type || ':' || p_source_id::text);\n PERFORM app.inventory_check_lines(p_lines);\n\n SELECT * INTO v_op FROM app.inventory_begin_operation(v_tenant, 'reserve', v_key,\n jsonb_build_object('source_type', v_source_type, 'source_id', p_source_id, 'lines', p_lines));\n IF v_op.existing IS NOT NULL THEN RETURN v_op.existing; END IF;\n\n FOR v_line IN SELECT * FROM jsonb_array_elements(p_lines) LOOP\n v_no := v_no + 1;\n BEGIN\n v_loc := coalesce((v_line->>'location_id')::uuid, (v_line->>'stock_location_id')::uuid);\n EXCEPTION WHEN OTHERS THEN\n RAISE EXCEPTION 'inventory: invalid line %: %', v_line, SQLERRM USING ERRCODE = '22023';\n END;\n v_unit := app.inventory_authorize_location(v_tenant, v_loc, 'inventory.create');\n SELECT * INTO v_ln FROM app.inventory_parse_line(v_tenant, v_line, 'positive');\n v_avail := app.inventory_available(v_tenant, v_ln.product_id, v_loc, v_ln.batch_number, v_ln.expiration_date);\n IF v_avail < v_ln.quantity THEN\n RAISE EXCEPTION 'inventory: insufficient stock to reserve product % at location % (% available, % requested)',\n v_ln.product_id, v_loc, v_avail, v_ln.quantity USING ERRCODE = '23514';\n END IF;\n INSERT INTO public.plg_inventory_reservations\n (tenant_id, unit_id, idempotency_key, line_no, source_type, source_id, product_id, stock_location_id, batch_number, expiration_date,\n quantity, measurement_unit_id, status, metadata)\n VALUES\n (v_tenant, v_unit, v_key, v_no, v_source_type, p_source_id, v_ln.product_id, v_loc, v_ln.batch_number, v_ln.expiration_date,\n v_ln.quantity, v_ln.measurement_unit_id, 'reserved', v_ln.declared)\n RETURNING id INTO v_id;\n v_reservations := v_reservations || app.inventory_reservation_json(v_id);\n v_positions := v_positions || app.inventory_position_json(v_tenant, v_ln.product_id, v_loc, v_ln.batch_number, v_ln.expiration_date);\n END LOOP;\n\n RETURN app.inventory_finish_operation(v_tenant, v_op.op_id, 'reserve', v_key,\n jsonb_build_object('operation_id', v_op.op_id, 'kind', 'reserve', 'idempotency_key', v_key,\n 'source_type', v_source_type, 'source_id', p_source_id, 'status', 'reserved',\n 'reservations', v_reservations, 'positions', v_positions),\n jsonb_build_object('source_type', v_source_type, 'source_id', p_source_id, 'lines', v_no));\nEND $$;\n\nCREATE FUNCTION public.inventory_reverse(p_idempotency_key text, p_reason text) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE\n v_tenant uuid := app.inventory_require_tenant();\n v_key text := nullif(btrim(p_idempotency_key), '');\n v_reason text := nullif(btrim(p_reason), '');\n v_op record;\n r record;\n v_id uuid;\n v_n integer := 0;\n v_no integer := 0;\n v_avail numeric;\n v_movements jsonb := '[]'::jsonb;\n v_reservations jsonb := '[]'::jsonb;\n v_positions jsonb := '[]'::jsonb;\n v_had boolean := false;\nBEGIN\n IF v_key IS NULL THEN\n RAISE EXCEPTION 'inventory: idempotency_key is required' USING ERRCODE = '22023';\n END IF;\n IF v_reason IS NULL THEN\n RAISE EXCEPTION 'inventory: a reversal needs a reason' USING ERRCODE = '22023';\n END IF;\n IF NOT EXISTS (SELECT 1 FROM public.plg_inventory_reservations x WHERE x.tenant_id = v_tenant AND x.idempotency_key = v_key)\n AND NOT EXISTS (SELECT 1 FROM public.plg_inventory_stock_movements m WHERE m.tenant_id = v_tenant AND m.idempotency_key = v_key AND m.kind <> 'reverse') THEN\n RAISE EXCEPTION 'inventory: nothing to reverse under key % in this tenant', v_key USING ERRCODE = '22023';\n END IF;\n -- authorization first: every unit touched by the key\n FOR r IN SELECT DISTINCT stock_location_id AS loc FROM public.plg_inventory_reservations x WHERE x.tenant_id = v_tenant AND x.idempotency_key = v_key\n UNION SELECT DISTINCT source_location_id FROM public.plg_inventory_stock_movements m WHERE m.tenant_id = v_tenant AND m.idempotency_key = v_key\n UNION SELECT DISTINCT destination_location_id FROM public.plg_inventory_stock_movements m WHERE m.tenant_id = v_tenant AND m.idempotency_key = v_key AND m.destination_location_id IS NOT NULL LOOP\n PERFORM app.inventory_authorize_location(v_tenant, r.loc, 'inventory.edit');\n END LOOP;\n\n SELECT * INTO v_op FROM app.inventory_begin_operation(v_tenant, 'reverse', v_key, jsonb_build_object('idempotency_key', v_key, 'reason', v_reason));\n IF v_op.existing IS NOT NULL THEN RETURN v_op.existing; END IF;\n\n -- 1. reservations under the key\n FOR r IN SELECT * FROM public.plg_inventory_reservations x\n WHERE x.tenant_id = v_tenant AND x.idempotency_key = v_key ORDER BY x.line_no FOR UPDATE LOOP\n v_had := true;\n IF r.status = 'reserved' THEN\n UPDATE public.plg_inventory_reservations x SET status = 'reversed', reversed_at = now(), reversed_by = auth.uid(), reason = v_reason\n WHERE x.id = r.id AND x.status = 'reserved';\n v_n := v_n + 1;\n ELSIF r.status = 'confirmed' AND r.movement_id IS NOT NULL THEN\n SELECT m.* INTO STRICT r FROM public.plg_inventory_stock_movements m WHERE m.id = r.movement_id;\n v_no := v_no + 1;\n INSERT INTO public.plg_inventory_stock_movements\n (tenant_id, product_id, kind, quantity, unit_cost, source_location_id, destination_location_id, batch_number, expiration_date, measurement_unit_id,\n document_type, document_number, reason, source_item_type, source_item_id, reverses_movement_id, idempotency_key, line_no, operation_id, metadata)\n VALUES\n (v_tenant, r.product_id, 'reverse', -r.quantity, r.unit_cost, r.source_location_id, r.destination_location_id, r.batch_number, r.expiration_date, r.measurement_unit_id,\n 'reversal', r.document_number, v_reason, r.source_item_type, r.source_item_id, r.id, 'reverse:' || v_key, v_no, v_op.op_id,\n jsonb_build_object('reversed_movement_id', r.id))\n RETURNING id INTO v_id;\n UPDATE public.plg_inventory_reservations x\n SET status = 'reversed', reversed_at = now(), reversed_by = auth.uid(), reason = v_reason, reversal_movement_id = v_id\n WHERE x.movement_id = r.id AND x.status = 'confirmed';\n v_movements := v_movements || app.inventory_movement_json(v_id);\n v_n := v_n + 1;\n END IF;\n END LOOP;\n FOR r IN SELECT * FROM public.plg_inventory_reservations x WHERE x.tenant_id = v_tenant AND x.idempotency_key = v_key ORDER BY x.line_no LOOP\n v_reservations := v_reservations || app.inventory_reservation_json(r.id);\n v_positions := v_positions || app.inventory_position_json(v_tenant, r.product_id, r.stock_location_id, r.batch_number, r.expiration_date);\n END LOOP;\n\n -- 2. plain movements under the key (receive / transfer / adjust), not yet reversed\n IF NOT v_had THEN\n FOR r IN SELECT m.* FROM public.plg_inventory_stock_movements m\n WHERE m.tenant_id = v_tenant AND m.idempotency_key = v_key AND m.kind <> 'reverse'\n AND NOT EXISTS (SELECT 1 FROM public.plg_inventory_stock_movements x WHERE x.reverses_movement_id = m.id)\n ORDER BY m.line_no, m.created_at LOOP\n -- undoing an 'in' or a transfer's arrival must not go below what is still available\n IF r.quantity > 0 THEN\n v_avail := app.inventory_available(v_tenant, r.product_id, r.source_location_id, r.batch_number, r.expiration_date);\n IF v_avail < r.quantity THEN\n RAISE EXCEPTION 'inventory: cannot reverse movement % — only % of % still available at the source location', r.id, v_avail, r.quantity USING ERRCODE = '23514';\n END IF;\n END IF;\n IF r.destination_location_id IS NOT NULL AND r.quantity < 0 THEN\n v_avail := app.inventory_available(v_tenant, r.product_id, r.destination_location_id, r.batch_number, r.expiration_date);\n IF v_avail < -r.quantity THEN\n RAISE EXCEPTION 'inventory: cannot reverse transfer % — only % of % still available at the destination', r.id, v_avail, -r.quantity USING ERRCODE = '23514';\n END IF;\n END IF;\n v_no := v_no + 1;\n INSERT INTO public.plg_inventory_stock_movements\n (tenant_id, product_id, kind, quantity, unit_cost, source_location_id, destination_location_id, batch_number, expiration_date, measurement_unit_id,\n document_type, document_number, reason, supplier_id, source_item_type, source_item_id, reverses_movement_id, idempotency_key, line_no, operation_id, metadata)\n VALUES\n (v_tenant, r.product_id, 'reverse', -r.quantity, r.unit_cost, r.source_location_id, r.destination_location_id, r.batch_number, r.expiration_date, r.measurement_unit_id,\n 'reversal', r.document_number, v_reason, r.supplier_id, r.source_item_type, r.source_item_id, r.id, 'reverse:' || v_key, v_no, v_op.op_id,\n jsonb_build_object('reversed_movement_id', r.id, 'reversed_kind', r.kind))\n RETURNING id INTO v_id;\n v_movements := v_movements || app.inventory_movement_json(v_id);\n v_positions := v_positions || app.inventory_position_json(v_tenant, r.product_id, r.source_location_id, r.batch_number, r.expiration_date);\n IF r.destination_location_id IS NOT NULL THEN\n v_positions := v_positions || app.inventory_position_json(v_tenant, r.product_id, r.destination_location_id, r.batch_number, r.expiration_date);\n END IF;\n v_n := v_n + 1;\n END LOOP;\n END IF;\n\n RETURN app.inventory_finish_operation(v_tenant, v_op.op_id, 'reverse', v_key,\n jsonb_build_object('operation_id', v_op.op_id, 'kind', 'reverse', 'idempotency_key', v_key, 'reason', v_reason,\n 'status', 'reversed', 'reversed_lines', v_n,\n 'movements', v_movements, 'reservations', v_reservations, 'positions', v_positions),\n jsonb_build_object('reason', v_reason, 'reversed_lines', v_n));\nEND $$;\n\nCREATE FUNCTION public.inventory_seed_measurement_units() RETURNS integer\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE v_tenant uuid := app.current_tenant_id();\nBEGIN\n IF v_tenant IS NULL THEN\n RAISE EXCEPTION 'inventory: no tenant in session' USING ERRCODE = '42501';\n END IF;\n IF NOT app.has_permission('inventory.manage') THEN\n RAISE EXCEPTION 'inventory: inventory.manage required' USING ERRCODE = '42501';\n END IF;\n RETURN public.inventory_seed_measurement_units(v_tenant);\nEND $$;\n\nCREATE FUNCTION public.inventory_seed_measurement_units(p_tenant uuid) RETURNS integer\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE\n v_n integer := 0;\n v_ins integer;\n v_kg uuid; v_g uuid; v_l uuid; v_ml uuid;\nBEGIN\n IF p_tenant IS NULL THEN RETURN 0; END IF;\n WITH ins AS (\n INSERT INTO public.plg_inventory_measurement_units (tenant_id, name, abbreviation, code, kind, is_base, is_active)\n SELECT p_tenant, s.name, s.abbr, s.code, s.kind, s.is_base, true\n FROM (VALUES\n ('Unidade', 'un', 'un', 'unit', true),\n ('Caixa', 'cx', 'cx', 'unit', false),\n ('Quilograma', 'kg', 'kg', 'mass', false),\n ('Grama', 'g', 'g', 'mass', true),\n ('Litro', 'L', 'l', 'volume', false),\n ('Mililitro', 'mL', 'ml', 'volume', true)\n ) AS s(name, abbr, code, kind, is_base)\n WHERE NOT EXISTS (SELECT 1 FROM public.plg_inventory_measurement_units u WHERE u.tenant_id = p_tenant AND lower(u.code) = s.code)\n RETURNING 1\n ) SELECT count(*) INTO v_ins FROM ins;\n v_n := v_n + coalesce(v_ins, 0);\n\n SELECT id INTO v_kg FROM public.plg_inventory_measurement_units WHERE tenant_id = p_tenant AND lower(code) = 'kg';\n SELECT id INTO v_g FROM public.plg_inventory_measurement_units WHERE tenant_id = p_tenant AND lower(code) = 'g';\n SELECT id INTO v_l FROM public.plg_inventory_measurement_units WHERE tenant_id = p_tenant AND lower(code) = 'l';\n SELECT id INTO v_ml FROM public.plg_inventory_measurement_units WHERE tenant_id = p_tenant AND lower(code) = 'ml';\n WITH ins AS (\n INSERT INTO public.plg_inventory_unit_conversions (tenant_id, from_unit_id, to_unit_id, factor)\n SELECT p_tenant, c.f, c.t, c.factor\n FROM (VALUES (v_kg, v_g, 1000::numeric), (v_l, v_ml, 1000::numeric)) AS c(f, t, factor)\n WHERE c.f IS NOT NULL AND c.t IS NOT NULL\n AND NOT EXISTS (SELECT 1 FROM public.plg_inventory_unit_conversions x\n WHERE x.tenant_id = p_tenant AND x.from_unit_id = c.f AND x.to_unit_id = c.t AND x.product_id IS NULL)\n RETURNING 1\n ) SELECT count(*) INTO v_ins FROM ins;\n RETURN v_n + coalesce(v_ins, 0);\nEND $$;\n\nCREATE FUNCTION public.inventory_transfer(p_from_location uuid, p_to_location uuid, p_lines jsonb, p_idempotency_key text DEFAULT NULL::text) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE\n v_tenant uuid := app.inventory_require_tenant();\n v_key text := coalesce(nullif(btrim(p_idempotency_key), ''), 'transfer:' || gen_random_uuid()::text);\n v_op record;\n v_from_unit uuid; v_to_unit uuid;\n v_line jsonb;\n v_ln record;\n v_no integer := 0;\n v_id uuid;\n v_avail numeric;\n v_cost numeric;\n v_movements jsonb := '[]'::jsonb;\n v_positions jsonb := '[]'::jsonb;\nBEGIN\n PERFORM app.inventory_check_lines(p_lines);\n IF p_from_location IS NULL OR p_to_location IS NULL OR p_from_location = p_to_location THEN\n RAISE EXCEPTION 'inventory: a transfer needs two different stock locations' USING ERRCODE = '22023';\n END IF;\n v_from_unit := app.inventory_authorize_location(v_tenant, p_from_location, 'inventory.edit');\n v_to_unit := app.inventory_authorize_location(v_tenant, p_to_location, 'inventory.create');\n\n SELECT * INTO v_op FROM app.inventory_begin_operation(v_tenant, 'transfer', v_key,\n jsonb_build_object('from_location', p_from_location, 'to_location', p_to_location, 'lines', p_lines));\n IF v_op.existing IS NOT NULL THEN RETURN v_op.existing; END IF;\n\n FOR v_line IN SELECT * FROM jsonb_array_elements(p_lines) LOOP\n v_no := v_no + 1;\n SELECT * INTO v_ln FROM app.inventory_parse_line(v_tenant, v_line, 'positive');\n v_avail := app.inventory_available(v_tenant, v_ln.product_id, p_from_location, v_ln.batch_number, v_ln.expiration_date);\n IF v_avail < v_ln.quantity THEN\n RAISE EXCEPTION 'inventory: insufficient stock to transfer product % from location % (% available, % requested)',\n v_ln.product_id, p_from_location, v_avail, v_ln.quantity USING ERRCODE = '23514';\n END IF;\n SELECT p.unit_cost INTO v_cost FROM public.plg_inventory_stock_positions p\n WHERE p.tenant_id = v_tenant AND p.product_id = v_ln.product_id AND p.stock_location_id = p_from_location\n AND coalesce(p.batch_number, '') = coalesce(v_ln.batch_number, '')\n AND coalesce(p.expiration_date, 'infinity'::date) = coalesce(v_ln.expiration_date, 'infinity'::date);\n INSERT INTO public.plg_inventory_stock_movements\n (tenant_id, product_id, kind, quantity, unit_cost, source_location_id, destination_location_id, batch_number, expiration_date, measurement_unit_id,\n document_type, notes, idempotency_key, line_no, operation_id, metadata)\n VALUES\n (v_tenant, v_ln.product_id, 'transfer', -v_ln.quantity, coalesce(v_cost, 0), p_from_location, p_to_location, v_ln.batch_number, v_ln.expiration_date, v_ln.measurement_unit_id,\n 'transfer', v_ln.notes, v_key, v_no, v_op.op_id, v_ln.declared)\n RETURNING id INTO v_id;\n v_movements := v_movements || app.inventory_movement_json(v_id);\n v_positions := v_positions || app.inventory_position_json(v_tenant, v_ln.product_id, p_from_location, v_ln.batch_number, v_ln.expiration_date)\n || app.inventory_position_json(v_tenant, v_ln.product_id, p_to_location, v_ln.batch_number, v_ln.expiration_date);\n END LOOP;\n\n RETURN app.inventory_finish_operation(v_tenant, v_op.op_id, 'transfer', v_key,\n jsonb_build_object('operation_id', v_op.op_id, 'kind', 'transfer', 'idempotency_key', v_key,\n 'from_location_id', p_from_location, 'to_location_id', p_to_location,\n 'from_unit_id', v_from_unit, 'to_unit_id', v_to_unit, 'movements', v_movements, 'positions', v_positions),\n jsonb_build_object('from_location_id', p_from_location, 'to_location_id', p_to_location, 'lines', v_no));\nEND $$;\n\nCREATE FUNCTION public.plg_inventory_on_fulfillment_completed(p_event jsonb) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE\n v_tenant uuid := nullif(p_event ->> 'tenant_id', '')::uuid;\n v_ful_id uuid := nullif(p_event ->> 'subject_id', '')::uuid;\n v_actor uuid := nullif(p_event ->> 'actor_id', '')::uuid;\n v_ful public.fulfillments%ROWTYPE;\n v_line record;\n v_result jsonb;\n v_results jsonb := '[]'::jsonb;\n v_count integer := 0;\nBEGIN\n IF v_tenant IS NULL OR v_ful_id IS NULL THEN\n RAISE EXCEPTION 'fulfillment.completed carried no tenant or subject id' USING ERRCODE = '22023';\n END IF;\n IF public.app_product_stock_owner(v_tenant) <> 'inventory' THEN\n RETURN jsonb_build_object('skipped', 'stock owner is not inventory for this tenant');\n END IF;\n\n SELECT * INTO v_ful FROM public.fulfillments f WHERE f.id = v_ful_id AND f.tenant_id = v_tenant;\n IF v_ful.id IS NULL THEN\n RETURN jsonb_build_object('skipped', 'fulfillment not found');\n END IF;\n IF v_ful.status NOT IN ('delivered', 'completed') THEN\n RETURN jsonb_build_object('skipped', 'delivery is no longer completed', 'current_status', v_ful.status);\n END IF;\n\n FOR v_line IN\n SELECT i.id, i.product_id, i.quantity\n FROM public.items i\n JOIN public.products p ON p.id = i.product_id AND p.tenant_id = v_tenant\n WHERE i.fulfillment_id = v_ful.id AND i.tenant_id = v_tenant\n AND i.product_id IS NOT NULL AND coalesce(i.quantity, 0) > 0\n AND p.kind = 'good'\n ORDER BY i.sort_order, i.created_at, i.id\n LOOP\n -- The DELIVERY line is the idempotency source, not the order line: two\n -- deliveries of the same order line are two movements, and re-running this\n -- handler is none.\n v_result := app.inventory_consume_order_item(\n v_tenant, v_ful.order_id, v_line.id, v_ful.unit_id,\n v_line.product_id, v_line.quantity, v_actor);\n v_results := v_results || v_result;\n v_count := v_count + 1;\n END LOOP;\n\n RETURN jsonb_build_object('fulfillment_id', v_ful.id, 'order_id', v_ful.order_id,\n 'consumed_items', v_count, 'results', v_results);\nEND $$;\n\nCREATE FUNCTION public.plg_inventory_on_order_completed(p_event jsonb) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE\n v_tenant uuid := nullif(p_event ->> 'tenant_id', '')::uuid;\n v_order uuid := nullif(p_event ->> 'subject_id', '')::uuid;\n v_actor uuid := nullif(p_event ->> 'actor_id', '')::uuid;\n v_order_row record;\n v_item record;\n v_result jsonb;\n v_results jsonb := '[]'::jsonb;\n v_count integer := 0;\n v_delivered integer := 0;\nBEGIN\n IF v_tenant IS NULL OR v_order IS NULL THEN\n RAISE EXCEPTION 'order.completed carried no tenant or subject id' USING ERRCODE = '22023';\n END IF;\n IF public.app_product_stock_owner(v_tenant) <> 'inventory' THEN\n RETURN jsonb_build_object('skipped', 'stock owner is not inventory for this tenant');\n END IF;\n\n SELECT o.status, o.unit_id INTO v_order_row\n FROM public.orders o WHERE o.id = v_order AND o.tenant_id = v_tenant;\n IF v_order_row.status IS NULL THEN\n RAISE EXCEPTION 'inventory: order % was not found for tenant %', v_order, v_tenant\n USING ERRCODE = '22023';\n END IF;\n IF v_order_row.status <> 'completed' THEN\n RETURN jsonb_build_object('skipped', 'order is no longer completed',\n 'order_id', v_order, 'current_status', v_order_row.status);\n END IF;\n\n FOR v_item IN\n SELECT oi.id, oi.product_id, oi.quantity\n FROM public.items oi\n JOIN public.products p ON p.id = oi.product_id AND p.tenant_id = v_tenant\n WHERE oi.order_id = v_order AND oi.tenant_id = v_tenant\n AND oi.cancelled_at IS NULL\n AND oi.product_id IS NOT NULL AND coalesce(oi.quantity, 0) > 0\n AND p.kind = 'good'\n ORDER BY oi.sort_order, oi.created_at, oi.id\n LOOP\n -- The delivery is the movement where a delivery exists.\n IF EXISTS (\n SELECT 1\n FROM public.item_associations a\n JOIN public.items t ON t.id = a.to_item_id\n WHERE a.from_item_id = v_item.id AND t.fulfillment_id IS NOT NULL\n ) THEN\n v_delivered := v_delivered + 1;\n CONTINUE;\n END IF;\n\n v_result := app.inventory_consume_order_item(\n v_tenant, v_order, v_item.id, v_order_row.unit_id,\n v_item.product_id, v_item.quantity, v_actor\n );\n v_results := v_results || v_result;\n v_count := v_count + 1;\n END LOOP;\n\n RETURN jsonb_build_object('order_id', v_order, 'consumed_items', v_count,\n 'delivered_elsewhere', v_delivered, 'results', v_results);\nEND $$;\n\nCREATE FUNCTION public.trg_fulfillments_emit() RETURNS trigger\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nBEGIN\n IF NEW.status NOT IN ('delivered', 'completed') THEN RETURN NULL; END IF;\n IF TG_OP = 'UPDATE' AND OLD.status IN ('delivered', 'completed') THEN RETURN NULL; END IF;\n\n PERFORM public.plg_emit_event(\n 'fulfillment.completed',\n jsonb_build_object(\n 'fulfillment_id', NEW.id,\n 'order_id', NEW.order_id,\n 'kind', NEW.kind,\n 'unit_id', NEW.unit_id,\n 'occurred_at', coalesce(NEW.occurred_at, now())\n ),\n 'fulfillment', NEW.id::text, NEW.tenant_id, NULL);\n RETURN NULL;\nEND $$;\n\nCREATE FUNCTION public.trg_plg_inventory_fulfillment_items_inherit() RETURNS trigger\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE v_tenant uuid; v_unit uuid;\nBEGIN\n SELECT i.tenant_id, i.unit_id INTO v_tenant, v_unit FROM public.items i WHERE i.id = NEW.item_id;\n IF v_tenant IS NULL THEN\n RAISE EXCEPTION 'plg_inventory_fulfillment_items: line % not found', NEW.item_id USING ERRCODE = '23503';\n END IF;\n NEW.tenant_id := v_tenant;\n IF NEW.unit_id IS NULL THEN NEW.unit_id := v_unit; END IF;\n RETURN NEW;\nEND $$;\n\n\n-- ── constraint ──────────────────────────────────────────────────────────\n\nALTER TABLE ONLY public.plg_inventory_count_items\n ADD CONSTRAINT plg_inventory_count_items_pkey PRIMARY KEY (id);\n\nALTER TABLE ONLY public.plg_inventory_count_items\n ADD CONSTRAINT plg_inventory_count_items_tenant_id_id_key UNIQUE (tenant_id, id);\n\nALTER TABLE ONLY public.plg_inventory_count_sessions\n ADD CONSTRAINT plg_inventory_count_sessions_pkey PRIMARY KEY (id);\n\nALTER TABLE ONLY public.plg_inventory_count_sessions\n ADD CONSTRAINT plg_inventory_count_sessions_tenant_id_id_key UNIQUE (tenant_id, id);\n\nALTER TABLE ONLY public.plg_inventory_fulfillment_items\n ADD CONSTRAINT plg_inventory_fulfillment_items_pkey PRIMARY KEY (item_id);\n\nALTER TABLE ONLY public.plg_inventory_measurement_units\n ADD CONSTRAINT plg_inventory_measurement_units_pkey PRIMARY KEY (id);\n\nALTER TABLE ONLY public.plg_inventory_measurement_units\n ADD CONSTRAINT plg_inventory_measurement_units_tenant_id_id_key UNIQUE (tenant_id, id);\n\nALTER TABLE ONLY public.plg_inventory_operations\n ADD CONSTRAINT plg_inventory_operations_pkey PRIMARY KEY (id);\n\nALTER TABLE ONLY public.plg_inventory_operations\n ADD CONSTRAINT plg_inventory_operations_tenant_id_id_key UNIQUE (tenant_id, id);\n\nALTER TABLE ONLY public.plg_inventory_product_categories\n ADD CONSTRAINT plg_inventory_product_categories_pkey PRIMARY KEY (id);\n\nALTER TABLE ONLY public.plg_inventory_product_categories\n ADD CONSTRAINT plg_inventory_product_categories_tenant_id_id_key UNIQUE (tenant_id, id);\n\nALTER TABLE ONLY public.plg_inventory_product_details\n ADD CONSTRAINT plg_inventory_product_details_pkey PRIMARY KEY (product_id);\n\nALTER TABLE ONLY public.plg_inventory_product_settings\n ADD CONSTRAINT plg_inventory_product_settings_pkey PRIMARY KEY (id);\n\nALTER TABLE ONLY public.plg_inventory_product_settings\n ADD CONSTRAINT plg_inventory_product_settings_tenant_id_id_key UNIQUE (tenant_id, id);\n\nALTER TABLE ONLY public.plg_inventory_recipe_groups\n ADD CONSTRAINT plg_inventory_recipe_groups_pkey PRIMARY KEY (id);\n\nALTER TABLE ONLY public.plg_inventory_recipe_ingredients\n ADD CONSTRAINT plg_inventory_recipe_ingredients_pkey PRIMARY KEY (id);\n\nALTER TABLE ONLY public.plg_inventory_recipes\n ADD CONSTRAINT plg_inventory_recipes_pkey PRIMARY KEY (id);\n\nALTER TABLE ONLY public.plg_inventory_recipes\n ADD CONSTRAINT plg_inventory_recipes_tenant_id_id_key UNIQUE (tenant_id, id);\n\nALTER TABLE ONLY public.plg_inventory_reservations\n ADD CONSTRAINT plg_inventory_reservations_pkey PRIMARY KEY (id);\n\nALTER TABLE ONLY public.plg_inventory_reservations\n ADD CONSTRAINT plg_inventory_reservations_tenant_id_id_key UNIQUE (tenant_id, id);\n\nALTER TABLE ONLY public.plg_inventory_stock_locations\n ADD CONSTRAINT plg_inventory_stock_locations_pkey PRIMARY KEY (id);\n\nALTER TABLE ONLY public.plg_inventory_stock_locations\n ADD CONSTRAINT plg_inventory_stock_locations_tenant_id_id_key UNIQUE (tenant_id, id);\n\nALTER TABLE ONLY public.plg_inventory_stock_movements\n ADD CONSTRAINT plg_inventory_stock_movements_pkey PRIMARY KEY (id);\n\nALTER TABLE ONLY public.plg_inventory_stock_movements\n ADD CONSTRAINT plg_inventory_stock_movements_tenant_id_id_key UNIQUE (tenant_id, id);\n\nALTER TABLE ONLY public.plg_inventory_stock_positions\n ADD CONSTRAINT plg_inventory_stock_positions_pkey PRIMARY KEY (id);\n\nALTER TABLE ONLY public.plg_inventory_stock_positions\n ADD CONSTRAINT plg_inventory_stock_positions_tenant_id_id_key UNIQUE (tenant_id, id);\n\nALTER TABLE ONLY public.plg_inventory_unit_conversions\n ADD CONSTRAINT plg_inventory_unit_conversions_pkey PRIMARY KEY (id);\n\nALTER TABLE ONLY public.plg_inventory_unit_conversions\n ADD CONSTRAINT plg_inventory_unit_conversions_tenant_id_id_key UNIQUE (tenant_id, id);\n\n\n-- ── view ────────────────────────────────────────────────────────────────\n\nCREATE VIEW public.v_inventory_balances WITH (security_invoker='true') AS\n SELECT p.id,\n p.tenant_id,\n p.unit_id,\n p.product_id,\n pl.name AS product_name,\n pl.sku AS product_sku,\n p.stock_location_id,\n l.name AS location_name,\n p.batch_number,\n p.expiration_date,\n p.quantity,\n COALESCE(r.reserved, (0)::numeric) AS reserved_quantity,\n (p.quantity - COALESCE(r.reserved, (0)::numeric)) AS available_quantity,\n p.unit_cost,\n (p.quantity * p.unit_cost) AS total_value,\n p.measurement_unit_id,\n mu.code AS measurement_unit_code,\n p.created_at,\n p.updated_at\n FROM ((((public.plg_inventory_stock_positions p\n LEFT JOIN public.plg_inventory_stock_locations l ON ((l.id = p.stock_location_id)))\n LEFT JOIN public.plg_inventory_measurement_units mu ON ((mu.id = p.measurement_unit_id)))\n LEFT JOIN LATERAL app.inventory_product_label(p.product_id) pl(name, sku) ON (true))\n LEFT JOIN LATERAL ( SELECT sum(x.quantity) AS reserved\n FROM public.plg_inventory_reservations x\n WHERE ((x.tenant_id = p.tenant_id) AND (x.product_id = p.product_id) AND (x.stock_location_id = p.stock_location_id) AND (COALESCE(x.batch_number, ''::text) = COALESCE(p.batch_number, ''::text)) AND (COALESCE(x.expiration_date, 'infinity'::date) = COALESCE(p.expiration_date, 'infinity'::date)) AND (x.status = 'reserved'::text))) r ON (true));\n\nCREATE VIEW public.v_inventory_low_stock WITH (security_invoker='true') AS\n WITH per_unit AS (\n SELECT b.tenant_id,\n b.product_id,\n b.unit_id,\n sum(b.quantity) AS on_hand,\n sum(b.available_quantity) AS available_quantity\n FROM public.v_inventory_balances b\n GROUP BY b.tenant_id, b.product_id, b.unit_id\n ), tenant_wide AS (\n SELECT b.tenant_id,\n b.product_id,\n NULL::uuid AS unit_id,\n sum(b.quantity) AS on_hand,\n sum(b.available_quantity) AS available_quantity\n FROM public.v_inventory_balances b\n GROUP BY b.tenant_id, b.product_id\n ), scopes AS (\n SELECT per_unit.tenant_id,\n per_unit.product_id,\n per_unit.unit_id,\n per_unit.on_hand,\n per_unit.available_quantity\n FROM per_unit\n UNION ALL\n SELECT tenant_wide.tenant_id,\n tenant_wide.product_id,\n tenant_wide.unit_id,\n tenant_wide.on_hand,\n tenant_wide.available_quantity\n FROM tenant_wide\n ), settings AS (\n SELECT s_1.tenant_id,\n s_1.product_id,\n s_1.unit_id,\n s_1.min_quantity,\n s_1.max_quantity\n FROM public.plg_inventory_product_settings s_1\n )\n SELECT s.tenant_id,\n s.product_id,\n pl.name AS product_name,\n pl.sku AS product_sku,\n s.unit_id,\n COALESCE(sc.on_hand, (0)::numeric) AS on_hand,\n COALESCE(sc.available_quantity, (0)::numeric) AS available_quantity,\n s.min_quantity,\n s.max_quantity,\n (s.min_quantity - COALESCE(sc.on_hand, (0)::numeric)) AS shortfall\n FROM ((settings s\n LEFT JOIN scopes sc ON (((sc.tenant_id = s.tenant_id) AND (sc.product_id = s.product_id) AND (NOT (sc.unit_id IS DISTINCT FROM s.unit_id)))))\n LEFT JOIN LATERAL app.inventory_product_label(s.product_id) pl(name, sku) ON (true))\n WHERE ((s.min_quantity > (0)::numeric) AND (COALESCE(sc.on_hand, (0)::numeric) <= s.min_quantity));\n\nCREATE VIEW public.v_inventory_movements WITH (security_invoker='true') AS\n SELECT m.id,\n m.tenant_id,\n m.unit_id,\n m.product_id,\n pl.name AS product_name,\n pl.sku AS product_sku,\n m.kind,\n CASE m.kind\n WHEN 'in'::text THEN 'entry'::text\n WHEN 'out'::text THEN\n CASE\n WHEN ((m.reason ~~* 'loss%'::text) OR (m.document_type = 'loss'::text)) THEN 'loss'::text\n ELSE 'exit'::text\n END\n WHEN 'transfer'::text THEN 'transfer'::text\n WHEN 'adjust'::text THEN 'adjustment'::text\n WHEN 'reverse'::text THEN 'adjustment'::text\n ELSE NULL::text\n END AS movement_type,\n m.quantity,\n abs(m.quantity) AS abs_quantity,\n m.unit_cost,\n m.total_cost,\n m.source_location_id,\n sl.name AS source_location_name,\n m.destination_location_id,\n dl.name AS destination_location_name,\n m.batch_number,\n m.expiration_date,\n m.measurement_unit_id,\n mu.code AS measurement_unit_code,\n m.supplier_id,\n sp.name AS supplier_name,\n m.document_type,\n m.document_number,\n m.reason,\n m.notes,\n m.movement_date,\n m.user_id,\n m.reverses_movement_id,\n m.idempotency_key,\n m.line_no,\n m.operation_id,\n m.source_item_type,\n m.source_item_id,\n m.metadata,\n m.created_at\n FROM (((((public.plg_inventory_stock_movements m\n LEFT JOIN LATERAL app.inventory_product_label(m.product_id) pl(name, sku) ON (true))\n LEFT JOIN public.plg_inventory_stock_locations sl ON ((sl.id = m.source_location_id)))\n LEFT JOIN public.plg_inventory_stock_locations dl ON ((dl.id = m.destination_location_id)))\n LEFT JOIN public.plg_inventory_measurement_units mu ON ((mu.id = m.measurement_unit_id)))\n LEFT JOIN public.people sp ON ((sp.id = m.supplier_id)));\n\nCREATE VIEW public.v_inventory_product_totals WITH (security_invoker='true') AS\n SELECT tenant_id,\n product_id,\n max(product_name) AS product_name,\n max(product_sku) AS product_sku,\n sum(quantity) AS on_hand,\n sum(reserved_quantity) AS reserved_quantity,\n sum(available_quantity) AS available_quantity,\n CASE\n WHEN (sum(quantity) > (0)::numeric) THEN round((sum((quantity * unit_cost)) / sum(quantity)), 4)\n ELSE max(unit_cost)\n END AS avg_unit_cost,\n sum(total_value) AS total_value,\n (count(DISTINCT stock_location_id))::integer AS location_count,\n (count(*))::integer AS position_count,\n min(expiration_date) AS next_expiration,\n max(updated_at) AS updated_at\n FROM public.v_inventory_balances b\n GROUP BY tenant_id, product_id;\n\nCREATE VIEW public.v_inventory_products WITH (security_invoker='true') AS\n SELECT p.id,\n p.tenant_id,\n p.category_id,\n p.name,\n p.description,\n p.sku,\n p.price,\n p.cost,\n p.currency,\n p.unit,\n p.image_url,\n p.status,\n p.is_active,\n p.tags,\n p.metadata,\n p.created_at,\n p.updated_at,\n p.created_by,\n p.custom_fields,\n p.unit_id,\n p.owner_id,\n p.kind,\n p.subkind,\n p.duration_minutes,\n p.service_kind,\n COALESCE(t.on_hand, (0)::numeric) AS stock,\n COALESCE(t.available_quantity, (0)::numeric) AS available,\n COALESCE(t.reserved_quantity, (0)::numeric) AS reserved,\n COALESCE(s.min_quantity, (0)::numeric) AS min_stock,\n s.max_quantity AS max_stock,\n t.location_count,\n t.next_expiration\n FROM ((public.products p\n LEFT JOIN public.v_inventory_product_totals t ON (((t.tenant_id = p.tenant_id) AND (t.product_id = p.id))))\n LEFT JOIN public.plg_inventory_product_settings s ON (((s.tenant_id = p.tenant_id) AND (s.product_id = p.id) AND (s.unit_id IS NULL))));\n\nCREATE VIEW public.v_stock_movements WITH (security_invoker='true') AS\n SELECT sm.id,\n sm.tenant_id,\n sm.product_id,\n sm.quantity,\n sm.kind AS movement_type,\n sm.unit_cost,\n sm.total_cost,\n sm.source_location_id AS stock_location_id,\n sm.destination_location_id,\n sm.supplier_id,\n sm.document_number,\n sm.reason,\n sm.notes,\n sm.movement_date,\n sm.user_id,\n sm.batch_number,\n sm.expiration_date,\n sm.metadata,\n sm.created_at,\n p.name AS product_name,\n p.sku AS product_sku,\n sl.name AS stock_location_name,\n dl.name AS destination_location_name\n FROM (((public.plg_inventory_stock_movements sm\n LEFT JOIN public.products p ON ((p.id = sm.product_id)))\n LEFT JOIN public.plg_inventory_stock_locations sl ON ((sl.id = sm.source_location_id)))\n LEFT JOIN public.plg_inventory_stock_locations dl ON ((dl.id = sm.destination_location_id)));\n\n\n-- ── index ───────────────────────────────────────────────────────────────\n\nCREATE INDEX idx_plg_inventory_count_items_tenant ON public.plg_inventory_count_items USING btree (tenant_id);\n\nCREATE INDEX idx_plg_inventory_count_sessions_status ON public.plg_inventory_count_sessions USING btree (tenant_id, status);\n\nCREATE INDEX idx_plg_inventory_count_sessions_tenant ON public.plg_inventory_count_sessions USING btree (tenant_id, opened_at DESC);\n\nCREATE INDEX idx_plg_inventory_measurement_units_tenant ON public.plg_inventory_measurement_units USING btree (tenant_id);\n\nCREATE INDEX idx_plg_inventory_product_categories_tenant ON public.plg_inventory_product_categories USING btree (tenant_id);\n\nCREATE INDEX idx_plg_inventory_product_details_category ON public.plg_inventory_product_details USING btree (category_id);\n\nCREATE INDEX idx_plg_inventory_product_details_supplier ON public.plg_inventory_product_details USING btree (supplier_id);\n\nCREATE INDEX idx_plg_inventory_product_details_tenant ON public.plg_inventory_product_details USING btree (tenant_id);\n\nCREATE INDEX idx_plg_inventory_recipe_groups_recipe ON public.plg_inventory_recipe_groups USING btree (recipe_id);\n\nCREATE INDEX idx_plg_inventory_recipe_groups_tenant ON public.plg_inventory_recipe_groups USING btree (tenant_id);\n\nCREATE INDEX idx_plg_inventory_recipe_ingredients_group ON public.plg_inventory_recipe_ingredients USING btree (group_id);\n\nCREATE INDEX idx_plg_inventory_recipe_ingredients_recipe ON public.plg_inventory_recipe_ingredients USING btree (recipe_id);\n\nCREATE INDEX idx_plg_inventory_recipe_ingredients_tenant ON public.plg_inventory_recipe_ingredients USING btree (tenant_id);\n\nCREATE INDEX idx_plg_inventory_recipes_product ON public.plg_inventory_recipes USING btree (product_id);\n\nCREATE INDEX idx_plg_inventory_recipes_tenant ON public.plg_inventory_recipes USING btree (tenant_id);\n\nCREATE INDEX idx_plg_inventory_stock_locations_tenant ON public.plg_inventory_stock_locations USING btree (tenant_id);\n\nCREATE INDEX idx_plg_inventory_stock_movements_date ON public.plg_inventory_stock_movements USING btree (tenant_id, movement_date);\n\nCREATE INDEX idx_plg_inventory_stock_movements_product ON public.plg_inventory_stock_movements USING btree (product_id);\n\nCREATE INDEX idx_plg_inventory_stock_movements_tenant ON public.plg_inventory_stock_movements USING btree (tenant_id);\n\nCREATE INDEX idx_plg_inventory_stock_positions_product ON public.plg_inventory_stock_positions USING btree (product_id);\n\nCREATE INDEX idx_plg_inventory_stock_positions_tenant ON public.plg_inventory_stock_positions USING btree (tenant_id);\n\nCREATE INDEX plg_inventory_count_items_tenant_owner_idx ON public.plg_inventory_count_items USING btree (tenant_id, owner_id) WHERE (owner_id IS NOT NULL);\n\nCREATE INDEX plg_inventory_count_items_tenant_unit_idx ON public.plg_inventory_count_items USING btree (tenant_id, unit_id);\n\nCREATE INDEX plg_inventory_count_sessions_tenant_owner_idx ON public.plg_inventory_count_sessions USING btree (tenant_id, owner_id) WHERE (owner_id IS NOT NULL);\n\nCREATE INDEX plg_inventory_count_sessions_tenant_unit_idx ON public.plg_inventory_count_sessions USING btree (tenant_id, unit_id);\n\nCREATE INDEX plg_inventory_fulfillment_items_batch_idx ON public.plg_inventory_fulfillment_items USING btree (tenant_id, batch_number) WHERE (batch_number IS NOT NULL);\n\nCREATE UNIQUE INDEX plg_inventory_measurement_units_base_key ON public.plg_inventory_measurement_units USING btree (tenant_id, kind) WHERE is_base;\n\nCREATE UNIQUE INDEX plg_inventory_measurement_units_code_key ON public.plg_inventory_measurement_units USING btree (tenant_id, lower(code)) WHERE (code IS NOT NULL);\n\nCREATE INDEX plg_inventory_measurement_units_tenant_owner_idx ON public.plg_inventory_measurement_units USING btree (tenant_id, owner_id) WHERE (owner_id IS NOT NULL);\n\nCREATE INDEX plg_inventory_measurement_units_tenant_unit_idx ON public.plg_inventory_measurement_units USING btree (tenant_id, unit_id);\n\nCREATE UNIQUE INDEX plg_inventory_operations_key ON public.plg_inventory_operations USING btree (tenant_id, kind, idempotency_key);\n\nCREATE INDEX plg_inventory_operations_tenant_owner_idx ON public.plg_inventory_operations USING btree (tenant_id, owner_id) WHERE (owner_id IS NOT NULL);\n\nCREATE INDEX plg_inventory_operations_tenant_unit_idx ON public.plg_inventory_operations USING btree (tenant_id, unit_id);\n\nCREATE INDEX plg_inventory_product_categories_tenant_owner_idx ON public.plg_inventory_product_categories USING btree (tenant_id, owner_id) WHERE (owner_id IS NOT NULL);\n\nCREATE INDEX plg_inventory_product_categories_tenant_unit_idx ON public.plg_inventory_product_categories USING btree (tenant_id, unit_id);\n\nCREATE UNIQUE INDEX plg_inventory_product_settings_product_unit_key ON public.plg_inventory_product_settings USING btree (tenant_id, product_id, COALESCE(unit_id, '00000000-0000-0000-0000-000000000000'::uuid));\n\nCREATE INDEX plg_inventory_product_settings_tenant_owner_idx ON public.plg_inventory_product_settings USING btree (tenant_id, owner_id) WHERE (owner_id IS NOT NULL);\n\nCREATE INDEX plg_inventory_product_settings_tenant_unit_idx ON public.plg_inventory_product_settings USING btree (tenant_id, unit_id);\n\nCREATE INDEX plg_inventory_recipes_tenant_owner_idx ON public.plg_inventory_recipes USING btree (tenant_id, owner_id) WHERE (owner_id IS NOT NULL);\n\nCREATE INDEX plg_inventory_recipes_tenant_unit_idx ON public.plg_inventory_recipes USING btree (tenant_id, unit_id);\n\nCREATE UNIQUE INDEX plg_inventory_reservations_key_line ON public.plg_inventory_reservations USING btree (tenant_id, idempotency_key, line_no);\n\nCREATE UNIQUE INDEX plg_inventory_reservations_one_live_per_source_uidx ON public.plg_inventory_reservations USING btree (tenant_id, source_type, source_id, line_no) WHERE (status <> 'reversed'::text);\n\nCREATE INDEX plg_inventory_reservations_open_idx ON public.plg_inventory_reservations USING btree (tenant_id, product_id, stock_location_id) WHERE (status = 'reserved'::text);\n\nCREATE INDEX plg_inventory_reservations_source_idx ON public.plg_inventory_reservations USING btree (tenant_id, source_type, source_id);\n\nCREATE INDEX plg_inventory_reservations_tenant_owner_idx ON public.plg_inventory_reservations USING btree (tenant_id, owner_id) WHERE (owner_id IS NOT NULL);\n\nCREATE INDEX plg_inventory_reservations_tenant_unit_idx ON public.plg_inventory_reservations USING btree (tenant_id, unit_id);\n\nCREATE INDEX plg_inventory_stock_locations_tenant_owner_idx ON public.plg_inventory_stock_locations USING btree (tenant_id, owner_id) WHERE (owner_id IS NOT NULL);\n\nCREATE INDEX plg_inventory_stock_locations_tenant_unit_idx ON public.plg_inventory_stock_locations USING btree (tenant_id, unit_id);\n\nCREATE UNIQUE INDEX plg_inventory_stock_locations_unit_name_key ON public.plg_inventory_stock_locations USING btree (tenant_id, unit_id, lower(name));\n\nCREATE INDEX plg_inventory_stock_movements_created_idx ON public.plg_inventory_stock_movements USING btree (tenant_id, created_at DESC);\n\nCREATE INDEX plg_inventory_stock_movements_dest_idx ON public.plg_inventory_stock_movements USING btree (tenant_id, destination_location_id, product_id) WHERE (destination_location_id IS NOT NULL);\n\nCREATE UNIQUE INDEX plg_inventory_stock_movements_idem_key ON public.plg_inventory_stock_movements USING btree (tenant_id, idempotency_key, line_no) WHERE (idempotency_key IS NOT NULL);\n\nCREATE UNIQUE INDEX plg_inventory_stock_movements_reverses_key ON public.plg_inventory_stock_movements USING btree (reverses_movement_id) WHERE (reverses_movement_id IS NOT NULL);\n\nCREATE INDEX plg_inventory_stock_movements_source_idx ON public.plg_inventory_stock_movements USING btree (tenant_id, source_location_id, product_id);\n\nCREATE INDEX plg_inventory_stock_movements_source_item_idx ON public.plg_inventory_stock_movements USING btree (tenant_id, source_item_type, source_item_id) WHERE (source_item_id IS NOT NULL);\n\nCREATE INDEX plg_inventory_stock_movements_tenant_owner_idx ON public.plg_inventory_stock_movements USING btree (tenant_id, owner_id) WHERE (owner_id IS NOT NULL);\n\nCREATE INDEX plg_inventory_stock_movements_tenant_unit_idx ON public.plg_inventory_stock_movements USING btree (tenant_id, unit_id);\n\nCREATE INDEX plg_inventory_stock_positions_expiry_idx ON public.plg_inventory_stock_positions USING btree (tenant_id, expiration_date) WHERE (expiration_date IS NOT NULL);\n\nCREATE UNIQUE INDEX plg_inventory_stock_positions_identity_key ON public.plg_inventory_stock_positions USING btree (tenant_id, product_id, stock_location_id, COALESCE(batch_number, ''::text), COALESCE(expiration_date, 'infinity'::date));\n\nCREATE INDEX plg_inventory_stock_positions_location_idx ON public.plg_inventory_stock_positions USING btree (tenant_id, stock_location_id, product_id);\n\nCREATE INDEX plg_inventory_stock_positions_tenant_owner_idx ON public.plg_inventory_stock_positions USING btree (tenant_id, owner_id) WHERE (owner_id IS NOT NULL);\n\nCREATE INDEX plg_inventory_stock_positions_tenant_unit_idx ON public.plg_inventory_stock_positions USING btree (tenant_id, unit_id);\n\nCREATE UNIQUE INDEX plg_inventory_unit_conversions_key ON public.plg_inventory_unit_conversions USING btree (tenant_id, from_unit_id, to_unit_id, COALESCE(product_id, '00000000-0000-0000-0000-000000000000'::uuid));\n\nCREATE INDEX plg_inventory_unit_conversions_product_idx ON public.plg_inventory_unit_conversions USING btree (tenant_id, product_id) WHERE (product_id IS NOT NULL);\n\nCREATE INDEX plg_inventory_unit_conversions_tenant_owner_idx ON public.plg_inventory_unit_conversions USING btree (tenant_id, owner_id) WHERE (owner_id IS NOT NULL);\n\nCREATE INDEX plg_inventory_unit_conversions_tenant_unit_idx ON public.plg_inventory_unit_conversions USING btree (tenant_id, unit_id);\n\nCREATE UNIQUE INDEX uq_plg_inventory_count_items_line ON public.plg_inventory_count_items USING btree (session_id, product_id);\n\nCREATE UNIQUE INDEX uq_plg_inventory_stock_positions_slot ON public.plg_inventory_stock_positions USING btree (tenant_id, product_id, stock_location_id, batch_number, expiration_date) NULLS NOT DISTINCT;\n\n\n-- ── fk constraint ───────────────────────────────────────────────────────\n\nALTER TABLE ONLY public.plg_inventory_count_items\n ADD CONSTRAINT plg_inventory_count_items_movement_id_fkey FOREIGN KEY (movement_id) REFERENCES public.plg_inventory_stock_movements(id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_count_items\n ADD CONSTRAINT plg_inventory_count_items_owner_fk FOREIGN KEY (tenant_id, owner_id) REFERENCES app.memberships(tenant_id, user_id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_count_items\n ADD CONSTRAINT plg_inventory_count_items_product_id_fkey FOREIGN KEY (product_id) REFERENCES public.products(id);\n\nALTER TABLE ONLY public.plg_inventory_count_items\n ADD CONSTRAINT plg_inventory_count_items_session_id_fkey FOREIGN KEY (session_id) REFERENCES public.plg_inventory_count_sessions(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_inventory_count_items\n ADD CONSTRAINT plg_inventory_count_items_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_inventory_count_items\n ADD CONSTRAINT plg_inventory_count_items_unit_fk FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_count_sessions\n ADD CONSTRAINT plg_inventory_count_sessions_category_id_fkey FOREIGN KEY (category_id) REFERENCES public.plg_inventory_product_categories(id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_count_sessions\n ADD CONSTRAINT plg_inventory_count_sessions_owner_fk FOREIGN KEY (tenant_id, owner_id) REFERENCES app.memberships(tenant_id, user_id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_count_sessions\n ADD CONSTRAINT plg_inventory_count_sessions_stock_location_id_fkey FOREIGN KEY (stock_location_id) REFERENCES public.plg_inventory_stock_locations(id);\n\nALTER TABLE ONLY public.plg_inventory_count_sessions\n ADD CONSTRAINT plg_inventory_count_sessions_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_inventory_count_sessions\n ADD CONSTRAINT plg_inventory_count_sessions_unit_fk FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_fulfillment_items\n ADD CONSTRAINT plg_inventory_fulfillment_items_item_id_fkey FOREIGN KEY (item_id) REFERENCES public.items(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_inventory_fulfillment_items\n ADD CONSTRAINT plg_inventory_fulfillment_items_parent_fk FOREIGN KEY (item_id, parent_kind) REFERENCES public.items(id, parent_kind) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_inventory_fulfillment_items\n ADD CONSTRAINT plg_inventory_fulfillment_items_stock_location_id_fkey FOREIGN KEY (stock_location_id) REFERENCES public.plg_inventory_stock_locations(id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_fulfillment_items\n ADD CONSTRAINT plg_inventory_fulfillment_items_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_inventory_measurement_units\n ADD CONSTRAINT plg_inventory_measurement_units_owner_fk FOREIGN KEY (tenant_id, owner_id) REFERENCES app.memberships(tenant_id, user_id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_measurement_units\n ADD CONSTRAINT plg_inventory_measurement_units_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_inventory_measurement_units\n ADD CONSTRAINT plg_inventory_measurement_units_unit_fk FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_operations\n ADD CONSTRAINT plg_inventory_operations_owner_fk FOREIGN KEY (tenant_id, owner_id) REFERENCES app.memberships(tenant_id, user_id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_operations\n ADD CONSTRAINT plg_inventory_operations_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_inventory_operations\n ADD CONSTRAINT plg_inventory_operations_unit_fk FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_product_categories\n ADD CONSTRAINT plg_inventory_product_categories_owner_fk FOREIGN KEY (tenant_id, owner_id) REFERENCES app.memberships(tenant_id, user_id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_product_categories\n ADD CONSTRAINT plg_inventory_product_categories_parent_id_fkey FOREIGN KEY (parent_id) REFERENCES public.plg_inventory_product_categories(id);\n\nALTER TABLE ONLY public.plg_inventory_product_categories\n ADD CONSTRAINT plg_inventory_product_categories_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_inventory_product_categories\n ADD CONSTRAINT plg_inventory_product_categories_unit_fk FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_product_details\n ADD CONSTRAINT plg_inventory_product_details_category_id_fkey FOREIGN KEY (category_id) REFERENCES public.plg_inventory_product_categories(id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_product_details\n ADD CONSTRAINT plg_inventory_product_details_default_location_id_fkey FOREIGN KEY (default_location_id) REFERENCES public.plg_inventory_stock_locations(id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_product_details\n ADD CONSTRAINT plg_inventory_product_details_measurement_unit_id_fkey FOREIGN KEY (measurement_unit_id) REFERENCES public.plg_inventory_measurement_units(id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_product_details\n ADD CONSTRAINT plg_inventory_product_details_product_id_fkey FOREIGN KEY (product_id) REFERENCES public.products(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_inventory_product_details\n ADD CONSTRAINT plg_inventory_product_details_purchase_unit_id_fkey FOREIGN KEY (purchase_unit_id) REFERENCES public.plg_inventory_measurement_units(id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_product_details\n ADD CONSTRAINT plg_inventory_product_details_supplier_id_fkey FOREIGN KEY (supplier_id) REFERENCES public.people(id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_product_details\n ADD CONSTRAINT plg_inventory_product_details_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_inventory_product_settings\n ADD CONSTRAINT plg_inventory_product_settings_default_location_id_fkey FOREIGN KEY (default_location_id) REFERENCES public.plg_inventory_stock_locations(id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_product_settings\n ADD CONSTRAINT plg_inventory_product_settings_measurement_unit_id_fkey FOREIGN KEY (measurement_unit_id) REFERENCES public.plg_inventory_measurement_units(id);\n\nALTER TABLE ONLY public.plg_inventory_product_settings\n ADD CONSTRAINT plg_inventory_product_settings_owner_fk FOREIGN KEY (tenant_id, owner_id) REFERENCES app.memberships(tenant_id, user_id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_product_settings\n ADD CONSTRAINT plg_inventory_product_settings_product_id_fkey FOREIGN KEY (product_id) REFERENCES public.products(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_inventory_product_settings\n ADD CONSTRAINT plg_inventory_product_settings_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_inventory_product_settings\n ADD CONSTRAINT plg_inventory_product_settings_unit_fk FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_recipe_groups\n ADD CONSTRAINT plg_inventory_recipe_groups_recipe_id_fkey FOREIGN KEY (recipe_id) REFERENCES public.plg_inventory_recipes(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_inventory_recipe_groups\n ADD CONSTRAINT plg_inventory_recipe_groups_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_inventory_recipe_ingredients\n ADD CONSTRAINT plg_inventory_recipe_ingredients_group_id_fkey FOREIGN KEY (group_id) REFERENCES public.plg_inventory_recipe_groups(id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_recipe_ingredients\n ADD CONSTRAINT plg_inventory_recipe_ingredients_product_id_fkey FOREIGN KEY (product_id) REFERENCES public.products(id);\n\nALTER TABLE ONLY public.plg_inventory_recipe_ingredients\n ADD CONSTRAINT plg_inventory_recipe_ingredients_recipe_id_fkey FOREIGN KEY (recipe_id) REFERENCES public.plg_inventory_recipes(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_inventory_recipe_ingredients\n ADD CONSTRAINT plg_inventory_recipe_ingredients_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_inventory_recipe_ingredients\n ADD CONSTRAINT plg_inventory_recipe_ingredients_unit_fk FOREIGN KEY (unit_id) REFERENCES public.plg_inventory_measurement_units(id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_recipes\n ADD CONSTRAINT plg_inventory_recipes_owner_fk FOREIGN KEY (tenant_id, owner_id) REFERENCES app.memberships(tenant_id, user_id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_recipes\n ADD CONSTRAINT plg_inventory_recipes_product_id_fkey FOREIGN KEY (product_id) REFERENCES public.products(id);\n\nALTER TABLE ONLY public.plg_inventory_recipes\n ADD CONSTRAINT plg_inventory_recipes_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_inventory_recipes\n ADD CONSTRAINT plg_inventory_recipes_unit_fk FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_recipes\n ADD CONSTRAINT plg_inventory_recipes_yield_unit_fk FOREIGN KEY (yield_unit_id) REFERENCES public.plg_inventory_measurement_units(id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_reservations\n ADD CONSTRAINT plg_inventory_reservations_measurement_unit_id_fkey FOREIGN KEY (measurement_unit_id) REFERENCES public.plg_inventory_measurement_units(id);\n\nALTER TABLE ONLY public.plg_inventory_reservations\n ADD CONSTRAINT plg_inventory_reservations_movement_id_fkey FOREIGN KEY (movement_id) REFERENCES public.plg_inventory_stock_movements(id);\n\nALTER TABLE ONLY public.plg_inventory_reservations\n ADD CONSTRAINT plg_inventory_reservations_owner_fk FOREIGN KEY (tenant_id, owner_id) REFERENCES app.memberships(tenant_id, user_id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_reservations\n ADD CONSTRAINT plg_inventory_reservations_product_id_fkey FOREIGN KEY (product_id) REFERENCES public.products(id);\n\nALTER TABLE ONLY public.plg_inventory_reservations\n ADD CONSTRAINT plg_inventory_reservations_reversal_movement_id_fkey FOREIGN KEY (reversal_movement_id) REFERENCES public.plg_inventory_stock_movements(id);\n\nALTER TABLE ONLY public.plg_inventory_reservations\n ADD CONSTRAINT plg_inventory_reservations_stock_location_id_fkey FOREIGN KEY (stock_location_id) REFERENCES public.plg_inventory_stock_locations(id);\n\nALTER TABLE ONLY public.plg_inventory_reservations\n ADD CONSTRAINT plg_inventory_reservations_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_inventory_reservations\n ADD CONSTRAINT plg_inventory_reservations_unit_fk FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_stock_locations\n ADD CONSTRAINT plg_inventory_stock_locations_owner_fk FOREIGN KEY (tenant_id, owner_id) REFERENCES app.memberships(tenant_id, user_id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_stock_locations\n ADD CONSTRAINT plg_inventory_stock_locations_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_inventory_stock_locations\n ADD CONSTRAINT plg_inventory_stock_locations_unit_fk FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_stock_movements\n ADD CONSTRAINT plg_inventory_stock_movements_destination_location_id_fkey FOREIGN KEY (destination_location_id) REFERENCES public.plg_inventory_stock_locations(id);\n\nALTER TABLE ONLY public.plg_inventory_stock_movements\n ADD CONSTRAINT plg_inventory_stock_movements_measurement_unit_id_fkey FOREIGN KEY (measurement_unit_id) REFERENCES public.plg_inventory_measurement_units(id);\n\nALTER TABLE ONLY public.plg_inventory_stock_movements\n ADD CONSTRAINT plg_inventory_stock_movements_operation_fk FOREIGN KEY (operation_id) REFERENCES public.plg_inventory_operations(id);\n\nALTER TABLE ONLY public.plg_inventory_stock_movements\n ADD CONSTRAINT plg_inventory_stock_movements_owner_fk FOREIGN KEY (tenant_id, owner_id) REFERENCES app.memberships(tenant_id, user_id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_stock_movements\n ADD CONSTRAINT plg_inventory_stock_movements_product_id_fkey FOREIGN KEY (product_id) REFERENCES public.products(id);\n\nALTER TABLE ONLY public.plg_inventory_stock_movements\n ADD CONSTRAINT plg_inventory_stock_movements_reverses_movement_id_fkey FOREIGN KEY (reverses_movement_id) REFERENCES public.plg_inventory_stock_movements(id);\n\nALTER TABLE ONLY public.plg_inventory_stock_movements\n ADD CONSTRAINT plg_inventory_stock_movements_stock_location_id_fkey FOREIGN KEY (source_location_id) REFERENCES public.plg_inventory_stock_locations(id);\n\nALTER TABLE ONLY public.plg_inventory_stock_movements\n ADD CONSTRAINT plg_inventory_stock_movements_supplier_id_fkey FOREIGN KEY (supplier_id) REFERENCES public.people(id);\n\nALTER TABLE ONLY public.plg_inventory_stock_movements\n ADD CONSTRAINT plg_inventory_stock_movements_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_inventory_stock_movements\n ADD CONSTRAINT plg_inventory_stock_movements_unit_fk FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_stock_positions\n ADD CONSTRAINT plg_inventory_stock_positions_measurement_unit_id_fkey FOREIGN KEY (measurement_unit_id) REFERENCES public.plg_inventory_measurement_units(id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_stock_positions\n ADD CONSTRAINT plg_inventory_stock_positions_owner_fk FOREIGN KEY (tenant_id, owner_id) REFERENCES app.memberships(tenant_id, user_id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_stock_positions\n ADD CONSTRAINT plg_inventory_stock_positions_product_id_fkey FOREIGN KEY (product_id) REFERENCES public.products(id);\n\nALTER TABLE ONLY public.plg_inventory_stock_positions\n ADD CONSTRAINT plg_inventory_stock_positions_stock_location_id_fkey FOREIGN KEY (stock_location_id) REFERENCES public.plg_inventory_stock_locations(id);\n\nALTER TABLE ONLY public.plg_inventory_stock_positions\n ADD CONSTRAINT plg_inventory_stock_positions_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_inventory_stock_positions\n ADD CONSTRAINT plg_inventory_stock_positions_unit_fk FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_unit_conversions\n ADD CONSTRAINT plg_inventory_unit_conversions_from_unit_id_fkey FOREIGN KEY (from_unit_id) REFERENCES public.plg_inventory_measurement_units(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_inventory_unit_conversions\n ADD CONSTRAINT plg_inventory_unit_conversions_owner_fk FOREIGN KEY (tenant_id, owner_id) REFERENCES app.memberships(tenant_id, user_id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_unit_conversions\n ADD CONSTRAINT plg_inventory_unit_conversions_product_id_fkey FOREIGN KEY (product_id) REFERENCES public.products(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_inventory_unit_conversions\n ADD CONSTRAINT plg_inventory_unit_conversions_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_inventory_unit_conversions\n ADD CONSTRAINT plg_inventory_unit_conversions_to_unit_id_fkey FOREIGN KEY (to_unit_id) REFERENCES public.plg_inventory_measurement_units(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_inventory_unit_conversions\n ADD CONSTRAINT plg_inventory_unit_conversions_unit_fk FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;\n\n\n-- ── trigger ─────────────────────────────────────────────────────────────\n\nCREATE TRIGGER fulfillments_emit_completed AFTER INSERT OR UPDATE OF status ON public.fulfillments FOR EACH ROW EXECUTE FUNCTION public.trg_fulfillments_emit();\n\nCREATE TRIGGER plg_inventory_count_items_updated_at BEFORE UPDATE ON public.plg_inventory_count_items FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nCREATE TRIGGER plg_inventory_count_sessions_updated_at BEFORE UPDATE ON public.plg_inventory_count_sessions FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nCREATE TRIGGER plg_inventory_fulfillment_items_inherit BEFORE INSERT OR UPDATE OF item_id ON public.plg_inventory_fulfillment_items FOR EACH ROW EXECUTE FUNCTION public.trg_plg_inventory_fulfillment_items_inherit();\n\nCREATE TRIGGER plg_inventory_fulfillment_items_updated_at BEFORE UPDATE ON public.plg_inventory_fulfillment_items FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nCREATE TRIGGER plg_inventory_measurement_units_updated_at BEFORE UPDATE ON public.plg_inventory_measurement_units FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nCREATE TRIGGER plg_inventory_operations_internal_only BEFORE INSERT OR DELETE OR UPDATE ON public.plg_inventory_operations FOR EACH ROW EXECUTE FUNCTION app.inventory_internal_only();\n\nCREATE TRIGGER plg_inventory_operations_updated_at BEFORE UPDATE ON public.plg_inventory_operations FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nCREATE TRIGGER plg_inventory_product_categories_updated_at BEFORE UPDATE ON public.plg_inventory_product_categories FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nCREATE TRIGGER plg_inventory_product_settings_updated_at BEFORE UPDATE ON public.plg_inventory_product_settings FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nCREATE TRIGGER plg_inventory_recipes_updated_at BEFORE UPDATE ON public.plg_inventory_recipes FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nCREATE TRIGGER plg_inventory_reservations_internal_only BEFORE INSERT OR DELETE OR UPDATE ON public.plg_inventory_reservations FOR EACH ROW EXECUTE FUNCTION app.inventory_internal_only();\n\nCREATE TRIGGER plg_inventory_reservations_one_per_source BEFORE INSERT OR UPDATE OF status, source_type, source_id, line_no ON public.plg_inventory_reservations FOR EACH ROW EXECUTE FUNCTION app.inventory_reservation_one_per_source();\n\nCREATE TRIGGER plg_inventory_reservations_updated_at BEFORE UPDATE ON public.plg_inventory_reservations FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nCREATE TRIGGER plg_inventory_stock_locations_before_update BEFORE UPDATE ON public.plg_inventory_stock_locations FOR EACH ROW EXECUTE FUNCTION app.inventory_locations_before_update();\n\nCREATE TRIGGER plg_inventory_stock_locations_updated_at BEFORE UPDATE ON public.plg_inventory_stock_locations FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nCREATE TRIGGER plg_inventory_stock_movements_after_insert AFTER INSERT ON public.plg_inventory_stock_movements FOR EACH ROW EXECUTE FUNCTION app.inventory_movements_after_insert();\n\nCREATE TRIGGER plg_inventory_stock_movements_append_only BEFORE DELETE OR UPDATE ON public.plg_inventory_stock_movements FOR EACH ROW EXECUTE FUNCTION app.inventory_movements_append_only();\n\nCREATE TRIGGER plg_inventory_stock_movements_before_insert BEFORE INSERT ON public.plg_inventory_stock_movements FOR EACH ROW EXECUTE FUNCTION app.inventory_movements_before_insert();\n\nCREATE TRIGGER plg_inventory_stock_movements_internal_only BEFORE INSERT ON public.plg_inventory_stock_movements FOR EACH ROW EXECUTE FUNCTION app.inventory_internal_only();\n\nCREATE TRIGGER plg_inventory_stock_movements_source_required BEFORE INSERT ON public.plg_inventory_stock_movements FOR EACH ROW EXECUTE FUNCTION app.inventory_movement_source_required();\n\nCREATE TRIGGER plg_inventory_stock_movements_updated_at BEFORE UPDATE ON public.plg_inventory_stock_movements FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nCREATE TRIGGER plg_inventory_stock_positions_internal_only BEFORE INSERT OR DELETE OR UPDATE ON public.plg_inventory_stock_positions FOR EACH ROW EXECUTE FUNCTION app.inventory_internal_only();\n\nCREATE TRIGGER plg_inventory_stock_positions_updated_at BEFORE UPDATE ON public.plg_inventory_stock_positions FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nCREATE TRIGGER plg_inventory_unit_conversions_updated_at BEFORE UPDATE ON public.plg_inventory_unit_conversions FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nCREATE TRIGGER tenants_inventory_seed_units AFTER INSERT ON public.tenants FOR EACH ROW EXECUTE FUNCTION app.trg_inventory_seed_units_for_tenant();\n\n\n-- ── row security ────────────────────────────────────────────────────────\n\nALTER TABLE public.plg_inventory_count_items ENABLE ROW LEVEL SECURITY;\n\nALTER TABLE public.plg_inventory_count_sessions ENABLE ROW LEVEL SECURITY;\n\nALTER TABLE public.plg_inventory_fulfillment_items ENABLE ROW LEVEL SECURITY;\n\nALTER TABLE public.plg_inventory_measurement_units ENABLE ROW LEVEL SECURITY;\n\nALTER TABLE public.plg_inventory_operations ENABLE ROW LEVEL SECURITY;\n\nALTER TABLE public.plg_inventory_product_categories ENABLE ROW LEVEL SECURITY;\n\nALTER TABLE public.plg_inventory_product_details ENABLE ROW LEVEL SECURITY;\n\nALTER TABLE public.plg_inventory_product_settings ENABLE ROW LEVEL SECURITY;\n\nALTER TABLE public.plg_inventory_recipe_groups ENABLE ROW LEVEL SECURITY;\n\nALTER TABLE public.plg_inventory_recipe_ingredients ENABLE ROW LEVEL SECURITY;\n\nALTER TABLE public.plg_inventory_recipes ENABLE ROW LEVEL SECURITY;\n\nALTER TABLE public.plg_inventory_reservations ENABLE ROW LEVEL SECURITY;\n\nALTER TABLE public.plg_inventory_stock_locations ENABLE ROW LEVEL SECURITY;\n\nALTER TABLE public.plg_inventory_stock_movements ENABLE ROW LEVEL SECURITY;\n\nALTER TABLE public.plg_inventory_stock_positions ENABLE ROW LEVEL SECURITY;\n\nALTER TABLE public.plg_inventory_unit_conversions ENABLE ROW LEVEL SECURITY;\n\n\n-- ── policy ──────────────────────────────────────────────────────────────\n\nCREATE POLICY plg_inventory_count_items_authz_delete ON public.plg_inventory_count_items FOR DELETE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_count_items.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('inventory.count_item'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.count_item'::text, plg_inventory_count_items.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_count_items.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.delete'::text, plg_inventory_count_items.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_count_items_authz_insert ON public.plg_inventory_count_items FOR INSERT TO authenticated WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_count_items.unit_id) AS has_unit)) AND ( SELECT app.has_permission('inventory.create'::text, plg_inventory_count_items.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_count_items_authz_select ON public.plg_inventory_count_items FOR SELECT TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_count_items.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('inventory.count_item'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.count_item'::text, plg_inventory_count_items.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_count_items.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.read'::text, plg_inventory_count_items.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_count_items_authz_update ON public.plg_inventory_count_items FOR UPDATE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_count_items.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('inventory.count_item'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.count_item'::text, plg_inventory_count_items.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_count_items.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.edit'::text, plg_inventory_count_items.unit_id) AS has_permission))) WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_count_items.unit_id) AS has_unit)) AND ( SELECT app.has_permission('inventory.edit'::text, plg_inventory_count_items.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_count_sessions_authz_delete ON public.plg_inventory_count_sessions FOR DELETE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_count_sessions.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('inventory.count_session'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.count_session'::text, plg_inventory_count_sessions.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_count_sessions.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.delete'::text, plg_inventory_count_sessions.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_count_sessions_authz_insert ON public.plg_inventory_count_sessions FOR INSERT TO authenticated WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_count_sessions.unit_id) AS has_unit)) AND ( SELECT app.has_permission('inventory.create'::text, plg_inventory_count_sessions.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_count_sessions_authz_select ON public.plg_inventory_count_sessions FOR SELECT TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_count_sessions.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('inventory.count_session'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.count_session'::text, plg_inventory_count_sessions.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_count_sessions.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.read'::text, plg_inventory_count_sessions.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_count_sessions_authz_update ON public.plg_inventory_count_sessions FOR UPDATE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_count_sessions.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('inventory.count_session'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.count_session'::text, plg_inventory_count_sessions.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_count_sessions.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.edit'::text, plg_inventory_count_sessions.unit_id) AS has_permission))) WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_count_sessions.unit_id) AS has_unit)) AND ( SELECT app.has_permission('inventory.edit'::text, plg_inventory_count_sessions.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_fulfillment_items_authz_delete ON public.plg_inventory_fulfillment_items FOR DELETE TO authenticated USING (((item_id IN ( SELECT i.id\n FROM public.items i)) AND ( SELECT app.has_permission('inventory.delete'::text, plg_inventory_fulfillment_items.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_fulfillment_items_authz_insert ON public.plg_inventory_fulfillment_items FOR INSERT TO authenticated WITH CHECK (((item_id IN ( SELECT i.id\n FROM public.items i)) AND ( SELECT app.has_permission('inventory.create'::text, plg_inventory_fulfillment_items.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_fulfillment_items_authz_select ON public.plg_inventory_fulfillment_items FOR SELECT TO authenticated USING ((item_id IN ( SELECT i.id\n FROM public.items i)));\n\nCREATE POLICY plg_inventory_fulfillment_items_authz_update ON public.plg_inventory_fulfillment_items FOR UPDATE TO authenticated USING (((item_id IN ( SELECT i.id\n FROM public.items i)) AND ( SELECT app.has_permission('inventory.edit'::text, plg_inventory_fulfillment_items.unit_id) AS has_permission))) WITH CHECK (((item_id IN ( SELECT i.id\n FROM public.items i)) AND ( SELECT app.has_permission('inventory.edit'::text, plg_inventory_fulfillment_items.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_measurement_units_authz_delete ON public.plg_inventory_measurement_units FOR DELETE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_measurement_units.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('inventory.measurement_unit'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.measurement_unit'::text, plg_inventory_measurement_units.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_measurement_units.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.delete'::text, plg_inventory_measurement_units.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_measurement_units_authz_insert ON public.plg_inventory_measurement_units FOR INSERT TO authenticated WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_measurement_units.unit_id) AS has_unit)) AND ( SELECT app.has_permission('inventory.create'::text, plg_inventory_measurement_units.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_measurement_units_authz_select ON public.plg_inventory_measurement_units FOR SELECT TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_measurement_units.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('inventory.measurement_unit'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.measurement_unit'::text, plg_inventory_measurement_units.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_measurement_units.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.read'::text, plg_inventory_measurement_units.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_measurement_units_authz_update ON public.plg_inventory_measurement_units FOR UPDATE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_measurement_units.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('inventory.measurement_unit'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.measurement_unit'::text, plg_inventory_measurement_units.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_measurement_units.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.edit'::text, plg_inventory_measurement_units.unit_id) AS has_permission))) WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_measurement_units.unit_id) AS has_unit)) AND ( SELECT app.has_permission('inventory.edit'::text, plg_inventory_measurement_units.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_operations_authz_delete ON public.plg_inventory_operations FOR DELETE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_operations.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('inventory.operation'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.operation'::text, plg_inventory_operations.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_operations.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.delete'::text, plg_inventory_operations.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_operations_authz_insert ON public.plg_inventory_operations FOR INSERT TO authenticated WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_operations.unit_id) AS has_unit)) AND ( SELECT app.has_permission('inventory.create'::text, plg_inventory_operations.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_operations_authz_select ON public.plg_inventory_operations FOR SELECT TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_operations.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('inventory.operation'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.operation'::text, plg_inventory_operations.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_operations.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.read'::text, plg_inventory_operations.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_operations_authz_update ON public.plg_inventory_operations FOR UPDATE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_operations.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('inventory.operation'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.operation'::text, plg_inventory_operations.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_operations.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.edit'::text, plg_inventory_operations.unit_id) AS has_permission))) WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_operations.unit_id) AS has_unit)) AND ( SELECT app.has_permission('inventory.edit'::text, plg_inventory_operations.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_product_categories_authz_delete ON public.plg_inventory_product_categories FOR DELETE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_product_categories.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('inventory.product_category'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.product_category'::text, plg_inventory_product_categories.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_product_categories.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.delete'::text, plg_inventory_product_categories.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_product_categories_authz_insert ON public.plg_inventory_product_categories FOR INSERT TO authenticated WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_product_categories.unit_id) AS has_unit)) AND ( SELECT app.has_permission('inventory.create'::text, plg_inventory_product_categories.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_product_categories_authz_select ON public.plg_inventory_product_categories FOR SELECT TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_product_categories.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('inventory.product_category'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.product_category'::text, plg_inventory_product_categories.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_product_categories.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.read'::text, plg_inventory_product_categories.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_product_categories_authz_update ON public.plg_inventory_product_categories FOR UPDATE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_product_categories.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('inventory.product_category'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.product_category'::text, plg_inventory_product_categories.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_product_categories.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.edit'::text, plg_inventory_product_categories.unit_id) AS has_permission))) WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_product_categories.unit_id) AS has_unit)) AND ( SELECT app.has_permission('inventory.edit'::text, plg_inventory_product_categories.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_product_categories_delete ON public.plg_inventory_product_categories FOR DELETE TO authenticated USING ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));\n\nCREATE POLICY plg_inventory_product_categories_insert ON public.plg_inventory_product_categories FOR INSERT TO authenticated WITH CHECK ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));\n\nCREATE POLICY plg_inventory_product_categories_select ON public.plg_inventory_product_categories FOR SELECT TO authenticated USING ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));\n\nCREATE POLICY plg_inventory_product_categories_update ON public.plg_inventory_product_categories FOR UPDATE TO authenticated USING ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));\n\nCREATE POLICY plg_inventory_product_details_delete ON public.plg_inventory_product_details FOR DELETE TO authenticated USING ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));\n\nCREATE POLICY plg_inventory_product_details_insert ON public.plg_inventory_product_details FOR INSERT TO authenticated WITH CHECK ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));\n\nCREATE POLICY plg_inventory_product_details_select ON public.plg_inventory_product_details FOR SELECT TO authenticated USING ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));\n\nCREATE POLICY plg_inventory_product_details_update ON public.plg_inventory_product_details FOR UPDATE TO authenticated USING ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));\n\nCREATE POLICY plg_inventory_product_settings_authz_delete ON public.plg_inventory_product_settings FOR DELETE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_product_settings.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('inventory.product_settings'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.product_settings'::text, plg_inventory_product_settings.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_product_settings.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.delete'::text, plg_inventory_product_settings.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_product_settings_authz_insert ON public.plg_inventory_product_settings FOR INSERT TO authenticated WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_product_settings.unit_id) AS has_unit)) AND ( SELECT app.has_permission('inventory.create'::text, plg_inventory_product_settings.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_product_settings_authz_select ON public.plg_inventory_product_settings FOR SELECT TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_product_settings.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('inventory.product_settings'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.product_settings'::text, plg_inventory_product_settings.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_product_settings.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.read'::text, plg_inventory_product_settings.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_product_settings_authz_update ON public.plg_inventory_product_settings FOR UPDATE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_product_settings.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('inventory.product_settings'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.product_settings'::text, plg_inventory_product_settings.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_product_settings.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.edit'::text, plg_inventory_product_settings.unit_id) AS has_permission))) WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_product_settings.unit_id) AS has_unit)) AND ( SELECT app.has_permission('inventory.edit'::text, plg_inventory_product_settings.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_recipe_groups_delete ON public.plg_inventory_recipe_groups FOR DELETE TO authenticated USING ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));\n\nCREATE POLICY plg_inventory_recipe_groups_insert ON public.plg_inventory_recipe_groups FOR INSERT TO authenticated WITH CHECK ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));\n\nCREATE POLICY plg_inventory_recipe_groups_select ON public.plg_inventory_recipe_groups FOR SELECT TO authenticated USING ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));\n\nCREATE POLICY plg_inventory_recipe_groups_update ON public.plg_inventory_recipe_groups FOR UPDATE TO authenticated USING ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));\n\nCREATE POLICY plg_inventory_recipe_ingredients_delete ON public.plg_inventory_recipe_ingredients FOR DELETE TO authenticated USING ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));\n\nCREATE POLICY plg_inventory_recipe_ingredients_insert ON public.plg_inventory_recipe_ingredients FOR INSERT TO authenticated WITH CHECK ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));\n\nCREATE POLICY plg_inventory_recipe_ingredients_select ON public.plg_inventory_recipe_ingredients FOR SELECT TO authenticated USING ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));\n\nCREATE POLICY plg_inventory_recipe_ingredients_update ON public.plg_inventory_recipe_ingredients FOR UPDATE TO authenticated USING ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));\n\nCREATE POLICY plg_inventory_recipes_authz_delete ON public.plg_inventory_recipes FOR DELETE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_recipes.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('inventory.recipe'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.recipe'::text, plg_inventory_recipes.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_recipes.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.delete'::text, plg_inventory_recipes.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_recipes_authz_insert ON public.plg_inventory_recipes FOR INSERT TO authenticated WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_recipes.unit_id) AS has_unit)) AND ( SELECT app.has_permission('inventory.create'::text, plg_inventory_recipes.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_recipes_authz_select ON public.plg_inventory_recipes FOR SELECT TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_recipes.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('inventory.recipe'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.recipe'::text, plg_inventory_recipes.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_recipes.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.read'::text, plg_inventory_recipes.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_recipes_authz_update ON public.plg_inventory_recipes FOR UPDATE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_recipes.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('inventory.recipe'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.recipe'::text, plg_inventory_recipes.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_recipes.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.edit'::text, plg_inventory_recipes.unit_id) AS has_permission))) WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_recipes.unit_id) AS has_unit)) AND ( SELECT app.has_permission('inventory.edit'::text, plg_inventory_recipes.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_recipes_delete ON public.plg_inventory_recipes FOR DELETE TO authenticated USING ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));\n\nCREATE POLICY plg_inventory_recipes_insert ON public.plg_inventory_recipes FOR INSERT TO authenticated WITH CHECK ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));\n\nCREATE POLICY plg_inventory_recipes_select ON public.plg_inventory_recipes FOR SELECT TO authenticated USING ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));\n\nCREATE POLICY plg_inventory_recipes_update ON public.plg_inventory_recipes FOR UPDATE TO authenticated USING ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));\n\nCREATE POLICY plg_inventory_reservations_authz_delete ON public.plg_inventory_reservations FOR DELETE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ( SELECT app.has_unit(plg_inventory_reservations.unit_id) AS has_unit) AND ((NOT ( SELECT app.owner_scoped('inventory.reservation'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.reservation'::text, plg_inventory_reservations.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_reservations.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.delete'::text, plg_inventory_reservations.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_reservations_authz_insert ON public.plg_inventory_reservations FOR INSERT TO authenticated WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_reservations.unit_id) AS has_unit)) AND ( SELECT app.has_permission('inventory.create'::text, plg_inventory_reservations.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_reservations_authz_select ON public.plg_inventory_reservations FOR SELECT TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ( SELECT app.has_unit(plg_inventory_reservations.unit_id) AS has_unit) AND ((NOT ( SELECT app.owner_scoped('inventory.reservation'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.reservation'::text, plg_inventory_reservations.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_reservations.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.read'::text, plg_inventory_reservations.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_reservations_authz_update ON public.plg_inventory_reservations FOR UPDATE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ( SELECT app.has_unit(plg_inventory_reservations.unit_id) AS has_unit) AND ((NOT ( SELECT app.owner_scoped('inventory.reservation'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.reservation'::text, plg_inventory_reservations.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_reservations.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.edit'::text, plg_inventory_reservations.unit_id) AS has_permission))) WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_reservations.unit_id) AS has_unit)) AND ( SELECT app.has_permission('inventory.edit'::text, plg_inventory_reservations.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_stock_locations_authz_delete ON public.plg_inventory_stock_locations FOR DELETE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ( SELECT app.has_unit(plg_inventory_stock_locations.unit_id) AS has_unit) AND ((NOT ( SELECT app.owner_scoped('inventory.stock_location'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.stock_location'::text, plg_inventory_stock_locations.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_stock_locations.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.delete'::text, plg_inventory_stock_locations.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_stock_locations_authz_insert ON public.plg_inventory_stock_locations FOR INSERT TO authenticated WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_stock_locations.unit_id) AS has_unit)) AND ( SELECT app.has_permission('inventory.create'::text, plg_inventory_stock_locations.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_stock_locations_authz_select ON public.plg_inventory_stock_locations FOR SELECT TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ( SELECT app.has_unit(plg_inventory_stock_locations.unit_id) AS has_unit) AND ((NOT ( SELECT app.owner_scoped('inventory.stock_location'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.stock_location'::text, plg_inventory_stock_locations.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_stock_locations.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.read'::text, plg_inventory_stock_locations.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_stock_locations_authz_update ON public.plg_inventory_stock_locations FOR UPDATE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ( SELECT app.has_unit(plg_inventory_stock_locations.unit_id) AS has_unit) AND ((NOT ( SELECT app.owner_scoped('inventory.stock_location'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.stock_location'::text, plg_inventory_stock_locations.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_stock_locations.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.edit'::text, plg_inventory_stock_locations.unit_id) AS has_permission))) WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_stock_locations.unit_id) AS has_unit)) AND ( SELECT app.has_permission('inventory.edit'::text, plg_inventory_stock_locations.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_stock_movements_authz_delete ON public.plg_inventory_stock_movements FOR DELETE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ( SELECT app.has_unit(plg_inventory_stock_movements.unit_id) AS has_unit) AND ((NOT ( SELECT app.owner_scoped('inventory.stock_movement'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.stock_movement'::text, plg_inventory_stock_movements.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_stock_movements.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.delete'::text, plg_inventory_stock_movements.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_stock_movements_authz_insert ON public.plg_inventory_stock_movements FOR INSERT TO authenticated WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_stock_movements.unit_id) AS has_unit)) AND ( SELECT app.has_permission('inventory.create'::text, plg_inventory_stock_movements.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_stock_movements_authz_select ON public.plg_inventory_stock_movements FOR SELECT TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ( SELECT app.has_unit(plg_inventory_stock_movements.unit_id) AS has_unit) AND ((NOT ( SELECT app.owner_scoped('inventory.stock_movement'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.stock_movement'::text, plg_inventory_stock_movements.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_stock_movements.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.read'::text, plg_inventory_stock_movements.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_stock_movements_authz_update ON public.plg_inventory_stock_movements FOR UPDATE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ( SELECT app.has_unit(plg_inventory_stock_movements.unit_id) AS has_unit) AND ((NOT ( SELECT app.owner_scoped('inventory.stock_movement'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.stock_movement'::text, plg_inventory_stock_movements.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_stock_movements.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.edit'::text, plg_inventory_stock_movements.unit_id) AS has_permission))) WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_stock_movements.unit_id) AS has_unit)) AND ( SELECT app.has_permission('inventory.edit'::text, plg_inventory_stock_movements.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_stock_positions_authz_delete ON public.plg_inventory_stock_positions FOR DELETE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ( SELECT app.has_unit(plg_inventory_stock_positions.unit_id) AS has_unit) AND ((NOT ( SELECT app.owner_scoped('inventory.stock_position'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.stock_position'::text, plg_inventory_stock_positions.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_stock_positions.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.delete'::text, plg_inventory_stock_positions.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_stock_positions_authz_insert ON public.plg_inventory_stock_positions FOR INSERT TO authenticated WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_stock_positions.unit_id) AS has_unit)) AND ( SELECT app.has_permission('inventory.create'::text, plg_inventory_stock_positions.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_stock_positions_authz_select ON public.plg_inventory_stock_positions FOR SELECT TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ( SELECT app.has_unit(plg_inventory_stock_positions.unit_id) AS has_unit) AND ((NOT ( SELECT app.owner_scoped('inventory.stock_position'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.stock_position'::text, plg_inventory_stock_positions.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_stock_positions.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.read'::text, plg_inventory_stock_positions.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_stock_positions_authz_update ON public.plg_inventory_stock_positions FOR UPDATE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ( SELECT app.has_unit(plg_inventory_stock_positions.unit_id) AS has_unit) AND ((NOT ( SELECT app.owner_scoped('inventory.stock_position'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.stock_position'::text, plg_inventory_stock_positions.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_stock_positions.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.edit'::text, plg_inventory_stock_positions.unit_id) AS has_permission))) WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_stock_positions.unit_id) AS has_unit)) AND ( SELECT app.has_permission('inventory.edit'::text, plg_inventory_stock_positions.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_unit_conversions_authz_delete ON public.plg_inventory_unit_conversions FOR DELETE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_unit_conversions.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('inventory.unit_conversion'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.unit_conversion'::text, plg_inventory_unit_conversions.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_unit_conversions.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.delete'::text, plg_inventory_unit_conversions.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_unit_conversions_authz_insert ON public.plg_inventory_unit_conversions FOR INSERT TO authenticated WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_unit_conversions.unit_id) AS has_unit)) AND ( SELECT app.has_permission('inventory.create'::text, plg_inventory_unit_conversions.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_unit_conversions_authz_select ON public.plg_inventory_unit_conversions FOR SELECT TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_unit_conversions.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('inventory.unit_conversion'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.unit_conversion'::text, plg_inventory_unit_conversions.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_unit_conversions.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.read'::text, plg_inventory_unit_conversions.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_unit_conversions_authz_update ON public.plg_inventory_unit_conversions FOR UPDATE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_unit_conversions.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('inventory.unit_conversion'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.unit_conversion'::text, plg_inventory_unit_conversions.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_unit_conversions.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.edit'::text, plg_inventory_unit_conversions.unit_id) AS has_permission))) WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_unit_conversions.unit_id) AS has_unit)) AND ( SELECT app.has_permission('inventory.edit'::text, plg_inventory_unit_conversions.unit_id) AS has_permission)));\n\n\n-- ── acl ─────────────────────────────────────────────────────────────────\n\nREVOKE ALL ON FUNCTION app.inventory_apply_to_position(p_tenant uuid, p_product uuid, p_location uuid, p_batch text, p_expiry date, p_effect numeric, p_unit_cost numeric, p_measurement_unit uuid, OUT o_quantity numeric, OUT o_unit_cost numeric) FROM PUBLIC;\n\nREVOKE ALL ON FUNCTION app.inventory_authorize_location(p_tenant uuid, p_location uuid, p_perm text) FROM PUBLIC;\n\nREVOKE ALL ON FUNCTION app.inventory_available(p_tenant uuid, p_product uuid, p_location uuid, p_batch text, p_expiry date) FROM PUBLIC;\n\nREVOKE ALL ON FUNCTION app.inventory_begin_operation(p_tenant uuid, p_kind text, p_key text, p_request jsonb, OUT op_id uuid, OUT existing jsonb) FROM PUBLIC;\n\nREVOKE ALL ON FUNCTION app.inventory_check_lines(p_lines jsonb) FROM PUBLIC;\n\nREVOKE ALL ON FUNCTION app.inventory_consume_order_item(p_tenant uuid, p_order uuid, p_order_item uuid, p_unit uuid, p_product uuid, p_quantity numeric, p_actor uuid) FROM PUBLIC;\n\nREVOKE ALL ON FUNCTION app.inventory_default_variant(p_tenant uuid, p_product uuid) FROM PUBLIC;\n\nREVOKE ALL ON FUNCTION app.inventory_finish_operation(p_tenant uuid, p_op uuid, p_kind text, p_key text, p_result jsonb, p_audit jsonb) FROM PUBLIC;\n\nREVOKE ALL ON FUNCTION app.inventory_movement_json(p_id uuid) FROM PUBLIC;\n\nREVOKE ALL ON FUNCTION app.inventory_parse_line(p_tenant uuid, p_line jsonb, p_mode text, OUT product_id uuid, OUT quantity numeric, OUT unit_cost numeric, OUT batch_number text, OUT expiration_date date, OUT measurement_unit_id uuid, OUT declared jsonb, OUT notes text) FROM PUBLIC;\n\nREVOKE ALL ON FUNCTION app.inventory_position_json(p_tenant uuid, p_product uuid, p_location uuid, p_batch text, p_expiry date) FROM PUBLIC;\n\nREVOKE ALL ON FUNCTION app.inventory_product_label(p_product uuid) FROM PUBLIC;\nGRANT ALL ON FUNCTION app.inventory_product_label(p_product uuid) TO authenticated;\nGRANT ALL ON FUNCTION app.inventory_product_label(p_product uuid) TO service_role;\n\nREVOKE ALL ON FUNCTION app.inventory_require_tenant() FROM PUBLIC;\n\nREVOKE ALL ON FUNCTION app.inventory_reservation_json(p_id uuid) FROM PUBLIC;\n\nREVOKE ALL ON FUNCTION app.inventory_reservation_one_per_source() FROM PUBLIC;\n\nREVOKE ALL ON FUNCTION app.inventory_sync_archetype_balance(p_tenant uuid, p_product uuid, p_location uuid, p_delta numeric) FROM PUBLIC;\n\nREVOKE ALL ON FUNCTION public.agent_inventory_upsert_recipe(p_tenant_id uuid, p_actor_user_id uuid, p_payload jsonb) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.agent_inventory_upsert_recipe(p_tenant_id uuid, p_actor_user_id uuid, p_payload jsonb) TO authenticated;\nGRANT ALL ON FUNCTION public.agent_inventory_upsert_recipe(p_tenant_id uuid, p_actor_user_id uuid, p_payload jsonb) TO service_role;\n\nREVOKE ALL ON FUNCTION public.app_product_stock_owner(p_tenant uuid) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.app_product_stock_owner(p_tenant uuid) TO service_role;\n\nREVOKE ALL ON FUNCTION public.inventory_adjust(p_location uuid, p_lines jsonb, p_reason text, p_idempotency_key text) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.inventory_adjust(p_location uuid, p_lines jsonb, p_reason text, p_idempotency_key text) TO authenticated;\nGRANT ALL ON FUNCTION public.inventory_adjust(p_location uuid, p_lines jsonb, p_reason text, p_idempotency_key text) TO service_role;\n\nREVOKE ALL ON FUNCTION public.inventory_close_count_session(p_session_id uuid, p_reason text) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.inventory_close_count_session(p_session_id uuid, p_reason text) TO authenticated;\nGRANT ALL ON FUNCTION public.inventory_close_count_session(p_session_id uuid, p_reason text) TO service_role;\n\nREVOKE ALL ON FUNCTION public.inventory_confirm(p_idempotency_key text) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.inventory_confirm(p_idempotency_key text) TO authenticated;\nGRANT ALL ON FUNCTION public.inventory_confirm(p_idempotency_key text) TO service_role;\n\nREVOKE ALL ON FUNCTION public.inventory_convert(p_tenant uuid, p_product uuid, p_qty numeric, p_from_unit uuid, p_to_unit uuid) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.inventory_convert(p_tenant uuid, p_product uuid, p_qty numeric, p_from_unit uuid, p_to_unit uuid) TO authenticated;\nGRANT ALL ON FUNCTION public.inventory_convert(p_tenant uuid, p_product uuid, p_qty numeric, p_from_unit uuid, p_to_unit uuid) TO service_role;\n\nREVOKE ALL ON FUNCTION public.inventory_open_count_session(p_stock_location_id uuid, p_category_id uuid, p_reference text, p_blind boolean, p_notes text) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.inventory_open_count_session(p_stock_location_id uuid, p_category_id uuid, p_reference text, p_blind boolean, p_notes text) TO authenticated;\nGRANT ALL ON FUNCTION public.inventory_open_count_session(p_stock_location_id uuid, p_category_id uuid, p_reference text, p_blind boolean, p_notes text) TO service_role;\n\nREVOKE ALL ON FUNCTION public.inventory_receive(p_location uuid, p_lines jsonb, p_document jsonb, p_idempotency_key text) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.inventory_receive(p_location uuid, p_lines jsonb, p_document jsonb, p_idempotency_key text) TO authenticated;\nGRANT ALL ON FUNCTION public.inventory_receive(p_location uuid, p_lines jsonb, p_document jsonb, p_idempotency_key text) TO service_role;\n\nREVOKE ALL ON FUNCTION public.inventory_register_product_image(p_product uuid, p_storage_path text, p_public_url text, p_file_name text, p_file_size integer, p_mime_type text) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.inventory_register_product_image(p_product uuid, p_storage_path text, p_public_url text, p_file_name text, p_file_size integer, p_mime_type text) TO authenticated;\nGRANT ALL ON FUNCTION public.inventory_register_product_image(p_product uuid, p_storage_path text, p_public_url text, p_file_name text, p_file_size integer, p_mime_type text) TO service_role;\n\nREVOKE ALL ON FUNCTION public.inventory_reserve(p_source_type text, p_source_id uuid, p_lines jsonb, p_idempotency_key text) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.inventory_reserve(p_source_type text, p_source_id uuid, p_lines jsonb, p_idempotency_key text) TO authenticated;\nGRANT ALL ON FUNCTION public.inventory_reserve(p_source_type text, p_source_id uuid, p_lines jsonb, p_idempotency_key text) TO service_role;\n\nREVOKE ALL ON FUNCTION public.inventory_reverse(p_idempotency_key text, p_reason text) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.inventory_reverse(p_idempotency_key text, p_reason text) TO authenticated;\nGRANT ALL ON FUNCTION public.inventory_reverse(p_idempotency_key text, p_reason text) TO service_role;\n\nREVOKE ALL ON FUNCTION public.inventory_seed_measurement_units() FROM PUBLIC;\nGRANT ALL ON FUNCTION public.inventory_seed_measurement_units() TO authenticated;\nGRANT ALL ON FUNCTION public.inventory_seed_measurement_units() TO service_role;\n\nREVOKE ALL ON FUNCTION public.inventory_seed_measurement_units(p_tenant uuid) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.inventory_seed_measurement_units(p_tenant uuid) TO service_role;\n\nREVOKE ALL ON FUNCTION public.inventory_transfer(p_from_location uuid, p_to_location uuid, p_lines jsonb, p_idempotency_key text) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.inventory_transfer(p_from_location uuid, p_to_location uuid, p_lines jsonb, p_idempotency_key text) TO authenticated;\nGRANT ALL ON FUNCTION public.inventory_transfer(p_from_location uuid, p_to_location uuid, p_lines jsonb, p_idempotency_key text) TO service_role;\n\nREVOKE ALL ON FUNCTION public.plg_inventory_on_fulfillment_completed(p_event jsonb) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.plg_inventory_on_fulfillment_completed(p_event jsonb) TO service_role;\n\nREVOKE ALL ON FUNCTION public.plg_inventory_on_order_completed(p_event jsonb) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.plg_inventory_on_order_completed(p_event jsonb) TO service_role;\n\nREVOKE ALL ON FUNCTION public.trg_fulfillments_emit() FROM PUBLIC;\nGRANT ALL ON FUNCTION public.trg_fulfillments_emit() TO service_role;\n\nREVOKE ALL ON FUNCTION public.trg_plg_inventory_fulfillment_items_inherit() FROM PUBLIC;\nGRANT ALL ON FUNCTION public.trg_plg_inventory_fulfillment_items_inherit() TO service_role;\n\nGRANT MAINTAIN ON TABLE public.plg_inventory_count_items TO anon;\nGRANT ALL ON TABLE public.plg_inventory_count_items TO authenticated;\nGRANT ALL ON TABLE public.plg_inventory_count_items TO service_role;\n\nGRANT MAINTAIN ON TABLE public.plg_inventory_count_sessions TO anon;\nGRANT ALL ON TABLE public.plg_inventory_count_sessions TO authenticated;\nGRANT ALL ON TABLE public.plg_inventory_count_sessions TO service_role;\n\nGRANT MAINTAIN ON TABLE public.plg_inventory_fulfillment_items TO anon;\nGRANT ALL ON TABLE public.plg_inventory_fulfillment_items TO authenticated;\nGRANT ALL ON TABLE public.plg_inventory_fulfillment_items TO service_role;\n\nGRANT ALL ON TABLE public.plg_inventory_measurement_units TO service_role;\nGRANT SELECT,INSERT,DELETE,UPDATE ON TABLE public.plg_inventory_measurement_units TO authenticated;\n\nGRANT ALL ON TABLE public.plg_inventory_operations TO service_role;\nGRANT SELECT ON TABLE public.plg_inventory_operations TO authenticated;\n\nGRANT MAINTAIN ON TABLE public.plg_inventory_product_categories TO anon;\nGRANT ALL ON TABLE public.plg_inventory_product_categories TO authenticated;\nGRANT ALL ON TABLE public.plg_inventory_product_categories TO service_role;\n\nGRANT MAINTAIN ON TABLE public.plg_inventory_product_details TO anon;\nGRANT ALL ON TABLE public.plg_inventory_product_details TO authenticated;\nGRANT ALL ON TABLE public.plg_inventory_product_details TO service_role;\n\nGRANT ALL ON TABLE public.plg_inventory_product_settings TO service_role;\nGRANT SELECT,INSERT,DELETE,UPDATE ON TABLE public.plg_inventory_product_settings TO authenticated;\n\nGRANT MAINTAIN ON TABLE public.plg_inventory_recipe_groups TO anon;\nGRANT ALL ON TABLE public.plg_inventory_recipe_groups TO authenticated;\nGRANT ALL ON TABLE public.plg_inventory_recipe_groups TO service_role;\n\nGRANT MAINTAIN ON TABLE public.plg_inventory_recipe_ingredients TO anon;\nGRANT ALL ON TABLE public.plg_inventory_recipe_ingredients TO authenticated;\nGRANT ALL ON TABLE public.plg_inventory_recipe_ingredients TO service_role;\n\nGRANT MAINTAIN ON TABLE public.plg_inventory_recipes TO anon;\nGRANT ALL ON TABLE public.plg_inventory_recipes TO authenticated;\nGRANT ALL ON TABLE public.plg_inventory_recipes TO service_role;\n\nGRANT ALL ON TABLE public.plg_inventory_reservations TO service_role;\nGRANT SELECT ON TABLE public.plg_inventory_reservations TO authenticated;\n\nGRANT ALL ON TABLE public.plg_inventory_stock_locations TO service_role;\nGRANT SELECT,INSERT,DELETE,UPDATE ON TABLE public.plg_inventory_stock_locations TO authenticated;\n\nGRANT ALL ON TABLE public.plg_inventory_stock_movements TO service_role;\nGRANT SELECT ON TABLE public.plg_inventory_stock_movements TO authenticated;\n\nGRANT ALL ON TABLE public.plg_inventory_stock_positions TO service_role;\nGRANT SELECT ON TABLE public.plg_inventory_stock_positions TO authenticated;\n\nGRANT ALL ON TABLE public.plg_inventory_unit_conversions TO service_role;\nGRANT SELECT,INSERT,DELETE,UPDATE ON TABLE public.plg_inventory_unit_conversions TO authenticated;\n\nGRANT ALL ON TABLE public.v_inventory_balances TO authenticated;\nGRANT ALL ON TABLE public.v_inventory_balances TO service_role;\n\nGRANT ALL ON TABLE public.v_inventory_low_stock TO authenticated;\nGRANT ALL ON TABLE public.v_inventory_low_stock TO service_role;\n\nGRANT ALL ON TABLE public.v_inventory_movements TO authenticated;\nGRANT ALL ON TABLE public.v_inventory_movements TO service_role;\n\nGRANT ALL ON TABLE public.v_inventory_product_totals TO authenticated;\nGRANT ALL ON TABLE public.v_inventory_product_totals TO service_role;\n\nGRANT MAINTAIN ON TABLE public.v_inventory_products TO anon;\nGRANT ALL ON TABLE public.v_inventory_products TO authenticated;\nGRANT ALL ON TABLE public.v_inventory_products TO service_role;\n\nGRANT MAINTAIN ON TABLE public.v_stock_movements TO anon;\nGRANT ALL ON TABLE public.v_stock_movements TO authenticated;\nGRANT ALL ON TABLE public.v_stock_movements TO service_role;\n\n\n-- ── comment ─────────────────────────────────────────────────────────────\n\nCOMMENT ON FUNCTION app.inventory_default_variant(p_tenant uuid, p_product uuid) IS 'Which variant a product-level movement belongs to (#276): the default one, else the first active. NULL when the catalogue has none — the movement then writes no balance rather than inventing a variant.';\n\nCOMMENT ON FUNCTION app.inventory_sync_archetype_balance(p_tenant uuid, p_product uuid, p_location uuid, p_delta numeric) IS 'Applies a movement''s delta to public.stock_balances at the archetype''s grain (#276). Batch and expiry stay with this plugin; the archetype gets the number.';\n\nCOMMENT ON FUNCTION public.plg_inventory_on_fulfillment_completed(p_event jsonb) IS 'Consumes a completed DELIVERY through the same door a completed sale uses (PRD 05). The quantity is the delivery line''s own, so an order of two that delivers one moves the balance by one — which a status column on the sold line could never express.';\n\nCOMMENT ON FUNCTION public.plg_inventory_on_order_completed(p_event jsonb) IS 'Consumes a completed order from the durable event log (024), skipping any line a delivery document already covered (PRD 05). Active recipes still expand into ingredients; a product without a recipe still consumes its own stock.';\n\nCOMMENT ON COLUMN public.plg_inventory_count_items.system_quantity IS 'What the system believed when the session OPENED — never re-read at close.';\n\nCOMMENT ON COLUMN public.plg_inventory_count_items.counted_quantity IS 'NULL means nobody counted this line. It is not zero and never emits an adjustment.';\n\nCOMMENT ON COLUMN public.plg_inventory_count_items.movement_id IS 'The adjustment this line already emitted. Set exactly once — the per-line idempotency key of the close.';\n\nCOMMENT ON TABLE public.plg_inventory_fulfillment_items IS 'What only a delivery line has (PRD 05): batch, expiry, who handed it over and from which stock location. Composite FK on (item_id, parent_kind) — the database refuses it over an invoice line, which is why this table has no tax column and that one has no batch.';\n\nCOMMENT ON TABLE public.plg_inventory_operations IS 'Idempotency ledger of the inventory RPCs: (tenant, kind, idempotency_key) → stored result. A replay returns the stored result and has no second effect.';\n\nCOMMENT ON COLUMN public.plg_inventory_product_details.conversion_factor IS 'How many measurement_unit_id fit in one purchase_unit_id. 1 when the product is bought and consumed in the same unit.';\n\nCOMMENT ON COLUMN public.plg_inventory_product_details.purpose IS 'purchase | sale | both | menu. Unconstrained: the vocabulary is per-app labels, not a schema fact.';\n\nCOMMENT ON TABLE public.plg_inventory_product_settings IS 'Per-product inventory settings (min/max quantity, stock unit, default location), tenant-wide (unit_id NULL) or per unit. Replaces products.min_stock.';\n\nCOMMENT ON COLUMN public.plg_inventory_recipes.metadata IS 'What the assistant could not resolve: {unmatchedIngredients:[{name,quantity,notes}], productHint}.';\n\nCOMMENT ON TABLE public.plg_inventory_reservations IS 'Consumption hooks: inventory_reserve → inventory_confirm (one out movement, exactly once) → inventory_reverse. Keyed by the source item through idempotency_key.';\n\nCOMMENT ON TABLE public.plg_inventory_stock_movements IS 'Append-only stock ledger (PRD 06). quantity is signed: the effect on the position at (product, source_location, batch, expiry); a transfer or reversed transfer applies -quantity at destination_location. Written only by the inventory_* RPCs.';\n\nCOMMENT ON COLUMN public.plg_inventory_stock_movements.stock_location_id IS 'DEPRECATED compat mirror of source_location_id, kept so the applied 004_stock_movement_view stays replay-safe. Dropped at the cut-over.';\n\nCOMMENT ON COLUMN public.plg_inventory_stock_movements.movement_type IS 'DEPRECATED compat mirror of kind, kept so the applied 004_stock_movement_view stays replay-safe. Dropped at the cut-over.';\n\nCOMMENT ON TABLE public.plg_inventory_stock_positions IS 'Derived balances per (product, location, batch, expiry). Maintained by the movements writer trigger; never written by app code. Read through v_inventory_balances / v_inventory_product_totals.';\n\nCOMMENT ON COLUMN public.plg_inventory_stock_positions.unit_type IS 'base | purchase | content — which of the product''s units this quantity is counted in.';\n\nCOMMENT ON TABLE public.plg_inventory_unit_conversions IS 'from_unit → to_unit × factor, tenant-wide (product_id NULL) or product-specific (a 250 mL bottle: bottle → mL × 250). Resolved by public.inventory_convert().';\n\nCOMMENT ON VIEW public.v_inventory_balances IS 'Balances per (product, location, batch, expiry) with reserved/available. security_invoker: the caller sees the units she has access to (inventory.read).';\n\nCOMMENT ON VIEW public.v_inventory_low_stock IS 'Products at or below the min quantity of plg_inventory_product_settings (tenant-wide row: unit_id NULL; per-unit rows).';\n\nCOMMENT ON VIEW public.v_inventory_movements IS 'The append-only ledger with names. movement_type is the SDK legacy label (entry/exit/loss/adjustment/transfer); kind is canonical.';\n\nCOMMENT ON VIEW public.v_inventory_product_totals IS 'Balances per product across the units the caller sees. Replaces products.stock as the read model.';\n\nCOMMENT ON VIEW public.v_inventory_products IS 'The catalogue with its balance (027): every products column, plus stock, available, reserved and min_stock DERIVED from the ledger and the product settings. It replaces the products.stock and products.min_stock columns spine 188 dropped — same names, same shape, one writer.';\n\n\n-- ── default privileges: restore ─────────────────────────────────────────\n-- Put back, so the NEXT thing installed inherits what the chain had.\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO anon$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO anon$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticated$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticated$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO service_role$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO service_role$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO anon$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO anon$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticated$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticated$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO service_role$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO service_role$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO authenticated$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO authenticated$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO service_role$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO service_role$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO anon$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO anon$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO authenticated$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO authenticated$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO service_role$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO service_role$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO postgres$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO postgres$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT MAINTAIN ON TABLES TO anon$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT MAINTAIN ON TABLES TO anon$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO authenticated$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO authenticated$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO service_role$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO service_role$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO postgres$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO postgres$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO anon$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO anon$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO authenticated$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO authenticated$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO service_role$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO service_role$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$--\n-- PostgreSQL database dump complete\n--$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$--\n-- PostgreSQL database dump complete\n--$stmt$;\nEND $dp$;\n`\n\nexport const MIGRATION_001_THE_USAGE_THAT_LEFT_STOCK_CAN_BE_AUDITED = `-- 001_the_usage_that_left_stock_can_be_audited.sql\n--\n-- AUDITORIA DE USO. No V1 o consumo declarado no atendimento fica numa FILA e\n-- só baixa o estoque depois que alguém aprova. O V2 não tem essa fila e não\n-- deveria ter: \\`order.completed\\` consome a receita no mesmo instante, pelo log\n-- de eventos, e é isso que faz o saldo e a venda contarem a mesma história.\n--\n-- O QUE A TELA DO V1 REALMENTE ENTREGA não é o represamento — é a PERGUNTA:\n-- \"o que o profissional disse ter usado bate com o padrão do serviço?\". No V1\n-- ela era feita antes da baixa; aqui é feita depois. A resposta é a mesma, e a\n-- correção continua possível porque o razão é append-only: corrigir é lançar o\n-- ajuste da diferença, nunca reescrever o que já saiu.\n--\n-- POR QUE UMA VISTA, E NÃO UMA TABELA DE AUDITORIA. Uma fila própria seria um\n-- segundo lugar guardando a mesma quantidade, e os dois divergem no primeiro\n-- estorno. Tudo de que a auditoria precisa já está escrito:\n--\n-- \\`plg_inventory_stock_movements\\` guarda \\`source_item_type='order_item'\\` e\n-- \\`source_item_id\\` apontando para a linha vendida, com \\`unit_cost\\`,\n-- \\`total_cost\\` e a localização de onde saiu.\n--\n-- \\`plg_inventory_recipes\\` + \\`_recipe_ingredients\\` guardam o PADRÃO — o mesmo\n-- número que \\`app.inventory_consume_order_item\\` usou para calcular a baixa.\n--\n-- \\`items.assignee_id\\` guarda QUEM executou (financeiro 019 já provou o elo),\n-- e \\`orders.party_id\\` guarda para quem.\n--\n-- A GRANULARIDADE É (linha vendida × produto), não o movimento. Um consumo se\n-- parte em vários movimentos quando o FIFO atravessa lotes: três linhas de\n-- razão para um frasco só confundiriam quem confere.\n--\n-- O PADRÃO É O DE HOJE. A receita pode ter mudado desde o atendimento, e a\n-- vista mostra a receita vigente — igual ao V1, que também lia o cadastro atual.\n-- Divergência antiga que sumiu porque a receita mudou é ruído, não achado.\n\nCREATE OR REPLACE VIEW public.v_inventory_usage_audit\nWITH (security_invoker = 'true') AS\nWITH consumed AS (\n SELECT m.tenant_id,\n m.unit_id,\n m.source_item_id AS item_id,\n m.product_id,\n -- o razão guarda saída como negativo; quem confere pensa em positivo\n sum(- m.quantity) AS posted_quantity,\n sum(m.total_cost) AS total_cost,\n min(m.created_at) AS used_at,\n min(m.movement_date) AS movement_date,\n (array_agg(m.source_location_id ORDER BY m.created_at, m.id))[1] AS stock_location_id,\n count(DISTINCT m.source_location_id)::int AS location_count,\n (array_agg(m.measurement_unit_id ORDER BY m.created_at, m.id))[1] AS measurement_unit_id,\n (array_agg(m.metadata ->> 'recipe_id' ORDER BY m.created_at, m.id))[1] AS recipe_id\n FROM public.plg_inventory_stock_movements m\n WHERE m.source_item_type = 'order_item'\n AND m.source_item_id IS NOT NULL\n AND m.kind = 'out'\n -- estornado não é consumo: devolver ao estoque apaga a linha da fila\n AND NOT EXISTS (\n SELECT 1 FROM public.plg_inventory_stock_movements r\n WHERE r.reverses_movement_id = m.id\n )\n GROUP BY m.tenant_id, m.unit_id, m.source_item_id, m.product_id\n), corrected AS (\n SELECT c.tenant_id,\n c.source_item_id AS item_id,\n c.product_id,\n -- devolver ao estoque é ajuste positivo, e reduz o consumido\n sum(- c.quantity) AS correction_delta,\n -- o razão guarda custo sempre positivo; o sinal vem da quantidade\n sum(- sign(c.quantity) * c.total_cost) AS correction_cost,\n max(c.created_at) AS corrected_at,\n (array_agg(c.reason ORDER BY c.created_at DESC, c.id DESC))[1] AS correction_reason\n FROM public.plg_inventory_stock_movements c\n WHERE c.source_item_type = 'usage_audit'\n AND c.source_item_id IS NOT NULL\n GROUP BY c.tenant_id, c.source_item_id, c.product_id\n)\nSELECT c.tenant_id,\n c.unit_id,\n c.item_id,\n c.product_id,\n p.name AS product_name,\n p.sku AS product_sku,\n c.used_at,\n c.movement_date,\n c.posted_quantity::numeric(14,4) AS posted_quantity,\n (c.posted_quantity + coalesce(k.correction_delta, 0))::numeric(14,4) AS net_quantity,\n std.standard_quantity::numeric(14,4) AS standard_quantity,\n CASE WHEN std.standard_quantity IS NULL THEN NULL\n ELSE ((c.posted_quantity + coalesce(k.correction_delta, 0))\n - std.standard_quantity)::numeric(14,4)\n END AS variance,\n u.abbreviation AS unit_abbreviation,\n -- o custo acompanha a conclusão da auditoria, não o que foi lançado:\n -- devolver um frasco devolve o custo dele junto\n (c.total_cost + coalesce(k.correction_cost, 0))::numeric(16,4) AS total_cost,\n c.stock_location_id,\n sl.name AS stock_location_name,\n -- mais de uma posição quer dizer que o FIFO atravessou lotes\n c.location_count,\n r.id AS recipe_id,\n r.name AS recipe_name,\n i.order_id,\n i.name AS item_name,\n o.reference_number AS order_reference,\n i.assignee_id AS performed_by_id,\n perf.name AS performed_by_name,\n o.party_id AS customer_id,\n cust.name AS customer_name,\n k.corrected_at,\n k.correction_reason,\n (k.item_id IS NOT NULL) AS is_corrected\n FROM consumed c\n LEFT JOIN corrected k\n ON k.tenant_id = c.tenant_id AND k.item_id = c.item_id AND k.product_id = c.product_id\n LEFT JOIN public.products p ON p.id = c.product_id\n LEFT JOIN public.plg_inventory_stock_locations sl ON sl.id = c.stock_location_id\n LEFT JOIN public.plg_inventory_measurement_units u ON u.id = c.measurement_unit_id\n -- A linha vendida e seu pedido chegam por LEFT JOIN de propósito: quem lê o\n -- estoque pode não ler a venda, e nesse caso a linha aparece sem o nome do\n -- profissional em vez de desaparecer da conferência.\n LEFT JOIN public.items i ON i.id = c.item_id AND i.tenant_id = c.tenant_id\n LEFT JOIN public.orders o ON o.id = i.order_id\n LEFT JOIN public.people perf ON perf.id = i.assignee_id\n LEFT JOIN public.people cust ON cust.id = o.party_id\n LEFT JOIN public.plg_inventory_recipes r\n ON r.id = nullif(c.recipe_id, '')::uuid AND r.tenant_id = c.tenant_id\n LEFT JOIN LATERAL (\n SELECT CASE\n WHEN r.id IS NOT NULL AND coalesce(r.yield_quantity, 0) > 0 THEN (\n SELECT round(sum(ri.quantity) * coalesce(i.quantity, 1) / r.yield_quantity, 4)\n FROM public.plg_inventory_recipe_ingredients ri\n WHERE ri.tenant_id = c.tenant_id\n AND ri.recipe_id = r.id\n AND ri.product_id = c.product_id\n )\n -- sem receita o produto vendido é o próprio consumido: o padrão é\n -- a quantidade da linha, e divergir aí seria erro de sistema\n WHEN r.id IS NULL THEN i.quantity\n ELSE NULL\n END AS standard_quantity\n ) std ON true;\n\nCOMMENT ON VIEW public.v_inventory_usage_audit IS\n 'O consumo que já saiu do estoque, por linha vendida e produto, ao lado do padrão da receita vigente e da correção que a auditoria lançou. Não cria fila: o razão já sabia tudo isso (001).';\n\nGRANT SELECT ON public.v_inventory_usage_audit TO authenticated;\nGRANT SELECT ON public.v_inventory_usage_audit TO service_role;\n\n-- ── corrigir é lançar a diferença, não reescrever o passado ─────────────────\n--\n-- No V1 \"Corrigir\" e \"Aprovar\" são um ato só porque a baixa ainda não tinha\n-- acontecido. Aqui ela aconteceu, então corrigir para 40 o que saiu como 50\n-- devolve 10 ao saldo — e o razão fica com as duas linhas, que é o que permite\n-- perguntar depois quem corrigiu o quê.\n--\n-- Um motivo é obrigatório, como no V1. Ajuste de estoque sem motivo é o que\n-- transforma uma conferência em um buraco novo.\nCREATE OR REPLACE FUNCTION public.inventory_correct_usage(\n p_item_id uuid,\n p_product_id uuid,\n p_quantity numeric,\n p_reason text\n) RETURNS jsonb\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $$\nDECLARE\n v_tenant uuid := app.inventory_require_tenant();\n v_reason text := nullif(btrim(p_reason), '');\n v_row record;\n v_delta numeric;\n v_unit uuid;\n v_key text;\n v_op record;\n v_id uuid;\nBEGIN\n IF v_reason IS NULL THEN\n RAISE EXCEPTION 'inventory: a usage correction needs a reason' USING ERRCODE = '22023';\n END IF;\n IF p_quantity IS NULL OR p_quantity < 0 THEN\n RAISE EXCEPTION 'inventory: the corrected quantity cannot be negative' USING ERRCODE = '22023';\n END IF;\n\n -- o consumido de hoje: a baixa original mais o que auditorias já corrigiram\n SELECT sum(- m.quantity) AS net,\n (array_agg(m.source_location_id ORDER BY m.created_at DESC, m.id DESC))[1] AS location_id,\n (array_agg(m.measurement_unit_id ORDER BY m.created_at DESC, m.id DESC))[1] AS measurement_unit_id,\n (array_agg(m.batch_number ORDER BY m.created_at DESC, m.id DESC))[1] AS batch_number,\n (array_agg(m.expiration_date ORDER BY m.created_at DESC, m.id DESC))[1] AS expiration_date,\n (array_agg(m.unit_cost ORDER BY m.created_at DESC, m.id DESC))[1] AS unit_cost\n INTO v_row\n FROM public.plg_inventory_stock_movements m\n WHERE m.tenant_id = v_tenant\n AND m.source_item_id = p_item_id\n AND m.product_id = p_product_id\n AND ((m.source_item_type = 'order_item' AND m.kind = 'out')\n OR (m.source_item_type = 'usage_audit' AND m.kind = 'adjust'))\n AND NOT EXISTS (\n SELECT 1 FROM public.plg_inventory_stock_movements r\n WHERE r.reverses_movement_id = m.id\n );\n\n IF v_row.net IS NULL THEN\n RAISE EXCEPTION 'inventory: no usage of product % on item % to correct', p_product_id, p_item_id\n USING ERRCODE = '22023';\n END IF;\n\n -- positivo devolve ao estoque, negativo tira mais\n v_delta := v_row.net - p_quantity;\n IF v_delta = 0 THEN\n RETURN jsonb_build_object('kind', 'usage_audit', 'status', 'unchanged',\n 'item_id', p_item_id, 'product_id', p_product_id,\n 'quantity', p_quantity);\n END IF;\n\n IF v_delta < 0\n AND app.inventory_available(v_tenant, p_product_id, v_row.location_id,\n v_row.batch_number, v_row.expiration_date) < - v_delta THEN\n RAISE EXCEPTION 'inventory: correcting to % would take product % below its available quantity at location %',\n p_quantity, p_product_id, v_row.location_id USING ERRCODE = '23514';\n END IF;\n\n v_unit := app.inventory_authorize_location(v_tenant, v_row.location_id, 'inventory.edit');\n\n -- corrigir duas vezes para o MESMO número é o mesmo ato, e a segunda chamada\n -- devolve o resultado da primeira em vez de mexer no saldo de novo\n v_key := 'usage_audit:' || p_item_id::text || ':' || p_product_id::text || ':' || p_quantity::text;\n\n SELECT * INTO v_op FROM app.inventory_begin_operation(\n v_tenant, 'adjust', v_key,\n jsonb_build_object('source', 'usage_audit', 'item_id', p_item_id,\n 'product_id', p_product_id, 'quantity', p_quantity, 'reason', v_reason));\n IF v_op.existing IS NOT NULL THEN\n RETURN v_op.existing;\n END IF;\n\n INSERT INTO public.plg_inventory_stock_movements\n (tenant_id, product_id, kind, quantity, unit_cost, source_location_id,\n batch_number, expiration_date, measurement_unit_id, document_type,\n reason, source_item_type, source_item_id, idempotency_key, line_no,\n operation_id, metadata)\n VALUES\n (v_tenant, p_product_id, 'adjust', v_delta, coalesce(v_row.unit_cost, 0), v_row.location_id,\n v_row.batch_number, v_row.expiration_date, v_row.measurement_unit_id, 'adjustment',\n v_reason, 'usage_audit', p_item_id, v_key, 1, v_op.op_id,\n jsonb_build_object('source', 'usage_audit', 'order_item_id', p_item_id,\n 'posted_quantity', v_row.net, 'corrected_quantity', p_quantity))\n RETURNING id INTO v_id;\n\n RETURN app.inventory_finish_operation(\n v_tenant, v_op.op_id, 'adjust', v_key,\n jsonb_build_object('operation_id', v_op.op_id, 'kind', 'usage_audit',\n 'idempotency_key', v_key, 'status', 'corrected',\n 'item_id', p_item_id, 'product_id', p_product_id,\n 'location_id', v_row.location_id, 'unit_id', v_unit,\n 'previous_quantity', v_row.net, 'quantity', p_quantity,\n 'delta', v_delta, 'reason', v_reason,\n 'movements', app.inventory_movement_json(v_id)),\n jsonb_build_object('source', 'usage_audit', 'item_id', p_item_id,\n 'product_id', p_product_id, 'unit_id', v_unit,\n 'previous_quantity', v_row.net, 'quantity', p_quantity,\n 'reason', v_reason));\nEND $$;\n\nCOMMENT ON FUNCTION public.inventory_correct_usage(uuid, uuid, numeric, text) IS\n 'Corrige o que o atendimento disse ter usado: lança o ajuste da diferença no razão, com motivo, e deixa o rastro ligado à linha vendida por source_item_type=usage_audit (001).';\n\nREVOKE ALL ON FUNCTION public.inventory_correct_usage(uuid, uuid, numeric, text) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.inventory_correct_usage(uuid, uuid, numeric, text) TO authenticated;\nGRANT ALL ON FUNCTION public.inventory_correct_usage(uuid, uuid, numeric, text) TO service_role;\n`\n\nexport const MIGRATION_002_A_AUDITORIA_FECHA_EM_LOTE = `-- 002_a_auditoria_fecha_em_lote.sql\n--\n-- A AUDITORIA VIRA UMA CONTAGEM. Corrigir linha a linha, cada uma com o seu\n-- motivo, é o desenho de quem conserta UM engano. Quem confere um dia inteiro\n-- desce a lista digitando o que realmente saiu e explica o conjunto UMA vez —\n-- é o mesmo gesto do fechamento de contagem (\\`inventory_close_count_session\\`),\n-- que emite N ajustes sob um motivo só.\n--\n-- POR QUE ISSO PRECISA DE UMA RPC NOVA, E NÃO DE UM LAÇO NO CLIENTE. Um laço\n-- de N chamadas produz N operações, N motivos iguais repetidos e N transações:\n-- a terceira pode falhar depois de as duas primeiras terem gravado, e o\n-- operador fica sem saber o que aplicou. Aqui o lote é UM\n-- \\`plg_inventory_operations\\` — um evento, com as linhas dentro — e o\n-- \\`operation_id\\` de cada movimento é o que devolve o lote inteiro depois.\n--\n-- O QUE **NÃO** MUDA: continua sendo o razão que manda. Aplicar o lote LANÇA a\n-- diferença de cada linha como ajuste; nada é reescrito, nada vira UPDATE de\n-- saldo, e uma linha cuja quantidade não mudou não gera movimento nenhum. Por\n-- isso o rascunho pode viver na tela: não existe segundo lugar guardando a\n-- mesma quantidade, que é o mesmo motivo pelo qual a 001 recusou uma fila.\n\n-- ── uma linha do lote, sem abrir operação própria ───────────────────────────\n--\n-- Extraído de \\`inventory_correct_usage\\` (001) para que o ato de uma linha e o\n-- de um lote sejam literalmente o mesmo código. Não abre nem fecha operação:\n-- quem chama já é dono de uma.\nCREATE OR REPLACE FUNCTION app.inventory_correct_usage_line(\n p_tenant uuid,\n p_op uuid,\n p_key text,\n p_line_no integer,\n p_item_id uuid,\n p_product_id uuid,\n p_quantity numeric,\n p_reason text\n) RETURNS jsonb\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $$\nDECLARE\n v_row record;\n v_delta numeric;\n v_unit uuid;\n v_id uuid;\nBEGIN\n IF p_quantity IS NULL OR p_quantity < 0 THEN\n RAISE EXCEPTION 'inventory: the corrected quantity cannot be negative' USING ERRCODE = '22023';\n END IF;\n\n -- o consumido de hoje: a baixa original mais o que auditorias já corrigiram\n SELECT sum(- m.quantity) AS net,\n (array_agg(m.source_location_id ORDER BY m.created_at DESC, m.id DESC))[1] AS location_id,\n (array_agg(m.measurement_unit_id ORDER BY m.created_at DESC, m.id DESC))[1] AS measurement_unit_id,\n (array_agg(m.batch_number ORDER BY m.created_at DESC, m.id DESC))[1] AS batch_number,\n (array_agg(m.expiration_date ORDER BY m.created_at DESC, m.id DESC))[1] AS expiration_date,\n (array_agg(m.unit_cost ORDER BY m.created_at DESC, m.id DESC))[1] AS unit_cost\n INTO v_row\n FROM public.plg_inventory_stock_movements m\n WHERE m.tenant_id = p_tenant\n AND m.source_item_id = p_item_id\n AND m.product_id = p_product_id\n AND ((m.source_item_type = 'order_item' AND m.kind = 'out')\n OR (m.source_item_type = 'usage_audit' AND m.kind = 'adjust'))\n AND NOT EXISTS (\n SELECT 1 FROM public.plg_inventory_stock_movements r\n WHERE r.reverses_movement_id = m.id\n );\n\n IF v_row.net IS NULL THEN\n RAISE EXCEPTION 'inventory: no usage of product % on item % to correct', p_product_id, p_item_id\n USING ERRCODE = '22023';\n END IF;\n\n -- positivo devolve ao estoque, negativo tira mais\n v_delta := v_row.net - p_quantity;\n IF v_delta = 0 THEN\n RETURN jsonb_build_object('status', 'unchanged', 'item_id', p_item_id,\n 'product_id', p_product_id, 'previous_quantity', v_row.net,\n 'quantity', p_quantity, 'delta', 0);\n END IF;\n\n IF v_delta < 0\n AND app.inventory_available(p_tenant, p_product_id, v_row.location_id,\n v_row.batch_number, v_row.expiration_date) < - v_delta THEN\n RAISE EXCEPTION 'inventory: correcting to % would take product % below its available quantity at location %',\n p_quantity, p_product_id, v_row.location_id USING ERRCODE = '23514';\n END IF;\n\n v_unit := app.inventory_authorize_location(p_tenant, v_row.location_id, 'inventory.edit');\n\n INSERT INTO public.plg_inventory_stock_movements\n (tenant_id, product_id, kind, quantity, unit_cost, source_location_id,\n batch_number, expiration_date, measurement_unit_id, document_type,\n reason, source_item_type, source_item_id, idempotency_key, line_no,\n operation_id, metadata)\n VALUES\n (p_tenant, p_product_id, 'adjust', v_delta, coalesce(v_row.unit_cost, 0), v_row.location_id,\n v_row.batch_number, v_row.expiration_date, v_row.measurement_unit_id, 'adjustment',\n p_reason, 'usage_audit', p_item_id, p_key, p_line_no, p_op,\n jsonb_build_object('source', 'usage_audit', 'order_item_id', p_item_id,\n 'posted_quantity', v_row.net, 'corrected_quantity', p_quantity))\n RETURNING id INTO v_id;\n\n RETURN jsonb_build_object('status', 'corrected', 'item_id', p_item_id,\n 'product_id', p_product_id, 'location_id', v_row.location_id,\n 'unit_id', v_unit, 'previous_quantity', v_row.net,\n 'quantity', p_quantity, 'delta', v_delta,\n 'movements', app.inventory_movement_json(v_id));\nEND $$;\n\nCOMMENT ON FUNCTION app.inventory_correct_usage_line(uuid, uuid, text, integer, uuid, uuid, numeric, text) IS\n 'Uma linha de correção de consumo dentro de uma operação já aberta: lança a diferença no razão. Compartilhada pela correção avulsa e pelo lote (002).';\n\nREVOKE ALL ON FUNCTION app.inventory_correct_usage_line(uuid, uuid, text, integer, uuid, uuid, numeric, text) FROM PUBLIC;\n\n-- ── a correção avulsa passa a ser um lote de uma linha ──────────────────────\n--\n-- Mesma assinatura, mesma chave de idempotência, mesmo retorno: nada que já\n-- chamava esta função precisa saber que o miolo mudou de casa.\nCREATE OR REPLACE FUNCTION public.inventory_correct_usage(\n p_item_id uuid,\n p_product_id uuid,\n p_quantity numeric,\n p_reason text\n) RETURNS jsonb\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $$\nDECLARE\n v_tenant uuid := app.inventory_require_tenant();\n v_reason text := nullif(btrim(p_reason), '');\n v_key text;\n v_op record;\n v_line jsonb;\nBEGIN\n IF v_reason IS NULL THEN\n RAISE EXCEPTION 'inventory: a usage correction needs a reason' USING ERRCODE = '22023';\n END IF;\n\n -- corrigir duas vezes para o MESMO número é o mesmo ato, e a segunda chamada\n -- devolve o resultado da primeira em vez de mexer no saldo de novo\n v_key := 'usage_audit:' || p_item_id::text || ':' || p_product_id::text || ':' || p_quantity::text;\n\n SELECT * INTO v_op FROM app.inventory_begin_operation(\n v_tenant, 'adjust', v_key,\n jsonb_build_object('source', 'usage_audit', 'item_id', p_item_id,\n 'product_id', p_product_id, 'quantity', p_quantity, 'reason', v_reason));\n IF v_op.existing IS NOT NULL THEN\n RETURN v_op.existing;\n END IF;\n\n v_line := app.inventory_correct_usage_line(\n v_tenant, v_op.op_id, v_key, 1, p_item_id, p_product_id, p_quantity, v_reason);\n\n IF v_line->>'status' = 'unchanged' THEN\n RETURN jsonb_build_object('kind', 'usage_audit', 'status', 'unchanged',\n 'item_id', p_item_id, 'product_id', p_product_id,\n 'quantity', p_quantity);\n END IF;\n\n RETURN app.inventory_finish_operation(\n v_tenant, v_op.op_id, 'adjust', v_key,\n jsonb_build_object('operation_id', v_op.op_id, 'kind', 'usage_audit',\n 'idempotency_key', v_key, 'status', 'corrected',\n 'item_id', p_item_id, 'product_id', p_product_id,\n 'location_id', v_line->'location_id', 'unit_id', v_line->'unit_id',\n 'previous_quantity', v_line->'previous_quantity',\n 'quantity', p_quantity, 'delta', v_line->'delta', 'reason', v_reason,\n 'movements', v_line->'movements'),\n jsonb_build_object('source', 'usage_audit', 'item_id', p_item_id,\n 'product_id', p_product_id, 'unit_id', v_line->'unit_id',\n 'previous_quantity', v_line->'previous_quantity',\n 'quantity', p_quantity, 'reason', v_reason));\nEND $$;\n\nCOMMENT ON FUNCTION public.inventory_correct_usage(uuid, uuid, numeric, text) IS\n 'Corrige o que o atendimento disse ter usado: lança o ajuste da diferença no razão, com motivo, e deixa o rastro ligado à linha vendida por source_item_type=usage_audit (001, reescrita sobre o miolo compartilhado em 002).';\n\nREVOKE ALL ON FUNCTION public.inventory_correct_usage(uuid, uuid, numeric, text) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.inventory_correct_usage(uuid, uuid, numeric, text) TO authenticated;\nGRANT ALL ON FUNCTION public.inventory_correct_usage(uuid, uuid, numeric, text) TO service_role;\n\n-- ── o lote: um motivo, N linhas, UM evento ──────────────────────────────────\n--\n-- \\`p_lines\\` é \\`[{\"item_id\": uuid, \"product_id\": uuid, \"quantity\": numeric}, …]\\`\n-- — a linha vendida, o produto e o que a conferência diz que realmente saiu.\n--\n-- A chave de idempotência sai do CONTEÚDO do lote (linhas ordenadas + motivo),\n-- não de um relógio: reenviar o mesmo lote — o duplo clique, a rede que caiu\n-- entre gravar e responder — devolve o resultado do primeiro envio. E mesmo\n-- sem isso o dano seria zero: a segunda passagem calcula delta 0 e não emite.\nCREATE OR REPLACE FUNCTION public.inventory_correct_usage_batch(\n p_reason text,\n p_lines jsonb\n) RETURNS jsonb\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $$\nDECLARE\n v_tenant uuid := app.inventory_require_tenant();\n v_reason text := nullif(btrim(p_reason), '');\n v_key text;\n v_op record;\n v_line jsonb;\n v_result jsonb;\n r record;\n v_no integer := 0;\n v_applied integer := 0;\n v_unchanged integer := 0;\n v_lines jsonb := '[]'::jsonb;\n v_movements jsonb := '[]'::jsonb;\nBEGIN\n IF v_reason IS NULL THEN\n RAISE EXCEPTION 'inventory: a usage correction needs a reason' USING ERRCODE = '22023';\n END IF;\n PERFORM app.inventory_check_lines(p_lines);\n\n SELECT 'usage_audit_batch:' || md5(v_reason || '|' || string_agg(\n (l->>'item_id') || ':' || (l->>'product_id') || ':' || (l->>'quantity'), ',' ORDER BY\n (l->>'item_id'), (l->>'product_id')))\n INTO v_key\n FROM jsonb_array_elements(p_lines) AS l;\n\n SELECT * INTO v_op FROM app.inventory_begin_operation(\n v_tenant, 'adjust', v_key,\n jsonb_build_object('source', 'usage_audit_batch', 'reason', v_reason, 'lines', p_lines));\n IF v_op.existing IS NOT NULL THEN\n RETURN v_op.existing;\n END IF;\n\n -- a ordem do array é a ordem em que a pessoa digitou, e é ela que o\n -- \\`line_no\\` guarda: reler o lote depois devolve a conferência como ela foi\n -- feita, não reordenada por id\n FOR r IN\n SELECT (l->>'item_id')::uuid AS item_id,\n (l->>'product_id')::uuid AS product_id,\n (l->>'quantity')::numeric AS quantity,\n ord\n FROM jsonb_array_elements(p_lines) WITH ORDINALITY AS t(l, ord)\n ORDER BY ord\n LOOP\n IF r.item_id IS NULL OR r.product_id IS NULL OR r.quantity IS NULL THEN\n RAISE EXCEPTION 'inventory: every audit line needs item_id, product_id and quantity'\n USING ERRCODE = '22023';\n END IF;\n v_no := v_no + 1;\n v_line := app.inventory_correct_usage_line(\n v_tenant, v_op.op_id, v_key, v_no, r.item_id, r.product_id, r.quantity, v_reason);\n IF v_line->>'status' = 'unchanged' THEN\n v_unchanged := v_unchanged + 1;\n ELSE\n v_applied := v_applied + 1;\n v_movements := v_movements || coalesce(v_line->'movements', '[]'::jsonb);\n END IF;\n v_lines := v_lines || jsonb_build_array(v_line - 'movements');\n END LOOP;\n\n v_result := jsonb_build_object(\n 'operation_id', v_op.op_id, 'kind', 'usage_audit_batch',\n 'idempotency_key', v_key, 'status', 'corrected', 'reason', v_reason,\n 'lines_applied', v_applied, 'lines_unchanged', v_unchanged,\n 'lines', v_lines, 'movements', v_movements);\n\n RETURN app.inventory_finish_operation(\n v_tenant, v_op.op_id, 'adjust', v_key, v_result,\n jsonb_build_object('source', 'usage_audit_batch', 'reason', v_reason,\n 'lines_applied', v_applied, 'lines_unchanged', v_unchanged));\nEND $$;\n\nCOMMENT ON FUNCTION public.inventory_correct_usage_batch(text, jsonb) IS\n 'Fecha uma conferência de consumo inteira: N linhas corrigidas sob UM motivo e UMA operação, cada diferença lançada como ajuste no razão. O operation_id devolve o lote depois (002).';\n\nREVOKE ALL ON FUNCTION public.inventory_correct_usage_batch(text, jsonb) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.inventory_correct_usage_batch(text, jsonb) TO authenticated;\nGRANT ALL ON FUNCTION public.inventory_correct_usage_batch(text, jsonb) TO service_role;\n`\n\nexport const MIGRATION_003_STOCK_COUNT_CLOSES_THROUGH_LEDGER = `-- 003_stock_count_closes_through_ledger.sql\n--\n-- A physical count is a reconciliation, not a second stock writer. The old\n-- function inserted values into GENERATED columns, recorded the counted total\n-- instead of the variance and then updated the position a second time outside\n-- the ledger trigger. This replacement emits one canonical \\`adjust\\` movement\n-- per divergent line and lets the existing movement trigger update the balance.\n--\n-- Safety gates: every line must be counted (zero is valid); any stock movement\n-- after the opening snapshot aborts the close; lot stock requires a future\n-- batch-aware count instead of guessing which lot changed.\n\nCREATE OR REPLACE FUNCTION public.inventory_close_count_session(p_session_id uuid, p_reason text DEFAULT NULL::text) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE\n v_tenant uuid := app.inventory_require_tenant();\n v_session public.plg_inventory_count_sessions%ROWTYPE;\n v_item public.plg_inventory_count_items%ROWTYPE;\n v_op record;\n v_delta numeric;\n v_current numeric;\n v_current_cost numeric;\n v_has_lots boolean;\n v_movement_id uuid;\n v_reason text;\n v_key text := 'count:' || p_session_id::text || ':close:v1';\n v_line_no int := 0;\n v_uncounted int;\n v_conflicts int;\n v_emitted int := 0;\n v_movements jsonb := '[]'::jsonb;\n v_positions jsonb := '[]'::jsonb;\n v_result jsonb;\nBEGIN\n SELECT * INTO v_session FROM public.plg_inventory_count_sessions\n WHERE id = p_session_id AND tenant_id = v_tenant FOR UPDATE;\n IF NOT FOUND THEN\n RETURN jsonb_build_object('ok', false, 'error', 'unknown count session');\n END IF;\n PERFORM app.inventory_authorize_location(v_tenant, v_session.stock_location_id, 'inventory.edit');\n\n IF v_session.status = 'closed' THEN\n RETURN jsonb_build_object('ok', true, 'session_id', p_session_id,\n 'status', 'closed', 'already_closed', true, 'adjustments_created', 0);\n END IF;\n IF v_session.status = 'cancelled' THEN\n RETURN jsonb_build_object('ok', false, 'error', 'this count was cancelled');\n END IF;\n\n v_reason := COALESCE(NULLIF(btrim(COALESCE(p_reason, '')), ''), 'Stock count');\n\n SELECT count(*) INTO v_uncounted\n FROM public.plg_inventory_count_items i\n WHERE i.session_id = p_session_id AND i.counted_quantity IS NULL;\n IF v_uncounted > 0 THEN\n RAISE EXCEPTION 'inventory: count session has % uncounted line(s)', v_uncounted\n USING ERRCODE = '22023', HINT = 'Count every line, including physical zero, before closing.';\n END IF;\n\n PERFORM p.id\n FROM public.plg_inventory_stock_positions p\n JOIN public.plg_inventory_count_items i\n ON i.tenant_id = p.tenant_id AND i.product_id = p.product_id\n WHERE i.session_id = p_session_id\n AND p.stock_location_id = v_session.stock_location_id\n ORDER BY p.id\n FOR UPDATE OF p;\n\n SELECT count(DISTINCT i.id) INTO v_conflicts\n FROM public.plg_inventory_count_items i\n JOIN public.plg_inventory_stock_movements m\n ON m.tenant_id = i.tenant_id\n AND m.product_id = i.product_id\n AND (m.source_location_id = v_session.stock_location_id\n OR m.destination_location_id = v_session.stock_location_id)\n WHERE i.session_id = p_session_id\n AND m.created_at > v_session.opened_at\n AND (i.movement_id IS NULL OR m.id <> i.movement_id);\n IF v_conflicts > 0 THEN\n RAISE EXCEPTION 'inventory: stock changed after this count opened (% line(s)); review and recount', v_conflicts\n USING ERRCODE = '40001';\n END IF;\n\n SELECT * INTO v_op FROM app.inventory_begin_operation(\n v_tenant, 'adjust', v_key,\n jsonb_build_object('count_session_id', p_session_id, 'reason', v_reason));\n IF v_op.existing IS NOT NULL THEN\n UPDATE public.plg_inventory_count_sessions\n SET status = 'closed', closed_at = coalesce(closed_at, now()),\n closed_by = coalesce(closed_by, auth.uid()), updated_at = now()\n WHERE id = p_session_id;\n RETURN v_op.existing || jsonb_build_object('already_closed', true, 'adjustments_created', 0);\n END IF;\n\n FOR v_item IN\n SELECT * FROM public.plg_inventory_count_items\n WHERE session_id = p_session_id AND movement_id IS NULL\n ORDER BY id\n FOR UPDATE\n LOOP\n SELECT coalesce(sum(p.quantity), 0),\n coalesce(max(p.unit_cost), v_item.unit_cost, 0),\n coalesce(bool_or((p.batch_number IS NOT NULL OR p.expiration_date IS NOT NULL) AND p.quantity <> 0), false)\n INTO v_current, v_current_cost, v_has_lots\n FROM public.plg_inventory_stock_positions p\n WHERE p.tenant_id = v_tenant\n AND p.product_id = v_item.product_id\n AND p.stock_location_id = v_session.stock_location_id;\n\n v_delta := v_item.counted_quantity - v_current;\n IF v_delta = 0 THEN CONTINUE; END IF;\n IF v_has_lots THEN\n RAISE EXCEPTION 'inventory: product % has batch stock; close it with a batch-aware count', v_item.product_id\n USING ERRCODE = '22023';\n END IF;\n\n v_line_no := v_line_no + 1;\n INSERT INTO public.plg_inventory_stock_movements (\n tenant_id, product_id, kind, quantity, unit_cost,\n source_location_id, document_type, reason,\n idempotency_key, line_no, operation_id,\n source_item_type, source_item_id, metadata\n ) VALUES (\n v_tenant, v_item.product_id, 'adjust', v_delta, v_current_cost,\n v_session.stock_location_id, 'stock_count', v_reason,\n v_key, v_line_no, v_op.op_id,\n 'stock_count_item', v_item.id,\n jsonb_build_object(\n 'countSessionId', p_session_id,\n 'countItemId', v_item.id,\n 'systemQuantity', v_item.system_quantity,\n 'currentQuantity', v_current,\n 'countedQuantity', v_item.counted_quantity,\n 'variance', v_delta\n )\n )\n RETURNING id INTO v_movement_id;\n\n UPDATE public.plg_inventory_count_items\n SET movement_id = v_movement_id, updated_at = now()\n WHERE id = v_item.id;\n v_movements := v_movements || app.inventory_movement_json(v_movement_id);\n v_positions := v_positions || app.inventory_position_json(\n v_tenant, v_item.product_id, v_session.stock_location_id, NULL, NULL);\n v_emitted := v_emitted + 1;\n END LOOP;\n\n UPDATE public.plg_inventory_count_sessions\n SET status = 'closed', closed_at = now(), closed_by = auth.uid(), updated_at = now()\n WHERE id = p_session_id;\n\n v_result := jsonb_build_object(\n 'ok', true, 'session_id', p_session_id, 'status', 'closed',\n 'already_closed', false, 'adjustments_created', v_emitted,\n 'operation_id', v_op.op_id, 'idempotency_key', v_key,\n 'movements', v_movements, 'positions', v_positions);\n RETURN app.inventory_finish_operation(\n v_tenant, v_op.op_id, 'count.close', v_key, v_result,\n jsonb_build_object('count_session_id', p_session_id, 'adjustments_created', v_emitted));\nEND;\n$$;\n`\n\nexport const MIGRATION_004_A_POSICAO_DE_ESTOQUE_ATRAVESSA = `-- A posição de estoque atravessa.\n--\n-- \\`stock_positions\\` — o saldo por produto × local × lote — estava no plano de\n-- leitura e sem destino: 1.861 linhas do tenant Pertinho do Céu ficaram\n-- \\`pendentes\\` em toda rodada, nunca ofertadas a writer nenhum. A nota no\n-- extrator dizia \"no V2 a posição é derivada do movimento\", e essa frase é\n-- verdadeira e insuficiente.\n--\n-- Derivar o saldo do movimento só dá o mesmo número quando o movimento\n-- atravessa INTEIRO. Neste tenant 2.208 dos 2.697 movimentos estavam em\n-- quarentena, então o saldo derivado seria o saldo de 18% do histórico —\n-- apresentado com a mesma confiança do saldo certo. E lote e validade, que a\n-- posição carrega e o movimento não, não se derivam de soma nenhuma.\n--\n-- Então a posição vem do V1 como fato, e não como conta.\n--\n-- ── O que o destino exige e a origem não dá ─────────────────────────────────\n--\n-- \\`plg_inventory_stock_positions\\` tem três colunas NOT NULL que precisam de\n-- tradução:\n--\n-- product_id → resolvido pelo ledger de \\`products\\`\n-- stock_location_id → pelo ledger de \\`stock_locations\\`\n-- unit_id → a unidade da EMPRESA (app.units), e o V1 guarda duas\n-- coisas com nome parecido: \\`unit_id\\` (a unidade de\n-- MEDIDA do item) e \\`company_unit_id\\` (a filial). É a\n-- segunda. Confundi-las poria a posição na unidade\n-- errada — e num tenant de 63 filiais isso é saldo no\n-- lugar de outro.\n--\n-- Quando a filial não vem na linha, ela vem do LOCAL: um local de estoque\n-- pertence a uma unidade, e o saldo que está nele está nela.\n--\n-- Idempotente.\n\n-- ─────────────────────────────────────────────────────────────────────────────\n-- 1. O writer\n-- ─────────────────────────────────────────────────────────────────────────────\n\nCREATE OR REPLACE FUNCTION migration.upsert_inv_position(\n p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $function$\nDECLARE\n v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);\n v_product uuid := (p_row ->> 'product_id')::uuid;\n v_location uuid := (p_row ->> 'stock_location_id')::uuid;\n v_unit uuid := (p_row ->> 'unit_id')::uuid;\n v_qty numeric := round(coalesce((p_row ->> 'quantity')::numeric, 0), 4);\n v_existing_tenant uuid;\n v_created boolean := false;\n v_standalone boolean := migration._standalone();\nBEGIN\n IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;\n IF p_id IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'uma posição precisa de id');\n END IF;\n IF v_product IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',\n 'reason', 'posição de saldo cujo produto ainda não atravessou');\n END IF;\n IF v_location IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',\n 'reason', 'posição de saldo sem local — saldo sem lugar não é saldo');\n END IF;\n\n -- A filial pelo LOCAL quando a linha não a traz: o local pertence a uma\n -- unidade, e o saldo que está nele está nela.\n IF v_unit IS NULL THEN\n SELECT l.unit_id INTO v_unit\n FROM public.plg_inventory_stock_locations l\n WHERE l.id = v_location AND l.tenant_id = p_tenant;\n END IF;\n IF v_unit IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',\n 'reason', 'posição sem unidade, e o local dela também não diz de qual é');\n END IF;\n\n -- \\`quantity >= 0\\` é CHECK da tabela. O V1 admite saldo negativo (saída que\n -- não conferiu com a entrada); recusar aqui, com a frase, é melhor que o erro\n -- cru do banco no meio do lote — e melhor que gravar zero, que apagaria a\n -- evidência de que a contagem daquele item está furada.\n IF v_qty < 0 THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',\n 'reason', format('saldo negativo (%s) — o V1 admite, o razão de estoque do V2 não', v_qty));\n END IF;\n\n SELECT tenant_id INTO v_existing_tenant FROM public.plg_inventory_stock_positions WHERE id = p_id;\n IF v_existing_tenant IS NOT NULL AND v_existing_tenant <> p_tenant THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'tenant',\n 'reason', format('%s already exists in another tenant', p_id));\n END IF;\n\n IF v_existing_tenant IS NULL THEN\n INSERT INTO public.plg_inventory_stock_positions\n (id, tenant_id, unit_id, product_id, stock_location_id, quantity, unit_cost,\n batch_number, expiration_date, unit_type)\n VALUES (p_id, p_tenant, v_unit, v_product, v_location, v_qty,\n round(coalesce((p_row ->> 'unit_cost')::numeric, 0), 4),\n p_row ->> 'batch_number', (p_row ->> 'expiration_date')::date,\n coalesce(nullif(btrim(p_row ->> 'unit_type'), ''), 'base'));\n v_created := true;\n ELSE\n UPDATE public.plg_inventory_stock_positions SET\n unit_id = coalesce(v_unit, unit_id),\n product_id = coalesce(v_product, product_id),\n stock_location_id = coalesce(v_location, stock_location_id),\n quantity = CASE WHEN p_row ? 'quantity' THEN v_qty ELSE quantity END,\n unit_cost = CASE WHEN p_row ? 'unit_cost' THEN round(coalesce((p_row ->> 'unit_cost')::numeric, 0), 4) ELSE unit_cost END,\n batch_number = CASE WHEN p_row ? 'batch_number' THEN p_row ->> 'batch_number' ELSE batch_number END,\n expiration_date = CASE WHEN p_row ? 'expiration_date' THEN (p_row ->> 'expiration_date')::date ELSE expiration_date END,\n unit_type = CASE WHEN p_row ? 'unit_type' THEN coalesce(nullif(btrim(p_row ->> 'unit_type'), ''), 'base') ELSE unit_type END,\n updated_at = now()\n WHERE id = p_id;\n END IF;\n\n IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_inventory_stock_positions', p_id, jsonb_build_object('reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END)); END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);\nEND $function$;\n\n-- ─────────────────────────────────────────────────────────────────────────────\n-- 2. O vocabulário de writers aceita o nome novo\n-- ─────────────────────────────────────────────────────────────────────────────\n\nDO $$\nDECLARE v_def text;\nBEGIN\n SELECT pg_get_constraintdef(oid) INTO v_def\n FROM pg_constraint WHERE conname = 'allowlist_writer_check';\n IF v_def IS NULL OR strpos(v_def, 'inv_position') > 0 THEN RETURN; END IF;\n ALTER TABLE migration.allowlist DROP CONSTRAINT allowlist_writer_check;\n EXECUTE replace(\n 'ALTER TABLE migration.allowlist ADD CONSTRAINT allowlist_writer_check ' || v_def,\n '''inv_movement''::text',\n '''inv_movement''::text, ''inv_position''::text');\nEND $$;\n\n-- ─────────────────────────────────────────────────────────────────────────────\n-- 3. O de-para\n-- ─────────────────────────────────────────────────────────────────────────────\n--\n-- \\`unit_id\\` do V1 é a unidade de MEDIDA e não a filial — por isso ela vai para\n-- metadata e quem responde pela filial é \\`company_unit_id\\`.\n\n-- A allowlist é CONGELADA (\\`trg_allowlist_frozen\\`): mudança de core-A é\n-- arquivo de migration, não escrita solta — e esta é um arquivo. A escotilha\n-- é transacional (\\`true\\` no terceiro argumento), então não fica porta aberta.\nSELECT set_config('migration.allowlist_unlock', 'on', true);\n\nSELECT migration._allow(\n 'stock_positions', 'public.plg_inventory_stock_positions', 'inventory',\n 'inv_position', '{}'::jsonb, ARRAY['quantity', 'unit_cost'],\n jsonb_build_object(\n 'uuid', 'id',\n 'product_id', 'product_id@products',\n 'stock_location_id', 'stock_location_id@stock_locations',\n 'company_unit_id', 'unit_id@companies:app.units',\n 'quantity', 'quantity',\n 'unit_cost', 'unit_cost',\n 'batch_number', 'batch_number',\n 'expiration_date', 'expiration_date',\n 'unit_type', 'unit_type',\n 'location', 'jsonb:v1.location_label',\n 'unit_id', 'jsonb:v1.measurement_unit_ref'\n ),\n 'O SALDO por produto × local × lote. Vem da origem como FATO e não derivado '\n 'do movimento: o saldo derivado só bate quando o movimento atravessa inteiro, '\n 'e lote e validade a posição carrega e o movimento não.'\n);\n\nSELECT set_config('migration.allowlist_unlock', 'off', true);\n\n\n-- ── quem pode executar ────────────────────────────────────────────────────\n--\n-- Função nova nasce executável por PUBLIC, e \\`anon\\`/\\`authenticated\\` herdam\n-- dali. Estas são de PORTE: SECURITY DEFINER, escrevem dados de qualquer\n-- inquilino, e uma delas alcançável pela chave anônima é a base inteira\n-- alcançável pela chave anônima. Só o service_role, que é quem roda a onda.\nREVOKE ALL ON FUNCTION migration.upsert_inv_position(uuid, uuid, jsonb, jsonb) FROM PUBLIC, anon, authenticated;\nGRANT EXECUTE ON FUNCTION migration.upsert_inv_position(uuid, uuid, jsonb, jsonb) TO service_role;\n`\n\nexport const MIGRATION_005_A_POSICAO_E_CONSEQUENCIA_DO_MOVIMENTO = `-- A posição é consequência do movimento.\n--\n-- Desfaz a migration 004, que estava errada — e o registro do porquê vale mais\n-- que o código que sai.\n--\n-- A 004 criou um writer para trazer \\`stock_positions\\` do V1 como FATO, com o\n-- argumento de que derivar o saldo do movimento só dá o mesmo número quando o\n-- movimento atravessa inteiro. O argumento era bom e a premissa tinha deixado\n-- de valer no mesmo dia: os 2.697 movimentos passaram a atravessar inteiros\n-- assim que a entrada por DANFE ganhou o local (a ponte no extrator).\n--\n-- E o destino já dizia isso, em voz alta:\n--\n-- plg_inventory_stock_positions is written only through the inventory RPCs\n-- (inventory_receive / _transfer …)\n--\n-- 339 das 1.861 linhas bateram nesse gatilho. O gatilho está certo: no V2 a\n-- posição não é algo que se escreve, é o que sobra depois que o movimento\n-- passou. Escrever por fora seria manter duas verdades sobre o mesmo saldo e\n-- deixar a divergência para depois.\n--\n-- ── A prova ─────────────────────────────────────────────────────────────────\n--\n-- Medido em 19/09/2026 no tenant Pertinho do Céu, depois de os movimentos\n-- entrarem:\n--\n-- V1 stock_positions 1.861 linhas 251.561,2484\n-- V2 plg_inventory_stock_positions 990 linhas 251.561,2484\n--\n-- O SALDO é o mesmo até o quarto decimal. A contagem de linhas difere porque o\n-- V1 guarda uma posição por lote/validade e o V2 consolida por produto × local\n-- quando não há lote — duas formas de escrever o mesmo estoque, e é o estoque\n-- que se confere.\n--\n-- ── O que sai e o que fica ──────────────────────────────────────────────────\n--\n-- Sai a linha de \\`stock_positions\\` da allowlist. Fica \\`migration.excluded_tables\\`\n-- com o motivo medido, para que a próxima pessoa que vir 1.861 linhas paradas\n-- no dashboard leia a resposta em vez de refazer a investigação.\n--\n-- O writer \\`upsert_inv_position\\` FICA. Ele não faz mal parado, e um pool que\n-- porte posição sem portar movimento vai precisar dele — o que muda é que este\n-- não é esse caso.\n--\n-- Idempotente.\n\n-- A linha está CONGELADA, e o gatilho recusa DELETE nela pelo mesmo motivo que\n-- recusa UPDATE: mudança de core-A é arquivo de migration. Este é o arquivo, e\n-- a escotilha é transacional — não fica porta aberta depois dele.\nSELECT set_config('migration.allowlist_unlock', 'on', true);\nDELETE FROM migration.allowlist WHERE source_table = 'stock_positions';\nSELECT set_config('migration.allowlist_unlock', 'off', true);\n\n-- \\`WHERE tenant_id IS NULL\\` no árbitro: a 073 da espinha trocou a PK de\n-- \\`migration.excluded_tables\\` por dois índices únicos PARCIAIS — um global e\n-- um por inquilino —, porque \"não trazer esta tabela\" passou a ser decisão de\n-- cliente e não do cluster. Índice parcial só serve de árbitro de ON CONFLICT\n-- quando o comando repete o mesmo recorte. Esta decisão é global.\nINSERT INTO migration.excluded_tables (source_table, reason)\nVALUES ('stock_positions',\n 'Consequência, não fato: no V2 a posição só se escreve pelas RPCs de '\n 'inventário (o gatilho recusa INSERT direto), e ela se forma sozinha '\n 'quando o movimento atravessa. Conferido em 19/09/2026 no Pertinho do '\n 'Céu — V1 1.861 linhas / 251.561,2484 contra V2 990 linhas / '\n '251.561,2484: mesmo saldo, formas diferentes de agrupar (o V1 guarda '\n 'por lote, o V2 consolida sem lote).')\n-- \\`WHERE tenant_id IS NULL\\` porque a PK de \\`excluded_tables\\` deixou de existir:\n-- o spine 070 a trocou por dois índices únicos PARCIAIS — um por tabela global,\n-- outro por tabela e tenant. \\`ON CONFLICT (source_table)\\` sozinho não casa com\n-- índice parcial, e a cadeia do zero parou aqui com \"there is no unique or\n-- exclusion constraint matching the ON CONFLICT specification\".\n--\n-- Esta linha é global (sem tenant), então é o índice global que ela quer.\nON CONFLICT (source_table) WHERE tenant_id IS NULL DO UPDATE SET reason = EXCLUDED.reason;\n`\n\nexport const MIGRATION_006_A_PRODUCAO_TEM_ORDEM_E_ESTACAO = `-- A produção tem ordem e estação.\n--\n-- O Pertinho do Céu produz: pega insumo, passa por etapas, sai produto. Seis\n-- tabelas do V1 descrevem isso e nenhuma tinha destino.\n--\n-- production_orders 3 a ordem: o que produzir, quanto, quando\n-- production_order_inputs 27 o insumo consumido\n-- production_order_outputs 2 o que saiu\n-- production_order_steps 7 a etapa, na estação, com tempo\n-- production_plan_items 2 o plano do dia, antes de virar ordem\n-- production_stations 4 onde a etapa acontece\n--\n-- O volume é ridículo — 45 linhas — e é exatamente por isso que vale trazer: a\n-- casa está COMEÇANDO a usar produção, e o dia em que ela usar de verdade o\n-- histórico já vai estar do lado certo. Reconstituir depois é impossível: uma\n-- ordem de produção fechada diz quanto de insumo virou quanto de produto, e\n-- esse número não se recalcula.\n--\n-- ── A receita já existe, a ordem não existia ────────────────────────────────\n--\n-- \\`plg_inventory_recipes\\`, \\`_recipe_ingredients\\` e \\`_recipe_groups\\` já estão no\n-- V2: é a FICHA, o que a coisa leva. O que faltava é a EXECUÇÃO da ficha — a\n-- ordem que a aplica num dia, com o que realmente entrou e o que realmente\n-- saiu. \\`recipe_snapshot\\` guarda a ficha como ela era no dia, porque mudar a\n-- receita amanhã não pode reescrever a produção de ontem.\n--\n-- ── A estação é do KDS também ───────────────────────────────────────────────\n--\n-- \\`production_stations\\` do V1 carrega sete colunas de KDS (\\`kds_enabled\\`,\n-- \\`kds_role\\`, \\`kds_sla_minutes\\`…). O V2 já tem \\`plg_kds_stations\\` para isso, e\n-- duplicar a configuração do monitor aqui daria duas respostas para \"qual a SLA\n-- desta estação\". Então elas vão para \\`metadata.kds\\`, de onde o dia da migração\n-- do KDS as lê — e a estação de PRODUÇÃO fica com o que é dela: nome, unidade,\n-- se está ativa.\n--\n-- Idempotente.\n\nCREATE TABLE IF NOT EXISTS public.plg_inventory_production_stations (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n unit_id uuid,\n name text NOT NULL,\n description text,\n is_active boolean DEFAULT true NOT NULL,\n sort_order integer DEFAULT 0 NOT NULL,\n metadata jsonb DEFAULT '{}'::jsonb NOT NULL,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL\n);\n\nCOMMENT ON TABLE public.plg_inventory_production_stations IS\n 'Onde a etapa de produção acontece. V1: production_stations — a configuração de KDS dela fica em metadata.kds, porque plg_kds_stations é quem responde por monitor.';\n\nCREATE TABLE IF NOT EXISTS public.plg_inventory_production_orders (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n unit_id uuid,\n order_number text,\n status text DEFAULT 'draft'::text NOT NULL,\n product_id uuid,\n recipe_id uuid,\n -- A ficha como ela era no dia. Mudar a receita amanhã não reescreve a\n -- produção de ontem.\n recipe_snapshot jsonb,\n planned_quantity numeric(18,4),\n measurement_unit_id uuid,\n planned_start timestamp with time zone,\n planned_end timestamp with time zone,\n actual_start timestamp with time zone,\n actual_end timestamp with time zone,\n source_type text,\n priority integer DEFAULT 0 NOT NULL,\n notes text,\n owner_id uuid,\n metadata jsonb DEFAULT '{}'::jsonb NOT NULL,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n CONSTRAINT plg_inventory_production_orders_window_check\n CHECK (actual_start IS NULL OR actual_end IS NULL OR actual_end >= actual_start)\n);\n\nCREATE TABLE IF NOT EXISTS public.plg_inventory_production_steps (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n production_order_id uuid NOT NULL,\n station_id uuid,\n sequence integer DEFAULT 0 NOT NULL,\n name text,\n instructions text,\n estimated_minutes integer,\n status text DEFAULT 'pending'::text NOT NULL,\n started_at timestamp with time zone,\n completed_at timestamp with time zone,\n -- O V1 guarda o nome do operador em texto, sem FK. Fica texto: inventar uma\n -- pessoa a partir de um nome digitado é como se cria cadastro duplicado.\n operator_name text,\n notes text,\n metadata jsonb DEFAULT '{}'::jsonb NOT NULL,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS public.plg_inventory_production_inputs (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n production_order_id uuid NOT NULL,\n step_id uuid,\n product_id uuid NOT NULL,\n -- Planejado contra real: é a diferença entre os dois que diz se a ficha\n -- está certa. Guardar só o real perderia a pergunta.\n planned_quantity numeric(18,4),\n actual_quantity numeric(18,4),\n measurement_unit_id uuid,\n stock_position_id uuid,\n unit_cost numeric(18,6),\n consumed_at timestamp with time zone,\n operator_name text,\n metadata jsonb DEFAULT '{}'::jsonb NOT NULL,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS public.plg_inventory_production_outputs (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n production_order_id uuid NOT NULL,\n product_id uuid NOT NULL,\n -- Principal, subproduto ou perda. É a palavra que separa rendimento de\n -- desperdício, e sem ela a conta de custo não fecha.\n output_type text DEFAULT 'main'::text NOT NULL,\n planned_quantity numeric(18,4),\n actual_quantity numeric(18,4),\n measurement_unit_id uuid,\n batch_number text,\n expiration_date date,\n stock_position_id uuid,\n unit_cost numeric(18,6),\n metadata jsonb DEFAULT '{}'::jsonb NOT NULL,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS public.plg_inventory_production_plan_items (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n unit_id uuid,\n plan_date date NOT NULL,\n product_id uuid NOT NULL,\n planned_quantity numeric(18,4),\n measurement_unit_id uuid,\n status text DEFAULT 'planned'::text NOT NULL,\n -- A ordem que o plano virou, quando virou. Nulo é plano ainda não liberado.\n production_order_id uuid,\n notes text,\n metadata jsonb DEFAULT '{}'::jsonb NOT NULL,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL\n);\n\nCOMMENT ON TABLE public.plg_inventory_production_plan_items IS\n 'O que se pretende produzir naquele dia, antes de virar ordem. V1: production_plan_items.';\n\n-- ── chaves ──────────────────────────────────────────────────────────────────\nDO $$\nDECLARE t text;\nBEGIN\n FOREACH t IN ARRAY ARRAY['plg_inventory_production_stations', 'plg_inventory_production_orders',\n 'plg_inventory_production_steps', 'plg_inventory_production_inputs',\n 'plg_inventory_production_outputs', 'plg_inventory_production_plan_items'] LOOP\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = ('public.' || t)::regclass AND contype = 'p') THEN\n EXECUTE format('ALTER TABLE ONLY public.%I ADD CONSTRAINT %I PRIMARY KEY (id)', t, t || '_pkey');\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = t || '_tenant_id_id_key') THEN\n EXECUTE format('ALTER TABLE ONLY public.%I ADD CONSTRAINT %I UNIQUE (tenant_id, id)', t, t || '_tenant_id_id_key');\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = t || '_tenant_fkey') THEN\n EXECUTE format('ALTER TABLE ONLY public.%I ADD CONSTRAINT %I FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE', t, t || '_tenant_fkey');\n END IF;\n END LOOP;\nEND $$;\n\nDO $$ BEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_production_stations_unit_fkey') THEN\n ALTER TABLE ONLY public.plg_inventory_production_stations ADD CONSTRAINT plg_inventory_production_stations_unit_fkey\n FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_production_orders_unit_fkey') THEN\n ALTER TABLE ONLY public.plg_inventory_production_orders ADD CONSTRAINT plg_inventory_production_orders_unit_fkey\n FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_production_orders_product_fkey') THEN\n ALTER TABLE ONLY public.plg_inventory_production_orders ADD CONSTRAINT plg_inventory_production_orders_product_fkey\n FOREIGN KEY (product_id) REFERENCES public.products(id) ON DELETE SET NULL;\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_production_orders_recipe_fkey') THEN\n ALTER TABLE ONLY public.plg_inventory_production_orders ADD CONSTRAINT plg_inventory_production_orders_recipe_fkey\n FOREIGN KEY (recipe_id) REFERENCES public.plg_inventory_recipes(id) ON DELETE SET NULL;\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_production_orders_uom_fkey') THEN\n ALTER TABLE ONLY public.plg_inventory_production_orders ADD CONSTRAINT plg_inventory_production_orders_uom_fkey\n FOREIGN KEY (measurement_unit_id) REFERENCES public.plg_inventory_measurement_units(id) ON DELETE SET NULL;\n END IF;\n\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_production_steps_order_fkey') THEN\n ALTER TABLE ONLY public.plg_inventory_production_steps ADD CONSTRAINT plg_inventory_production_steps_order_fkey\n FOREIGN KEY (tenant_id, production_order_id) REFERENCES public.plg_inventory_production_orders(tenant_id, id) ON DELETE CASCADE;\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_production_steps_station_fkey') THEN\n ALTER TABLE ONLY public.plg_inventory_production_steps ADD CONSTRAINT plg_inventory_production_steps_station_fkey\n FOREIGN KEY (station_id) REFERENCES public.plg_inventory_production_stations(id) ON DELETE SET NULL;\n END IF;\n\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_production_inputs_order_fkey') THEN\n ALTER TABLE ONLY public.plg_inventory_production_inputs ADD CONSTRAINT plg_inventory_production_inputs_order_fkey\n FOREIGN KEY (tenant_id, production_order_id) REFERENCES public.plg_inventory_production_orders(tenant_id, id) ON DELETE CASCADE;\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_production_inputs_step_fkey') THEN\n ALTER TABLE ONLY public.plg_inventory_production_inputs ADD CONSTRAINT plg_inventory_production_inputs_step_fkey\n FOREIGN KEY (step_id) REFERENCES public.plg_inventory_production_steps(id) ON DELETE SET NULL;\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_production_inputs_product_fkey') THEN\n ALTER TABLE ONLY public.plg_inventory_production_inputs ADD CONSTRAINT plg_inventory_production_inputs_product_fkey\n FOREIGN KEY (product_id) REFERENCES public.products(id) ON DELETE CASCADE;\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_production_inputs_uom_fkey') THEN\n ALTER TABLE ONLY public.plg_inventory_production_inputs ADD CONSTRAINT plg_inventory_production_inputs_uom_fkey\n FOREIGN KEY (measurement_unit_id) REFERENCES public.plg_inventory_measurement_units(id) ON DELETE SET NULL;\n END IF;\n\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_production_outputs_order_fkey') THEN\n ALTER TABLE ONLY public.plg_inventory_production_outputs ADD CONSTRAINT plg_inventory_production_outputs_order_fkey\n FOREIGN KEY (tenant_id, production_order_id) REFERENCES public.plg_inventory_production_orders(tenant_id, id) ON DELETE CASCADE;\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_production_outputs_product_fkey') THEN\n ALTER TABLE ONLY public.plg_inventory_production_outputs ADD CONSTRAINT plg_inventory_production_outputs_product_fkey\n FOREIGN KEY (product_id) REFERENCES public.products(id) ON DELETE CASCADE;\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_production_outputs_uom_fkey') THEN\n ALTER TABLE ONLY public.plg_inventory_production_outputs ADD CONSTRAINT plg_inventory_production_outputs_uom_fkey\n FOREIGN KEY (measurement_unit_id) REFERENCES public.plg_inventory_measurement_units(id) ON DELETE SET NULL;\n END IF;\n\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_production_plan_items_unit_fkey') THEN\n ALTER TABLE ONLY public.plg_inventory_production_plan_items ADD CONSTRAINT plg_inventory_production_plan_items_unit_fkey\n FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_production_plan_items_product_fkey') THEN\n ALTER TABLE ONLY public.plg_inventory_production_plan_items ADD CONSTRAINT plg_inventory_production_plan_items_product_fkey\n FOREIGN KEY (product_id) REFERENCES public.products(id) ON DELETE CASCADE;\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_production_plan_items_order_fkey') THEN\n ALTER TABLE ONLY public.plg_inventory_production_plan_items ADD CONSTRAINT plg_inventory_production_plan_items_order_fkey\n FOREIGN KEY (production_order_id) REFERENCES public.plg_inventory_production_orders(id) ON DELETE SET NULL;\n END IF;\nEND $$;\n\nCREATE UNIQUE INDEX IF NOT EXISTS plg_inventory_production_orders_number_uidx\n ON public.plg_inventory_production_orders USING btree (tenant_id, order_number)\n WHERE order_number IS NOT NULL;\nCREATE INDEX IF NOT EXISTS plg_inventory_production_orders_open_idx\n ON public.plg_inventory_production_orders USING btree (tenant_id, unit_id, status, planned_start);\nCREATE INDEX IF NOT EXISTS plg_inventory_production_steps_order_idx\n ON public.plg_inventory_production_steps USING btree (tenant_id, production_order_id, sequence);\nCREATE INDEX IF NOT EXISTS plg_inventory_production_inputs_order_idx\n ON public.plg_inventory_production_inputs USING btree (tenant_id, production_order_id);\nCREATE INDEX IF NOT EXISTS plg_inventory_production_inputs_product_idx\n ON public.plg_inventory_production_inputs USING btree (tenant_id, product_id, consumed_at DESC);\nCREATE INDEX IF NOT EXISTS plg_inventory_production_outputs_order_idx\n ON public.plg_inventory_production_outputs USING btree (tenant_id, production_order_id);\nCREATE INDEX IF NOT EXISTS plg_inventory_production_plan_items_day_idx\n ON public.plg_inventory_production_plan_items USING btree (tenant_id, unit_id, plan_date);\nCREATE UNIQUE INDEX IF NOT EXISTS plg_inventory_production_stations_name_uidx\n ON public.plg_inventory_production_stations USING btree (tenant_id, unit_id, lower(name))\n WHERE unit_id IS NOT NULL;\n\n-- RLS, uma a uma e por extenso. O laço adiante já liga tudo isto — mas ele liga\n-- por \\`EXECUTE format(...)\\`, e o portão que revisa plugin lê o ARQUIVO, não o\n-- banco: para ele, tabela com tenant_id e sem um ENABLE literal é tabela\n-- aberta. Uma garantia que o revisor não consegue ler é uma garantia que\n-- ninguém confere.\nALTER TABLE public.plg_inventory_production_stations ENABLE ROW LEVEL SECURITY;\nALTER TABLE public.plg_inventory_production_orders ENABLE ROW LEVEL SECURITY;\nALTER TABLE public.plg_inventory_production_steps ENABLE ROW LEVEL SECURITY;\nALTER TABLE public.plg_inventory_production_inputs ENABLE ROW LEVEL SECURITY;\nALTER TABLE public.plg_inventory_production_outputs ENABLE ROW LEVEL SECURITY;\nALTER TABLE public.plg_inventory_production_plan_items ENABLE ROW LEVEL SECURITY;\n\nDO $$\nDECLARE t text;\nDECLARE tem_unidade boolean;\nBEGIN\n FOREACH t IN ARRAY ARRAY['plg_inventory_production_stations', 'plg_inventory_production_orders',\n 'plg_inventory_production_steps', 'plg_inventory_production_inputs',\n 'plg_inventory_production_outputs', 'plg_inventory_production_plan_items'] LOOP\n tem_unidade := t IN ('plg_inventory_production_stations', 'plg_inventory_production_orders',\n 'plg_inventory_production_plan_items');\n EXECUTE format('DROP TRIGGER IF EXISTS %I ON public.%I', t || '_updated_at', t);\n EXECUTE format('CREATE TRIGGER %I BEFORE UPDATE ON public.%I FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at()', t || '_updated_at', t);\n EXECUTE format('ALTER TABLE public.%I FORCE ROW LEVEL SECURITY', t);\n EXECUTE format('ALTER TABLE public.%I ENABLE ROW LEVEL SECURITY', t);\n EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE public.%I TO authenticated', t);\n EXECUTE format('GRANT ALL ON TABLE public.%I TO service_role', t);\n EXECUTE format('DROP POLICY IF EXISTS %I ON public.%I', t || '_authz_select', t);\n EXECUTE format($f$CREATE POLICY %I ON public.%I FOR SELECT TO authenticated USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (%s)\n AND (SELECT app.has_permission('inventory.read', %s)))$f$,\n t || '_authz_select', t,\n CASE WHEN tem_unidade THEN format('unit_id IS NULL OR (SELECT app.has_unit(%I.unit_id))', t) ELSE 'true' END,\n CASE WHEN tem_unidade THEN format('%I.unit_id', t) ELSE 'NULL::uuid' END);\n EXECUTE format('DROP POLICY IF EXISTS %I ON public.%I', t || '_authz_write', t);\n EXECUTE format($f$CREATE POLICY %I ON public.%I FOR ALL TO authenticated USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('inventory.edit', %s)))\n WITH CHECK (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('inventory.edit', %s)))$f$,\n t || '_authz_write', t,\n CASE WHEN tem_unidade THEN format('%I.unit_id', t) ELSE 'NULL::uuid' END,\n CASE WHEN tem_unidade THEN format('%I.unit_id', t) ELSE 'NULL::uuid' END);\n END LOOP;\nEND $$;\n\nINSERT INTO public.scoped_resources (resource_table, unit_column, owner_column, shareable, default_visibility, label)\nVALUES ('plg_inventory_production_orders', 'unit_id', 'owner_id', false, 'unit', 'Ordens de produção')\nON CONFLICT (resource_table) DO UPDATE\n SET unit_column = EXCLUDED.unit_column, default_visibility = EXCLUDED.default_visibility, label = EXCLUDED.label;\n`\n\nexport const MIGRATION_007_A_PRODUCAO_ATRAVESSA = `-- A produção atravessa.\n--\n-- A 006 fez as seis tabelas. Esta escreve o de-para com \\`by_shape\\` (spine 123).\n-- A ordem importa: estação antes da etapa, ordem antes de tudo que pendura\n-- nela, e o plano por último porque ele aponta para a ordem que gerou.\n--\n-- ── A configuração de KDS não vem para cá ───────────────────────────────────\n--\n-- \\`production_stations\\` carrega sete colunas de KDS (\\`kds_enabled\\`, \\`kds_role\\`,\n-- \\`kds_sla_minutes\\`…). Elas vão para \\`metadata.kds\\` e não para coluna: o V2 tem\n-- \\`plg_kds_stations\\`, e duplicar a configuração do monitor aqui daria duas\n-- respostas para \"qual a SLA desta estação\". Quando o KDS portar, ele lê o\n-- metadata e a estação de produção fica com o que é dela.\n--\n-- ── O movimento de estoque fica como referência ─────────────────────────────\n--\n-- \\`stock_movement_uuid\\` no insumo e no produto aponta para a baixa/entrada que\n-- a produção gerou. Vem como referência em metadata, não como FK: o movimento\n-- atravessa por outro caminho (\\`stock_movements\\`) e amarrar os dois aqui\n-- exigiria que a ordem de produção esperasse o razão — uma dependência que o\n-- volume (45 linhas) não justifica.\n--\n-- Idempotente.\n\nSET LOCAL migration.allowlist_unlock = 'on';\n\nSELECT migration._allow(\n 'production_stations', 'public.plg_inventory_production_stations', 'inventory', 'by_shape',\n jsonb_build_object('target', 'public.plg_inventory_production_stations'),\n NULL::text[],\n jsonb_build_object(\n 'uuid', 'id',\n 'name', 'name',\n 'description', 'description',\n 'active', 'is_active',\n 'kds_display_order', 'sort_order',\n 'created_at', 'created_at',\n 'updated_at', 'updated_at',\n 'tenant_id', '-',\n 'unit_id', '-',\n 'kds_enabled', 'jsonb:kds.enabled',\n 'kds_role', 'jsonb:kds.role',\n 'is_kds_fallback', 'jsonb:kds.is_fallback',\n 'kds_sla_minutes', 'jsonb:kds.sla_minutes',\n 'kds_catalog_mode', 'jsonb:kds.catalog_mode',\n 'kds_config_version', 'jsonb:kds.config_version',\n 'id', 'jsonb:inventory.v1_ref'),\n 'Onde a etapa acontece. \\`unit_id\\` da origem é uuid de companies e não o id '\n 'numérico que o ledger resolve — a unidade vem pelo augment do extrator. A '\n 'configuração de KDS fica em metadata.kds porque plg_kds_stations é quem '\n 'responde por monitor.'\n);\n\nSELECT migration._allow(\n 'production_orders', 'public.plg_inventory_production_orders', 'inventory', 'by_shape',\n jsonb_build_object('target', 'public.plg_inventory_production_orders'),\n NULL::text[],\n jsonb_build_object(\n 'uuid', 'id',\n 'unit_id', 'unit_id@companies',\n 'order_number', 'order_number',\n 'status', 'status',\n 'product_id', 'product_id@products',\n 'recipe_id', 'recipe_id@recipes',\n 'recipe_snapshot', 'recipe_snapshot',\n 'planned_qty', 'planned_quantity',\n 'unit_id_measure', 'measurement_unit_id@measurement_units',\n 'planned_start', 'planned_start',\n 'planned_end', 'planned_end',\n 'actual_start', 'actual_start',\n 'actual_end', 'actual_end',\n 'source_type', 'source_type',\n 'priority', 'priority',\n 'notes', 'notes',\n 'created_at', 'created_at',\n 'updated_at', 'updated_at',\n 'tenant_id', '-',\n 'created_by', '-',\n 'released_by', '-',\n 'finished_by', '-',\n 'id', 'jsonb:inventory.v1_ref'),\n 'A ordem de produção. \\`recipe_snapshot\\` é a ficha como ela era no dia — '\n 'mudar a receita amanhã não pode reescrever a produção de ontem.'\n);\n\nSELECT migration._allow(\n 'production_order_steps', 'public.plg_inventory_production_steps', 'inventory', 'by_shape',\n jsonb_build_object('target', 'public.plg_inventory_production_steps'),\n NULL::text[],\n jsonb_build_object(\n 'uuid', 'id',\n 'order_id', 'production_order_id@production_orders',\n 'production_station_id', 'station_id@production_stations',\n 'sequence', 'sequence',\n 'name', 'name',\n 'instructions', 'instructions',\n 'estimated_minutes', 'estimated_minutes',\n 'status', 'status',\n 'started_at', 'started_at',\n 'completed_at', 'completed_at',\n 'operator_name', 'operator_name',\n 'notes', 'notes',\n 'created_at', 'created_at',\n 'updated_at', 'updated_at',\n 'tenant_id', '-',\n 'id', 'jsonb:inventory.v1_ref'),\n 'A etapa, na estação, com tempo. \\`operator_name\\` continua texto: inventar uma '\n 'pessoa a partir de um nome digitado é como se cria cadastro duplicado.'\n);\n\nSELECT migration._allow(\n 'production_order_inputs', 'public.plg_inventory_production_inputs', 'inventory', 'by_shape',\n jsonb_build_object('target', 'public.plg_inventory_production_inputs'),\n NULL::text[],\n jsonb_build_object(\n 'uuid', 'id',\n 'order_id', 'production_order_id@production_orders',\n 'step_id', 'step_id@production_order_steps',\n 'product_id', 'product_id@products',\n 'planned_qty', 'planned_quantity',\n 'actual_qty', 'actual_quantity',\n 'unit_id', 'measurement_unit_id@measurement_units',\n 'stock_position_id', 'stock_position_id@stock_positions',\n 'unit_cost', 'unit_cost',\n 'consumed_at', 'consumed_at',\n 'operator_name', 'operator_name',\n 'created_at', 'created_at',\n 'updated_at', 'updated_at',\n 'tenant_id', '-',\n 'product_uuid', '-',\n 'unit_label_id', '-',\n 'stock_movement_uuid', 'jsonb:inventory.v1_stock_movement_ref',\n 'id', 'jsonb:inventory.v1_ref'),\n 'O insumo consumido. Planejado E real: é a diferença entre os dois que diz se '\n 'a ficha está certa. \\`unit_id\\` aqui é UNIDADE DE MEDIDA, não filial — o nome '\n 'da coluna do V1 engana, e por isso o destino se chama measurement_unit_id.'\n);\n\nSELECT migration._allow(\n 'production_order_outputs', 'public.plg_inventory_production_outputs', 'inventory', 'by_shape',\n jsonb_build_object('target', 'public.plg_inventory_production_outputs'),\n NULL::text[],\n jsonb_build_object(\n 'uuid', 'id',\n 'order_id', 'production_order_id@production_orders',\n 'product_id', 'product_id@products',\n 'output_type', 'output_type',\n 'planned_qty', 'planned_quantity',\n 'actual_qty', 'actual_quantity',\n 'unit_id', 'measurement_unit_id@measurement_units',\n 'batch_number', 'batch_number',\n 'expiration_date', 'expiration_date',\n 'stock_position_id', 'stock_position_id@stock_positions',\n 'unit_cost', 'unit_cost',\n 'created_at', 'created_at',\n 'updated_at', 'updated_at',\n 'tenant_id', '-',\n 'product_uuid', '-',\n 'stock_movement_uuid', 'jsonb:inventory.v1_stock_movement_ref',\n 'id', 'jsonb:inventory.v1_ref'),\n 'O que saiu: principal, subproduto ou perda. É essa palavra que separa '\n 'rendimento de desperdício, e sem ela a conta de custo não fecha.'\n);\n\nSELECT migration._allow(\n 'production_plan_items', 'public.plg_inventory_production_plan_items', 'inventory', 'by_shape',\n jsonb_build_object('target', 'public.plg_inventory_production_plan_items'),\n NULL::text[],\n jsonb_build_object(\n 'uuid', 'id',\n 'unit_id', 'unit_id@companies',\n 'plan_date', 'plan_date',\n 'product_id', 'product_id@products',\n 'planned_qty', 'planned_quantity',\n 'unit_id_measure', 'measurement_unit_id@measurement_units',\n 'status', 'status',\n 'production_order_id', 'production_order_id@production_orders',\n 'notes', 'notes',\n 'created_at', 'created_at',\n 'updated_at', 'updated_at',\n 'tenant_id', '-',\n 'id', 'jsonb:inventory.v1_ref'),\n 'O que se pretende produzir naquele dia, antes de virar ordem. Vem por último '\n 'porque aponta para a ordem que ele gerou.'\n);\n\nDELETE FROM migration.excluded_tables\n WHERE source_table IN ('production_stations', 'production_orders', 'production_order_steps',\n 'production_order_inputs', 'production_order_outputs', 'production_plan_items');\n\nRESET migration.allowlist_unlock;\n`\n\nexport const MIGRATION_008_O_GRUPO_DA_RECEITA_TAMBEM_E_DE_UMA_CASA = `-- O grupo da receita também é de uma casa.\n--\n-- \\`plg_inventory_recipes\\` e \\`plg_inventory_recipe_ingredients\\` já carregam\n-- \\`unit_id\\`. \\`plg_inventory_recipe_groups\\` — que é irmão dos dois e agrupa\n-- ingredientes alternativos dentro da mesma ficha — ficou sem.\n--\n-- São 172 grupos no Pertinho do Céu. Sem a coluna, o grupo de uma cozinha\n-- aparece na ficha da outra, e a substituição de ingrediente sugere um produto\n-- que aquela casa não tem em estoque.\n--\n-- Idempotente.\n\nALTER TABLE public.plg_inventory_recipe_groups ADD COLUMN IF NOT EXISTS unit_id uuid;\n\nDO $$ BEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_recipe_groups_unit_fkey') THEN\n ALTER TABLE public.plg_inventory_recipe_groups ADD CONSTRAINT plg_inventory_recipe_groups_unit_fkey\n FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;\n END IF;\nEND $$;\n\nCOMMENT ON COLUMN public.plg_inventory_recipe_groups.unit_id IS\n 'A cozinha a que o grupo pertence — as irmãs plg_inventory_recipes e _recipe_ingredients já a tinham (008).';\n\nCREATE INDEX IF NOT EXISTS plg_inventory_recipe_groups_unit_idx\n ON public.plg_inventory_recipe_groups USING btree (tenant_id, unit_id);\n`\n\nexport const MIGRATION_009_A_DIRECAO_DO_MOVIMENTO_NAO_ESTA_NO_NUMERO = `-- A direção do movimento não está no número — e o V2 guarda ela lá.\n--\n-- \\`plg_inventory_stock_movements\\` assina a quantidade. O CHECK diz isso em voz\n-- alta desde o baseline:\n--\n-- plg_inventory_stock_movements_sign_check\n-- (kind = 'in' AND quantity > 0)\n-- OR (kind = 'out' AND quantity < 0)\n-- OR (kind = 'transfer' AND quantity < 0)\n-- OR kind IN ('adjust', 'reverse')\n--\n-- E não é decoração: \\`app.inventory_movements_after_insert\\` soma \\`NEW.quantity\\`\n-- na posição de origem e \\`-NEW.quantity\\` na de destino. O sinal É o efeito no\n-- saldo. Uma saída positiva não seria recusada por capricho — ela AUMENTARIA o\n-- estoque.\n--\n-- O V1 guarda a direção em outro lugar: no \\`movement_type\\`. O número é sempre a\n-- magnitude. Medido em 21/09/2026 sobre os 2.997 movimentos do banco inteiro,\n-- todos os inquilinos:\n--\n-- entrada 2.871 menor 0,001 maior 10.000 negativos: 0\n-- saida 119 menor 0,2 maior 26 negativos: 0\n-- ajuste 6 menor 1 maior 1 negativos: 0\n-- transferencia 1 menor 1 maior 1 negativos: 0\n--\n-- \\`migration.upsert_inv_movement\\` traduzia o RÓTULO (\\`saida\\` → \\`out\\`) e passava\n-- o número adiante intacto. Ou seja: toda saída e toda transferência do V1 era\n-- uma violação de constraint garantida. Isso ficou dois inquilinos sem\n-- aparecer porque o Pertinho do Céu só tem entrada — 2.707 de 2.707. O Espaço\n-- Facial é o primeiro com saída, e as três saídas dele que tinham local\n-- bateram na constraint:\n--\n-- state: 23514: ... violates check constraint\n-- \"plg_inventory_stock_movements_sign_check\" ×3\n--\n-- ── O ajuste é o caso que não se resolve pelo rótulo ─────────────────────────\n--\n-- \\`entrada\\`, \\`saida\\` e \\`transferencia\\` dizem a direção no nome. \\`ajuste\\` não:\n-- ele tanto acha quanto perde, e o V1 escreve 1 nos dois casos. Quem sabe é a\n-- contagem que o gerou — \\`stock_count_items.counted_qty - system_qty_at_review\\`\n-- é a diferença apurada, com sinal. Conferido nos 4 ajustes de inventário do\n-- Espaço Facial: os dois \"falta apurada\" dão -1, os dois \"sobra apurada\" dão\n-- +1. O extrator passou a mandar isso como \\`adjust_delta\\` (ver\n-- fayz-etl/fayz_etl/sources/beautysoft_v1.py, passo \\`stock_movements\\`), e o\n-- de-para abaixo abre a porta para a coluna.\n--\n-- Quando o ajuste não vem de contagem, \\`adjust_delta\\` chega nulo — e aí a\n-- linha vai para a quarentena em vez de entrar com o sinal chutado. São 2\n-- linhas hoje (\"Correção de auditoria (estorno)\"), e as duas já estavam\n-- paradas por falta de local. Somar onde era para subtrair é o erro que\n-- ninguém vê: ele não quebra nada, só deixa o saldo errado.\n--\n-- ── O que NÃO muda ──────────────────────────────────────────────────────────\n--\n-- A guarda de entrada continua exigindo \\`quantity > 0\\`: o writer recebe do V1,\n-- e no V1 a direção é o tipo, não o sinal. O que muda é a SAÍDA dele — a\n-- tradução para a convenção da tabela de destino, que é onde ela sempre\n-- deveria ter acontecido. As 2.208 + 47 entradas já migradas não se movem: para\n-- \\`in\\`, assinado e magnitude são o mesmo número, e a tabela é append-only.\n--\n-- Idempotente: CREATE OR REPLACE e um UPDATE de jsonb por concatenação.\n\n-- ─────────────────────────────────────────────────────────────────────────────\n-- 1. O writer assina o número\n-- ─────────────────────────────────────────────────────────────────────────────\n\nCREATE OR REPLACE FUNCTION migration.upsert_inv_movement(\n p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $function$\nDECLARE\n v_kind_raw text := nullif(btrim(lower(coalesce(p_row ->> 'kind', ''))), '');\n v_kind text := CASE v_kind_raw\n WHEN 'in' THEN 'in' WHEN 'out' THEN 'out'\n WHEN 'transfer' THEN 'transfer' WHEN 'adjust' THEN 'adjust' WHEN 'reverse' THEN 'reverse'\n -- the SDK's legacy labels, and V1's pt-BR\n WHEN 'entry' THEN 'in' WHEN 'entrada' THEN 'in'\n WHEN 'exit' THEN 'out' WHEN 'saida' THEN 'out' WHEN 'saída' THEN 'out'\n WHEN 'loss' THEN 'out' WHEN 'perda' THEN 'out'\n WHEN 'adjustment' THEN 'adjust' WHEN 'ajuste' THEN 'adjust'\n WHEN 'transferencia' THEN 'transfer' WHEN 'transferência' THEN 'transfer'\n END;\n v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);\n -- A magnitude, como a origem a manda, e o número ASSINADO que a tabela pede.\n -- Os dois existem porque são contratos de lados diferentes da fronteira.\n v_qty numeric := coalesce((p_row ->> 'quantity')::numeric, 0);\n v_delta numeric := (p_row ->> 'adjust_delta')::numeric;\n v_signed numeric;\n v_exists boolean;\n v_standalone boolean := migration._standalone();\nBEGIN\n IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;\n IF p_id IS NULL OR (p_row ->> 'product_id') IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk', 'reason', 'a stock movement needs an id and its product');\n END IF;\n IF v_kind IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',\n 'reason', format('unknown movement type %L (expected in|out|transfer|adjust|reverse, or a V1 label of one)', coalesce(v_kind_raw, '<null>')));\n END IF;\n IF v_qty <= 0 THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value', 'reason', 'a stock movement needs a quantity > 0 — direction is the kind, not the sign');\n END IF;\n IF v_kind = 'transfer' AND (p_row ->> 'destination_location_id') IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'a transfer needs its destination location');\n END IF;\n\n -- A tradução. O rótulo diz a direção em três dos cinco tipos; no ajuste ela\n -- vem da contagem, e sem ela não se inventa.\n IF v_kind = 'adjust' THEN\n IF v_delta IS NULL OR v_delta = 0 THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',\n 'reason', 'an adjustment does not say which way it goes — the V1 writes the size and leaves the sign to the count it came from (adjust_delta)');\n END IF;\n v_signed := sign(v_delta) * v_qty;\n ELSIF v_kind IN ('out', 'transfer') THEN\n v_signed := -v_qty;\n ELSE\n -- 'in' e 'reverse'. O estorno mantém a magnitude: o sign_check o dispensa,\n -- e quem decide o sentido dele é o movimento que ele desfaz.\n v_signed := v_qty;\n END IF;\n\n SELECT EXISTS (SELECT 1 FROM public.plg_inventory_stock_movements WHERE id = p_id) INTO v_exists;\n IF v_exists THEN\n -- the ledger is append-only: a second offer of the same movement is a replay,\n -- not an update. Nothing to do, and saying \"migrated\" again is honest —\n -- the destination already holds exactly this row.\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', 'already in the ledger (append-only)');\n END IF;\n\n -- 005's single-writer gate refuses any write \"however privileged\" unless this\n -- is set. The migration IS one of the internal writers — definer-only, fenced\n -- and audited — so it declares itself rather than being exempted, and the\n -- after-insert trigger derives the position from what it writes.\n PERFORM set_config('fayz.inventory_internal', 'on', true);\n INSERT INTO public.plg_inventory_stock_movements\n (id, tenant_id, product_id, kind, quantity, unit_cost, total_cost,\n source_location_id, destination_location_id, batch_number, expiration_date,\n document_number, reason, notes, movement_date, metadata)\n VALUES (p_id, p_tenant, (p_row ->> 'product_id')::uuid, v_kind,\n v_signed,\n (p_row ->> 'unit_cost')::numeric, (p_row ->> 'total_cost')::numeric,\n (p_row ->> 'source_location_id')::uuid, (p_row ->> 'destination_location_id')::uuid,\n p_row ->> 'batch_number', (p_row ->> 'expiration_date')::date,\n p_row ->> 'document_number', p_row ->> 'reason', p_row ->> 'notes',\n coalesce((p_row ->> 'movement_date')::timestamptz, now()), v_meta);\n PERFORM set_config('fayz.inventory_internal', 'off', true);\n\n IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_inventory_stock_movements', p_id, jsonb_build_object('reason', 'created')); END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', 'created');\nEND $function$;\n\nCOMMENT ON FUNCTION migration.upsert_inv_movement(uuid, uuid, jsonb, jsonb) IS\n 'Movimento de estoque do V1 para o V2. Recebe magnitude + tipo (a convenção do V1) e grava quantidade ASSINADA (a convenção da tabela). A direção do ajuste vem de adjust_delta — ver 009.';\n\n-- ─────────────────────────────────────────────────────────────────────────────\n-- 2. O de-para abre a porta para a direção do ajuste\n-- ─────────────────────────────────────────────────────────────────────────────\n--\n-- \\`adjust_delta\\` não é coluna do V1: é derivada pelo plano de leitura, a partir\n-- de \\`stock_count_items\\`. Entra no mapa como campo simples porque é isso que\n-- ela é no \\`p_row\\` — o writer lê e não grava, então ela não precisa (nem pode)\n-- casar com coluna do destino.\n\nSET LOCAL migration.allowlist_unlock = 'on';\n\nUPDATE migration.allowlist\n SET columns = columns || jsonb_build_object('adjust_delta', 'adjust_delta')\n WHERE source_table = 'stock_movements';\n\nRESET migration.allowlist_unlock;\n`\n\nexport const MIGRATION_010_A_ENTRADA_DE_NOTAS_ATRAVESSA = `-- A entrada de notas atravessa.\n--\n-- A importação de DANFE do V1 — 607 notas (527 no Pertinho do Céu, 80 no\n-- Espaço Facial), 3.008 linhas, 68 lotes — não tinha nenhuma linha na\n-- \\`migration.allowlist\\`. Este arquivo escreve a dela e a das linhas; a parcela,\n-- que é do financeiro, mora em plugin-financial/084 (o financeiro roda ANTES do\n-- inventário na corrente, então o de-para dela não podia vir por aqui).\n--\n-- ── O achado que mudou o desenho: a linha da nota JÁ é o movimento ──────────\n--\n-- O caminho óbvio seria \\`danfe_import_items\\` → \\`plg_inventory_stock_movements\\`.\n-- Medido em 21/09/2026 no V1 de produção, ele DOBRARIA o estoque:\n--\n-- tenant 8: 2.707 dos 2.860 itens (94,7%) já têm um \\`stock_movements\\` no V1,\n-- e a tabela \\`stock_movements\\` do tenant 8 é INTEIRA de DANFE —\n-- 2.707 de 2.707 linhas com \\`danfe_import_item_id\\`.\n-- ledger: 2.757 movimentos já atravessaram (\\`status = 'migrated'\\`).\n--\n-- Quantidade e custo batem linha a linha (o movimento é gerado pela conciliação\n-- da nota). Criar um segundo movimento a partir do item somaria a mesma entrada\n-- duas vezes — e, pior, o gatilho \\`plg_inventory_stock_movements_after_insert\\`\n-- deriva a POSIÇÃO do movimento, então o saldo dobraria junto, sem ninguém\n-- acusar.\n--\n-- Então a linha da nota não cria movimento: ela ADOTA o que já atravessou.\n-- \\`movement_uuid\\` resolve pelo ledger de \\`stock_movements\\`, o writer devolve\n-- aquele id como \\`target_id\\`, e o conteúdo da linha — o que o movimento não\n-- carrega: NCM, CFOP, tributo, fator de conversão, produto sugerido pela IA —\n-- vai para o \\`result\\` da operação, que é onde o V2 já guarda o que uma operação\n-- de inventário produziu (\\`app.inventory_movement_json\\`, \\`inventory_receive\\`).\n--\n-- Item sem movimento no V1 (153 no tenant 8, 114 no tenant 1) é linha que nunca\n-- virou estoque: entra no \\`result\\` e fica \\`skipped\\`. O porte não inventa\n-- entrada que a origem não fez.\n--\n-- ── E o conteúdo FISCAL da nota também já atravessa ─────────────────────────\n--\n-- 607 de 607 notas têm a contraparte em \\`dfe_inbox\\` pela chave de acesso, e as\n-- 607 têm o MESMO número de linhas em \\`dfe_inbox_items\\` (149 = 149 no tenant 1,\n-- 2.859 = 2.859 no tenant 8). O XML, o tributo por coluna e a duplicata já vão\n-- para \\`plg_fiscalbr_inbox_*\\` por fiscal_br/003. Copiar tudo de novo aqui daria\n-- duas respostas para \"qual o ICMS desta linha\".\n--\n-- O que NÃO atravessava é a DECISÃO DE ENTRADA feita sobre a nota: a qual\n-- produto a linha foi ligada, em que unidade de estoque, com que fator de\n-- conversão, em que conta contábil, e o plano de pagamento que o operador\n-- montou. É isso que este arquivo traz.\n--\n-- ── A nota é uma operação de recebimento ────────────────────────────────────\n--\n-- \\`plg_inventory_operations\\` é o razão de operações do inventário, com\n-- vocabulário fechado (\\`receive | transfer | adjust | reserve | confirm |\n-- reverse\\`). Uma importação de DANFE é um \\`receive\\`, e a \\`idempotency_key\\` dela\n-- é a CHAVE DE ACESSO: 607 de 607 notas têm chave, e ela é única dentro do\n-- tenant nas duas casas. Isso faz o \\`op.idempotency_key = doc.access_key\\`\n-- ligar a operação ao documento fiscal sem coluna nova e sem FK entre plugins\n-- (o fiscal_br roda DEPOIS do inventário).\n--\n-- ── A coluna que eu tive de criar ───────────────────────────────────────────\n--\n-- \\`plg_inventory_operations\\` não tinha \\`metadata\\`. Sem ela o \\`by_shape\\` joga\n-- fora EM SILÊNCIO tudo que não casa com uma coluna do destino — que aqui são\n-- os 20 campos da nota — e joga fora também a proveniência que\n-- \\`_upsert_row_impl\\` carimba em toda linha portada (\\`migration.source_table\\`,\n-- \\`source_numeric_id\\`, \\`batch_id\\`). Seriam 607 notas no V2 sem como dizer de\n-- onde vieram. É a única coluna nova deste arquivo.\n--\n-- Idempotente.\n\n-- ─────────────────────────────────────────────────────────────────────────────\n-- 1. A operação ganha onde guardar a nota\n-- ─────────────────────────────────────────────────────────────────────────────\n\nALTER TABLE public.plg_inventory_operations\n ADD COLUMN IF NOT EXISTS metadata jsonb NOT NULL DEFAULT '{}'::jsonb;\n\nCOMMENT ON COLUMN public.plg_inventory_operations.metadata IS\n 'O que a operação carrega e nenhuma coluna nomeia. No porte é a nota de '\n 'entrada inteira (metadata.danfe) e a proveniência da linha (metadata.migration). '\n 'A identidade da nota fica em idempotency_key, que é a chave de acesso.';\n\n-- ─────────────────────────────────────────────────────────────────────────────\n-- 2. O writer genérico com a porteira do inventário aberta\n-- ─────────────────────────────────────────────────────────────────────────────\n--\n-- \\`migration.upsert_by_shape\\` (spine 123/124) resolve o upsert desta tabela\n-- sozinho: as colunas do destino já têm o nome que o de-para produz. O que ele\n-- NÃO faz é abrir a porteira \\`fayz.inventory_internal\\`, e as três tabelas de\n-- inventário recusam qualquer escrita sem ela — inclusive a do porte, que é\n-- definer e cercada:\n--\n-- inventory: plg_inventory_operations is written only through the inventory RPCs\n--\n-- Foi essa frase que pôs 339 posições em quarentena na 004 e que a 005 acabou\n-- dando por certa. Ela continua certa para a POSIÇÃO — a 005 tirou\n-- \\`stock_positions\\` da allowlist porque o saldo é consequência do movimento —\n-- e não diz nada contra a OPERAÇÃO, que é um fato do V1 e não um derivado de\n-- nada. O que ela exige é que quem escreve se declare.\n--\n-- Em vez de copiar o by_shape inteiro só para acrescentar duas linhas, este\n-- writer abre a porteira, delega, e fecha. A porteira é transacional\n-- (\\`set_config(..., true)\\`): ela morre com a transação e não vaza para a linha\n-- seguinte do lote.\n\nCREATE OR REPLACE FUNCTION migration.upsert_inv_shape(\n p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $function$\nDECLARE\n v_prev text := coalesce(current_setting('fayz.inventory_internal', true), 'off');\n v_res jsonb;\nBEGIN\n PERFORM set_config('fayz.inventory_internal', 'on', true);\n v_res := migration.upsert_by_shape(p_tenant, p_id, p_row, p_args);\n PERFORM set_config('fayz.inventory_internal', v_prev, true);\n RETURN v_res;\nEND $function$;\n\nCOMMENT ON FUNCTION migration.upsert_inv_shape(uuid, uuid, jsonb, jsonb) IS\n 'upsert_by_shape com fayz.inventory_internal ligado. Para a tabela de inventário '\n 'que já tem a forma certa e cuja porteira de escrita única recusaria o porte.';\n\n-- ─────────────────────────────────────────────────────────────────────────────\n-- 3. O writer da linha da nota — adota, nunca duplica\n-- ─────────────────────────────────────────────────────────────────────────────\n--\n-- Serve as duas partes da nota, escolhidas por \\`writer_args.part\\`:\n--\n-- 'item' a linha da nota → o MOVIMENTO que ela gerou no V1\n-- 'batch' o lote da linha → a POSIÇÃO que aquele movimento derivou\n--\n-- Nenhuma das duas insere: as duas devolvem o id do que já está lá. É a única\n-- forma de a linha atravessar sem somar estoque duas vezes, porque\n-- \\`plg_inventory_stock_movements\\` é append-only (o gatilho recusa até um UPDATE\n-- que só acrescente coluna nula) e a posição é consequência do movimento.\n--\n-- O conteúdo da linha vai para \\`plg_inventory_operations.result\\`, indexado pelo\n-- id da linha — objeto e não lista, para a segunda oferta da mesma linha\n-- substituir a primeira em vez de empilhar.\n\nCREATE OR REPLACE FUNCTION migration.upsert_inv_danfe_line(\n p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $function$\nDECLARE\n v_part text := coalesce(p_args ->> 'part', 'item');\n v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);\n v_operation uuid := (p_row ->> 'operation_id')::uuid;\n v_movement uuid := (p_row ->> 'movement_id')::uuid;\n v_product uuid := (p_row ->> 'product_id')::uuid;\n v_location uuid := (p_row ->> 'stock_location_id')::uuid;\n v_target uuid;\n v_linha jsonb;\n v_prev text;\n v_standalone boolean := migration._standalone();\nBEGIN\n IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;\n\n IF p_id IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state',\n 'reason', 'uma linha de nota precisa de id');\n END IF;\n IF v_operation IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',\n 'reason', 'linha de nota cuja importação ainda não atravessou');\n END IF;\n IF NOT EXISTS (SELECT 1 FROM public.plg_inventory_operations o\n WHERE o.id = v_operation AND o.tenant_id = p_tenant) THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',\n 'reason', format('a operação %s não existe neste tenant', v_operation));\n END IF;\n\n -- O que atravessa da linha: tudo que o movimento não nomeia. Fica no \\`result\\`\n -- da operação, que é onde o V2 guarda o que uma operação produziu.\n -- \\`coalesce\\` porque \\`NULL || jsonb\\` é NULL, e uma nota sem nenhum campo de\n -- linha mapeado apagaria o registro inteiro em silêncio.\n v_linha := jsonb_strip_nulls(\n coalesce(v_meta -> 'danfe' -> CASE WHEN v_part = 'batch' THEN 'batch' ELSE 'line' END, '{}'::jsonb)\n || jsonb_build_object(\n 'movement_id', to_jsonb(v_movement),\n 'product_id', to_jsonb(v_product),\n 'line_no', p_row -> 'line_no',\n 'quantity', p_row -> 'quantity',\n 'batch_number', p_row -> 'batch_number',\n 'expiration_date', p_row -> 'expiration_date'));\n\n IF v_part = 'batch' THEN\n -- O lote não cria posição: ele aponta para a que o movimento derivou. A\n -- chave da posição é (tenant, produto, local, lote, validade) — a mesma que\n -- \\`app.inventory_apply_to_position\\` usa para decidir se soma ou cria.\n IF v_movement IS NOT NULL AND v_product IS NOT NULL AND v_location IS NOT NULL THEN\n SELECT sp.id INTO v_target\n FROM public.plg_inventory_stock_positions sp\n WHERE sp.tenant_id = p_tenant\n AND sp.product_id = v_product\n AND sp.stock_location_id = v_location\n AND sp.batch_number IS NOT DISTINCT FROM nullif(btrim(p_row ->> 'batch_number'), '')\n AND sp.expiration_date IS NOT DISTINCT FROM (p_row ->> 'expiration_date')::date\n LIMIT 1;\n END IF;\n ELSE\n v_target := v_movement;\n END IF;\n\n -- A porteira do inventário: a operação recusa UPDATE sem ela, do porte\n -- inclusive. Transacional, para não vazar para a linha seguinte.\n v_prev := coalesce(current_setting('fayz.inventory_internal', true), 'off');\n PERFORM set_config('fayz.inventory_internal', 'on', true);\n UPDATE public.plg_inventory_operations o\n SET result = coalesce(o.result, '{}'::jsonb) || jsonb_build_object(\n CASE WHEN v_part = 'batch' THEN 'batches' ELSE 'lines' END,\n coalesce(o.result -> CASE WHEN v_part = 'batch' THEN 'batches' ELSE 'lines' END, '{}'::jsonb)\n || jsonb_build_object(p_id::text, v_linha))\n WHERE o.id = v_operation AND o.tenant_id = p_tenant;\n PERFORM set_config('fayz.inventory_internal', v_prev, true);\n\n IF v_target IS NULL THEN\n -- Linha declarada e nunca conciliada: no V1 ela não virou estoque, e o\n -- porte não inventa a entrada que a origem não fez. O conteúdo dela já foi\n -- gravado no \\`result\\` acima — o que falta é só o vínculo com o razão.\n RETURN jsonb_build_object('status', 'skipped',\n 'reason', CASE WHEN v_part = 'batch'\n THEN 'lote sem posição no V2 — o movimento dele não atravessou'\n ELSE 'linha sem movimento no V1 — nunca virou estoque' END);\n END IF;\n\n IF v_standalone THEN\n PERFORM migration._audit(p_tenant,\n CASE WHEN v_part = 'batch' THEN 'public.plg_inventory_stock_positions'\n ELSE 'public.plg_inventory_stock_movements' END,\n v_target, jsonb_build_object('reason', 'adopted'));\n END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', v_target,\n 'reason', CASE WHEN v_part = 'batch'\n THEN 'a posição deste lote já é consequência do movimento'\n ELSE 'a linha é o movimento que já atravessou' END);\nEND $function$;\n\nCOMMENT ON FUNCTION migration.upsert_inv_danfe_line(uuid, uuid, jsonb, jsonb) IS\n 'A linha (ou o lote) da nota de entrada. Adota o movimento/posição que o V1 já '\n 'gerou em vez de criar um segundo — 94,7% das linhas do tenant 8 têm movimento, '\n 'e duplicá-lo dobraria o estoque — e guarda o conteúdo da linha em operations.result.';\n\n-- ─────────────────────────────────────────────────────────────────────────────\n-- 4. O vocabulário de writers aceita os nomes novos\n-- ─────────────────────────────────────────────────────────────────────────────\n--\n-- Lendo e costurando o CHECK que está lá, nunca reescrevendo com lista literal:\n-- a lista literal apaga as palavras que os outros passos da corrente\n-- acrescentaram, e o erro só aparece na onda seguinte.\n\nDO $$\nDECLARE v_def text;\nBEGIN\n SELECT pg_get_constraintdef(oid) INTO v_def\n FROM pg_constraint WHERE conname = 'allowlist_writer_check';\n IF v_def IS NULL OR strpos(v_def, 'inv_shape') > 0 THEN RETURN; END IF;\n ALTER TABLE migration.allowlist DROP CONSTRAINT allowlist_writer_check;\n EXECUTE replace(\n 'ALTER TABLE migration.allowlist ADD CONSTRAINT allowlist_writer_check ' || v_def,\n '''person''::text', '''person''::text, ''inv_shape''::text');\nEND $$;\n\nDO $$\nDECLARE v_def text;\nBEGIN\n SELECT pg_get_constraintdef(oid) INTO v_def\n FROM pg_constraint WHERE conname = 'allowlist_writer_check';\n IF v_def IS NULL OR strpos(v_def, 'inv_danfe_line') > 0 THEN RETURN; END IF;\n ALTER TABLE migration.allowlist DROP CONSTRAINT allowlist_writer_check;\n EXECUTE replace(\n 'ALTER TABLE migration.allowlist ADD CONSTRAINT allowlist_writer_check ' || v_def,\n '''person''::text', '''person''::text, ''inv_danfe_line''::text');\nEND $$;\n\n-- ─────────────────────────────────────────────────────────────────────────────\n-- 5. O de-para\n-- ─────────────────────────────────────────────────────────────────────────────\n--\n-- A ordem é a da dependência: a nota antes das linhas, as linhas antes dos\n-- lotes. As três resolvem \\`import_id@danfe_imports\\` pelo ledger, e o ledger só\n-- responde depois que a nota atravessou.\n\nSET LOCAL migration.allowlist_unlock = 'on';\n\nSELECT migration._allow(\n 'danfe_imports', 'public.plg_inventory_operations', 'inventory', 'inv_shape',\n jsonb_build_object('target', 'public.plg_inventory_operations'),\n NULL::text[],\n jsonb_build_object(\n 'uuid', 'id',\n -- \\`kind\\` e \\`op_status\\` vêm do augment do extrator: o V1 não guarda a\n -- palavra \"receive\" porque a tabela INTEIRA é de entrada, e o status dele\n -- tem cinco palavras (pending | partial | stocked | conciliated |\n -- asset_registered) onde o V2 tem duas. Medido: 71 pendentes e 536\n -- concluídas nos dois tenants.\n 'kind', 'kind',\n 'op_status', 'status',\n 'access_key', 'idempotency_key',\n 'unit_id', 'unit_id@companies',\n 'created_at', 'created_at',\n 'updated_at', 'updated_at',\n 'tenant_id', '-',\n 'status', 'jsonb:danfe.v1_status',\n 'nfe_number', 'jsonb:danfe.nfe_number',\n 'nfe_series', 'jsonb:danfe.nfe_series',\n 'issue_date', 'jsonb:danfe.issue_date',\n 'nature_operation', 'jsonb:danfe.nature_operation',\n 'supplier_name', 'jsonb:danfe.supplier.name',\n 'supplier_cnpj', 'jsonb:danfe.supplier.cnpj',\n 'supplier_id', 'jsonb:danfe.supplier.v1_ref',\n 'total_value', 'jsonb:danfe.totals.total',\n 'discount_value', 'jsonb:danfe.totals.discount',\n 'freight_value', 'jsonb:danfe.totals.freight',\n 'insurance_value', 'jsonb:danfe.totals.insurance',\n 'other_expenses', 'jsonb:danfe.totals.other',\n 'total_items', 'jsonb:danfe.total_items',\n 'conciliated_items', 'jsonb:danfe.conciliated_items',\n 'imported_by', 'jsonb:danfe.imported_by',\n 'payment_summary', 'jsonb:danfe.payment_summary',\n 'default_chart_account_id', 'jsonb:danfe.v1_default_chart_account_ref',\n 'id', 'jsonb:danfe.v1_ref'),\n 'A importação de DANFE é uma operação de RECEBIMENTO, e a idempotency_key '\n 'dela é a chave de acesso — 607 de 607 notas têm chave e ela é única no '\n 'tenant, então op.idempotency_key = doc.access_key liga a operação ao '\n 'documento fiscal sem FK entre plugins. O conteúdo fiscal não é copiado: as '\n '607 notas já estão em plg_fiscalbr_inbox_documents, com o mesmo número de '\n 'linhas em 607 de 607.'\n);\n\nSELECT migration._allow(\n 'danfe_import_items', 'public.plg_inventory_stock_movements', 'inventory', 'inv_danfe_line',\n jsonb_build_object('part', 'item'),\n NULL::text[],\n jsonb_build_object(\n 'uuid', 'id',\n 'import_id', 'operation_id@danfe_imports',\n -- O movimento que ESTA linha gerou no V1. Resolve pelo ledger de\n -- \\`stock_movements\\`; nulo quando a linha nunca foi conciliada, e\n -- irresolvido (quarentena \\`fk\\`) quando o movimento existe e não atravessou.\n 'movement_uuid', 'movement_id@stock_movements',\n 'linked_product_id', 'product_id@products',\n 'item_number', 'line_no',\n 'created_at', 'created_at',\n 'tenant_id', '-',\n 'description', 'jsonb:danfe.line.description',\n 'product_code_supplier', 'jsonb:danfe.line.supplier_code',\n 'ean_barcode', 'jsonb:danfe.line.ean',\n 'ncm', 'jsonb:danfe.line.ncm',\n 'cfop', 'jsonb:danfe.line.cfop',\n 'quantity', 'jsonb:danfe.line.quantity',\n 'unit_abbreviation', 'jsonb:danfe.line.unit',\n 'unit_price', 'jsonb:danfe.line.unit_price',\n 'total_price', 'jsonb:danfe.line.total_price',\n 'icms_value', 'jsonb:danfe.line.icms_value',\n 'ipi_value', 'jsonb:danfe.line.ipi_value',\n 'pis_value', 'jsonb:danfe.line.pis_value',\n 'cofins_value', 'jsonb:danfe.line.cofins_value',\n 'entry_type', 'jsonb:danfe.line.entry_type',\n 'cst_entry', 'jsonb:danfe.line.cst_entry',\n 'chart_account_id', 'jsonb:danfe.line.v1_chart_account_ref',\n 'stock_unit_id', 'jsonb:danfe.line.v1_stock_unit_ref',\n 'stock_quantity', 'jsonb:danfe.line.stock_quantity',\n 'conversion_factor', 'jsonb:danfe.line.conversion_factor',\n 'cost_mode', 'jsonb:danfe.line.cost_mode',\n 'reconciled', 'jsonb:danfe.line.reconciled',\n 'xml_batches', 'jsonb:danfe.line.xml_batches',\n 'ai_suggested', 'jsonb:danfe.line.ai.suggested',\n 'ai_create_product', 'jsonb:danfe.line.ai.create_product',\n 'ai_product_name', 'jsonb:danfe.line.ai.product_name',\n 'ai_product_barcode', 'jsonb:danfe.line.ai.product_barcode',\n 'ai_product_cost_price', 'jsonb:danfe.line.ai.product_cost_price',\n 'ai_product_unit_id', 'jsonb:danfe.line.ai.v1_product_unit_ref',\n 'id', 'jsonb:danfe.line.v1_ref'),\n 'A linha da nota. NÃO cria movimento: 2.707 dos 2.860 itens do tenant 8 já '\n 'têm o movimento que o V1 gerou, e ele já atravessou — um segundo dobraria a '\n 'entrada e, pelo gatilho after_insert, o saldo junto. A linha adota aquele '\n 'movimento e guarda em operations.result o que ele não nomeia: fator de '\n 'conversão, conta contábil, CST de entrada e o produto sugerido pela IA.'\n);\n\nSELECT migration._allow(\n 'danfe_import_item_batches', 'public.plg_inventory_stock_positions', 'inventory', 'inv_danfe_line',\n jsonb_build_object('part', 'batch'),\n NULL::text[],\n jsonb_build_object(\n 'uuid', 'id',\n 'import_id', 'operation_id@danfe_imports',\n 'product_ref', 'product_id@products',\n 'stock_location_id', 'stock_location_id@stock_locations',\n 'stock_movement_uuid', 'movement_id@stock_movements',\n 'quantity', 'quantity',\n 'batch_number', 'batch_number',\n 'expiration_date', 'expiration_date',\n 'created_at', 'created_at',\n 'updated_at', 'updated_at',\n 'tenant_id', '-',\n 'item_id', 'jsonb:danfe.batch.v1_item_ref',\n 'received_at', 'jsonb:danfe.batch.received_at',\n 'received_by', 'jsonb:danfe.batch.v1_received_by',\n 'source', 'jsonb:danfe.batch.source',\n 'id', 'jsonb:danfe.batch.v1_ref'),\n 'O lote declarado na entrada — 66 no tenant 1 (todos do XML) e 2 no tenant 8 '\n '(manuais). Não desfaz a 005: a posição continua sendo consequência do '\n 'movimento e este de-para não grava nenhuma. O gatilho after_insert já criou '\n 'a posição com este lote e esta validade, e a linha do lote aponta para ela. '\n '32 dos 68 lotes têm movimento; os outros 36 ficam skipped, porque lote sem '\n 'movimento nunca foi saldo.'\n);\n\nDELETE FROM migration.excluded_tables\n WHERE source_table IN ('danfe_imports', 'danfe_import_items', 'danfe_import_item_batches');\n\nRESET migration.allowlist_unlock;\n\n\n-- ── quem pode executar ────────────────────────────────────────────────────\n--\n-- Função nova nasce executável por PUBLIC, e \\`anon\\`/\\`authenticated\\` herdam\n-- dali. Estas são de PORTE: SECURITY DEFINER, escrevem dados de qualquer\n-- inquilino, e uma delas alcançável pela chave anônima é a base inteira\n-- alcançável pela chave anônima. Só o service_role, que é quem roda a onda.\nREVOKE ALL ON FUNCTION migration.upsert_inv_shape(uuid, uuid, jsonb, jsonb) FROM PUBLIC, anon, authenticated;\nGRANT EXECUTE ON FUNCTION migration.upsert_inv_shape(uuid, uuid, jsonb, jsonb) TO service_role;\nREVOKE ALL ON FUNCTION migration.upsert_inv_danfe_line(uuid, uuid, jsonb, jsonb) FROM PUBLIC, anon, authenticated;\nGRANT EXECUTE ON FUNCTION migration.upsert_inv_danfe_line(uuid, uuid, jsonb, jsonb) TO service_role;\n`\n\nexport const MIGRATION_011_O_CONSUMO_TEM_DUAS_ORIGENS = `-- 011_o_consumo_tem_duas_origens.sql\n--\n-- A AUDITORIA DE USO ESTAVA VAZIA, E A CULPA NÃO ERA DELA.\n--\n-- A 001 leu o consumo no único lugar onde ele existia quando foi escrita: o\n-- razão, \\`plg_inventory_stock_movements\\` com \\`source_item_type='order_item'\\`.\n-- Isso pressupõe que consumir DEU BAIXA. É verdade para o consumo que nasce no\n-- V2 — \\`order.completed\\` consome a receita no mesmo instante — e é falso para\n-- tudo o que veio do V1.\n--\n-- O NÚMERO QUE OBRIGA ESTA MIGRATION. No V1, tenant 1:\n--\n-- execution_products ......................... 117.024 linhas\n-- stock_deducted = true ...................... 0\n-- stock_movement_id preenchido ............... 0\n--\n-- Zero. Em nenhum momento da história daquele banco o consumo declarado virou\n-- movimento de estoque — é exatamente o que os dois FLOW.md descrevem: no V1 a\n-- auditoria é um PORTÃO PRÉ-BAIXA, e a fila nunca foi drenada. Migrado, esse\n-- histórico chegou ao V2 como \\`plg_clinical_session_products\\` (116.911 linhas\n-- no Espaço Facial) e não produziu nenhum movimento, porque não havia nenhum\n-- para produzir.\n--\n-- Resultado medido no cluster, pelo papel \\`authenticated\\` com as claims do\n-- tenant: \\`v_inventory_consumption_audit\\` devolvia **0 linhas**. A tela do V2 é um\n-- porte fiel da do V1 — produto, quantidade com o «Padrão: N» só quando\n-- diverge, «Sem posição», data, profissional, cliente — e estava passando\n-- fome.\n--\n-- O QUE MUDA AQUI: o consumo passa a ter DUAS ORIGENS.\n--\n-- \\`ledger\\` — a baixa que realmente saiu do estoque (a 001, intacta).\n-- \\`clinical\\` — a declaração do atendimento que nunca virou baixa.\n--\n-- A PERGUNTA DA TELA É A MESMA NAS DUAS. «O que o profissional disse ter usado\n-- bate com o padrão?» não depende de o saldo ter mudado. O que muda é o que\n-- «corrigir» significa, e é por isso que a vista ganha \\`stock_posted\\`: na\n-- origem do razão corrigir DEVOLVE ao saldo; na origem clínica não há o que\n-- devolver, e corrigir é o mesmo ato do V1 — conserta a quantidade declarada e\n-- aprova.\n--\n-- POR QUE NÃO UMA FILA NOVA. Pelo mesmo motivo da 001: seria um segundo lugar\n-- guardando a mesma quantidade. \\`plg_clinical_session_products\\` JÁ é o registro\n-- do consumo declarado, com \\`default_quantity\\` (o «Padrão: N» do V1),\n-- \\`used_quantity\\`, \\`is_audited\\`, \\`audited_at\\` e \\`audit_notes\\`. A auditoria lê\n-- de onde o dado mora.\n--\n-- FORMA DA VISTA: as 29 colunas existentes continuam nas mesmas posições e com\n-- os MESMOS tipos — \\`variance\\` é \\`numeric\\` sem typmod, e um \\`numeric(14,4)\\` ali\n-- faria o \\`CREATE OR REPLACE\\` recusar. As duas colunas novas entram no fim, que\n-- é o único lugar onde o REPLACE aceita coluna nova. Nada de DROP: consultado o\n-- \\`pg_depend\\`, nada depende desta vista hoje, e mesmo assim um DROP CASCADE\n-- aqui seria o quarto acidente da casa.\n--\n-- \\`security_invoker\\` PRESERVADO. Sem ele a vista leria com os direitos de quem\n-- a criou e furaria a RLS das duas origens.\n--\n-- CONSEQUÊNCIA CONHECIDA, e ela é de propósito: com \\`security_invoker\\`, a\n-- origem clínica é filtrada pela policy de \\`plg_clinical_session_products\\`, que\n-- exige \\`clinical.read\\`. Um estoquista com \\`inventory.read\\` e sem\n-- \\`clinical.read\\` continua vendo a auditoria vazia. Alargar isso é decisão da\n-- policy do plugin clínico — dono da tabela —, não de uma migration do estoque,\n-- e escondê-la atrás de um \\`security_definer\\` seria trocar uma tela vazia por\n-- um vazamento.\n\n-- ── replay-safety ───────────────────────────────────────────────────────────\n--\n-- DROP before CREATE, and the reason is the rule this file exists under: the\n-- chain bench re-applies every migration on a populated database, and\n-- \\`CREATE OR REPLACE VIEW\\` may only APPEND columns — it refuses a narrower\n-- column list with \"cannot drop columns from view\". Files 012 through 018\n-- each widen this view, so on the second pass this statement would meet the\n-- final, wider shape and fail.\n--\n-- Dropping is free here and nowhere else: this view is born in this file. Its\n-- predecessor \\`v_inventory_usage_audit\\` (001, applied 2026-09-06) keeps its own\n-- name and its own narrow contract precisely so that it never has to be\n-- touched — an applied file with a changed checksum is a hard stop for\n-- \\`fayz db apply\\` on every cluster that already ran it.\nDROP VIEW IF EXISTS public.v_inventory_consumption_audit CASCADE;\n\nCREATE OR REPLACE VIEW public.v_inventory_consumption_audit\nWITH (security_invoker = 'true') AS\nWITH consumed AS (\n SELECT m.tenant_id,\n m.unit_id,\n m.source_item_id AS item_id,\n m.product_id,\n -- o razão guarda saída como negativo; quem confere pensa em positivo\n sum(- m.quantity) AS posted_quantity,\n sum(m.total_cost) AS total_cost,\n min(m.created_at) AS used_at,\n min(m.movement_date) AS movement_date,\n (array_agg(m.source_location_id ORDER BY m.created_at, m.id))[1] AS stock_location_id,\n count(DISTINCT m.source_location_id)::int AS location_count,\n (array_agg(m.measurement_unit_id ORDER BY m.created_at, m.id))[1] AS measurement_unit_id,\n (array_agg(m.metadata ->> 'recipe_id' ORDER BY m.created_at, m.id))[1] AS recipe_id\n FROM public.plg_inventory_stock_movements m\n WHERE m.source_item_type = 'order_item'\n AND m.source_item_id IS NOT NULL\n AND m.kind = 'out'\n -- estornado não é consumo: devolver ao estoque apaga a linha da fila\n AND NOT EXISTS (\n SELECT 1 FROM public.plg_inventory_stock_movements r\n WHERE r.reverses_movement_id = m.id\n )\n GROUP BY m.tenant_id, m.unit_id, m.source_item_id, m.product_id\n), corrected AS (\n SELECT c.tenant_id,\n c.source_item_id AS item_id,\n c.product_id,\n -- devolver ao estoque é ajuste positivo, e reduz o consumido\n sum(- c.quantity) AS correction_delta,\n -- o razão guarda custo sempre positivo; o sinal vem da quantidade\n sum(- sign(c.quantity) * c.total_cost) AS correction_cost,\n max(c.created_at) AS corrected_at,\n (array_agg(c.reason ORDER BY c.created_at DESC, c.id DESC))[1] AS correction_reason\n FROM public.plg_inventory_stock_movements c\n WHERE c.source_item_type = 'usage_audit'\n AND c.source_item_id IS NOT NULL\n GROUP BY c.tenant_id, c.source_item_id, c.product_id\n),\n\n-- ── origem 1: o consumo que saiu do estoque (a 001, sem uma vírgula a menos) ─\nledger AS (\n SELECT c.tenant_id,\n c.unit_id,\n c.item_id,\n c.product_id,\n p.name AS product_name,\n p.sku AS product_sku,\n c.used_at,\n c.movement_date,\n c.posted_quantity,\n (c.posted_quantity + coalesce(k.correction_delta, 0)) AS net_quantity,\n std.standard_quantity,\n CASE WHEN std.standard_quantity IS NULL THEN NULL\n ELSE ((c.posted_quantity + coalesce(k.correction_delta, 0))\n - std.standard_quantity)\n END AS variance,\n u.abbreviation AS unit_abbreviation,\n -- o custo acompanha a conclusão da auditoria, não o que foi lançado:\n -- devolver um frasco devolve o custo dele junto\n (c.total_cost + coalesce(k.correction_cost, 0)) AS total_cost,\n c.stock_location_id,\n sl.name AS stock_location_name,\n -- mais de uma posição quer dizer que o FIFO atravessou lotes\n c.location_count,\n r.id AS recipe_id,\n r.name AS recipe_name,\n i.order_id,\n i.name AS item_name,\n o.reference_number AS order_reference,\n i.assignee_id AS performed_by_id,\n perf.name AS performed_by_name,\n o.party_id AS customer_id,\n cust.name AS customer_name,\n k.corrected_at,\n k.correction_reason,\n (k.item_id IS NOT NULL) AS is_corrected,\n 'ledger'::text AS source,\n true AS stock_posted\n FROM consumed c\n LEFT JOIN corrected k\n ON k.tenant_id = c.tenant_id AND k.item_id = c.item_id AND k.product_id = c.product_id\n LEFT JOIN public.products p ON p.id = c.product_id\n LEFT JOIN public.plg_inventory_stock_locations sl ON sl.id = c.stock_location_id\n LEFT JOIN public.plg_inventory_measurement_units u ON u.id = c.measurement_unit_id\n -- A linha vendida e seu pedido chegam por LEFT JOIN de propósito: quem lê o\n -- estoque pode não ler a venda, e nesse caso a linha aparece sem o nome do\n -- profissional em vez de desaparecer da conferência.\n LEFT JOIN public.items i ON i.id = c.item_id AND i.tenant_id = c.tenant_id\n LEFT JOIN public.orders o ON o.id = i.order_id\n LEFT JOIN public.people perf ON perf.id = i.assignee_id\n LEFT JOIN public.people cust ON cust.id = o.party_id\n LEFT JOIN public.plg_inventory_recipes r\n ON r.id = nullif(c.recipe_id, '')::uuid AND r.tenant_id = c.tenant_id\n LEFT JOIN LATERAL (\n SELECT CASE\n WHEN r.id IS NOT NULL AND coalesce(r.yield_quantity, 0) > 0 THEN (\n SELECT round(sum(ri.quantity) * coalesce(i.quantity, 1) / r.yield_quantity, 4)\n FROM public.plg_inventory_recipe_ingredients ri\n WHERE ri.tenant_id = c.tenant_id\n AND ri.recipe_id = r.id\n AND ri.product_id = c.product_id\n )\n -- sem receita o produto vendido é o próprio consumido: o padrão é\n -- a quantidade da linha, e divergir aí seria erro de sistema\n WHEN r.id IS NULL THEN i.quantity\n ELSE NULL\n END AS standard_quantity\n ) std ON true\n),\n\n-- ── origem 2: a declaração do atendimento que nunca virou baixa ─────────────\n--\n-- IDENTIDADE. A vista é chaveada por (item_id, produto), e 116.144 das 116.911\n-- linhas clínicas não têm \\`order_item_id\\` — o consumo do V1 pendurava na\n-- execução, não na linha vendida. Então \\`item_id\\` aqui é o \\`id\\` da PRÓPRIA\n-- linha clínica. Ela é única, é estável, é o que a tela usa como chave de linha\n-- e é por ela que a correção encontra o registro de volta.\n--\n-- O PADRÃO VEM DO V1, NÃO DA RECEITA. \\`default_quantity\\` é literalmente o\n-- «Padrão: N» que a tela do V1 mostrava — o número que estava cadastrado no\n-- serviço no dia do atendimento. Recalcular pela receita vigente do V2 seria\n-- comparar o passado com um padrão que ele não teve.\n--\n-- \\`nullif(default_quantity, 0)\\` NÃO É COSMÉTICO: 377 linhas vieram com padrão\n-- zero, que no V1 quer dizer «sem padrão cadastrado» (a tela testa\n-- \\`default_quantity > 0\\` antes de mostrar o selo). Sem o nullif, cada uma delas\n-- viraria uma divergência do valor inteiro e o filtro «só divergentes» passaria\n-- de 172 achados reais para 421 — afogar o achado é o mesmo que não ter achado.\nclinical AS (\n SELECT cs.tenant_id,\n cs.unit_id,\n cs.id AS item_id,\n cs.product_id,\n p.name AS product_name,\n p.sku AS product_sku,\n coalesce(cs.executed_on::timestamptz, cs.created_at) AS used_at,\n coalesce(cs.executed_on, cs.created_at::date) AS movement_date,\n -- Não houve postagem. «posted» aqui é o DECLARADO, e quem diz a verdade\n -- sobre o razão é \\`stock_posted = false\\`, logo abaixo. Igual ao V1,\n -- corrigir reescreve \\`used_quantity\\` no lugar, então declarado e\n -- líquido são sempre o mesmo número nesta origem.\n coalesce(cs.used_quantity, 0) AS posted_quantity,\n coalesce(cs.used_quantity, 0) AS net_quantity,\n nullif(cs.default_quantity, 0) AS standard_quantity,\n CASE WHEN nullif(cs.default_quantity, 0) IS NULL THEN NULL\n ELSE coalesce(cs.used_quantity, 0) - cs.default_quantity\n END AS variance,\n u.abbreviation AS unit_abbreviation,\n -- custo zero não é «não sei»: é «nada saiu do estoque, logo nada\n -- custou ao estoque». Inventar aqui o preço de catálogo daria à tela\n -- um valor que nenhum razão jamais escreveu.\n 0::numeric AS total_cost,\n NULL::uuid AS stock_location_id,\n NULL::text AS stock_location_name,\n -- o V1 mostrava «Sem posição» em 117.024 de 117.024 linhas; a ausência\n -- de local aqui é o porte fiel daquele selo, não uma perda de dado\n 1 AS location_count,\n NULL::uuid AS recipe_id,\n NULL::text AS recipe_name,\n i.order_id,\n -- o V1 mostrava o SERVIÇO na linha; no V2 o serviço é um \\`products\\`\n sv.name AS item_name,\n o.reference_number AS order_reference,\n coalesce(cs.assignee_id, s.performed_by_id, s.assignee_id) AS performed_by_id,\n perf.name AS performed_by_name,\n s.party_id AS customer_id,\n cust.name AS customer_name,\n -- «Corrigir» e «Aprovar» eram um ato só no V1: auditado é o conferido\n cs.audited_at AS corrected_at,\n cs.audit_notes AS correction_reason,\n cs.is_audited AS is_corrected,\n 'clinical'::text AS source,\n false AS stock_posted\n FROM public.plg_clinical_session_products cs\n LEFT JOIN public.plg_clinical_sessions s\n ON s.tenant_id = cs.tenant_id AND s.id = cs.session_id\n LEFT JOIN public.products p ON p.id = cs.product_id\n LEFT JOIN public.products sv ON sv.id = s.service_id\n LEFT JOIN public.plg_inventory_measurement_units u ON u.id = cs.used_unit_id\n LEFT JOIN public.items i ON i.id = cs.order_item_id AND i.tenant_id = cs.tenant_id\n LEFT JOIN public.orders o ON o.id = i.order_id\n LEFT JOIN public.people perf ON perf.id = coalesce(cs.assignee_id, s.performed_by_id, s.assignee_id)\n LEFT JOIN public.people cust ON cust.id = s.party_id\n -- A MESMA LINHA NÃO CONTA DUAS VEZES. Se a declaração clínica já produziu\n -- uma saída no razão, quem manda é o razão: é ele que tem custo, posição e\n -- a correção que devolve saldo.\n --\n -- O teste de estorno NÃO se repete aqui de propósito. A 001 decidiu que\n -- saída estornada sai da fila; se o gêmeo clínico reaparecesse quando o\n -- estorno acontece, a mesma linha voltaria à conferência pela porta dos\n -- fundos e diria o contrário do que a 001 disse.\n WHERE NOT EXISTS (\n SELECT 1\n FROM public.plg_inventory_stock_movements m\n WHERE m.tenant_id = cs.tenant_id\n AND m.source_item_type = 'order_item'\n AND m.source_item_id = cs.order_item_id\n AND m.product_id = cs.product_id\n AND m.kind = 'out'\n )\n)\n\n-- O casco externo existe pelos TYPMODS. Um UNION apaga \\`numeric(14,4)\\` e deixa\n-- \\`numeric\\` puro, e o \\`CREATE OR REPLACE VIEW\\` recusa mudança de tipo de coluna\n-- existente — inclusive typmod. Então cada coluna volta ao tipo que ela já\n-- tinha, um a um. \\`variance\\` fica \\`numeric\\` SEM typmod porque é assim que ela\n-- está no catálogo hoje; apertá-la para \\`numeric(14,4)\\` seria o mesmo erro pelo\n-- lado oposto.\nSELECT a.tenant_id,\n a.unit_id,\n a.item_id,\n a.product_id,\n a.product_name,\n a.product_sku,\n a.used_at,\n a.movement_date,\n a.posted_quantity::numeric(14,4) AS posted_quantity,\n a.net_quantity::numeric(14,4) AS net_quantity,\n a.standard_quantity::numeric(14,4) AS standard_quantity,\n a.variance::numeric AS variance,\n a.unit_abbreviation,\n a.total_cost::numeric(16,4) AS total_cost,\n a.stock_location_id,\n a.stock_location_name,\n a.location_count,\n a.recipe_id,\n a.recipe_name,\n a.order_id,\n a.item_name,\n a.order_reference,\n a.performed_by_id,\n a.performed_by_name,\n a.customer_id,\n a.customer_name,\n a.corrected_at,\n a.correction_reason,\n a.is_corrected,\n -- ── colunas novas, no fim, que é onde o REPLACE aceita ──────────────\n a.source,\n a.stock_posted\n FROM (\n SELECT * FROM ledger\n UNION ALL\n SELECT * FROM clinical\n ) a;\n\nCOMMENT ON VIEW public.v_inventory_consumption_audit IS\n 'O consumo declarado em cada atendimento ao lado do padrão, por linha e produto, nas suas duas origens: o que saiu do razão (source=ledger) e o que o V1 declarou e nunca baixou (source=clinical, stock_posted=false). Não cria fila: os dois lugares já guardavam tudo (001, 011).';\n\nGRANT SELECT ON public.v_inventory_consumption_audit TO authenticated;\nGRANT SELECT ON public.v_inventory_consumption_audit TO service_role;\n\n-- ── índices: a conferência é sempre (tenant, mais recente primeiro) ─────────\n--\n-- A tela pagina 50 por vez ordenando por \\`used_at DESC\\` sobre 116.911 linhas.\n-- Sem isto a vista varre a tabela inteira a cada página.\nCREATE INDEX IF NOT EXISTS plg_clinical_session_products_audit_idx\n ON public.plg_clinical_session_products (tenant_id, executed_on DESC, id);\n\n-- O anti-join da deduplicação entra pelo par (linha vendida, produto)\nCREATE INDEX IF NOT EXISTS plg_clinical_session_products_order_item_idx\n ON public.plg_clinical_session_products (tenant_id, order_item_id, product_id)\n WHERE order_item_id IS NOT NULL;\n\n-- ───────────────────────────────────────────────────────────────────────────\n-- CORRIGIR, NA ORIGEM CLÍNICA\n--\n-- A 002 fez de \\`app.inventory_correct_usage_line\\` o miolo único: a correção\n-- avulsa e o lote são o mesmo código. Ele procura a baixa no razão e, não\n-- achando, levanta «no usage of product % on item % to correct». Com a vista\n-- servindo 116.890 linhas clínicas, esse erro passaria a ser a resposta normal\n-- da tela — o botão existiria só para falhar.\n--\n-- O QUE CORRIGIR SIGNIFICA EM CADA ORIGEM:\n--\n-- razão — a baixa aconteceu; corrigir 50 para 40 DEVOLVE 10 ao saldo, como\n-- ajuste, porque o razão é append-only. (inalterado)\n-- clínica — nada saiu; não há o que devolver. Corrigir é o ato do V1:\n-- conserta a quantidade declarada, exige motivo, e APROVA junto —\n-- «Salvar e Aprovar» era um botão só, nunca dois.\n--\n-- Lançar um ajuste de estoque para a origem clínica seria pior que não\n-- corrigir: criaria movimento para um consumo que nunca teve movimento, e o\n-- saldo passaria a contar uma devolução de algo que nunca saiu.\n--\n-- PERMISSÃO, EXPLICITAMENTE. A função é SECURITY DEFINER, então o UPDATE não\n-- passa pela RLS de \\`plg_clinical_session_products\\` — quem tem de barrar é este\n-- código. Exige-se \\`inventory.edit\\` na unidade do consumo, que é a mesma regra\n-- do caminho do razão (\\`inventory_authorize_location\\` cobra \\`inventory.edit\\` na\n-- unidade do local). Linha sem unidade (906 delas) cai no teste «em qualquer\n-- unidade», espelhando o \\`unit_id IS NULL OR has_unit(...)\\` da própria policy.\nCREATE OR REPLACE FUNCTION app.inventory_correct_usage_line(\n p_tenant uuid,\n p_op uuid,\n p_key text,\n p_line_no integer,\n p_item_id uuid,\n p_product_id uuid,\n p_quantity numeric,\n p_reason text\n) RETURNS jsonb\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $$\nDECLARE\n v_row record;\n v_cs record;\n v_delta numeric;\n v_unit uuid;\n v_id uuid;\nBEGIN\n IF p_quantity IS NULL OR p_quantity < 0 THEN\n RAISE EXCEPTION 'inventory: the corrected quantity cannot be negative' USING ERRCODE = '22023';\n END IF;\n\n -- o consumido de hoje: a baixa original mais o que auditorias já corrigiram\n SELECT sum(- m.quantity) AS net,\n (array_agg(m.source_location_id ORDER BY m.created_at DESC, m.id DESC))[1] AS location_id,\n (array_agg(m.measurement_unit_id ORDER BY m.created_at DESC, m.id DESC))[1] AS measurement_unit_id,\n (array_agg(m.batch_number ORDER BY m.created_at DESC, m.id DESC))[1] AS batch_number,\n (array_agg(m.expiration_date ORDER BY m.created_at DESC, m.id DESC))[1] AS expiration_date,\n (array_agg(m.unit_cost ORDER BY m.created_at DESC, m.id DESC))[1] AS unit_cost\n INTO v_row\n FROM public.plg_inventory_stock_movements m\n WHERE m.tenant_id = p_tenant\n AND m.source_item_id = p_item_id\n AND m.product_id = p_product_id\n AND ((m.source_item_type = 'order_item' AND m.kind = 'out')\n OR (m.source_item_type = 'usage_audit' AND m.kind = 'adjust'))\n AND NOT EXISTS (\n SELECT 1 FROM public.plg_inventory_stock_movements r\n WHERE r.reverses_movement_id = m.id\n );\n\n -- ── a origem clínica: consumo declarado que nunca virou baixa ────────────\n IF v_row.net IS NULL THEN\n SELECT cs.id, cs.unit_id, cs.used_quantity, cs.is_audited\n INTO v_cs\n FROM public.plg_clinical_session_products cs\n WHERE cs.tenant_id = p_tenant\n AND cs.id = p_item_id\n AND cs.product_id = p_product_id;\n\n IF v_cs.id IS NULL THEN\n RAISE EXCEPTION 'inventory: no usage of product % on item % to correct', p_product_id, p_item_id\n USING ERRCODE = '22023';\n END IF;\n\n IF v_cs.unit_id IS NOT NULL THEN\n IF NOT app.has_unit(v_cs.unit_id) THEN\n RAISE EXCEPTION 'inventory: no access to the unit of usage %', p_item_id USING ERRCODE = '42501';\n END IF;\n IF NOT app.has_permission('inventory.edit', v_cs.unit_id) THEN\n RAISE EXCEPTION 'inventory: inventory.edit required at the unit of usage %', p_item_id\n USING ERRCODE = '42501';\n END IF;\n ELSIF NOT app.has_permission_anywhere('inventory.edit') THEN\n RAISE EXCEPTION 'inventory: inventory.edit required to correct usage %', p_item_id\n USING ERRCODE = '42501';\n END IF;\n\n -- Conferir e não mudar número AINDA é conferir: a linha pendente vira\n -- auditada. Só é «unchanged» o que já estava auditado com este número —\n -- reenviar o mesmo lote não reescreve a data da auditoria.\n IF coalesce(v_cs.used_quantity, 0) = p_quantity AND v_cs.is_audited THEN\n RETURN jsonb_build_object('status', 'unchanged', 'origin', 'clinical',\n 'item_id', p_item_id, 'product_id', p_product_id,\n 'previous_quantity', coalesce(v_cs.used_quantity, 0),\n 'quantity', p_quantity, 'delta', 0);\n END IF;\n\n UPDATE public.plg_clinical_session_products\n SET used_quantity = p_quantity,\n is_audited = true,\n audited_at = now(),\n audited_by = auth.uid(),\n audit_notes = p_reason,\n updated_at = now()\n WHERE tenant_id = p_tenant AND id = p_item_id AND product_id = p_product_id;\n\n -- Sem \\`movements\\`: nada entrou no razão, e devolver uma lista vazia é o que\n -- diz isso a quem chamou. O lote continua contando a linha como aplicada.\n RETURN jsonb_build_object('status', 'corrected', 'origin', 'clinical',\n 'item_id', p_item_id, 'product_id', p_product_id,\n 'unit_id', v_cs.unit_id,\n 'previous_quantity', coalesce(v_cs.used_quantity, 0),\n 'quantity', p_quantity,\n 'delta', coalesce(v_cs.used_quantity, 0) - p_quantity,\n 'movements', '[]'::jsonb);\n END IF;\n\n -- ── a origem do razão: inalterada desde a 002 ────────────────────────────\n -- positivo devolve ao estoque, negativo tira mais\n v_delta := v_row.net - p_quantity;\n IF v_delta = 0 THEN\n RETURN jsonb_build_object('status', 'unchanged', 'origin', 'ledger', 'item_id', p_item_id,\n 'product_id', p_product_id, 'previous_quantity', v_row.net,\n 'quantity', p_quantity, 'delta', 0);\n END IF;\n\n IF v_delta < 0\n AND app.inventory_available(p_tenant, p_product_id, v_row.location_id,\n v_row.batch_number, v_row.expiration_date) < - v_delta THEN\n RAISE EXCEPTION 'inventory: correcting to % would take product % below its available quantity at location %',\n p_quantity, p_product_id, v_row.location_id USING ERRCODE = '23514';\n END IF;\n\n v_unit := app.inventory_authorize_location(p_tenant, v_row.location_id, 'inventory.edit');\n\n INSERT INTO public.plg_inventory_stock_movements\n (tenant_id, product_id, kind, quantity, unit_cost, source_location_id,\n batch_number, expiration_date, measurement_unit_id, document_type,\n reason, source_item_type, source_item_id, idempotency_key, line_no,\n operation_id, metadata)\n VALUES\n (p_tenant, p_product_id, 'adjust', v_delta, coalesce(v_row.unit_cost, 0), v_row.location_id,\n v_row.batch_number, v_row.expiration_date, v_row.measurement_unit_id, 'adjustment',\n p_reason, 'usage_audit', p_item_id, p_key, p_line_no, p_op,\n jsonb_build_object('source', 'usage_audit', 'order_item_id', p_item_id,\n 'posted_quantity', v_row.net, 'corrected_quantity', p_quantity))\n RETURNING id INTO v_id;\n\n RETURN jsonb_build_object('status', 'corrected', 'origin', 'ledger', 'item_id', p_item_id,\n 'product_id', p_product_id, 'location_id', v_row.location_id,\n 'unit_id', v_unit, 'previous_quantity', v_row.net,\n 'quantity', p_quantity, 'delta', v_delta,\n 'movements', app.inventory_movement_json(v_id));\nEND $$;\n\nCOMMENT ON FUNCTION app.inventory_correct_usage_line(uuid, uuid, text, integer, uuid, uuid, numeric, text) IS\n 'Uma linha de correção de consumo dentro de uma operação já aberta. Origem razão: lança a diferença como ajuste. Origem clínica (consumo do V1 que nunca baixou): conserta a quantidade declarada e aprova, sem tocar no saldo (002, 011).';\n\nREVOKE ALL ON FUNCTION app.inventory_correct_usage_line(uuid, uuid, text, integer, uuid, uuid, numeric, text) FROM PUBLIC;\n`\n\nexport const MIGRATION_012_O_CONSUMO_CLINICO_NAO_PRECISA_DA_TABELA_DE_ITENS_INTEIRA = `-- O consumo clínico não precisa da tabela de itens inteira.\n--\n-- A auditoria de uso continuava estourando o \\`statement_timeout\\` do papel\n-- \\`authenticated\\` (8 s) mesmo depois de a clinical/003 baratear a política. O\n-- plano, lido com o tempo solto, mostrou onde:\n--\n-- Hash (cost=154686.67 rows=23255) (actual rows=455925)\n--\n-- O ramo clínico da vista faz \\`LEFT JOIN items i ON i.id = cs.order_item_id\\`, e\n-- o planejador resolve isso montando um hash de \\`public.items\\` INTEIRA —\n-- 455.925 linhas — para casar com 116.911 consumos. Só que **767** desses\n-- consumos têm \\`order_item_id\\`: no V1 o produto usado pendura na execução, não\n-- na linha vendida. O hash existe para 0,7% das linhas e é pago por 100%.\n--\n-- O mesmo vale para \\`orders\\` e para as duas idas a \\`people\\` (120.808 linhas\n-- cada uma, uma para quem executou e outra para a cliente).\n--\n-- ── A correção é a forma do join, não o dado ────────────────────────────────\n--\n-- Cada um vira LATERAL com a condição de existência dentro. Assim o planejador\n-- deixa de montar hash e passa a fazer busca por chave primária — 767 buscas em\n-- vez de um hash de 455 mil linhas —, e as linhas sem \\`order_item_id\\` não\n-- procuram nada.\n--\n-- \\`LEFT JOIN LATERAL ... ON true\\` preserva a semântica do LEFT JOIN: a linha do\n-- consumo sobrevive mesmo quando não há item, que é o caso de 116.144 delas.\n--\n-- Medido no cluster, como o usuário que estava com a tela aberta, com ROLLBACK:\n--\n-- 50 linhas ordenadas por data 1,94 s (antes: estourava os 8 s)\n-- contagem total 1,27 s 116.911 linhas — a MESMA de antes\n--\n-- A contagem idêntica é o teste que importa: uma vista que fica rápida\n-- devolvendo menos linha não ficou rápida, ficou errada.\n--\n-- Idempotente. \\`CREATE OR REPLACE\\` mantém nome, ordem e tipo das colunas — só\n-- os joins mudam — e mantém \\`security_invoker\\`, sem o qual a vista passaria a\n-- ler com os direitos de quem a criou e furaria a RLS.\n\nCREATE OR REPLACE VIEW public.v_inventory_consumption_audit WITH (security_invoker = true) AS\n WITH consumed AS (\n SELECT m.tenant_id,\n m.unit_id,\n m.source_item_id AS item_id,\n m.product_id,\n sum(- m.quantity) AS posted_quantity,\n sum(m.total_cost) AS total_cost,\n min(m.created_at) AS used_at,\n min(m.movement_date) AS movement_date,\n (array_agg(m.source_location_id ORDER BY m.created_at, m.id))[1] AS stock_location_id,\n count(DISTINCT m.source_location_id)::integer AS location_count,\n (array_agg(m.measurement_unit_id ORDER BY m.created_at, m.id))[1] AS measurement_unit_id,\n (array_agg(m.metadata ->> 'recipe_id'::text ORDER BY m.created_at, m.id))[1] AS recipe_id\n FROM plg_inventory_stock_movements m\n WHERE m.source_item_type = 'order_item'::text AND m.source_item_id IS NOT NULL AND m.kind = 'out'::text AND NOT (EXISTS ( SELECT 1\n FROM plg_inventory_stock_movements r\n WHERE r.reverses_movement_id = m.id))\n GROUP BY m.tenant_id, m.unit_id, m.source_item_id, m.product_id\n ), corrected AS (\n SELECT c.tenant_id,\n c.source_item_id AS item_id,\n c.product_id,\n sum(- c.quantity) AS correction_delta,\n sum((- sign(c.quantity)) * c.total_cost) AS correction_cost,\n max(c.created_at) AS corrected_at,\n (array_agg(c.reason ORDER BY c.created_at DESC, c.id DESC))[1] AS correction_reason\n FROM plg_inventory_stock_movements c\n WHERE c.source_item_type = 'usage_audit'::text AND c.source_item_id IS NOT NULL\n GROUP BY c.tenant_id, c.source_item_id, c.product_id\n ), ledger AS (\n SELECT c.tenant_id,\n c.unit_id,\n c.item_id,\n c.product_id,\n p.name AS product_name,\n p.sku AS product_sku,\n c.used_at,\n c.movement_date,\n c.posted_quantity,\n c.posted_quantity + COALESCE(k.correction_delta, 0::numeric) AS net_quantity,\n std.standard_quantity,\n CASE\n WHEN std.standard_quantity IS NULL THEN NULL::numeric\n ELSE c.posted_quantity + COALESCE(k.correction_delta, 0::numeric) - std.standard_quantity\n END AS variance,\n u.abbreviation AS unit_abbreviation,\n c.total_cost + COALESCE(k.correction_cost, 0::numeric) AS total_cost,\n c.stock_location_id,\n sl.name AS stock_location_name,\n c.location_count,\n r.id AS recipe_id,\n r.name AS recipe_name,\n i.order_id,\n i.name AS item_name,\n o.reference_number AS order_reference,\n i.assignee_id AS performed_by_id,\n perf.name AS performed_by_name,\n o.party_id AS customer_id,\n cust.name AS customer_name,\n k.corrected_at,\n k.correction_reason,\n k.item_id IS NOT NULL AS is_corrected,\n 'ledger'::text AS source,\n true AS stock_posted\n FROM consumed c\n LEFT JOIN corrected k ON k.tenant_id = c.tenant_id AND k.item_id = c.item_id AND k.product_id = c.product_id\n LEFT JOIN products p ON p.id = c.product_id\n LEFT JOIN plg_inventory_stock_locations sl ON sl.id = c.stock_location_id\n LEFT JOIN plg_inventory_measurement_units u ON u.id = c.measurement_unit_id\n LEFT JOIN items i ON i.id = c.item_id AND i.tenant_id = c.tenant_id\n LEFT JOIN orders o ON o.id = i.order_id\n LEFT JOIN people perf ON perf.id = i.assignee_id\n LEFT JOIN people cust ON cust.id = o.party_id\n LEFT JOIN plg_inventory_recipes r ON r.id = NULLIF(c.recipe_id, ''::text)::uuid AND r.tenant_id = c.tenant_id\n LEFT JOIN LATERAL ( SELECT\n CASE\n WHEN r.id IS NOT NULL AND COALESCE(r.yield_quantity, 0::numeric) > 0::numeric THEN ( SELECT round(sum(ri.quantity) * COALESCE(i.quantity, 1::numeric) / r.yield_quantity, 4) AS round\n FROM plg_inventory_recipe_ingredients ri\n WHERE ri.tenant_id = c.tenant_id AND ri.recipe_id = r.id AND ri.product_id = c.product_id)\n WHEN r.id IS NULL THEN i.quantity\n ELSE NULL::numeric\n END AS standard_quantity) std ON true\n ), clinical AS (\n SELECT cs.tenant_id,\n cs.unit_id,\n cs.id AS item_id,\n cs.product_id,\n p.name AS product_name,\n p.sku AS product_sku,\n COALESCE(cs.executed_on::timestamp with time zone, cs.created_at) AS used_at,\n COALESCE(cs.executed_on, cs.created_at::date) AS movement_date,\n COALESCE(cs.used_quantity, 0::numeric) AS posted_quantity,\n COALESCE(cs.used_quantity, 0::numeric) AS net_quantity,\n NULLIF(cs.default_quantity, 0::numeric) AS standard_quantity,\n CASE\n WHEN NULLIF(cs.default_quantity, 0::numeric) IS NULL THEN NULL::numeric\n ELSE COALESCE(cs.used_quantity, 0::numeric) - cs.default_quantity\n END AS variance,\n u.abbreviation AS unit_abbreviation,\n 0::numeric AS total_cost,\n NULL::uuid AS stock_location_id,\n NULL::text AS stock_location_name,\n 1 AS location_count,\n NULL::uuid AS recipe_id,\n NULL::text AS recipe_name,\n i.order_id,\n sv.name AS item_name,\n o.reference_number AS order_reference,\n COALESCE(cs.assignee_id, s.performed_by_id, s.assignee_id) AS performed_by_id,\n perf.name AS performed_by_name,\n s.party_id AS customer_id,\n cust.name AS customer_name,\n cs.audited_at AS corrected_at,\n cs.audit_notes AS correction_reason,\n cs.is_audited AS is_corrected,\n 'clinical'::text AS source,\n false AS stock_posted\n FROM plg_clinical_session_products cs\n LEFT JOIN plg_clinical_sessions s ON s.tenant_id = cs.tenant_id AND s.id = cs.session_id\n LEFT JOIN products p ON p.id = cs.product_id\n LEFT JOIN products sv ON sv.id = s.service_id\n LEFT JOIN plg_inventory_measurement_units u ON u.id = cs.used_unit_id\n LEFT JOIN LATERAL ( SELECT it.id, it.order_id, it.name, it.assignee_id\n FROM items it\n WHERE cs.order_item_id IS NOT NULL\n AND it.id = cs.order_item_id AND it.tenant_id = cs.tenant_id) i ON true\n LEFT JOIN LATERAL ( SELECT ord.id, ord.reference_number, ord.party_id\n FROM orders ord\n WHERE i.order_id IS NOT NULL AND ord.id = i.order_id) o ON true\n LEFT JOIN LATERAL ( SELECT pe.id, pe.name\n FROM people pe\n WHERE pe.id = COALESCE(cs.assignee_id, s.performed_by_id, s.assignee_id)) perf ON true\n LEFT JOIN LATERAL ( SELECT pe.id, pe.name\n FROM people pe\n WHERE pe.id = s.party_id) cust ON true\n WHERE NOT (EXISTS ( SELECT 1\n FROM plg_inventory_stock_movements m\n WHERE m.tenant_id = cs.tenant_id AND m.source_item_type = 'order_item'::text AND m.source_item_id = cs.order_item_id AND m.product_id = cs.product_id AND m.kind = 'out'::text))\n )\n SELECT tenant_id,\n unit_id,\n item_id,\n product_id,\n product_name,\n product_sku,\n used_at,\n movement_date,\n posted_quantity::numeric(14,4) AS posted_quantity,\n net_quantity::numeric(14,4) AS net_quantity,\n standard_quantity::numeric(14,4) AS standard_quantity,\n variance,\n unit_abbreviation,\n total_cost::numeric(16,4) AS total_cost,\n stock_location_id,\n stock_location_name,\n location_count,\n recipe_id,\n recipe_name,\n order_id,\n item_name,\n order_reference,\n performed_by_id,\n performed_by_name,\n customer_id,\n customer_name,\n corrected_at,\n correction_reason,\n is_corrected,\n source,\n stock_posted\n FROM ( SELECT ledger.tenant_id,\n ledger.unit_id,\n ledger.item_id,\n ledger.product_id,\n ledger.product_name,\n ledger.product_sku,\n ledger.used_at,\n ledger.movement_date,\n ledger.posted_quantity,\n ledger.net_quantity,\n ledger.standard_quantity,\n ledger.variance,\n ledger.unit_abbreviation,\n ledger.total_cost,\n ledger.stock_location_id,\n ledger.stock_location_name,\n ledger.location_count,\n ledger.recipe_id,\n ledger.recipe_name,\n ledger.order_id,\n ledger.item_name,\n ledger.order_reference,\n ledger.performed_by_id,\n ledger.performed_by_name,\n ledger.customer_id,\n ledger.customer_name,\n ledger.corrected_at,\n ledger.correction_reason,\n ledger.is_corrected,\n ledger.source,\n ledger.stock_posted\n FROM ledger\n UNION ALL\n SELECT clinical.tenant_id,\n clinical.unit_id,\n clinical.item_id,\n clinical.product_id,\n clinical.product_name,\n clinical.product_sku,\n clinical.used_at,\n clinical.movement_date,\n clinical.posted_quantity,\n clinical.net_quantity,\n clinical.standard_quantity,\n clinical.variance,\n clinical.unit_abbreviation,\n clinical.total_cost,\n clinical.stock_location_id,\n clinical.stock_location_name,\n clinical.location_count,\n clinical.recipe_id,\n clinical.recipe_name,\n clinical.order_id,\n clinical.item_name,\n clinical.order_reference,\n clinical.performed_by_id,\n clinical.performed_by_name,\n clinical.customer_id,\n clinical.customer_name,\n clinical.corrected_at,\n clinical.correction_reason,\n clinical.is_corrected,\n clinical.source,\n clinical.stock_posted\n FROM clinical) a;;\n`\n\nexport const MIGRATION_013_A_FILA_DE_AUDITORIA_TEM_UMA_JANELA = `-- A fila de auditoria tem uma janela, e a janela precisa de índice.\n--\n-- \\`v_inventory_consumption_audit\\` atravessa a história inteira do consumo. No Espaço\n-- Facial isso é 116.939 linhas, e pedir as 50 mais recentes custava 7 s de\n-- banco: a tela voltava \\`canceling statement due to statement timeout\\` e, pior,\n-- mostrava \"Nenhum consumo fora do padrão\" por cima do erro.\n--\n-- A tela passou a pedir uma janela (\\`used_at >= agora - 30 dias\\`), mas o ramo\n-- clínico não tinha como responder por ela: \\`used_at\\` é\n-- \\`coalesce(executed_on::timestamptz, created_at)\\` e essa expressão NÃO é\n-- indexável — o cast de \\`date\\` para \\`timestamptz\\` depende do fuso da sessão e\n-- o Postgres o recusa em índice.\n--\n-- \\`timezone('UTC', executed_on::timestamp)\\` é o MESMO valor (o cluster roda em\n-- UTC) e é imutável. Trocada a expressão, o índice abaixo existe e a janela\n-- deixa de ser uma varredura.\n\nCREATE INDEX IF NOT EXISTS plg_clinical_session_products_used_at_idx\n ON public.plg_clinical_session_products (\n tenant_id,\n (COALESCE(timezone('UTC', executed_on::timestamp), created_at)) DESC\n );\n\nCREATE OR REPLACE VIEW public.v_inventory_consumption_audit WITH (security_invoker = true) AS\n WITH consumed AS (\n SELECT m.tenant_id,\n m.unit_id,\n m.source_item_id AS item_id,\n m.product_id,\n sum(- m.quantity) AS posted_quantity,\n sum(m.total_cost) AS total_cost,\n min(m.created_at) AS used_at,\n min(m.movement_date) AS movement_date,\n (array_agg(m.source_location_id ORDER BY m.created_at, m.id))[1] AS stock_location_id,\n count(DISTINCT m.source_location_id)::integer AS location_count,\n (array_agg(m.measurement_unit_id ORDER BY m.created_at, m.id))[1] AS measurement_unit_id,\n (array_agg(m.metadata ->> 'recipe_id'::text ORDER BY m.created_at, m.id))[1] AS recipe_id\n FROM plg_inventory_stock_movements m\n WHERE m.source_item_type = 'order_item'::text AND m.source_item_id IS NOT NULL AND m.kind = 'out'::text AND NOT (EXISTS ( SELECT 1\n FROM plg_inventory_stock_movements r\n WHERE r.reverses_movement_id = m.id))\n GROUP BY m.tenant_id, m.unit_id, m.source_item_id, m.product_id\n ), corrected AS (\n SELECT c.tenant_id,\n c.source_item_id AS item_id,\n c.product_id,\n sum(- c.quantity) AS correction_delta,\n sum((- sign(c.quantity)) * c.total_cost) AS correction_cost,\n max(c.created_at) AS corrected_at,\n (array_agg(c.reason ORDER BY c.created_at DESC, c.id DESC))[1] AS correction_reason\n FROM plg_inventory_stock_movements c\n WHERE c.source_item_type = 'usage_audit'::text AND c.source_item_id IS NOT NULL\n GROUP BY c.tenant_id, c.source_item_id, c.product_id\n ), ledger AS (\n SELECT c.tenant_id,\n c.unit_id,\n c.item_id,\n c.product_id,\n p.name AS product_name,\n p.sku AS product_sku,\n c.used_at,\n c.movement_date,\n c.posted_quantity,\n c.posted_quantity + COALESCE(k.correction_delta, 0::numeric) AS net_quantity,\n std.standard_quantity,\n CASE\n WHEN std.standard_quantity IS NULL THEN NULL::numeric\n ELSE c.posted_quantity + COALESCE(k.correction_delta, 0::numeric) - std.standard_quantity\n END AS variance,\n u.abbreviation AS unit_abbreviation,\n c.total_cost + COALESCE(k.correction_cost, 0::numeric) AS total_cost,\n c.stock_location_id,\n sl.name AS stock_location_name,\n c.location_count,\n r.id AS recipe_id,\n r.name AS recipe_name,\n i.order_id,\n i.name AS item_name,\n o.reference_number AS order_reference,\n i.assignee_id AS performed_by_id,\n perf.name AS performed_by_name,\n o.party_id AS customer_id,\n cust.name AS customer_name,\n k.corrected_at,\n k.correction_reason,\n k.item_id IS NOT NULL AS is_corrected,\n 'ledger'::text AS source,\n true AS stock_posted\n FROM consumed c\n LEFT JOIN corrected k ON k.tenant_id = c.tenant_id AND k.item_id = c.item_id AND k.product_id = c.product_id\n LEFT JOIN products p ON p.id = c.product_id\n LEFT JOIN plg_inventory_stock_locations sl ON sl.id = c.stock_location_id\n LEFT JOIN plg_inventory_measurement_units u ON u.id = c.measurement_unit_id\n LEFT JOIN items i ON i.id = c.item_id AND i.tenant_id = c.tenant_id\n LEFT JOIN orders o ON o.id = i.order_id\n LEFT JOIN people perf ON perf.id = i.assignee_id\n LEFT JOIN people cust ON cust.id = o.party_id\n LEFT JOIN plg_inventory_recipes r ON r.id = NULLIF(c.recipe_id, ''::text)::uuid AND r.tenant_id = c.tenant_id\n LEFT JOIN LATERAL ( SELECT\n CASE\n WHEN r.id IS NOT NULL AND COALESCE(r.yield_quantity, 0::numeric) > 0::numeric THEN ( SELECT round(sum(ri.quantity) * COALESCE(i.quantity, 1::numeric) / r.yield_quantity, 4) AS round\n FROM plg_inventory_recipe_ingredients ri\n WHERE ri.tenant_id = c.tenant_id AND ri.recipe_id = r.id AND ri.product_id = c.product_id)\n WHEN r.id IS NULL THEN i.quantity\n ELSE NULL::numeric\n END AS standard_quantity) std ON true\n ), clinical AS (\n SELECT cs.tenant_id,\n cs.unit_id,\n cs.id AS item_id,\n cs.product_id,\n p.name AS product_name,\n p.sku AS product_sku,\n COALESCE(timezone('UTC', cs.executed_on::timestamp), cs.created_at) AS used_at,\n COALESCE(cs.executed_on, cs.created_at::date) AS movement_date,\n COALESCE(cs.used_quantity, 0::numeric) AS posted_quantity,\n COALESCE(cs.used_quantity, 0::numeric) AS net_quantity,\n NULLIF(cs.default_quantity, 0::numeric) AS standard_quantity,\n CASE\n WHEN NULLIF(cs.default_quantity, 0::numeric) IS NULL THEN NULL::numeric\n ELSE COALESCE(cs.used_quantity, 0::numeric) - cs.default_quantity\n END AS variance,\n u.abbreviation AS unit_abbreviation,\n 0::numeric AS total_cost,\n NULL::uuid AS stock_location_id,\n NULL::text AS stock_location_name,\n 1 AS location_count,\n NULL::uuid AS recipe_id,\n NULL::text AS recipe_name,\n i.order_id,\n sv.name AS item_name,\n o.reference_number AS order_reference,\n COALESCE(cs.assignee_id, s.performed_by_id, s.assignee_id) AS performed_by_id,\n perf.name AS performed_by_name,\n s.party_id AS customer_id,\n cust.name AS customer_name,\n cs.audited_at AS corrected_at,\n cs.audit_notes AS correction_reason,\n cs.is_audited AS is_corrected,\n 'clinical'::text AS source,\n false AS stock_posted\n FROM plg_clinical_session_products cs\n LEFT JOIN plg_clinical_sessions s ON s.tenant_id = cs.tenant_id AND s.id = cs.session_id\n LEFT JOIN products p ON p.id = cs.product_id\n LEFT JOIN products sv ON sv.id = s.service_id\n LEFT JOIN plg_inventory_measurement_units u ON u.id = cs.used_unit_id\n LEFT JOIN LATERAL ( SELECT it.id, it.order_id, it.name, it.assignee_id\n FROM items it\n WHERE cs.order_item_id IS NOT NULL\n AND it.id = cs.order_item_id AND it.tenant_id = cs.tenant_id) i ON true\n LEFT JOIN LATERAL ( SELECT ord.id, ord.reference_number, ord.party_id\n FROM orders ord\n WHERE i.order_id IS NOT NULL AND ord.id = i.order_id) o ON true\n LEFT JOIN LATERAL ( SELECT pe.id, pe.name\n FROM people pe\n WHERE pe.id = COALESCE(cs.assignee_id, s.performed_by_id, s.assignee_id)) perf ON true\n LEFT JOIN LATERAL ( SELECT pe.id, pe.name\n FROM people pe\n WHERE pe.id = s.party_id) cust ON true\n WHERE NOT (EXISTS ( SELECT 1\n FROM plg_inventory_stock_movements m\n WHERE m.tenant_id = cs.tenant_id AND m.source_item_type = 'order_item'::text AND m.source_item_id = cs.order_item_id AND m.product_id = cs.product_id AND m.kind = 'out'::text))\n )\n SELECT tenant_id,\n unit_id,\n item_id,\n product_id,\n product_name,\n product_sku,\n used_at,\n movement_date,\n posted_quantity::numeric(14,4) AS posted_quantity,\n net_quantity::numeric(14,4) AS net_quantity,\n standard_quantity::numeric(14,4) AS standard_quantity,\n variance,\n unit_abbreviation,\n total_cost::numeric(16,4) AS total_cost,\n stock_location_id,\n stock_location_name,\n location_count,\n recipe_id,\n recipe_name,\n order_id,\n item_name,\n order_reference,\n performed_by_id,\n performed_by_name,\n customer_id,\n customer_name,\n corrected_at,\n correction_reason,\n is_corrected,\n source,\n stock_posted\n FROM ( SELECT ledger.tenant_id,\n ledger.unit_id,\n ledger.item_id,\n ledger.product_id,\n ledger.product_name,\n ledger.product_sku,\n ledger.used_at,\n ledger.movement_date,\n ledger.posted_quantity,\n ledger.net_quantity,\n ledger.standard_quantity,\n ledger.variance,\n ledger.unit_abbreviation,\n ledger.total_cost,\n ledger.stock_location_id,\n ledger.stock_location_name,\n ledger.location_count,\n ledger.recipe_id,\n ledger.recipe_name,\n ledger.order_id,\n ledger.item_name,\n ledger.order_reference,\n ledger.performed_by_id,\n ledger.performed_by_name,\n ledger.customer_id,\n ledger.customer_name,\n ledger.corrected_at,\n ledger.correction_reason,\n ledger.is_corrected,\n ledger.source,\n ledger.stock_posted\n FROM ledger\n UNION ALL\n SELECT clinical.tenant_id,\n clinical.unit_id,\n clinical.item_id,\n clinical.product_id,\n clinical.product_name,\n clinical.product_sku,\n clinical.used_at,\n clinical.movement_date,\n clinical.posted_quantity,\n clinical.net_quantity,\n clinical.standard_quantity,\n clinical.variance,\n clinical.unit_abbreviation,\n clinical.total_cost,\n clinical.stock_location_id,\n clinical.stock_location_name,\n clinical.location_count,\n clinical.recipe_id,\n clinical.recipe_name,\n clinical.order_id,\n clinical.item_name,\n clinical.order_reference,\n clinical.performed_by_id,\n clinical.performed_by_name,\n clinical.customer_id,\n clinical.customer_name,\n clinical.corrected_at,\n clinical.correction_reason,\n clinical.is_corrected,\n clinical.source,\n clinical.stock_posted\n FROM clinical) a;;\n`\n\nexport const MIGRATION_014_A_SESSAO_SE_BUSCA_UMA_A_UMA = `-- O atendimento entra na fila UM A UM, não a tabela inteira.\n--\n-- A vista buscava a sessão de cada consumo com um LEFT JOIN comum. Sob RLS\n-- isso é caro de um jeito que não aparece no EXPLAIN de quem tem chave de\n-- serviço: a política de \\`plg_clinical_sessions\\` chama \\`app.has_unit\\` e\n-- \\`app.has_permission\\` POR LINHA, e o planejador varre a tabela inteira antes\n-- de casar com os poucos consumos da janela. Medido aqui: 232.264 chamadas,\n-- 6,4 s — e a tela voltava \\`canceling statement due to statement timeout\\` com\n-- a janela de 30 dias já aplicada.\n--\n-- LATERAL amarra a busca à linha de fora: a sessão é procurada pela chave\n-- (tenant, id) uma vez por consumo, e a RLS roda dezenas de vezes em vez de\n-- centenas de milhares. É a mesma correção que a 012 já tinha feito para\n-- \\`items\\`, \\`orders\\` e \\`people\\` — a sessão tinha ficado de fora.\n\nCREATE OR REPLACE VIEW public.v_inventory_consumption_audit WITH (security_invoker = true) AS\n WITH consumed AS (\n SELECT m.tenant_id,\n m.unit_id,\n m.source_item_id AS item_id,\n m.product_id,\n sum(- m.quantity) AS posted_quantity,\n sum(m.total_cost) AS total_cost,\n min(m.created_at) AS used_at,\n min(m.movement_date) AS movement_date,\n (array_agg(m.source_location_id ORDER BY m.created_at, m.id))[1] AS stock_location_id,\n count(DISTINCT m.source_location_id)::integer AS location_count,\n (array_agg(m.measurement_unit_id ORDER BY m.created_at, m.id))[1] AS measurement_unit_id,\n (array_agg(m.metadata ->> 'recipe_id'::text ORDER BY m.created_at, m.id))[1] AS recipe_id\n FROM plg_inventory_stock_movements m\n WHERE m.source_item_type = 'order_item'::text AND m.source_item_id IS NOT NULL AND m.kind = 'out'::text AND NOT (EXISTS ( SELECT 1\n FROM plg_inventory_stock_movements r\n WHERE r.reverses_movement_id = m.id))\n GROUP BY m.tenant_id, m.unit_id, m.source_item_id, m.product_id\n ), corrected AS (\n SELECT c.tenant_id,\n c.source_item_id AS item_id,\n c.product_id,\n sum(- c.quantity) AS correction_delta,\n sum((- sign(c.quantity)) * c.total_cost) AS correction_cost,\n max(c.created_at) AS corrected_at,\n (array_agg(c.reason ORDER BY c.created_at DESC, c.id DESC))[1] AS correction_reason\n FROM plg_inventory_stock_movements c\n WHERE c.source_item_type = 'usage_audit'::text AND c.source_item_id IS NOT NULL\n GROUP BY c.tenant_id, c.source_item_id, c.product_id\n ), ledger AS (\n SELECT c.tenant_id,\n c.unit_id,\n c.item_id,\n c.product_id,\n p.name AS product_name,\n p.sku AS product_sku,\n c.used_at,\n c.movement_date,\n c.posted_quantity,\n c.posted_quantity + COALESCE(k.correction_delta, 0::numeric) AS net_quantity,\n std.standard_quantity,\n CASE\n WHEN std.standard_quantity IS NULL THEN NULL::numeric\n ELSE c.posted_quantity + COALESCE(k.correction_delta, 0::numeric) - std.standard_quantity\n END AS variance,\n u.abbreviation AS unit_abbreviation,\n c.total_cost + COALESCE(k.correction_cost, 0::numeric) AS total_cost,\n c.stock_location_id,\n sl.name AS stock_location_name,\n c.location_count,\n r.id AS recipe_id,\n r.name AS recipe_name,\n i.order_id,\n i.name AS item_name,\n o.reference_number AS order_reference,\n i.assignee_id AS performed_by_id,\n perf.name AS performed_by_name,\n o.party_id AS customer_id,\n cust.name AS customer_name,\n k.corrected_at,\n k.correction_reason,\n k.item_id IS NOT NULL AS is_corrected,\n 'ledger'::text AS source,\n true AS stock_posted\n FROM consumed c\n LEFT JOIN corrected k ON k.tenant_id = c.tenant_id AND k.item_id = c.item_id AND k.product_id = c.product_id\n LEFT JOIN products p ON p.id = c.product_id\n LEFT JOIN plg_inventory_stock_locations sl ON sl.id = c.stock_location_id\n LEFT JOIN plg_inventory_measurement_units u ON u.id = c.measurement_unit_id\n LEFT JOIN items i ON i.id = c.item_id AND i.tenant_id = c.tenant_id\n LEFT JOIN orders o ON o.id = i.order_id\n LEFT JOIN people perf ON perf.id = i.assignee_id\n LEFT JOIN people cust ON cust.id = o.party_id\n LEFT JOIN plg_inventory_recipes r ON r.id = NULLIF(c.recipe_id, ''::text)::uuid AND r.tenant_id = c.tenant_id\n LEFT JOIN LATERAL ( SELECT\n CASE\n WHEN r.id IS NOT NULL AND COALESCE(r.yield_quantity, 0::numeric) > 0::numeric THEN ( SELECT round(sum(ri.quantity) * COALESCE(i.quantity, 1::numeric) / r.yield_quantity, 4) AS round\n FROM plg_inventory_recipe_ingredients ri\n WHERE ri.tenant_id = c.tenant_id AND ri.recipe_id = r.id AND ri.product_id = c.product_id)\n WHEN r.id IS NULL THEN i.quantity\n ELSE NULL::numeric\n END AS standard_quantity) std ON true\n ), clinical AS (\n SELECT cs.tenant_id,\n cs.unit_id,\n cs.id AS item_id,\n cs.product_id,\n p.name AS product_name,\n p.sku AS product_sku,\n COALESCE(timezone('UTC', cs.executed_on::timestamp), cs.created_at) AS used_at,\n COALESCE(cs.executed_on, cs.created_at::date) AS movement_date,\n COALESCE(cs.used_quantity, 0::numeric) AS posted_quantity,\n COALESCE(cs.used_quantity, 0::numeric) AS net_quantity,\n NULLIF(cs.default_quantity, 0::numeric) AS standard_quantity,\n CASE\n WHEN NULLIF(cs.default_quantity, 0::numeric) IS NULL THEN NULL::numeric\n ELSE COALESCE(cs.used_quantity, 0::numeric) - cs.default_quantity\n END AS variance,\n u.abbreviation AS unit_abbreviation,\n 0::numeric AS total_cost,\n NULL::uuid AS stock_location_id,\n NULL::text AS stock_location_name,\n 1 AS location_count,\n NULL::uuid AS recipe_id,\n NULL::text AS recipe_name,\n i.order_id,\n sv.name AS item_name,\n o.reference_number AS order_reference,\n COALESCE(cs.assignee_id, s.performed_by_id, s.assignee_id) AS performed_by_id,\n perf.name AS performed_by_name,\n s.party_id AS customer_id,\n cust.name AS customer_name,\n cs.audited_at AS corrected_at,\n cs.audit_notes AS correction_reason,\n cs.is_audited AS is_corrected,\n 'clinical'::text AS source,\n false AS stock_posted\n FROM plg_clinical_session_products cs\n LEFT JOIN LATERAL ( SELECT ss.party_id, ss.performed_by_id, ss.assignee_id, ss.service_id\n FROM plg_clinical_sessions ss\n WHERE ss.tenant_id = cs.tenant_id AND ss.id = cs.session_id) s ON true\n LEFT JOIN products p ON p.id = cs.product_id\n LEFT JOIN products sv ON sv.id = s.service_id\n LEFT JOIN plg_inventory_measurement_units u ON u.id = cs.used_unit_id\n LEFT JOIN LATERAL ( SELECT it.id, it.order_id, it.name, it.assignee_id\n FROM items it\n WHERE cs.order_item_id IS NOT NULL\n AND it.id = cs.order_item_id AND it.tenant_id = cs.tenant_id) i ON true\n LEFT JOIN LATERAL ( SELECT ord.id, ord.reference_number, ord.party_id\n FROM orders ord\n WHERE i.order_id IS NOT NULL AND ord.id = i.order_id) o ON true\n LEFT JOIN LATERAL ( SELECT pe.id, pe.name\n FROM people pe\n WHERE pe.id = COALESCE(cs.assignee_id, s.performed_by_id, s.assignee_id)) perf ON true\n LEFT JOIN LATERAL ( SELECT pe.id, pe.name\n FROM people pe\n WHERE pe.id = s.party_id) cust ON true\n WHERE NOT (EXISTS ( SELECT 1\n FROM plg_inventory_stock_movements m\n WHERE m.tenant_id = cs.tenant_id AND m.source_item_type = 'order_item'::text AND m.source_item_id = cs.order_item_id AND m.product_id = cs.product_id AND m.kind = 'out'::text))\n )\n SELECT tenant_id,\n unit_id,\n item_id,\n product_id,\n product_name,\n product_sku,\n used_at,\n movement_date,\n posted_quantity::numeric(14,4) AS posted_quantity,\n net_quantity::numeric(14,4) AS net_quantity,\n standard_quantity::numeric(14,4) AS standard_quantity,\n variance,\n unit_abbreviation,\n total_cost::numeric(16,4) AS total_cost,\n stock_location_id,\n stock_location_name,\n location_count,\n recipe_id,\n recipe_name,\n order_id,\n item_name,\n order_reference,\n performed_by_id,\n performed_by_name,\n customer_id,\n customer_name,\n corrected_at,\n correction_reason,\n is_corrected,\n source,\n stock_posted\n FROM ( SELECT ledger.tenant_id,\n ledger.unit_id,\n ledger.item_id,\n ledger.product_id,\n ledger.product_name,\n ledger.product_sku,\n ledger.used_at,\n ledger.movement_date,\n ledger.posted_quantity,\n ledger.net_quantity,\n ledger.standard_quantity,\n ledger.variance,\n ledger.unit_abbreviation,\n ledger.total_cost,\n ledger.stock_location_id,\n ledger.stock_location_name,\n ledger.location_count,\n ledger.recipe_id,\n ledger.recipe_name,\n ledger.order_id,\n ledger.item_name,\n ledger.order_reference,\n ledger.performed_by_id,\n ledger.performed_by_name,\n ledger.customer_id,\n ledger.customer_name,\n ledger.corrected_at,\n ledger.correction_reason,\n ledger.is_corrected,\n ledger.source,\n ledger.stock_posted\n FROM ledger\n UNION ALL\n SELECT clinical.tenant_id,\n clinical.unit_id,\n clinical.item_id,\n clinical.product_id,\n clinical.product_name,\n clinical.product_sku,\n clinical.used_at,\n clinical.movement_date,\n clinical.posted_quantity,\n clinical.net_quantity,\n clinical.standard_quantity,\n clinical.variance,\n clinical.unit_abbreviation,\n clinical.total_cost,\n clinical.stock_location_id,\n clinical.stock_location_name,\n clinical.location_count,\n clinical.recipe_id,\n clinical.recipe_name,\n clinical.order_id,\n clinical.item_name,\n clinical.order_reference,\n clinical.performed_by_id,\n clinical.performed_by_name,\n clinical.customer_id,\n clinical.customer_name,\n clinical.corrected_at,\n clinical.correction_reason,\n clinical.is_corrected,\n clinical.source,\n clinical.stock_posted\n FROM clinical) a;;\n`\n\nexport const MIGRATION_015_LATERAL_SEM_CERCA_NAO_E_LATERAL = `-- LATERAL sem cerca não é LATERAL.\n--\n-- A 012 e a 014 escreveram as buscas de \\`items\\`, \\`orders\\`, \\`people\\` e da\n-- sessão como LATERAL para que cada uma rodasse por linha. O planejador\n-- desfez o gesto: subconsulta simples é ACHATADA de volta a junção comum, e o\n-- plano voltava a varrer \\`people\\` inteira (61.854 linhas) e a sessão inteira.\n-- Sob RLS cada uma dessas linhas paga \\`app.has_unit\\` + \\`app.has_permission\\`,\n-- e a tela morria nos 8 s do PostgREST.\n--\n-- \\`LIMIT 1\\` é a cerca: o Postgres não achata subconsulta com limite. A chave\n-- é única nos quatro casos, então o limite não muda resultado nenhum — só\n-- impede que a busca de UMA linha vire uma varredura de todas.\n\nCREATE OR REPLACE VIEW public.v_inventory_consumption_audit WITH (security_invoker = true) AS\n WITH consumed AS (\n SELECT m.tenant_id,\n m.unit_id,\n m.source_item_id AS item_id,\n m.product_id,\n sum(- m.quantity) AS posted_quantity,\n sum(m.total_cost) AS total_cost,\n min(m.created_at) AS used_at,\n min(m.movement_date) AS movement_date,\n (array_agg(m.source_location_id ORDER BY m.created_at, m.id))[1] AS stock_location_id,\n count(DISTINCT m.source_location_id)::integer AS location_count,\n (array_agg(m.measurement_unit_id ORDER BY m.created_at, m.id))[1] AS measurement_unit_id,\n (array_agg(m.metadata ->> 'recipe_id'::text ORDER BY m.created_at, m.id))[1] AS recipe_id\n FROM plg_inventory_stock_movements m\n WHERE m.source_item_type = 'order_item'::text AND m.source_item_id IS NOT NULL AND m.kind = 'out'::text AND NOT (EXISTS ( SELECT 1\n FROM plg_inventory_stock_movements r\n WHERE r.reverses_movement_id = m.id))\n GROUP BY m.tenant_id, m.unit_id, m.source_item_id, m.product_id\n ), corrected AS (\n SELECT c.tenant_id,\n c.source_item_id AS item_id,\n c.product_id,\n sum(- c.quantity) AS correction_delta,\n sum((- sign(c.quantity)) * c.total_cost) AS correction_cost,\n max(c.created_at) AS corrected_at,\n (array_agg(c.reason ORDER BY c.created_at DESC, c.id DESC))[1] AS correction_reason\n FROM plg_inventory_stock_movements c\n WHERE c.source_item_type = 'usage_audit'::text AND c.source_item_id IS NOT NULL\n GROUP BY c.tenant_id, c.source_item_id, c.product_id\n ), ledger AS (\n SELECT c.tenant_id,\n c.unit_id,\n c.item_id,\n c.product_id,\n p.name AS product_name,\n p.sku AS product_sku,\n c.used_at,\n c.movement_date,\n c.posted_quantity,\n c.posted_quantity + COALESCE(k.correction_delta, 0::numeric) AS net_quantity,\n std.standard_quantity,\n CASE\n WHEN std.standard_quantity IS NULL THEN NULL::numeric\n ELSE c.posted_quantity + COALESCE(k.correction_delta, 0::numeric) - std.standard_quantity\n END AS variance,\n u.abbreviation AS unit_abbreviation,\n c.total_cost + COALESCE(k.correction_cost, 0::numeric) AS total_cost,\n c.stock_location_id,\n sl.name AS stock_location_name,\n c.location_count,\n r.id AS recipe_id,\n r.name AS recipe_name,\n i.order_id,\n i.name AS item_name,\n o.reference_number AS order_reference,\n i.assignee_id AS performed_by_id,\n perf.name AS performed_by_name,\n o.party_id AS customer_id,\n cust.name AS customer_name,\n k.corrected_at,\n k.correction_reason,\n k.item_id IS NOT NULL AS is_corrected,\n 'ledger'::text AS source,\n true AS stock_posted\n FROM consumed c\n LEFT JOIN corrected k ON k.tenant_id = c.tenant_id AND k.item_id = c.item_id AND k.product_id = c.product_id\n LEFT JOIN products p ON p.id = c.product_id\n LEFT JOIN plg_inventory_stock_locations sl ON sl.id = c.stock_location_id\n LEFT JOIN plg_inventory_measurement_units u ON u.id = c.measurement_unit_id\n LEFT JOIN items i ON i.id = c.item_id AND i.tenant_id = c.tenant_id\n LEFT JOIN orders o ON o.id = i.order_id\n LEFT JOIN people perf ON perf.id = i.assignee_id\n LEFT JOIN people cust ON cust.id = o.party_id\n LEFT JOIN plg_inventory_recipes r ON r.id = NULLIF(c.recipe_id, ''::text)::uuid AND r.tenant_id = c.tenant_id\n LEFT JOIN LATERAL ( SELECT\n CASE\n WHEN r.id IS NOT NULL AND COALESCE(r.yield_quantity, 0::numeric) > 0::numeric THEN ( SELECT round(sum(ri.quantity) * COALESCE(i.quantity, 1::numeric) / r.yield_quantity, 4) AS round\n FROM plg_inventory_recipe_ingredients ri\n WHERE ri.tenant_id = c.tenant_id AND ri.recipe_id = r.id AND ri.product_id = c.product_id)\n WHEN r.id IS NULL THEN i.quantity\n ELSE NULL::numeric\n END AS standard_quantity) std ON true\n ), clinical AS (\n SELECT cs.tenant_id,\n cs.unit_id,\n cs.id AS item_id,\n cs.product_id,\n p.name AS product_name,\n p.sku AS product_sku,\n COALESCE(timezone('UTC', cs.executed_on::timestamp), cs.created_at) AS used_at,\n COALESCE(cs.executed_on, cs.created_at::date) AS movement_date,\n COALESCE(cs.used_quantity, 0::numeric) AS posted_quantity,\n COALESCE(cs.used_quantity, 0::numeric) AS net_quantity,\n NULLIF(cs.default_quantity, 0::numeric) AS standard_quantity,\n CASE\n WHEN NULLIF(cs.default_quantity, 0::numeric) IS NULL THEN NULL::numeric\n ELSE COALESCE(cs.used_quantity, 0::numeric) - cs.default_quantity\n END AS variance,\n u.abbreviation AS unit_abbreviation,\n 0::numeric AS total_cost,\n NULL::uuid AS stock_location_id,\n NULL::text AS stock_location_name,\n 1 AS location_count,\n NULL::uuid AS recipe_id,\n NULL::text AS recipe_name,\n i.order_id,\n sv.name AS item_name,\n o.reference_number AS order_reference,\n COALESCE(cs.assignee_id, s.performed_by_id, s.assignee_id) AS performed_by_id,\n perf.name AS performed_by_name,\n s.party_id AS customer_id,\n cust.name AS customer_name,\n cs.audited_at AS corrected_at,\n cs.audit_notes AS correction_reason,\n cs.is_audited AS is_corrected,\n 'clinical'::text AS source,\n false AS stock_posted\n FROM plg_clinical_session_products cs\n LEFT JOIN LATERAL ( SELECT ss.party_id, ss.performed_by_id, ss.assignee_id, ss.service_id\n FROM plg_clinical_sessions ss\n WHERE ss.tenant_id = cs.tenant_id AND ss.id = cs.session_id LIMIT 1) s ON true\n LEFT JOIN products p ON p.id = cs.product_id\n LEFT JOIN products sv ON sv.id = s.service_id\n LEFT JOIN plg_inventory_measurement_units u ON u.id = cs.used_unit_id\n LEFT JOIN LATERAL ( SELECT it.id, it.order_id, it.name, it.assignee_id\n FROM items it\n WHERE cs.order_item_id IS NOT NULL\n AND it.id = cs.order_item_id AND it.tenant_id = cs.tenant_id LIMIT 1) i ON true\n LEFT JOIN LATERAL ( SELECT ord.id, ord.reference_number, ord.party_id\n FROM orders ord\n WHERE i.order_id IS NOT NULL AND ord.id = i.order_id LIMIT 1) o ON true\n LEFT JOIN LATERAL ( SELECT pe.id, pe.name\n FROM people pe\n WHERE pe.id = COALESCE(cs.assignee_id, s.performed_by_id, s.assignee_id) LIMIT 1) perf ON true\n LEFT JOIN LATERAL ( SELECT pe.id, pe.name\n FROM people pe\n WHERE pe.id = s.party_id LIMIT 1) cust ON true\n WHERE NOT (EXISTS ( SELECT 1\n FROM plg_inventory_stock_movements m\n WHERE m.tenant_id = cs.tenant_id AND m.source_item_type = 'order_item'::text AND m.source_item_id = cs.order_item_id AND m.product_id = cs.product_id AND m.kind = 'out'::text))\n )\n SELECT tenant_id,\n unit_id,\n item_id,\n product_id,\n product_name,\n product_sku,\n used_at,\n movement_date,\n posted_quantity::numeric(14,4) AS posted_quantity,\n net_quantity::numeric(14,4) AS net_quantity,\n standard_quantity::numeric(14,4) AS standard_quantity,\n variance,\n unit_abbreviation,\n total_cost::numeric(16,4) AS total_cost,\n stock_location_id,\n stock_location_name,\n location_count,\n recipe_id,\n recipe_name,\n order_id,\n item_name,\n order_reference,\n performed_by_id,\n performed_by_name,\n customer_id,\n customer_name,\n corrected_at,\n correction_reason,\n is_corrected,\n source,\n stock_posted\n FROM ( SELECT ledger.tenant_id,\n ledger.unit_id,\n ledger.item_id,\n ledger.product_id,\n ledger.product_name,\n ledger.product_sku,\n ledger.used_at,\n ledger.movement_date,\n ledger.posted_quantity,\n ledger.net_quantity,\n ledger.standard_quantity,\n ledger.variance,\n ledger.unit_abbreviation,\n ledger.total_cost,\n ledger.stock_location_id,\n ledger.stock_location_name,\n ledger.location_count,\n ledger.recipe_id,\n ledger.recipe_name,\n ledger.order_id,\n ledger.item_name,\n ledger.order_reference,\n ledger.performed_by_id,\n ledger.performed_by_name,\n ledger.customer_id,\n ledger.customer_name,\n ledger.corrected_at,\n ledger.correction_reason,\n ledger.is_corrected,\n ledger.source,\n ledger.stock_posted\n FROM ledger\n UNION ALL\n SELECT clinical.tenant_id,\n clinical.unit_id,\n clinical.item_id,\n clinical.product_id,\n clinical.product_name,\n clinical.product_sku,\n clinical.used_at,\n clinical.movement_date,\n clinical.posted_quantity,\n clinical.net_quantity,\n clinical.standard_quantity,\n clinical.variance,\n clinical.unit_abbreviation,\n clinical.total_cost,\n clinical.stock_location_id,\n clinical.stock_location_name,\n clinical.location_count,\n clinical.recipe_id,\n clinical.recipe_name,\n clinical.order_id,\n clinical.item_name,\n clinical.order_reference,\n clinical.performed_by_id,\n clinical.performed_by_name,\n clinical.customer_id,\n clinical.customer_name,\n clinical.corrected_at,\n clinical.correction_reason,\n clinical.is_corrected,\n clinical.source,\n clinical.stock_posted\n FROM clinical) a;;\n`\n\nexport const MIGRATION_016_A_DATA_DO_CONSUMO_E_UMA_COLUNA = `-- A data do consumo passa a ser uma COLUNA, e por isso a janela usa índice.\n--\n-- Sob RLS o Postgres roda as regras da política ANTES das condições de quem\n-- pergunta — a não ser que a condição seja \\`leakproof\\`. A janela da fila\n-- (\\`used_at >= agora - 30 dias\\`) era uma EXPRESSÃO com \\`timezone()\\`, que não é\n-- leakproof: o índice da 013 existia e o plano o ignorava, varrendo as 116.934\n-- linhas e chamando \\`app.has_unit\\` 116.028 vezes. Medido: 7,9 s, e a tela\n-- morria nos 8 s do PostgREST.\n--\n-- Coluna gerada e gravada resolve pela raiz: a comparação vira \\`coluna >=\n-- constante\\`, que é leakproof, entra como condição de índice e a RLS passa a\n-- rodar sobre as dezenas de linhas da janela em vez da tabela inteira. O valor\n-- é o mesmo de antes (o cluster roda em UTC), calculado uma vez na escrita.\n\nALTER TABLE public.plg_clinical_session_products\n ADD COLUMN IF NOT EXISTS used_at timestamptz\n GENERATED ALWAYS AS (COALESCE(timezone('UTC', executed_on::timestamp), created_at)) STORED;\n\nDROP INDEX IF EXISTS public.plg_clinical_session_products_used_at_idx;\nCREATE INDEX IF NOT EXISTS plg_clinical_session_products_used_at_idx\n ON public.plg_clinical_session_products (tenant_id, used_at DESC);\n\nCREATE OR REPLACE VIEW public.v_inventory_consumption_audit WITH (security_invoker = true) AS\n WITH consumed AS (\n SELECT m.tenant_id,\n m.unit_id,\n m.source_item_id AS item_id,\n m.product_id,\n sum(- m.quantity) AS posted_quantity,\n sum(m.total_cost) AS total_cost,\n min(m.created_at) AS used_at,\n min(m.movement_date) AS movement_date,\n (array_agg(m.source_location_id ORDER BY m.created_at, m.id))[1] AS stock_location_id,\n count(DISTINCT m.source_location_id)::integer AS location_count,\n (array_agg(m.measurement_unit_id ORDER BY m.created_at, m.id))[1] AS measurement_unit_id,\n (array_agg(m.metadata ->> 'recipe_id'::text ORDER BY m.created_at, m.id))[1] AS recipe_id\n FROM plg_inventory_stock_movements m\n WHERE m.source_item_type = 'order_item'::text AND m.source_item_id IS NOT NULL AND m.kind = 'out'::text AND NOT (EXISTS ( SELECT 1\n FROM plg_inventory_stock_movements r\n WHERE r.reverses_movement_id = m.id))\n GROUP BY m.tenant_id, m.unit_id, m.source_item_id, m.product_id\n ), corrected AS (\n SELECT c.tenant_id,\n c.source_item_id AS item_id,\n c.product_id,\n sum(- c.quantity) AS correction_delta,\n sum((- sign(c.quantity)) * c.total_cost) AS correction_cost,\n max(c.created_at) AS corrected_at,\n (array_agg(c.reason ORDER BY c.created_at DESC, c.id DESC))[1] AS correction_reason\n FROM plg_inventory_stock_movements c\n WHERE c.source_item_type = 'usage_audit'::text AND c.source_item_id IS NOT NULL\n GROUP BY c.tenant_id, c.source_item_id, c.product_id\n ), ledger AS (\n SELECT c.tenant_id,\n c.unit_id,\n c.item_id,\n c.product_id,\n p.name AS product_name,\n p.sku AS product_sku,\n c.used_at,\n c.movement_date,\n c.posted_quantity,\n c.posted_quantity + COALESCE(k.correction_delta, 0::numeric) AS net_quantity,\n std.standard_quantity,\n CASE\n WHEN std.standard_quantity IS NULL THEN NULL::numeric\n ELSE c.posted_quantity + COALESCE(k.correction_delta, 0::numeric) - std.standard_quantity\n END AS variance,\n u.abbreviation AS unit_abbreviation,\n c.total_cost + COALESCE(k.correction_cost, 0::numeric) AS total_cost,\n c.stock_location_id,\n sl.name AS stock_location_name,\n c.location_count,\n r.id AS recipe_id,\n r.name AS recipe_name,\n i.order_id,\n i.name AS item_name,\n o.reference_number AS order_reference,\n i.assignee_id AS performed_by_id,\n perf.name AS performed_by_name,\n o.party_id AS customer_id,\n cust.name AS customer_name,\n k.corrected_at,\n k.correction_reason,\n k.item_id IS NOT NULL AS is_corrected,\n 'ledger'::text AS source,\n true AS stock_posted\n FROM consumed c\n LEFT JOIN corrected k ON k.tenant_id = c.tenant_id AND k.item_id = c.item_id AND k.product_id = c.product_id\n LEFT JOIN products p ON p.id = c.product_id\n LEFT JOIN plg_inventory_stock_locations sl ON sl.id = c.stock_location_id\n LEFT JOIN plg_inventory_measurement_units u ON u.id = c.measurement_unit_id\n LEFT JOIN items i ON i.id = c.item_id AND i.tenant_id = c.tenant_id\n LEFT JOIN orders o ON o.id = i.order_id\n LEFT JOIN people perf ON perf.id = i.assignee_id\n LEFT JOIN people cust ON cust.id = o.party_id\n LEFT JOIN plg_inventory_recipes r ON r.id = NULLIF(c.recipe_id, ''::text)::uuid AND r.tenant_id = c.tenant_id\n LEFT JOIN LATERAL ( SELECT\n CASE\n WHEN r.id IS NOT NULL AND COALESCE(r.yield_quantity, 0::numeric) > 0::numeric THEN ( SELECT round(sum(ri.quantity) * COALESCE(i.quantity, 1::numeric) / r.yield_quantity, 4) AS round\n FROM plg_inventory_recipe_ingredients ri\n WHERE ri.tenant_id = c.tenant_id AND ri.recipe_id = r.id AND ri.product_id = c.product_id)\n WHEN r.id IS NULL THEN i.quantity\n ELSE NULL::numeric\n END AS standard_quantity) std ON true\n ), clinical AS (\n SELECT cs.tenant_id,\n cs.unit_id,\n cs.id AS item_id,\n cs.product_id,\n p.name AS product_name,\n p.sku AS product_sku,\n cs.used_at,\n COALESCE(cs.executed_on, cs.created_at::date) AS movement_date,\n COALESCE(cs.used_quantity, 0::numeric) AS posted_quantity,\n COALESCE(cs.used_quantity, 0::numeric) AS net_quantity,\n NULLIF(cs.default_quantity, 0::numeric) AS standard_quantity,\n CASE\n WHEN NULLIF(cs.default_quantity, 0::numeric) IS NULL THEN NULL::numeric\n ELSE COALESCE(cs.used_quantity, 0::numeric) - cs.default_quantity\n END AS variance,\n u.abbreviation AS unit_abbreviation,\n 0::numeric AS total_cost,\n NULL::uuid AS stock_location_id,\n NULL::text AS stock_location_name,\n 1 AS location_count,\n NULL::uuid AS recipe_id,\n NULL::text AS recipe_name,\n i.order_id,\n sv.name AS item_name,\n o.reference_number AS order_reference,\n COALESCE(cs.assignee_id, s.performed_by_id, s.assignee_id) AS performed_by_id,\n perf.name AS performed_by_name,\n s.party_id AS customer_id,\n cust.name AS customer_name,\n cs.audited_at AS corrected_at,\n cs.audit_notes AS correction_reason,\n cs.is_audited AS is_corrected,\n 'clinical'::text AS source,\n false AS stock_posted\n FROM plg_clinical_session_products cs\n LEFT JOIN LATERAL ( SELECT ss.party_id, ss.performed_by_id, ss.assignee_id, ss.service_id\n FROM plg_clinical_sessions ss\n WHERE ss.tenant_id = cs.tenant_id AND ss.id = cs.session_id LIMIT 1) s ON true\n LEFT JOIN products p ON p.id = cs.product_id\n LEFT JOIN products sv ON sv.id = s.service_id\n LEFT JOIN plg_inventory_measurement_units u ON u.id = cs.used_unit_id\n LEFT JOIN LATERAL ( SELECT it.id, it.order_id, it.name, it.assignee_id\n FROM items it\n WHERE cs.order_item_id IS NOT NULL\n AND it.id = cs.order_item_id AND it.tenant_id = cs.tenant_id LIMIT 1) i ON true\n LEFT JOIN LATERAL ( SELECT ord.id, ord.reference_number, ord.party_id\n FROM orders ord\n WHERE i.order_id IS NOT NULL AND ord.id = i.order_id LIMIT 1) o ON true\n LEFT JOIN LATERAL ( SELECT pe.id, pe.name\n FROM people pe\n WHERE pe.id = COALESCE(cs.assignee_id, s.performed_by_id, s.assignee_id) LIMIT 1) perf ON true\n LEFT JOIN LATERAL ( SELECT pe.id, pe.name\n FROM people pe\n WHERE pe.id = s.party_id LIMIT 1) cust ON true\n WHERE NOT (EXISTS ( SELECT 1\n FROM plg_inventory_stock_movements m\n WHERE m.tenant_id = cs.tenant_id AND m.source_item_type = 'order_item'::text AND m.source_item_id = cs.order_item_id AND m.product_id = cs.product_id AND m.kind = 'out'::text))\n )\n SELECT tenant_id,\n unit_id,\n item_id,\n product_id,\n product_name,\n product_sku,\n used_at,\n movement_date,\n posted_quantity::numeric(14,4) AS posted_quantity,\n net_quantity::numeric(14,4) AS net_quantity,\n standard_quantity::numeric(14,4) AS standard_quantity,\n variance,\n unit_abbreviation,\n total_cost::numeric(16,4) AS total_cost,\n stock_location_id,\n stock_location_name,\n location_count,\n recipe_id,\n recipe_name,\n order_id,\n item_name,\n order_reference,\n performed_by_id,\n performed_by_name,\n customer_id,\n customer_name,\n corrected_at,\n correction_reason,\n is_corrected,\n source,\n stock_posted\n FROM ( SELECT ledger.tenant_id,\n ledger.unit_id,\n ledger.item_id,\n ledger.product_id,\n ledger.product_name,\n ledger.product_sku,\n ledger.used_at,\n ledger.movement_date,\n ledger.posted_quantity,\n ledger.net_quantity,\n ledger.standard_quantity,\n ledger.variance,\n ledger.unit_abbreviation,\n ledger.total_cost,\n ledger.stock_location_id,\n ledger.stock_location_name,\n ledger.location_count,\n ledger.recipe_id,\n ledger.recipe_name,\n ledger.order_id,\n ledger.item_name,\n ledger.order_reference,\n ledger.performed_by_id,\n ledger.performed_by_name,\n ledger.customer_id,\n ledger.customer_name,\n ledger.corrected_at,\n ledger.correction_reason,\n ledger.is_corrected,\n ledger.source,\n ledger.stock_posted\n FROM ledger\n UNION ALL\n SELECT clinical.tenant_id,\n clinical.unit_id,\n clinical.item_id,\n clinical.product_id,\n clinical.product_name,\n clinical.product_sku,\n clinical.used_at,\n clinical.movement_date,\n clinical.posted_quantity,\n clinical.net_quantity,\n clinical.standard_quantity,\n clinical.variance,\n clinical.unit_abbreviation,\n clinical.total_cost,\n clinical.stock_location_id,\n clinical.stock_location_name,\n clinical.location_count,\n clinical.recipe_id,\n clinical.recipe_name,\n clinical.order_id,\n clinical.item_name,\n clinical.order_reference,\n clinical.performed_by_id,\n clinical.performed_by_name,\n clinical.customer_id,\n clinical.customer_name,\n clinical.corrected_at,\n clinical.correction_reason,\n clinical.is_corrected,\n clinical.source,\n clinical.stock_posted\n FROM clinical) a;;\n`\n\nexport const MIGRATION_017_APROVAR_E_BAIXAR_SAO_DOIS_GESTOS = `-- Aprovar e baixar no estoque são DOIS gestos, e faltavam os dois.\n--\n-- A fila da Auditoria de Uso existe desde o começo deste porte: o injetor\n-- lança o que usou, a linha chega aqui, e o estoquista confere. Só que a tela\n-- só sabia CORRIGIR a quantidade — não havia como aceitar. O consumo entrava\n-- na fila e o saldo nunca caía. A pergunta que a jornada 4 do V1 responde\n-- (\"executei e o estoque não mudou\") continuava sem resposta, porque o botão\n-- que a responde não existia.\n--\n-- O V1 tem três ações por linha, e elas são diferentes de propósito:\n--\n-- 📍 Baixar no estoque aceita E desconta do saldo\n-- ✏️ Corrigir conserta a quantidade digitada errada\n-- ✅ Aprovar aceita o consumo, sem mexer no saldo\n--\n-- \"Aprovar\" sem baixar existe para o consumo que não sai do estoque da casa —\n-- produto do próprio cliente, amostra do fornecedor, item já baixado por fora.\n--\n-- SOBRE SALDO NEGATIVO. Esta função NÃO exige saldo. O movimento é o registro\n-- do que foi usado numa pessoa, e isso aconteceu independentemente do que o\n-- sistema achava que havia na prateleira. Recusar a baixa por falta de saldo\n-- esconderia o consumo e deixaria a diferença invisível; deixar o saldo ficar\n-- negativo é exatamente o sinal que o estoquista precisa ver.\n\nCREATE OR REPLACE FUNCTION public.inventory_approve_usage_batch(\n p_lines jsonb,\n p_post boolean DEFAULT false,\n p_reason text DEFAULT NULL\n)\nRETURNS jsonb\nLANGUAGE plpgsql\nSECURITY DEFINER\nSET search_path TO ''\nAS $fn$\nDECLARE\n v_tenant uuid := app.inventory_require_tenant();\n v_actor uuid := auth.uid();\n v_key text;\n v_op record;\n r record;\n v_cs record;\n v_location uuid;\n v_cost numeric;\n v_movement uuid;\n v_no integer := 0;\n v_aprovadas integer := 0;\n v_baixadas integer := 0;\n v_puladas integer := 0;\n v_movements jsonb := '[]'::jsonb;\nBEGIN\n PERFORM app.inventory_check_lines(p_lines);\n\n SELECT 'usage_audit_approve:' || CASE WHEN p_post THEN 'post:' ELSE 'ack:' END\n || md5(string_agg((l->>'item_id'), ',' ORDER BY (l->>'item_id')))\n INTO v_key\n FROM jsonb_array_elements(p_lines) AS l;\n\n SELECT * INTO v_op FROM app.inventory_begin_operation(\n -- \\`confirm\\` é a operação de quem TIRA do estoque por consumo — o mesmo\n -- verbo que o consumo de item de pedido usa. 'out' não existe no catálogo\n -- de operações (só de movimentos), e o CHECK recusava.\n v_tenant, CASE WHEN p_post THEN 'confirm' ELSE 'adjust' END, v_key,\n jsonb_build_object('source', 'usage_audit_approve', 'post', p_post, 'lines', p_lines));\n IF v_op.existing IS NOT NULL THEN\n RETURN v_op.existing;\n END IF;\n\n FOR r IN\n SELECT (l->>'item_id')::uuid AS item_id, ord\n FROM jsonb_array_elements(p_lines) WITH ORDINALITY AS t(l, ord)\n ORDER BY ord\n LOOP\n v_no := v_no + 1;\n\n SELECT cs.* INTO v_cs\n FROM public.plg_clinical_session_products cs\n WHERE cs.tenant_id = v_tenant AND cs.id = r.item_id\n FOR UPDATE;\n\n -- Linha que não é consumo clínico já nasceu como movimento do razão: ela\n -- não tem o que aprovar, e some da fila por outro caminho.\n IF NOT FOUND THEN\n v_puladas := v_puladas + 1;\n CONTINUE;\n END IF;\n\n IF p_post AND coalesce(v_cs.used_quantity, 0) > 0 THEN\n -- ONDE A BAIXA ACONTECE. Preferência: o local padrão do produto na\n -- unidade, depois o da ficha, depois o primeiro local ativo dela.\n SELECT candidate.location_id INTO v_location\n FROM (\n SELECT s.default_location_id AS location_id,\n CASE WHEN v_cs.unit_id IS NOT NULL AND s.unit_id = v_cs.unit_id THEN 1 ELSE 2 END AS priority\n FROM public.plg_inventory_product_settings s\n WHERE s.tenant_id = v_tenant\n AND s.product_id = v_cs.product_id\n AND s.default_location_id IS NOT NULL\n AND (s.unit_id IS NULL OR s.unit_id = v_cs.unit_id)\n UNION ALL\n SELECT d.default_location_id, 3\n FROM public.plg_inventory_product_details d\n WHERE d.tenant_id = v_tenant\n AND d.product_id = v_cs.product_id\n AND d.default_location_id IS NOT NULL\n UNION ALL\n SELECT l.id, 4\n FROM public.plg_inventory_stock_locations l\n WHERE l.tenant_id = v_tenant AND l.is_active\n ) candidate\n JOIN public.plg_inventory_stock_locations l\n ON l.id = candidate.location_id AND l.tenant_id = v_tenant AND l.is_active\n WHERE v_cs.unit_id IS NULL OR l.unit_id = v_cs.unit_id OR l.unit_id IS NULL\n ORDER BY candidate.priority, l.created_at, l.id\n LIMIT 1;\n\n IF v_location IS NULL THEN\n RAISE EXCEPTION 'inventory: nenhum local de estoque ativo para a unidade deste consumo'\n USING ERRCODE = '22023';\n END IF;\n\n SELECT p.unit_cost INTO v_cost\n FROM public.plg_inventory_stock_positions p\n WHERE p.tenant_id = v_tenant AND p.product_id = v_cs.product_id\n AND p.stock_location_id = v_location\n ORDER BY p.expiration_date NULLS LAST, p.created_at\n LIMIT 1;\n\n INSERT INTO public.plg_inventory_stock_movements\n (tenant_id, unit_id, product_id, kind, quantity, unit_cost, source_location_id,\n measurement_unit_id, document_type, document_number,\n source_item_type, source_item_id, idempotency_key, line_no, operation_id,\n user_id, metadata)\n VALUES\n (v_tenant, v_cs.unit_id, v_cs.product_id, 'out', -v_cs.used_quantity,\n coalesce(v_cost, 0), v_location, v_cs.used_unit_id, 'consumption',\n coalesce(v_cs.session_id::text, v_cs.id::text),\n 'usage_audit', v_cs.id, v_key, v_no, v_op.op_id, v_actor,\n jsonb_strip_nulls(jsonb_build_object(\n 'source', 'usage_audit_approve',\n 'session_id', v_cs.session_id,\n 'order_item_id', v_cs.order_item_id,\n 'reason', nullif(btrim(p_reason), ''))))\n RETURNING id INTO v_movement;\n\n v_movements := v_movements || app.inventory_movement_json(v_movement);\n v_baixadas := v_baixadas + 1;\n END IF;\n\n -- CONFERIDO é o mesmo carimbo nos dois gestos: o que sai da fila é o que\n -- alguém olhou, tenha ou não baixado.\n UPDATE public.plg_clinical_session_products cs\n SET is_audited = true,\n audited_at = now(),\n audited_by = coalesce(cs.audited_by, v_actor),\n audit_notes = coalesce(nullif(btrim(p_reason), ''), cs.audit_notes),\n updated_at = now()\n WHERE cs.id = v_cs.id;\n v_aprovadas := v_aprovadas + 1;\n END LOOP;\n\n RETURN app.inventory_finish_operation(\n v_tenant, v_op.op_id, CASE WHEN p_post THEN 'confirm' ELSE 'adjust' END, v_key,\n jsonb_build_object(\n 'operation_id', v_op.op_id, 'kind', 'usage_audit_approve',\n 'idempotency_key', v_key, 'status', CASE WHEN p_post THEN 'posted' ELSE 'approved' END,\n 'lines_approved', v_aprovadas, 'lines_posted', v_baixadas,\n 'lines_skipped', v_puladas, 'movements', v_movements),\n jsonb_build_object('source', 'usage_audit_approve', 'post', p_post,\n 'lines_approved', v_aprovadas, 'lines_posted', v_baixadas));\nEND $fn$;\n\nGRANT EXECUTE ON FUNCTION public.inventory_approve_usage_batch(jsonb, boolean, text) TO authenticated;\n`\n\nexport const MIGRATION_018_O_NOME_NAO_SAI_DA_VISTA = `-- O NOME NÃO SAI DA VISTA. A vista devolve o id; quem pinta busca o nome.\n--\n-- \\`people\\` tem 120.379 linhas neste tenant e uma política de RLS que passa por\n-- \\`resource_grants\\`. Buscar o nome do profissional e o da cliente DENTRO da\n-- vista fazia essa política rodar linha a linha: a mesma consulta que custa\n-- 9 ms com chave de serviço levava **30 segundos** com a sessão de quem usa a\n-- tela, e o PostgREST desistia aos 8.\n--\n-- Nenhum índice conserta isso, porque o problema não é achar a pessoa — é a\n-- política ser avaliada uma vez por candidato. A saída é não perguntar ali: a\n-- vista entrega \\`performed_by_id\\` e \\`customer_id\\`, e a tela busca os ~50 nomes\n-- de uma vez, numa consulta só, com a RLS rodando uma vez para todos.\n\nCREATE OR REPLACE VIEW public.v_inventory_consumption_audit WITH (security_invoker = true) AS\n WITH consumed AS (\n SELECT m.tenant_id,\n m.unit_id,\n m.source_item_id AS item_id,\n m.product_id,\n sum(- m.quantity) AS posted_quantity,\n sum(m.total_cost) AS total_cost,\n min(m.created_at) AS used_at,\n min(m.movement_date) AS movement_date,\n (array_agg(m.source_location_id ORDER BY m.created_at, m.id))[1] AS stock_location_id,\n count(DISTINCT m.source_location_id)::integer AS location_count,\n (array_agg(m.measurement_unit_id ORDER BY m.created_at, m.id))[1] AS measurement_unit_id,\n (array_agg(m.metadata ->> 'recipe_id'::text ORDER BY m.created_at, m.id))[1] AS recipe_id\n FROM plg_inventory_stock_movements m\n WHERE m.source_item_type = 'order_item'::text AND m.source_item_id IS NOT NULL AND m.kind = 'out'::text AND NOT (EXISTS ( SELECT 1\n FROM plg_inventory_stock_movements r\n WHERE r.reverses_movement_id = m.id))\n GROUP BY m.tenant_id, m.unit_id, m.source_item_id, m.product_id\n ), corrected AS (\n SELECT c.tenant_id,\n c.source_item_id AS item_id,\n c.product_id,\n sum(- c.quantity) AS correction_delta,\n sum((- sign(c.quantity)) * c.total_cost) AS correction_cost,\n max(c.created_at) AS corrected_at,\n (array_agg(c.reason ORDER BY c.created_at DESC, c.id DESC))[1] AS correction_reason\n FROM plg_inventory_stock_movements c\n WHERE c.source_item_type = 'usage_audit'::text AND c.source_item_id IS NOT NULL\n GROUP BY c.tenant_id, c.source_item_id, c.product_id\n ), ledger AS (\n SELECT c.tenant_id,\n c.unit_id,\n c.item_id,\n c.product_id,\n p.name AS product_name,\n p.sku AS product_sku,\n c.used_at,\n c.movement_date,\n c.posted_quantity,\n c.posted_quantity + COALESCE(k.correction_delta, 0::numeric) AS net_quantity,\n std.standard_quantity,\n CASE\n WHEN std.standard_quantity IS NULL THEN NULL::numeric\n ELSE c.posted_quantity + COALESCE(k.correction_delta, 0::numeric) - std.standard_quantity\n END AS variance,\n u.abbreviation AS unit_abbreviation,\n c.total_cost + COALESCE(k.correction_cost, 0::numeric) AS total_cost,\n c.stock_location_id,\n sl.name AS stock_location_name,\n c.location_count,\n r.id AS recipe_id,\n r.name AS recipe_name,\n i.order_id,\n i.name AS item_name,\n o.reference_number AS order_reference,\n i.assignee_id AS performed_by_id,\n NULL::text AS performed_by_name,\n o.party_id AS customer_id,\n NULL::text AS customer_name,\n k.corrected_at,\n k.correction_reason,\n k.item_id IS NOT NULL AS is_corrected,\n 'ledger'::text AS source,\n true AS stock_posted\n FROM consumed c\n LEFT JOIN corrected k ON k.tenant_id = c.tenant_id AND k.item_id = c.item_id AND k.product_id = c.product_id\n LEFT JOIN products p ON p.id = c.product_id\n LEFT JOIN plg_inventory_stock_locations sl ON sl.id = c.stock_location_id\n LEFT JOIN plg_inventory_measurement_units u ON u.id = c.measurement_unit_id\n LEFT JOIN items i ON i.id = c.item_id AND i.tenant_id = c.tenant_id\n LEFT JOIN orders o ON o.id = i.order_id\n LEFT JOIN people perf ON perf.id = i.assignee_id\n LEFT JOIN people cust ON cust.id = o.party_id\n LEFT JOIN plg_inventory_recipes r ON r.id = NULLIF(c.recipe_id, ''::text)::uuid AND r.tenant_id = c.tenant_id\n LEFT JOIN LATERAL ( SELECT\n CASE\n WHEN r.id IS NOT NULL AND COALESCE(r.yield_quantity, 0::numeric) > 0::numeric THEN ( SELECT round(sum(ri.quantity) * COALESCE(i.quantity, 1::numeric) / r.yield_quantity, 4) AS round\n FROM plg_inventory_recipe_ingredients ri\n WHERE ri.tenant_id = c.tenant_id AND ri.recipe_id = r.id AND ri.product_id = c.product_id)\n WHEN r.id IS NULL THEN i.quantity\n ELSE NULL::numeric\n END AS standard_quantity) std ON true\n ), clinical AS (\n SELECT cs.tenant_id,\n cs.unit_id,\n cs.id AS item_id,\n cs.product_id,\n p.name AS product_name,\n p.sku AS product_sku,\n cs.used_at,\n COALESCE(cs.executed_on, cs.created_at::date) AS movement_date,\n COALESCE(cs.used_quantity, 0::numeric) AS posted_quantity,\n COALESCE(cs.used_quantity, 0::numeric) AS net_quantity,\n NULLIF(cs.default_quantity, 0::numeric) AS standard_quantity,\n CASE\n WHEN NULLIF(cs.default_quantity, 0::numeric) IS NULL THEN NULL::numeric\n ELSE COALESCE(cs.used_quantity, 0::numeric) - cs.default_quantity\n END AS variance,\n u.abbreviation AS unit_abbreviation,\n 0::numeric AS total_cost,\n NULL::uuid AS stock_location_id,\n NULL::text AS stock_location_name,\n 1 AS location_count,\n NULL::uuid AS recipe_id,\n NULL::text AS recipe_name,\n i.order_id,\n sv.name AS item_name,\n o.reference_number AS order_reference,\n COALESCE(cs.assignee_id, s.performed_by_id, s.assignee_id) AS performed_by_id,\n NULL::text AS performed_by_name,\n s.party_id AS customer_id,\n NULL::text AS customer_name,\n cs.audited_at AS corrected_at,\n cs.audit_notes AS correction_reason,\n cs.is_audited AS is_corrected,\n 'clinical'::text AS source,\n false AS stock_posted\n FROM plg_clinical_session_products cs\n LEFT JOIN LATERAL ( SELECT ss.party_id, ss.performed_by_id, ss.assignee_id, ss.service_id\n FROM plg_clinical_sessions ss\n WHERE ss.tenant_id = cs.tenant_id AND ss.id = cs.session_id LIMIT 1) s ON true\n LEFT JOIN products p ON p.id = cs.product_id\n LEFT JOIN products sv ON sv.id = s.service_id\n LEFT JOIN plg_inventory_measurement_units u ON u.id = cs.used_unit_id\n LEFT JOIN LATERAL ( SELECT it.id, it.order_id, it.name, it.assignee_id\n FROM items it\n WHERE cs.order_item_id IS NOT NULL\n AND it.id = cs.order_item_id AND it.tenant_id = cs.tenant_id LIMIT 1) i ON true\n LEFT JOIN LATERAL ( SELECT ord.id, ord.reference_number, ord.party_id\n FROM orders ord\n WHERE i.order_id IS NOT NULL AND ord.id = i.order_id LIMIT 1) o ON true\n WHERE NOT (EXISTS ( SELECT 1\n FROM plg_inventory_stock_movements m\n WHERE m.tenant_id = cs.tenant_id AND m.source_item_type = 'order_item'::text AND m.source_item_id = cs.order_item_id AND m.product_id = cs.product_id AND m.kind = 'out'::text))\n )\n SELECT tenant_id,\n unit_id,\n item_id,\n product_id,\n product_name,\n product_sku,\n used_at,\n movement_date,\n posted_quantity::numeric(14,4) AS posted_quantity,\n net_quantity::numeric(14,4) AS net_quantity,\n standard_quantity::numeric(14,4) AS standard_quantity,\n variance,\n unit_abbreviation,\n total_cost::numeric(16,4) AS total_cost,\n stock_location_id,\n stock_location_name,\n location_count,\n recipe_id,\n recipe_name,\n order_id,\n item_name,\n order_reference,\n performed_by_id,\n performed_by_name,\n customer_id,\n customer_name,\n corrected_at,\n correction_reason,\n is_corrected,\n source,\n stock_posted\n FROM ( SELECT ledger.tenant_id,\n ledger.unit_id,\n ledger.item_id,\n ledger.product_id,\n ledger.product_name,\n ledger.product_sku,\n ledger.used_at,\n ledger.movement_date,\n ledger.posted_quantity,\n ledger.net_quantity,\n ledger.standard_quantity,\n ledger.variance,\n ledger.unit_abbreviation,\n ledger.total_cost,\n ledger.stock_location_id,\n ledger.stock_location_name,\n ledger.location_count,\n ledger.recipe_id,\n ledger.recipe_name,\n ledger.order_id,\n ledger.item_name,\n ledger.order_reference,\n ledger.performed_by_id,\n ledger.performed_by_name,\n ledger.customer_id,\n ledger.customer_name,\n ledger.corrected_at,\n ledger.correction_reason,\n ledger.is_corrected,\n ledger.source,\n ledger.stock_posted\n FROM ledger\n UNION ALL\n SELECT clinical.tenant_id,\n clinical.unit_id,\n clinical.item_id,\n clinical.product_id,\n clinical.product_name,\n clinical.product_sku,\n clinical.used_at,\n clinical.movement_date,\n clinical.posted_quantity,\n clinical.net_quantity,\n clinical.standard_quantity,\n clinical.variance,\n clinical.unit_abbreviation,\n clinical.total_cost,\n clinical.stock_location_id,\n clinical.stock_location_name,\n clinical.location_count,\n clinical.recipe_id,\n clinical.recipe_name,\n clinical.order_id,\n clinical.item_name,\n clinical.order_reference,\n clinical.performed_by_id,\n clinical.performed_by_name,\n clinical.customer_id,\n clinical.customer_name,\n clinical.corrected_at,\n clinical.correction_reason,\n clinical.is_corrected,\n clinical.source,\n clinical.stock_posted\n FROM clinical) a;;\n`\n\nexport const MIGRATION_019_SEM_SALDO_NAO_SE_BAIXA = `-- Sem saldo não se baixa — e a fila diz qual produto faltou.\n--\n-- A 017 assumiu que a baixa sempre cabe. Não cabe: o gatilho de posição da\n-- plataforma recusa saída sem lastro, e o estoque deste tenant está zerado em\n-- 223 dos 225 produtos. O clique voltava com o erro cru do Postgres\n-- (\"insufficient stock (no position for product …)\") e a linha sumia do\n-- alcance de quem tentou.\n--\n-- Agora a linha sem saldo é SEPARADA: não é baixada, não é carimbada como\n-- conferida — continua pendente, que é o estado certo enquanto a entrada não\n-- for dada —, e volta com nome de produto, quanto precisa e quanto há.\n\nCREATE OR REPLACE FUNCTION public.inventory_approve_usage_batch(\n p_lines jsonb,\n p_post boolean DEFAULT false,\n p_reason text DEFAULT NULL\n)\nRETURNS jsonb\nLANGUAGE plpgsql\nSECURITY DEFINER\nSET search_path TO ''\nAS $fn$\nDECLARE\n v_tenant uuid := app.inventory_require_tenant();\n v_actor uuid := auth.uid();\n v_key text;\n v_op record;\n r record;\n v_cs record;\n v_location uuid;\n v_cost numeric;\n v_movement uuid;\n v_no integer := 0;\n v_aprovadas integer := 0;\n v_baixadas integer := 0;\n v_puladas integer := 0;\n v_sem_saldo integer := 0;\n v_faltando jsonb := '[]'::jsonb;\n v_disp numeric;\n v_movements jsonb := '[]'::jsonb;\nBEGIN\n PERFORM app.inventory_check_lines(p_lines);\n\n SELECT 'usage_audit_approve:' || CASE WHEN p_post THEN 'post:' ELSE 'ack:' END\n || md5(string_agg((l->>'item_id'), ',' ORDER BY (l->>'item_id')))\n INTO v_key\n FROM jsonb_array_elements(p_lines) AS l;\n\n SELECT * INTO v_op FROM app.inventory_begin_operation(\n -- \\`confirm\\` é a operação de quem TIRA do estoque por consumo — o mesmo\n -- verbo que o consumo de item de pedido usa. 'out' não existe no catálogo\n -- de operações (só de movimentos), e o CHECK recusava.\n v_tenant, CASE WHEN p_post THEN 'confirm' ELSE 'adjust' END, v_key,\n jsonb_build_object('source', 'usage_audit_approve', 'post', p_post, 'lines', p_lines));\n IF v_op.existing IS NOT NULL THEN\n RETURN v_op.existing;\n END IF;\n\n FOR r IN\n SELECT (l->>'item_id')::uuid AS item_id, ord\n FROM jsonb_array_elements(p_lines) WITH ORDINALITY AS t(l, ord)\n ORDER BY ord\n LOOP\n v_no := v_no + 1;\n\n SELECT cs.* INTO v_cs\n FROM public.plg_clinical_session_products cs\n WHERE cs.tenant_id = v_tenant AND cs.id = r.item_id\n FOR UPDATE;\n\n -- Linha que não é consumo clínico já nasceu como movimento do razão: ela\n -- não tem o que aprovar, e some da fila por outro caminho.\n IF NOT FOUND THEN\n v_puladas := v_puladas + 1;\n CONTINUE;\n END IF;\n\n IF p_post AND coalesce(v_cs.used_quantity, 0) > 0 THEN\n -- ONDE A BAIXA ACONTECE. Preferência: o local padrão do produto na\n -- unidade, depois o da ficha, depois o primeiro local ativo dela.\n SELECT candidate.location_id INTO v_location\n FROM (\n SELECT s.default_location_id AS location_id,\n CASE WHEN v_cs.unit_id IS NOT NULL AND s.unit_id = v_cs.unit_id THEN 1 ELSE 2 END AS priority\n FROM public.plg_inventory_product_settings s\n WHERE s.tenant_id = v_tenant\n AND s.product_id = v_cs.product_id\n AND s.default_location_id IS NOT NULL\n AND (s.unit_id IS NULL OR s.unit_id = v_cs.unit_id)\n UNION ALL\n SELECT d.default_location_id, 3\n FROM public.plg_inventory_product_details d\n WHERE d.tenant_id = v_tenant\n AND d.product_id = v_cs.product_id\n AND d.default_location_id IS NOT NULL\n UNION ALL\n SELECT l.id, 4\n FROM public.plg_inventory_stock_locations l\n WHERE l.tenant_id = v_tenant AND l.is_active\n ) candidate\n JOIN public.plg_inventory_stock_locations l\n ON l.id = candidate.location_id AND l.tenant_id = v_tenant AND l.is_active\n WHERE v_cs.unit_id IS NULL OR l.unit_id = v_cs.unit_id OR l.unit_id IS NULL\n ORDER BY candidate.priority, l.created_at, l.id\n LIMIT 1;\n\n IF v_location IS NULL THEN\n RAISE EXCEPTION 'inventory: nenhum local de estoque ativo para a unidade deste consumo'\n USING ERRCODE = '22023';\n END IF;\n\n -- SEM SALDO NÃO SE BAIXA, e quem clicou precisa saber por quê.\n --\n -- A plataforma não deixa o saldo ficar negativo: o gatilho de posição\n -- recusa a saída sem lastro. Em vez de devolver o erro cru do banco, a\n -- linha é separada e volta nomeada — a fila mantém o consumo pendente,\n -- que é o estado certo enquanto a entrada não for dada.\n SELECT coalesce(sum(p.quantity), 0) INTO v_disp\n FROM public.plg_inventory_stock_positions p\n WHERE p.tenant_id = v_tenant AND p.product_id = v_cs.product_id\n AND p.stock_location_id = v_location;\n\n IF coalesce(v_disp, 0) < v_cs.used_quantity THEN\n v_sem_saldo := v_sem_saldo + 1;\n v_faltando := v_faltando || jsonb_build_array(jsonb_build_object(\n 'item_id', v_cs.id,\n 'product', (SELECT pr.name FROM public.products pr WHERE pr.id = v_cs.product_id),\n 'needed', v_cs.used_quantity,\n 'available', coalesce(v_disp, 0)));\n CONTINUE;\n END IF;\n\n SELECT p.unit_cost INTO v_cost\n FROM public.plg_inventory_stock_positions p\n WHERE p.tenant_id = v_tenant AND p.product_id = v_cs.product_id\n AND p.stock_location_id = v_location\n ORDER BY p.expiration_date NULLS LAST, p.created_at\n LIMIT 1;\n\n INSERT INTO public.plg_inventory_stock_movements\n (tenant_id, unit_id, product_id, kind, quantity, unit_cost, source_location_id,\n measurement_unit_id, document_type, document_number,\n source_item_type, source_item_id, idempotency_key, line_no, operation_id,\n user_id, metadata)\n VALUES\n (v_tenant, v_cs.unit_id, v_cs.product_id, 'out', -v_cs.used_quantity,\n coalesce(v_cost, 0), v_location, v_cs.used_unit_id, 'consumption',\n coalesce(v_cs.session_id::text, v_cs.id::text),\n 'usage_audit', v_cs.id, v_key, v_no, v_op.op_id, v_actor,\n jsonb_strip_nulls(jsonb_build_object(\n 'source', 'usage_audit_approve',\n 'session_id', v_cs.session_id,\n 'order_item_id', v_cs.order_item_id,\n 'reason', nullif(btrim(p_reason), ''))))\n RETURNING id INTO v_movement;\n\n v_movements := v_movements || app.inventory_movement_json(v_movement);\n v_baixadas := v_baixadas + 1;\n END IF;\n\n -- CONFERIDO é o mesmo carimbo nos dois gestos: o que sai da fila é o que\n -- alguém olhou, tenha ou não baixado.\n UPDATE public.plg_clinical_session_products cs\n SET is_audited = true,\n audited_at = now(),\n audited_by = coalesce(cs.audited_by, v_actor),\n audit_notes = coalesce(nullif(btrim(p_reason), ''), cs.audit_notes),\n updated_at = now()\n WHERE cs.id = v_cs.id;\n v_aprovadas := v_aprovadas + 1;\n END LOOP;\n\n RETURN app.inventory_finish_operation(\n v_tenant, v_op.op_id, CASE WHEN p_post THEN 'confirm' ELSE 'adjust' END, v_key,\n jsonb_build_object(\n 'operation_id', v_op.op_id, 'kind', 'usage_audit_approve',\n 'idempotency_key', v_key, 'status', CASE WHEN p_post THEN 'posted' ELSE 'approved' END,\n 'lines_approved', v_aprovadas, 'lines_posted', v_baixadas,\n 'lines_skipped', v_puladas, 'lines_no_stock', v_sem_saldo,\n 'missing', v_faltando, 'movements', v_movements),\n jsonb_build_object('source', 'usage_audit_approve', 'post', p_post,\n 'lines_approved', v_aprovadas, 'lines_posted', v_baixadas,\n 'lines_no_stock', v_sem_saldo));\nEND $fn$;\n\nGRANT EXECUTE ON FUNCTION public.inventory_approve_usage_batch(jsonb, boolean, text) TO authenticated;\n`\n\nexport const MIGRATION_020_O_PRODUTO_E_VARIAS_COISAS_AO_MESMO_TEMPO = `-- Um produto é VÁRIAS coisas ao mesmo tempo, e cada uma tem a sua ficha.\n--\n-- O V2 guardava o tipo do produto como UM valor dentro de \\`products.metadata\\`\n-- («ingredient» | «sale» | «intermediate» | «asset»). O V1 não: ele tem SETE\n-- interruptores independentes, e cada um liga uma ficha própria no cadastro.\n-- Um shampoo de salão é o caso que quebra o valor único — a casa o usa no\n-- atendimento (insumo) E o revende ao cliente (venda), e com um valor só\n-- alguém tem de escolher qual das duas fichas o produto pode ter.\n--\n-- Então as flags viram COLUNAS, e cada ficha vira uma tabela com a chave no\n-- produto. \\`metadata->>productType\\` continua escrito pelo provider porque é\n-- dele que a faceta da lista («Insumos / Venda / Intermediários / Patrimônio»,\n-- igual à do V1) se serve; ele passa a ser DERIVADO das flags, não a origem.\n--\n-- As fichas são tabelas separadas, e não trinta colunas em\n-- \\`plg_inventory_product_details\\`, porque nenhuma casa usa as quatro: um salão\n-- nunca abre a de patrimônio, e um restaurante nunca abre a de revenda\n-- profissional. Linha que não existe não custa leitura, e a ficha inteira some\n-- junto com o produto (\\`ON DELETE CASCADE\\`).\n\n-- ---------------------------------------------------------------------------\n-- 1. As flags e o resto dos Dados Gerais\n-- ---------------------------------------------------------------------------\n\nALTER TABLE public.plg_inventory_product_details\n ADD COLUMN IF NOT EXISTS is_ingredient boolean NOT NULL DEFAULT false,\n ADD COLUMN IF NOT EXISTS is_for_sale boolean NOT NULL DEFAULT false,\n ADD COLUMN IF NOT EXISTS is_intermediate boolean NOT NULL DEFAULT false,\n ADD COLUMN IF NOT EXISTS is_asset boolean NOT NULL DEFAULT false,\n ADD COLUMN IF NOT EXISTS is_produced boolean NOT NULL DEFAULT false,\n ADD COLUMN IF NOT EXISTS is_resale boolean NOT NULL DEFAULT false,\n ADD COLUMN IF NOT EXISTS track_by_unit_label boolean NOT NULL DEFAULT false,\n -- Rastreabilidade na ENTRADA. No V1 vive na Classificação e vale para\n -- qualquer produto: revenda também tem lote e validade.\n ADD COLUMN IF NOT EXISTS require_batch_for_stock boolean NOT NULL DEFAULT false,\n ADD COLUMN IF NOT EXISTS require_expiry_for_stock boolean NOT NULL DEFAULT false,\n ADD COLUMN IF NOT EXISTS commission_percent numeric(7,4) NOT NULL DEFAULT 0,\n ADD COLUMN IF NOT EXISTS professional_resale_price numeric(16,4),\n ADD COLUMN IF NOT EXISTS chart_account_id uuid,\n ADD COLUMN IF NOT EXISTS content_per_unit numeric(14,4),\n ADD COLUMN IF NOT EXISTS content_unit_id uuid,\n ADD COLUMN IF NOT EXISTS avg_purchase_price numeric(16,4),\n ADD COLUMN IF NOT EXISTS avg_purchase_price_updated_at timestamp with time zone;\n\nCOMMENT ON COLUMN public.plg_inventory_product_details.chart_account_id IS\n 'Conta de despesa em plg_financial_chart_of_accounts. Sem chave estrangeira de propósito: a conta de estoque existe em tenant que não instalou o financeiro.';\nCOMMENT ON COLUMN public.plg_inventory_product_details.content_per_unit IS\n 'Quanto de content_unit_id cabe em UMA measurement_unit_id (1 frasco contém 300 ml).';\nCOMMENT ON COLUMN public.plg_inventory_product_details.avg_purchase_price IS\n 'Custo médio, reescrito a cada entrada de estoque. avg_purchase_price_updated_at carimba a última.';\nCOMMENT ON COLUMN public.plg_inventory_product_details.track_by_unit_label IS\n 'Alto custo / etiquetado: cada unidade é acompanhada individualmente.';\n\nDO $$\nBEGIN\n IF NOT EXISTS (\n SELECT 1 FROM pg_constraint\n WHERE conname = 'plg_inventory_product_details_content_unit_id_fkey'\n ) THEN\n ALTER TABLE public.plg_inventory_product_details\n ADD CONSTRAINT plg_inventory_product_details_content_unit_id_fkey\n FOREIGN KEY (content_unit_id)\n REFERENCES public.plg_inventory_measurement_units(id) ON DELETE SET NULL;\n END IF;\nEND $$;\n\n-- O catálogo que já existe nasceu com UM tipo. Acender a flag correspondente\n-- mantém a tela igual ao que a pessoa cadastrou — sem isto, 169 produtos\n-- abririam o formulário sem nenhuma ficha e pareceriam ter perdido o tipo.\nUPDATE public.plg_inventory_product_details d\n SET is_ingredient = (p.metadata ->> 'productType') = 'ingredient',\n is_for_sale = (p.metadata ->> 'productType') = 'sale',\n is_intermediate = (p.metadata ->> 'productType') = 'intermediate',\n is_asset = (p.metadata ->> 'productType') = 'asset'\n FROM public.products p\n WHERE p.id = d.product_id\n AND p.metadata ->> 'productType' IS NOT NULL\n AND NOT (d.is_ingredient OR d.is_for_sale OR d.is_intermediate OR d.is_asset);\n\n-- Produto que existe sem linha de detalhe nenhuma também precisa da flag, ou\n-- o formulário abre sem ficha e a lista perde a faceta.\nINSERT INTO public.plg_inventory_product_details\n (product_id, tenant_id, is_ingredient, is_for_sale, is_intermediate, is_asset)\nSELECT p.id, p.tenant_id,\n (p.metadata ->> 'productType') = 'ingredient',\n (p.metadata ->> 'productType') = 'sale',\n (p.metadata ->> 'productType') = 'intermediate',\n (p.metadata ->> 'productType') = 'asset'\n FROM public.products p\n WHERE p.metadata ->> 'productType' IS NOT NULL\n AND NOT EXISTS (\n SELECT 1 FROM public.plg_inventory_product_details d WHERE d.product_id = p.id\n )\nON CONFLICT (product_id) DO NOTHING;\n\n-- ---------------------------------------------------------------------------\n-- 2. Códigos de barras — vários por produto, um principal\n-- ---------------------------------------------------------------------------\n--\n-- O produto já tinha UM código em \\`products.metadata->>barcode\\`. Um só não\n-- serve ao balcão: a mesma mercadoria chega em embalagem de 500 ml e de 1 L,\n-- com códigos diferentes, e quem bipa não escolhe qual.\n\nCREATE TABLE IF NOT EXISTS public.plg_inventory_product_barcodes (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n product_id uuid NOT NULL,\n barcode text NOT NULL,\n barcode_type text DEFAULT 'EAN-13' NOT NULL,\n description text,\n is_primary boolean DEFAULT false NOT NULL,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n CONSTRAINT plg_inventory_product_barcodes_pkey PRIMARY KEY (id),\n CONSTRAINT plg_inventory_product_barcodes_product_id_fkey\n FOREIGN KEY (product_id) REFERENCES public.products(id) ON DELETE CASCADE,\n CONSTRAINT plg_inventory_product_barcodes_tenant_id_fkey\n FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE\n);\n\nCREATE INDEX IF NOT EXISTS idx_plg_inventory_product_barcodes_product\n ON public.plg_inventory_product_barcodes (product_id);\nCREATE UNIQUE INDEX IF NOT EXISTS idx_plg_inventory_product_barcodes_code\n ON public.plg_inventory_product_barcodes (tenant_id, barcode);\n-- Principal é UM. Sem isto dois «principais» convivem e quem lê a etiqueta\n-- pega o que vier primeiro.\nCREATE UNIQUE INDEX IF NOT EXISTS idx_plg_inventory_product_barcodes_primary\n ON public.plg_inventory_product_barcodes (product_id) WHERE is_primary;\n\n-- ---------------------------------------------------------------------------\n-- 3. Ficha de insumo\n-- ---------------------------------------------------------------------------\n\nCREATE TABLE IF NOT EXISTS public.plg_inventory_product_ingredient_info (\n product_id uuid NOT NULL,\n tenant_id uuid NOT NULL,\n minimum_quantity numeric(16,4) DEFAULT 0 NOT NULL,\n maximum_quantity numeric(16,4),\n reorder_point numeric(16,4),\n lead_time_days integer,\n shelf_life_days integer,\n storage_temperature text,\n allowed_supplier_ids uuid[],\n notes text,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n CONSTRAINT plg_inventory_product_ingredient_info_pkey PRIMARY KEY (product_id),\n CONSTRAINT plg_inventory_product_ingredient_info_product_id_fkey\n FOREIGN KEY (product_id) REFERENCES public.products(id) ON DELETE CASCADE,\n CONSTRAINT plg_inventory_product_ingredient_info_tenant_id_fkey\n FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE\n);\n\nCOMMENT ON COLUMN public.plg_inventory_product_ingredient_info.reorder_point IS\n 'Saldo em que a compra deve ser disparada. Fica ACIMA do mínimo: o mínimo é o que não se pode furar, este é o aviso que chega a tempo.';\n\n-- ---------------------------------------------------------------------------\n-- 4. Ficha de venda e os preços por canal\n-- ---------------------------------------------------------------------------\n\nCREATE TABLE IF NOT EXISTS public.plg_inventory_product_sale_info (\n product_id uuid NOT NULL,\n tenant_id uuid NOT NULL,\n menu_description text,\n allergens text,\n preparation_time_minutes integer,\n serves_quantity integer,\n calories integer,\n sold_by_weight boolean DEFAULT false NOT NULL,\n highlight_delivery boolean DEFAULT false NOT NULL,\n available_for_delivery boolean DEFAULT true NOT NULL,\n available_for_counter boolean DEFAULT true NOT NULL,\n notes text,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n CONSTRAINT plg_inventory_product_sale_info_pkey PRIMARY KEY (product_id),\n CONSTRAINT plg_inventory_product_sale_info_product_id_fkey\n FOREIGN KEY (product_id) REFERENCES public.products(id) ON DELETE CASCADE,\n CONSTRAINT plg_inventory_product_sale_info_tenant_id_fkey\n FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE\n);\n\n-- O canal é texto livre, e não um CHECK: a lista do V1 (balcão, delivery\n-- próprio, iFood, 99Food, Keeta) é o cardápio de HOJE, e cada praça tem o seu.\n-- Um CHECK aqui obrigaria uma migration para aceitar o próximo aplicativo.\nCREATE TABLE IF NOT EXISTS public.plg_inventory_product_sale_channels (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n product_id uuid NOT NULL,\n channel text NOT NULL,\n price numeric(16,4) DEFAULT 0 NOT NULL,\n active boolean DEFAULT true NOT NULL,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n CONSTRAINT plg_inventory_product_sale_channels_pkey PRIMARY KEY (id),\n CONSTRAINT plg_inventory_product_sale_channels_product_id_fkey\n FOREIGN KEY (product_id) REFERENCES public.products(id) ON DELETE CASCADE,\n CONSTRAINT plg_inventory_product_sale_channels_tenant_id_fkey\n FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE\n);\n\nCREATE UNIQUE INDEX IF NOT EXISTS idx_plg_inventory_product_sale_channels_one\n ON public.plg_inventory_product_sale_channels (product_id, channel);\n\n-- ---------------------------------------------------------------------------\n-- 5. Ficha de intermediário\n-- ---------------------------------------------------------------------------\n\nCREATE TABLE IF NOT EXISTS public.plg_inventory_product_intermediate_info (\n product_id uuid NOT NULL,\n tenant_id uuid NOT NULL,\n yield_percentage numeric(7,2),\n preparation_time_minutes integer,\n shelf_life_hours integer,\n batch_size numeric(16,4),\n batch_unit_id uuid,\n storage_instructions text,\n notes text,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n CONSTRAINT plg_inventory_product_intermediate_info_pkey PRIMARY KEY (product_id),\n CONSTRAINT plg_inventory_product_intermediate_info_product_id_fkey\n FOREIGN KEY (product_id) REFERENCES public.products(id) ON DELETE CASCADE,\n CONSTRAINT plg_inventory_product_intermediate_info_batch_unit_id_fkey\n FOREIGN KEY (batch_unit_id) REFERENCES public.plg_inventory_measurement_units(id) ON DELETE SET NULL,\n CONSTRAINT plg_inventory_product_intermediate_info_tenant_id_fkey\n FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE\n);\n\n-- ---------------------------------------------------------------------------\n-- 6. Ficha de patrimônio\n-- ---------------------------------------------------------------------------\n--\n-- O responsável aponta para \\`people\\` e não para uma tabela de funcionários:\n-- no V2 quem trabalha na casa é uma pessoa como as outras.\n\nCREATE TABLE IF NOT EXISTS public.plg_inventory_product_asset_info (\n product_id uuid NOT NULL,\n tenant_id uuid NOT NULL,\n asset_value numeric(16,4) DEFAULT 0 NOT NULL,\n residual_value numeric(16,4) DEFAULT 0 NOT NULL,\n useful_life_months integer,\n depreciation_rate numeric(7,4),\n serial_number text,\n acquisition_date date,\n warranty_expiry_date date,\n asset_status text DEFAULT 'active' NOT NULL,\n asset_location text,\n responsible_person_id uuid,\n insurance_policy text,\n last_maintenance_date date,\n next_maintenance_date date,\n notes text,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n CONSTRAINT plg_inventory_product_asset_info_pkey PRIMARY KEY (product_id),\n CONSTRAINT plg_inventory_product_asset_info_product_id_fkey\n FOREIGN KEY (product_id) REFERENCES public.products(id) ON DELETE CASCADE,\n CONSTRAINT plg_inventory_product_asset_info_responsible_person_id_fkey\n FOREIGN KEY (responsible_person_id) REFERENCES public.people(id) ON DELETE SET NULL,\n CONSTRAINT plg_inventory_product_asset_info_tenant_id_fkey\n FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE\n);\n\n-- ---------------------------------------------------------------------------\n-- 7. A cerca de cada ficha\n-- ---------------------------------------------------------------------------\n--\n-- Mesma regra do resto do plugin: a linha é da casa que a escreveu, e a\n-- pergunta é uma só (\\`user_tenant_ids()\\`), feita uma vez por consulta.\n\nDO $$\nDECLARE\n t text;\nBEGIN\n FOREACH t IN ARRAY ARRAY[\n 'plg_inventory_product_barcodes',\n 'plg_inventory_product_ingredient_info',\n 'plg_inventory_product_sale_info',\n 'plg_inventory_product_sale_channels',\n 'plg_inventory_product_intermediate_info',\n 'plg_inventory_product_asset_info'\n ]\n LOOP\n EXECUTE format('ALTER TABLE public.%I ENABLE ROW LEVEL SECURITY', t);\n EXECUTE format('ALTER TABLE public.%I FORCE ROW LEVEL SECURITY', t);\n EXECUTE format('CREATE INDEX IF NOT EXISTS idx_%s_tenant ON public.%I (tenant_id)', t, t);\n\n EXECUTE format('DROP POLICY IF EXISTS %I ON public.%I', t || '_select', t);\n EXECUTE format(\n 'CREATE POLICY %I ON public.%I FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))',\n t || '_select', t);\n\n EXECUTE format('DROP POLICY IF EXISTS %I ON public.%I', t || '_insert', t);\n EXECUTE format(\n 'CREATE POLICY %I ON public.%I FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()))',\n t || '_insert', t);\n\n EXECUTE format('DROP POLICY IF EXISTS %I ON public.%I', t || '_update', t);\n EXECUTE format(\n 'CREATE POLICY %I ON public.%I FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))',\n t || '_update', t);\n\n EXECUTE format('DROP POLICY IF EXISTS %I ON public.%I', t || '_delete', t);\n EXECUTE format(\n 'CREATE POLICY %I ON public.%I FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))',\n t || '_delete', t);\n\n EXECUTE format('GRANT ALL ON TABLE public.%I TO authenticated', t);\n EXECUTE format('GRANT ALL ON TABLE public.%I TO service_role', t);\n END LOOP;\nEND $$;\n`\n\nexport const MIGRATION_021_O_RAZAO_DIZ_DE_ONDE_VEIO = `-- ---------------------------------------------------------------------------\n-- O razão de estoque diz de onde veio, e a tela consegue abrir a nota.\n-- ---------------------------------------------------------------------------\n-- Comparando uma entrada migrada do V1 com uma que a V2 acabou de gravar:\n--\n-- V1 reason = «Entrada via DANFE NF-e 5411455» document_number = NULL\n-- V2 reason = NULL document_number = «NF-e 4693»\n--\n-- A tela de Movimentações mostra o MOTIVO como legenda debaixo do produto, e é a\n-- resposta para a pergunta nº 1 do FLOW.md do V1 — «de onde vieram essas entradas\n-- que eu não lancei?». Nas linhas novas ela estava escrevendo «Sem motivo\n-- registrado» sobre uma entrada que tinha motivo: a nota fiscal.\n--\n-- Duas causas, as duas aqui:\n--\n-- ① \\`inventory_receive\\` NUNCA GRAVOU \\`reason\\`. A coluna não estava na lista do\n-- INSERT. O chamador até mandava a frase no cabeçalho (\\`notes\\`), mas o\n-- \\`coalesce(v_ln.notes, v_doc->>'notes')\\` dá a vez à anotação DA LINHA, que\n-- toda entrada de nota preenche com a descrição do item. A frase nunca\n-- chegava. Agora existe uma chave própria: \\`reason\\` no cabeçalho, e nada muda\n-- para quem não a manda — continua NULL, como hoje.\n--\n-- ② A VIEW ESCONDIA O CAMINHO DE VOLTA. \\`v_stock_movements\\` não expunha\n-- \\`document_type\\` nem \\`operation_id\\`, que são o que diz «isto é uma NF-e» e\n-- «foi esta operação». Sem eles a tela tem o número da nota escrito e nenhum\n-- jeito de abri-la: \\`plg_fiscalbr_inbox_documents.inventory_operation_id\\`\n-- aponta para a mesma operação, e é por ali que o link se faz.\n--\n-- De quebra, três colunas que o V1 mostra e a V2 não tinha como mostrar: a\n-- UNIDADE junto da quantidade (o V1 escreve «12 SR», «100 UN», e a V2 escrevia só\n-- «12»), e o NOME do fornecedor em vez do uuid.\n--\n-- \\`security_invoker=true\\` fica: sem ele a view lê com os direitos de quem a\n-- criou, e uma view de movimento sem invoker é vazamento entre contas.\n-- ---------------------------------------------------------------------------\n\nCREATE OR REPLACE VIEW public.v_stock_movements\nWITH (security_invoker = true) AS\n SELECT sm.id,\n sm.tenant_id,\n sm.product_id,\n sm.quantity,\n sm.kind AS movement_type,\n sm.unit_cost,\n sm.total_cost,\n sm.source_location_id AS stock_location_id,\n sm.destination_location_id,\n sm.supplier_id,\n sm.document_number,\n sm.reason,\n sm.notes,\n sm.movement_date,\n sm.user_id,\n sm.batch_number,\n sm.expiration_date,\n sm.metadata,\n sm.created_at,\n p.name AS product_name,\n p.sku AS product_sku,\n sl.name AS stock_location_name,\n dl.name AS destination_location_name,\n -- NO FIM, e não no meio: \\`CREATE OR REPLACE VIEW\\` só sabe ACRESCENTAR coluna.\n -- Inserir uma antes de \\`product_name\\` faz o Postgres entender que a coluna\n -- está sendo RENOMEADA, e ele recusa — «cannot change name of view column».\n sm.document_type,\n sm.operation_id,\n sm.measurement_unit_id,\n mu.abbreviation AS measurement_unit_abbreviation,\n sup.name AS supplier_name\n FROM plg_inventory_stock_movements sm\n LEFT JOIN products p ON p.id = sm.product_id\n LEFT JOIN plg_inventory_stock_locations sl ON sl.id = sm.source_location_id\n LEFT JOIN plg_inventory_stock_locations dl ON dl.id = sm.destination_location_id\n LEFT JOIN plg_inventory_measurement_units mu ON mu.id = sm.measurement_unit_id\n LEFT JOIN people sup ON sup.id = sm.supplier_id;\n\nCOMMENT ON VIEW public.v_stock_movements IS\n 'O razão de estoque para a tela: movimento + nome do produto, do local, da unidade e do fornecedor, mais document_type/operation_id para a linha conseguir abrir o documento que a originou.';\n\n-- ── \\`inventory_receive\\` passa a gravar o motivo ────────────────────────────\n-- Corpo igual ao que está no cluster, com \\`reason\\` a mais. Reescrever de memória\n-- é como se perde uma regra; isto é o texto real com duas linhas trocadas.\n\nCREATE OR REPLACE FUNCTION public.inventory_receive(p_location uuid, p_lines jsonb, p_document jsonb DEFAULT '{}'::jsonb, p_idempotency_key text DEFAULT NULL::text)\n RETURNS jsonb\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $function$\nDECLARE\n v_tenant uuid := app.inventory_require_tenant();\n v_key text := coalesce(nullif(btrim(p_idempotency_key), ''), 'receive:' || gen_random_uuid()::text);\n v_op record; v_unit uuid; v_line jsonb; v_ln record; v_no integer := 0; v_id uuid;\n v_movements jsonb := '[]'::jsonb; v_positions jsonb := '[]'::jsonb;\n v_doc jsonb := coalesce(p_document, '{}'::jsonb); v_supplier uuid; v_date date;\nBEGIN\n PERFORM app.inventory_check_lines(p_lines);\n v_unit := app.inventory_authorize_location(v_tenant, p_location, 'inventory.create');\n BEGIN\n v_supplier := (v_doc->>'supplier_id')::uuid;\n v_date := (v_doc->>'date')::date;\n EXCEPTION WHEN OTHERS THEN\n RAISE EXCEPTION 'inventory: invalid document: %', SQLERRM USING ERRCODE = '22023';\n END;\n IF v_supplier IS NOT NULL AND NOT EXISTS (SELECT 1 FROM public.people s WHERE s.id = v_supplier AND s.tenant_id = v_tenant) THEN\n RAISE EXCEPTION 'inventory: supplier % not found in this tenant', v_supplier USING ERRCODE = '22023';\n END IF;\n\n SELECT * INTO v_op FROM app.inventory_begin_operation(v_tenant, 'receive', v_key,\n jsonb_build_object('location', p_location, 'lines', p_lines, 'document', v_doc));\n IF v_op.existing IS NOT NULL THEN RETURN v_op.existing; END IF;\n\n FOR v_line IN SELECT * FROM jsonb_array_elements(p_lines) LOOP\n v_no := v_no + 1;\n SELECT * INTO v_ln FROM app.inventory_parse_line(v_tenant, v_line, 'positive');\n INSERT INTO public.plg_inventory_stock_movements\n (tenant_id, product_id, variant_id, kind, quantity, unit_cost, source_location_id, batch_number, expiration_date, measurement_unit_id,\n supplier_id, document_type, document_number, notes, reason, movement_date, idempotency_key, line_no, operation_id, metadata)\n VALUES\n (v_tenant, v_ln.product_id, v_ln.variant_id, 'in', v_ln.quantity, coalesce(v_ln.unit_cost, 0), p_location, v_ln.batch_number, v_ln.expiration_date, v_ln.measurement_unit_id,\n v_supplier, coalesce(v_doc->>'type', 'manual'), v_doc->>'ref', coalesce(v_ln.notes, v_doc->>'notes'),\n -- O MOTIVO, que é do chamador e não do razão. Ver a migration.\n nullif(v_doc->>'reason', ''), coalesce(v_date, current_date),\n v_key, v_no, v_op.op_id, v_ln.declared)\n RETURNING id INTO v_id;\n v_movements := v_movements || app.inventory_movement_json(v_id);\n v_positions := v_positions || app.inventory_position_json(v_tenant, v_ln.product_id, p_location, v_ln.batch_number, v_ln.expiration_date);\n END LOOP;\n\n RETURN app.inventory_finish_operation(v_tenant, v_op.op_id, 'receive', v_key,\n jsonb_build_object('operation_id', v_op.op_id, 'kind', 'receive', 'idempotency_key', v_key,\n 'location_id', p_location, 'unit_id', v_unit, 'movements', v_movements, 'positions', v_positions),\n jsonb_build_object('location_id', p_location, 'unit_id', v_unit, 'lines', v_no, 'document_type', coalesce(v_doc->>'type', 'manual')));\nEND $function$;\n`\n\nexport const MIGRATION_022_RECEBER_ESTOQUE_VOLTA_A_FUNCIONAR = `-- ---------------------------------------------------------------------------\n-- 022 — receber estoque volta a funcionar.\n--\n-- A 021 passou a gravar o \\`reason\\` em \\`inventory_receive\\` — e, na mesma linha,\n-- acrescentou \\`variant_id\\`. Esse campo não existe de dois jeitos:\n--\n-- · \\`plg_inventory_stock_movements\\` NÃO tem a coluna \\`variant_id\\` na cadeia\n-- deste plugin (só \\`reason\\`, desde a baseline). Num banco do zero o INSERT\n-- nomeia coluna inexistente.\n-- · \\`app.inventory_parse_line\\` NÃO devolve \\`variant_id\\` — os OUT dela são\n-- product_id, quantity, unit_cost, batch_number, expiration_date,\n-- measurement_unit_id, declared e notes. Então \\`v_ln.variant_id\\` também\n-- não resolve nos clusters onde alguém criou a coluna por fora.\n--\n-- Nenhum dos dois erros aparece ao CRIAR a função: o corpo de uma plpgsql só é\n-- analisado na primeira chamada. O resultado é que RECEBER ESTOQUE estava\n-- quebrado — em produção, onde a 021 já foi aplicada, e no banco de testes.\n-- Quem pegou foi a suíte pgTAP do inventário, com 14 asserções vermelhas; e\n-- ninguém tinha visto porque a cadeia morria lá na db/162, antes de chegar nos\n-- testes.\n--\n-- Esta migration repõe a função SEM \\`variant_id\\`. O \\`reason\\`, que é o motivo\n-- de existir da 021, fica. Variante de produto é outra feature, e quando\n-- chegar vai precisar de coluna na cadeia e de saída na \\`parse_line\\` — as duas\n-- coisas, não meia.\n--\n-- Não reescreve a 021: ela já está no ledger dos clusters, e arquivo aplicado\n-- não se edita. Idempotente.\n-- ---------------------------------------------------------------------------\n\nCREATE OR REPLACE FUNCTION public.inventory_receive(p_location uuid, p_lines jsonb, p_document jsonb DEFAULT '{}'::jsonb, p_idempotency_key text DEFAULT NULL::text)\n RETURNS jsonb\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $function$\nDECLARE\n v_tenant uuid := app.inventory_require_tenant();\n v_key text := coalesce(nullif(btrim(p_idempotency_key), ''), 'receive:' || gen_random_uuid()::text);\n v_op record; v_unit uuid; v_line jsonb; v_ln record; v_no integer := 0; v_id uuid;\n v_movements jsonb := '[]'::jsonb; v_positions jsonb := '[]'::jsonb;\n v_doc jsonb := coalesce(p_document, '{}'::jsonb); v_supplier uuid; v_date date;\nBEGIN\n PERFORM app.inventory_check_lines(p_lines);\n v_unit := app.inventory_authorize_location(v_tenant, p_location, 'inventory.create');\n BEGIN\n v_supplier := (v_doc->>'supplier_id')::uuid;\n v_date := (v_doc->>'date')::date;\n EXCEPTION WHEN OTHERS THEN\n RAISE EXCEPTION 'inventory: invalid document: %', SQLERRM USING ERRCODE = '22023';\n END;\n IF v_supplier IS NOT NULL AND NOT EXISTS (SELECT 1 FROM public.people s WHERE s.id = v_supplier AND s.tenant_id = v_tenant) THEN\n RAISE EXCEPTION 'inventory: supplier % not found in this tenant', v_supplier USING ERRCODE = '22023';\n END IF;\n\n SELECT * INTO v_op FROM app.inventory_begin_operation(v_tenant, 'receive', v_key,\n jsonb_build_object('location', p_location, 'lines', p_lines, 'document', v_doc));\n IF v_op.existing IS NOT NULL THEN RETURN v_op.existing; END IF;\n\n FOR v_line IN SELECT * FROM jsonb_array_elements(p_lines) LOOP\n v_no := v_no + 1;\n SELECT * INTO v_ln FROM app.inventory_parse_line(v_tenant, v_line, 'positive');\n INSERT INTO public.plg_inventory_stock_movements\n (tenant_id, product_id, kind, quantity, unit_cost, source_location_id, batch_number, expiration_date, measurement_unit_id,\n supplier_id, document_type, document_number, notes, reason, movement_date, idempotency_key, line_no, operation_id, metadata)\n VALUES\n (v_tenant, v_ln.product_id, 'in', v_ln.quantity, coalesce(v_ln.unit_cost, 0), p_location, v_ln.batch_number, v_ln.expiration_date, v_ln.measurement_unit_id,\n v_supplier, coalesce(v_doc->>'type', 'manual'), v_doc->>'ref', coalesce(v_ln.notes, v_doc->>'notes'),\n -- O MOTIVO, que é do chamador e não do razão. Ver a migration.\n nullif(v_doc->>'reason', ''), coalesce(v_date, current_date),\n v_key, v_no, v_op.op_id, v_ln.declared)\n RETURNING id INTO v_id;\n v_movements := v_movements || app.inventory_movement_json(v_id);\n v_positions := v_positions || app.inventory_position_json(v_tenant, v_ln.product_id, p_location, v_ln.batch_number, v_ln.expiration_date);\n END LOOP;\n\n RETURN app.inventory_finish_operation(v_tenant, v_op.op_id, 'receive', v_key,\n jsonb_build_object('operation_id', v_op.op_id, 'kind', 'receive', 'idempotency_key', v_key,\n 'location_id', p_location, 'unit_id', v_unit, 'movements', v_movements, 'positions', v_positions),\n jsonb_build_object('location_id', p_location, 'unit_id', v_unit, 'lines', v_no, 'document_type', coalesce(v_doc->>'type', 'manual')));\nEND $function$;\n`\n\nexport const MIGRATION_023_A_RECEITA_SE_ESCREVE_COM_PERMISSAO = `-- ---------------------------------------------------------------------------\n-- 023 — a receita se escreve com permissão.\n--\n-- The baseline kept the pre-authz insert/update/delete policies on recipes and\n-- product categories next to the *_authz_* ones. Permissive policies are ORed,\n-- so \\`tenant_id IN user_tenant_ids()\\` let any member (viewer, staff, kitchen)\n-- write what inventory.create/edit/delete is supposed to gate.\n--\n-- The old SELECT policies stay on purpose: agenda's service kit\n-- (getServiceKit) reads recipes as reception/professional, who hold no\n-- inventory.read. Narrowing reads is a separate decision.\n-- ---------------------------------------------------------------------------\n\nDROP POLICY IF EXISTS plg_inventory_recipes_insert ON public.plg_inventory_recipes;\nDROP POLICY IF EXISTS plg_inventory_recipes_update ON public.plg_inventory_recipes;\nDROP POLICY IF EXISTS plg_inventory_recipes_delete ON public.plg_inventory_recipes;\n\nDROP POLICY IF EXISTS plg_inventory_product_categories_insert ON public.plg_inventory_product_categories;\nDROP POLICY IF EXISTS plg_inventory_product_categories_update ON public.plg_inventory_product_categories;\nDROP POLICY IF EXISTS plg_inventory_product_categories_delete ON public.plg_inventory_product_categories;\n`\n\nexport const MIGRATION_024_A_CATEGORIA_DO_PRODUTO_E_DO_CATALOGO = `-- ---------------------------------------------------------------------------\n-- 024 — a categoria do produto é do catálogo.\n--\n-- A product category is the \\`categories\\` archetype with kind 'good' (the\n-- catalog contract, and what products.category_id already points to). This\n-- plugin kept a private copy, plg_inventory_product_categories, so the stock\n-- screens read a table nobody else writes: imported and catalog-created\n-- categories never showed up there, and a product could carry two different\n-- categories at once.\n--\n-- The product's category is products.category_id. details.category_id was a\n-- second answer to the same question; it is copied into products where\n-- products had none, and the plugin stops reading or writing it.\n--\n-- Additive and idempotent: rows of the private table are folded into\n-- categories under the same id, then the two FKs move. The private table is\n-- left in place, unreferenced, so this step destroys nothing; dropping it is a\n-- later, reported step.\n-- ---------------------------------------------------------------------------\n\nDO $$\nBEGIN\n IF to_regclass('public.plg_inventory_product_categories') IS NULL THEN\n RETURN;\n END IF;\n\n -- Parents first is not needed: parent_id is written in a second pass.\n INSERT INTO public.categories (id, tenant_id, kind, name, is_active, unit_id, owner_id, created_at, updated_at)\n SELECT c.id, c.tenant_id, 'good', c.name, c.is_active, c.unit_id, c.owner_id, c.created_at, c.updated_at\n FROM public.plg_inventory_product_categories c\n ON CONFLICT (id) DO NOTHING;\n\n UPDATE public.categories k\n SET parent_id = c.parent_id\n FROM public.plg_inventory_product_categories c\n WHERE k.id = c.id AND k.parent_id IS NULL AND c.parent_id IS NOT NULL;\nEND $$;\n\nUPDATE public.products p\n SET category_id = d.category_id\n FROM public.plg_inventory_product_details d\n WHERE d.product_id = p.id AND p.category_id IS NULL AND d.category_id IS NOT NULL\n AND EXISTS (SELECT 1 FROM public.categories c WHERE c.id = d.category_id AND c.tenant_id = p.tenant_id);\n\nALTER TABLE public.plg_inventory_product_details\n DROP CONSTRAINT IF EXISTS plg_inventory_product_details_category_id_fkey;\nALTER TABLE public.plg_inventory_product_details\n ADD CONSTRAINT plg_inventory_product_details_category_id_fkey\n FOREIGN KEY (category_id) REFERENCES public.categories(id) ON DELETE SET NULL;\n\nALTER TABLE public.plg_inventory_count_sessions\n DROP CONSTRAINT IF EXISTS plg_inventory_count_sessions_category_id_fkey;\nALTER TABLE public.plg_inventory_count_sessions\n ADD CONSTRAINT plg_inventory_count_sessions_category_id_fkey\n FOREIGN KEY (category_id) REFERENCES public.categories(id) ON DELETE SET NULL;\n`\n\nexport const MIGRATION_025_A_FICHA_TECNICA_POR_ESTACAO = `-- ---------------------------------------------------------------------------\n-- 025 — the technical sheet, by station.\n--\n-- What the V1 sheet has and the V2 tables did not:\n-- * yield_mode: portion (the sheet IS one unit sold) vs batch (the sheet\n-- produces yield_quantity). Same units, opposite cost math — not inferable.\n-- * the station of each component (recipe_groups.station_id).\n-- * the reading unit of an ingredient line (70 mL of an oil counted in L).\n-- * production outputs with cost split (filé + aparas + pele).\n-- And one write path: inventory_save_recipe() replaces groups, lines and\n-- outputs in ONE transaction. V1 deleted and re-inserted without one, and a\n-- failed insert left the sheet empty (V1-082).\n--\n-- Idempotent.\n-- ---------------------------------------------------------------------------\n\n-- 1. Portion × batch -------------------------------------------------------\nALTER TABLE public.plg_inventory_recipes\n ADD COLUMN IF NOT EXISTS yield_mode text;\n\n-- Same backfill rule as V1 (20261015130000): batch when the product is an\n-- intermediate or is itself an ingredient of another sheet.\nUPDATE public.plg_inventory_recipes r\n SET yield_mode = CASE\n WHEN EXISTS (SELECT 1 FROM public.plg_inventory_product_details d\n WHERE d.product_id = r.product_id AND d.is_intermediate) THEN 'batch'\n WHEN EXISTS (SELECT 1 FROM public.plg_inventory_recipe_ingredients i\n WHERE i.product_id = r.product_id AND i.tenant_id = r.tenant_id) THEN 'batch'\n ELSE 'portion' END\n WHERE r.yield_mode IS NULL;\n\nALTER TABLE public.plg_inventory_recipes ALTER COLUMN yield_mode SET DEFAULT 'portion';\nALTER TABLE public.plg_inventory_recipes ALTER COLUMN yield_mode SET NOT NULL;\n\nDO $$ BEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_recipes_yield_mode_check') THEN\n ALTER TABLE public.plg_inventory_recipes ADD CONSTRAINT plg_inventory_recipes_yield_mode_check\n CHECK (yield_mode IN ('portion', 'batch'));\n END IF;\nEND $$;\n\nCOMMENT ON COLUMN public.plg_inventory_recipes.yield_mode IS\n 'portion = the sheet is 1 unit of the product (yield only describes); batch = the sheet produces yield_quantity (cost divides). V1: recipes.yield_mode.';\n\n-- 2. Station of a component -----------------------------------------------\nALTER TABLE public.plg_inventory_recipe_groups\n ADD COLUMN IF NOT EXISTS station_id uuid;\n\nDO $$ BEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_recipe_groups_station_fkey') THEN\n ALTER TABLE public.plg_inventory_recipe_groups ADD CONSTRAINT plg_inventory_recipe_groups_station_fkey\n FOREIGN KEY (station_id) REFERENCES public.plg_inventory_production_stations(id) ON DELETE SET NULL;\n END IF;\nEND $$;\n\nCOMMENT ON COLUMN public.plg_inventory_recipe_groups.station_id IS\n 'Estação de Produção do componente. V1: recipe_groups.production_station_id.';\n\n-- 3. Reading unit of an ingredient line -----------------------------------\n-- quantity stays in the ingredient''s BASE unit (unit_id); this is only how\n-- the line is read and typed.\nALTER TABLE public.plg_inventory_recipe_ingredients\n ADD COLUMN IF NOT EXISTS display_unit_id uuid;\n\nDO $$ BEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_recipe_ingredients_display_unit_fkey') THEN\n ALTER TABLE public.plg_inventory_recipe_ingredients ADD CONSTRAINT plg_inventory_recipe_ingredients_display_unit_fkey\n FOREIGN KEY (display_unit_id) REFERENCES public.plg_inventory_measurement_units(id) ON DELETE SET NULL;\n END IF;\nEND $$;\n\n-- 4. Production outputs ---------------------------------------------------\nCREATE TABLE IF NOT EXISTS public.plg_inventory_recipe_outputs (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n unit_id uuid,\n recipe_id uuid NOT NULL,\n product_id uuid NOT NULL,\n output_type text DEFAULT 'main' NOT NULL,\n expected_quantity numeric(14,4) NOT NULL,\n measurement_unit_id uuid,\n cost_share numeric(9,4),\n display_order integer DEFAULT 0 NOT NULL,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL\n);\n\nCOMMENT ON TABLE public.plg_inventory_recipe_outputs IS\n 'Saídas da produção de uma ficha (filé + aparas + pele). cost_share em branco em todas = rateio pelo valor de mercado; descarte não leva custo. V1: recipe_outputs.';\n\nDO $$ BEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_recipe_outputs_pkey') THEN\n ALTER TABLE ONLY public.plg_inventory_recipe_outputs ADD CONSTRAINT plg_inventory_recipe_outputs_pkey PRIMARY KEY (id);\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_recipe_outputs_tenant_fkey') THEN\n ALTER TABLE ONLY public.plg_inventory_recipe_outputs ADD CONSTRAINT plg_inventory_recipe_outputs_tenant_fkey\n FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_recipe_outputs_unit_fkey') THEN\n ALTER TABLE ONLY public.plg_inventory_recipe_outputs ADD CONSTRAINT plg_inventory_recipe_outputs_unit_fkey\n FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_recipe_outputs_recipe_fkey') THEN\n ALTER TABLE ONLY public.plg_inventory_recipe_outputs ADD CONSTRAINT plg_inventory_recipe_outputs_recipe_fkey\n FOREIGN KEY (recipe_id) REFERENCES public.plg_inventory_recipes(id) ON DELETE CASCADE;\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_recipe_outputs_product_fkey') THEN\n ALTER TABLE ONLY public.plg_inventory_recipe_outputs ADD CONSTRAINT plg_inventory_recipe_outputs_product_fkey\n FOREIGN KEY (product_id) REFERENCES public.products(id);\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_recipe_outputs_uom_fkey') THEN\n ALTER TABLE ONLY public.plg_inventory_recipe_outputs ADD CONSTRAINT plg_inventory_recipe_outputs_uom_fkey\n FOREIGN KEY (measurement_unit_id) REFERENCES public.plg_inventory_measurement_units(id) ON DELETE SET NULL;\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_recipe_outputs_type_check') THEN\n ALTER TABLE ONLY public.plg_inventory_recipe_outputs ADD CONSTRAINT plg_inventory_recipe_outputs_type_check\n CHECK (output_type IN ('main', 'coproduct', 'byproduct', 'waste'));\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_recipe_outputs_qty_check') THEN\n ALTER TABLE ONLY public.plg_inventory_recipe_outputs ADD CONSTRAINT plg_inventory_recipe_outputs_qty_check\n CHECK (expected_quantity > 0);\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_recipe_outputs_share_check') THEN\n ALTER TABLE ONLY public.plg_inventory_recipe_outputs ADD CONSTRAINT plg_inventory_recipe_outputs_share_check\n CHECK (cost_share IS NULL OR (cost_share >= 0 AND cost_share <= 100));\n END IF;\nEND $$;\n\nCREATE INDEX IF NOT EXISTS plg_inventory_recipe_outputs_recipe_idx\n ON public.plg_inventory_recipe_outputs USING btree (tenant_id, recipe_id, display_order);\n\nALTER TABLE public.plg_inventory_recipe_outputs ENABLE ROW LEVEL SECURITY;\nALTER TABLE public.plg_inventory_recipe_outputs FORCE ROW LEVEL SECURITY;\n\nGRANT SELECT, INSERT, UPDATE, DELETE ON TABLE public.plg_inventory_recipe_outputs TO authenticated;\nGRANT ALL ON TABLE public.plg_inventory_recipe_outputs TO service_role;\n\nDROP TRIGGER IF EXISTS plg_inventory_recipe_outputs_updated_at ON public.plg_inventory_recipe_outputs;\nCREATE TRIGGER plg_inventory_recipe_outputs_updated_at BEFORE UPDATE ON public.plg_inventory_recipe_outputs\n FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nDROP POLICY IF EXISTS plg_inventory_recipe_outputs_authz_select ON public.plg_inventory_recipe_outputs;\nCREATE POLICY plg_inventory_recipe_outputs_authz_select ON public.plg_inventory_recipe_outputs\n FOR SELECT TO authenticated USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (unit_id IS NULL OR (SELECT app.has_unit(plg_inventory_recipe_outputs.unit_id)))\n AND (SELECT app.has_permission('inventory.read', plg_inventory_recipe_outputs.unit_id)));\n\nDROP POLICY IF EXISTS plg_inventory_recipe_outputs_authz_write ON public.plg_inventory_recipe_outputs;\nCREATE POLICY plg_inventory_recipe_outputs_authz_write ON public.plg_inventory_recipe_outputs\n FOR ALL TO authenticated USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('inventory.edit', plg_inventory_recipe_outputs.unit_id)))\n WITH CHECK (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('inventory.edit', plg_inventory_recipe_outputs.unit_id)));\n\n-- 5. Having a sheet = being produced ----------------------------------------\n-- V1 flips is_produced when the sheet is saved; sheets that crossed over\n-- before the flag existed never got it, so the list and the product tab\n-- did not see them.\nUPDATE public.plg_inventory_product_details d\n SET is_produced = true\n WHERE NOT d.is_produced\n AND EXISTS (SELECT 1 FROM public.plg_inventory_recipes r\n WHERE r.product_id = d.product_id AND r.is_active);\n\n-- 6. The one write path -----------------------------------------------------\n-- SECURITY INVOKER on purpose: every row goes through the same RLS the screen\n-- has (inventory.create / inventory.edit on the recipe''s unit).\nCREATE OR REPLACE FUNCTION public.inventory_save_recipe(p_recipe jsonb)\nRETURNS uuid\nLANGUAGE plpgsql\nSECURITY INVOKER\nSET search_path = ''\nAS $$\nDECLARE\n v_tenant uuid := app.current_tenant_id();\n v_id uuid := nullif(p_recipe->>'id', '')::uuid;\n v_product uuid := nullif(p_recipe->>'productId', '')::uuid;\n v_unit uuid;\n v_group jsonb;\n v_group_id uuid;\n v_line jsonb;\n v_out jsonb;\n v_gi int := 0;\n v_li int;\n v_oi int := 0;\n v_cost numeric := nullif(p_recipe->>'unitCost', '')::numeric;\nBEGIN\n IF v_tenant IS NULL THEN\n RAISE EXCEPTION 'no active tenant' USING ERRCODE = '42501';\n END IF;\n IF v_product IS NULL THEN\n RAISE EXCEPTION 'productId is required' USING ERRCODE = '22023';\n END IF;\n IF coalesce(trim(p_recipe->>'name'), '') = '' THEN\n RAISE EXCEPTION 'name is required' USING ERRCODE = '22023';\n END IF;\n\n IF v_id IS NULL THEN\n -- One active sheet per product: reuse the one already there.\n SELECT r.id INTO v_id FROM public.plg_inventory_recipes r\n WHERE r.tenant_id = v_tenant AND r.product_id = v_product AND r.is_active\n ORDER BY r.updated_at DESC LIMIT 1;\n END IF;\n\n IF v_id IS NULL THEN\n INSERT INTO public.plg_inventory_recipes\n (tenant_id, product_id, name, description, yield_quantity, yield_unit_id, yield_mode,\n preparation_time_minutes, instructions, is_active, unit_id, owner_id)\n VALUES\n (v_tenant, v_product, trim(p_recipe->>'name'), nullif(p_recipe->>'description', ''),\n coalesce(nullif(p_recipe->>'yieldQuantity', '')::numeric, 1),\n nullif(p_recipe->>'yieldUnitId', '')::uuid,\n coalesce(nullif(p_recipe->>'yieldMode', ''), 'portion'),\n nullif(p_recipe->>'preparationTimeMinutes', '')::int,\n nullif(p_recipe->>'instructions', ''), true,\n nullif(p_recipe->>'unitId', '')::uuid, auth.uid())\n RETURNING id, unit_id INTO v_id, v_unit;\n ELSE\n UPDATE public.plg_inventory_recipes SET\n name = trim(p_recipe->>'name'),\n description = nullif(p_recipe->>'description', ''),\n product_id = v_product,\n yield_quantity = coalesce(nullif(p_recipe->>'yieldQuantity', '')::numeric, 1),\n yield_unit_id = nullif(p_recipe->>'yieldUnitId', '')::uuid,\n yield_mode = coalesce(nullif(p_recipe->>'yieldMode', ''), 'portion'),\n preparation_time_minutes = nullif(p_recipe->>'preparationTimeMinutes', '')::int,\n instructions = nullif(p_recipe->>'instructions', ''),\n is_active = true,\n updated_at = now()\n WHERE id = v_id AND tenant_id = v_tenant\n RETURNING unit_id INTO v_unit;\n IF NOT FOUND THEN\n RAISE EXCEPTION 'recipe not found or not writable' USING ERRCODE = '42501';\n END IF;\n END IF;\n\n DELETE FROM public.plg_inventory_recipe_ingredients WHERE recipe_id = v_id AND tenant_id = v_tenant;\n DELETE FROM public.plg_inventory_recipe_groups WHERE recipe_id = v_id AND tenant_id = v_tenant;\n DELETE FROM public.plg_inventory_recipe_outputs WHERE recipe_id = v_id AND tenant_id = v_tenant;\n\n FOR v_group IN SELECT * FROM jsonb_array_elements(coalesce(p_recipe->'groups', '[]'::jsonb)) LOOP\n INSERT INTO public.plg_inventory_recipe_groups\n (tenant_id, unit_id, recipe_id, name, station_id, preparation_notes, estimated_minutes, display_order)\n VALUES\n (v_tenant, v_unit, v_id,\n coalesce(nullif(trim(v_group->>'name'), ''), 'Componente ' || (v_gi + 1)),\n nullif(v_group->>'stationId', '')::uuid,\n nullif(v_group->>'preparationNotes', ''),\n nullif(v_group->>'estimatedMinutes', '')::int,\n v_gi)\n RETURNING id INTO v_group_id;\n\n v_li := 0;\n FOR v_line IN SELECT * FROM jsonb_array_elements(coalesce(v_group->'ingredients', '[]'::jsonb)) LOOP\n -- unit_id on a line is the MEASUREMENT unit (base of the ingredient),\n -- not the house: the house of a line is its recipe's.\n INSERT INTO public.plg_inventory_recipe_ingredients\n (tenant_id, recipe_id, group_id, product_id, quantity, unit_id, display_unit_id, notes, display_order)\n VALUES\n (v_tenant, v_id, v_group_id, (v_line->>'productId')::uuid,\n (v_line->>'quantity')::numeric,\n nullif(v_line->>'unitId', '')::uuid,\n nullif(v_line->>'displayUnitId', '')::uuid,\n nullif(v_line->>'notes', ''), v_li);\n v_li := v_li + 1;\n END LOOP;\n v_gi := v_gi + 1;\n END LOOP;\n\n FOR v_out IN SELECT * FROM jsonb_array_elements(coalesce(p_recipe->'outputs', '[]'::jsonb)) LOOP\n INSERT INTO public.plg_inventory_recipe_outputs\n (tenant_id, unit_id, recipe_id, product_id, output_type, expected_quantity,\n measurement_unit_id, cost_share, display_order)\n VALUES\n (v_tenant, v_unit, v_id, (v_out->>'productId')::uuid,\n coalesce(nullif(v_out->>'outputType', ''), 'main'),\n (v_out->>'expectedQuantity')::numeric,\n nullif(v_out->>'measurementUnitId', '')::uuid,\n CASE WHEN v_out->>'outputType' = 'waste' THEN NULL\n ELSE nullif(v_out->>'costShare', '')::numeric END,\n v_oi);\n v_oi := v_oi + 1;\n END LOOP;\n\n -- Having a sheet = being produced; and the sheet's cost per unit becomes the\n -- product's cost — it is what another sheet reads when this one is its input.\n INSERT INTO public.plg_inventory_product_details (product_id, tenant_id, is_produced)\n VALUES (v_product, v_tenant, true)\n ON CONFLICT (product_id) DO UPDATE SET is_produced = true;\n\n IF v_cost IS NOT NULL AND v_cost > 0 THEN\n UPDATE public.products SET cost = round(v_cost, 4), updated_at = now()\n WHERE id = v_product AND tenant_id = v_tenant;\n END IF;\n\n RETURN v_id;\nEND;\n$$;\n\nREVOKE ALL ON FUNCTION public.inventory_save_recipe(jsonb) FROM PUBLIC, anon;\nGRANT EXECUTE ON FUNCTION public.inventory_save_recipe(jsonb) TO authenticated;\n`\n\nexport const MIGRATION_026_STOCK_COST_KNOWS_ITS_CURRENCY = `-- ============================================================================\n-- 026 — stock cost knows its currency\n--\n-- \\`unit_cost\\` was a bare number. With a shop in Rio and one in Barcelona, and\n-- shirts paid for in dollars, three currencies would have met in one weighted\n-- average.\n--\n-- The rule: the cost a position carries is ALWAYS in the currency of the unit\n-- that holds the stock, so every sum and average that already exists stays\n-- right. What was actually paid, when it was paid in something else, is kept\n-- beside it (\\`source_currency\\`, \\`source_unit_cost\\`, \\`fx_rate\\`) and converted\n-- once, on the way in.\n--\n-- A receipt says the currency on the document (or the line):\n-- inventory_receive(loc, lines, '{\"currency\":\"USD\",\"fx_rate\":5.42}')\n-- With no rate given, the tenant's rate for the day is used; with none on\n-- file, the receipt is refused rather than booked at a made-up cost.\n--\n-- Moving stock between units of different currencies is refused for now: the\n-- same cost number would land in the other currency unconverted.\n-- ============================================================================\n\nDO $$\nBEGIN\n IF NOT EXISTS (SELECT 1 FROM information_schema.columns\n WHERE table_schema = 'public' AND table_name = 'plg_inventory_stock_movements' AND column_name = 'currency') THEN\n ALTER TABLE public.plg_inventory_stock_movements ADD COLUMN currency text NOT NULL DEFAULT 'BRL';\n ALTER TABLE public.plg_inventory_stock_movements ALTER COLUMN currency DROP DEFAULT;\n END IF;\n IF NOT EXISTS (SELECT 1 FROM information_schema.columns\n WHERE table_schema = 'public' AND table_name = 'plg_inventory_stock_positions' AND column_name = 'currency') THEN\n ALTER TABLE public.plg_inventory_stock_positions ADD COLUMN currency text NOT NULL DEFAULT 'BRL';\n ALTER TABLE public.plg_inventory_stock_positions ALTER COLUMN currency DROP DEFAULT;\n END IF;\nEND $$;\nALTER TABLE public.plg_inventory_stock_movements ADD COLUMN IF NOT EXISTS source_currency text;\nALTER TABLE public.plg_inventory_stock_movements ADD COLUMN IF NOT EXISTS source_unit_cost numeric;\nALTER TABLE public.plg_inventory_stock_movements ADD COLUMN IF NOT EXISTS fx_rate numeric;\n\nCOMMENT ON COLUMN public.plg_inventory_stock_movements.currency IS 'Currency of unit_cost/total_cost: the unit that holds the stock.';\nCOMMENT ON COLUMN public.plg_inventory_stock_movements.source_currency IS 'Currency the goods were paid in, when not the unit''s.';\nCOMMENT ON COLUMN public.plg_inventory_stock_movements.source_unit_cost IS 'Unit cost as paid, in source_currency.';\nCOMMENT ON COLUMN public.plg_inventory_stock_movements.fx_rate IS '1 source_currency = fx_rate of the unit''s currency, as used for this entry.';\nCOMMENT ON COLUMN public.plg_inventory_stock_positions.currency IS 'Currency of unit_cost: the unit that holds this balance.';\n\nCREATE OR REPLACE FUNCTION app.inventory_movements_currency()\n RETURNS trigger\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $function$\nDECLARE\n v_unit_cur text := app.unit_currency(NEW.tenant_id, NEW.unit_id);\n v_dst_unit uuid;\n v_src text := nullif(upper(btrim(coalesce(NEW.source_currency, ''))), '');\nBEGIN\n NEW.currency := v_unit_cur;\n\n IF NEW.destination_location_id IS NOT NULL THEN\n SELECT l.unit_id INTO v_dst_unit FROM public.plg_inventory_stock_locations l WHERE l.id = NEW.destination_location_id;\n IF app.unit_currency(NEW.tenant_id, v_dst_unit) <> v_unit_cur THEN\n RAISE EXCEPTION 'Transferência entre filiais de moedas diferentes (% → %) ainda não é suportada: dê saída em uma e entrada na outra, informando o custo.',\n v_unit_cur, app.unit_currency(NEW.tenant_id, v_dst_unit)\n USING ERRCODE = '0A000';\n END IF;\n END IF;\n\n IF v_src IS NULL OR v_src = v_unit_cur THEN\n NEW.source_currency := NULL; NEW.source_unit_cost := NULL; NEW.fx_rate := NULL;\n RETURN NEW;\n END IF;\n\n NEW.source_currency := v_src;\n NEW.source_unit_cost := coalesce(NEW.source_unit_cost, NEW.unit_cost, 0);\n -- The tenant's rate table belongs to financial, which may not be installed.\n IF NEW.fx_rate IS NULL AND to_regprocedure('public.plg_financial_fx_rate(uuid,text,text,date)') IS NOT NULL THEN\n EXECUTE 'SELECT public.plg_financial_fx_rate($1, $2, $3, $4)'\n INTO NEW.fx_rate USING NEW.tenant_id, v_src, v_unit_cur, NEW.movement_date;\n END IF;\n IF NEW.fx_rate IS NULL OR NEW.fx_rate <= 0 THEN\n RAISE EXCEPTION 'Informe a taxa de câmbio de % para % desta entrada (ou cadastre a taxa do dia).', v_src, v_unit_cur\n USING ERRCODE = '22023';\n END IF;\n NEW.unit_cost := round(NEW.source_unit_cost * NEW.fx_rate, 4);\n NEW.total_cost := abs(NEW.quantity) * NEW.unit_cost;\n RETURN NEW;\nEND $function$;\n\n-- Named to fire after \\`plg_inventory_stock_movements_before_insert\\`, which\n-- stamps the unit and the cost this converts.\nDROP TRIGGER IF EXISTS plg_inventory_stock_movements_currency ON public.plg_inventory_stock_movements;\nCREATE TRIGGER plg_inventory_stock_movements_currency\n BEFORE INSERT ON public.plg_inventory_stock_movements\n FOR EACH ROW EXECUTE FUNCTION app.inventory_movements_currency();\n\nCREATE OR REPLACE FUNCTION app.inventory_positions_currency()\n RETURNS trigger\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $function$\nDECLARE v_unit uuid;\nBEGIN\n SELECT l.unit_id INTO v_unit FROM public.plg_inventory_stock_locations l WHERE l.id = NEW.stock_location_id;\n NEW.currency := app.unit_currency(NEW.tenant_id, coalesce(v_unit, NEW.unit_id));\n RETURN NEW;\nEND $function$;\nDROP TRIGGER IF EXISTS plg_inventory_stock_positions_currency ON public.plg_inventory_stock_positions;\nCREATE TRIGGER plg_inventory_stock_positions_currency\n BEFORE INSERT ON public.plg_inventory_stock_positions\n FOR EACH ROW EXECUTE FUNCTION app.inventory_positions_currency();\n\n-- The receipt passes the document's currency through. Patched in place: the\n-- live body differs from the file that created it.\nDO $patch$\nDECLARE\n v_fn regprocedure := 'public.inventory_receive(uuid, jsonb, jsonb, text)'::regprocedure;\n v_def text := pg_get_functiondef(v_fn);\n v_cols constant text := 'idempotency_key, line_no, operation_id, metadata)';\n v_vals constant text := 'v_key, v_no, v_op.op_id, v_ln.declared)';\nBEGIN\n IF position('source_currency' in v_def) > 0 THEN\n RETURN; -- already passes it (a replay, or a later rewrite)\n END IF;\n IF position(v_cols in v_def) = 0 OR position(v_vals in v_def) = 0 THEN\n RAISE EXCEPTION '026: inventory_receive no longer has the INSERT this extends; patch it by hand';\n END IF;\n v_def := overlay(v_def PLACING 'idempotency_key, line_no, operation_id, metadata, source_currency, fx_rate)'\n FROM position(v_cols in v_def) FOR length(v_cols));\n v_def := overlay(v_def PLACING\n 'v_key, v_no, v_op.op_id, v_ln.declared,' || E'\\\\n'\n || ' nullif(upper(btrim(coalesce(v_line->>''currency'', v_doc->>''currency'', ''''))), ''''),' || E'\\\\n'\n || ' coalesce(nullif(v_line->>''fx_rate'', '''')::numeric, nullif(v_doc->>''fx_rate'', '''')::numeric))'\n FROM position(v_vals in v_def) FOR length(v_vals));\n EXECUTE v_def;\nEND $patch$;\n`\n\nexport const MIGRATION_027_A_RECEIPT_NAMES_THE_LINE_IT_CAME_FROM = `-- ============================================================================\n-- 027 — a receipt names the line it came from\n--\n-- Stock received because of a bill had no tie to the bill line: the ledger\n-- kept \\`source_item_type\\` / \\`source_item_id\\` columns, but \\`inventory_receive\\`\n-- never filled them, so \"did this line enter stock?\" was answered by guessing\n-- from the invoice number. A line of the receipt can now say where it came\n-- from, and the receipt writes it down.\n--\n-- Patched in place, as 026 did: the live body differs from the files.\n-- ============================================================================\n\nDO $patch$\nDECLARE\n v_fn regprocedure := 'public.inventory_receive(uuid, jsonb, jsonb, text)'::regprocedure;\n v_def text := pg_get_functiondef(v_fn);\n v_cols constant text := 'metadata, source_currency, fx_rate)';\n v_vals constant text := 'nullif(v_doc->>''fx_rate'', '''')::numeric))';\nBEGIN\n IF position('source_item_id' in v_def) > 0 THEN\n RETURN; -- already writes it (a replay, or a later rewrite)\n END IF;\n IF position(v_cols in v_def) = 0 OR position(v_vals in v_def) = 0 THEN\n RAISE EXCEPTION '027: inventory_receive no longer has the INSERT this extends; patch it by hand';\n END IF;\n v_def := overlay(v_def PLACING 'metadata, source_currency, fx_rate, source_item_type, source_item_id)'\n FROM position(v_cols in v_def) FOR length(v_cols));\n v_def := overlay(v_def PLACING\n 'nullif(v_doc->>''fx_rate'', '''')::numeric),' || E'\\\\n'\n || ' nullif(v_line->>''source_item_type'', ''''), nullif(v_line->>''source_item_id'', '''')::uuid)'\n FROM position(v_vals in v_def) FOR length(v_vals));\n EXECUTE v_def;\nEND $patch$;\n\nCREATE INDEX IF NOT EXISTS plg_inventory_stock_movements_source_item_idx\n ON public.plg_inventory_stock_movements (tenant_id, source_item_id) WHERE source_item_id IS NOT NULL;\n`\n\nexport const MIGRATION_028_A_RECEIPT_KEEPS_THE_VARIANT = `-- ============================================================================\n-- 028 — a receipt keeps the variant\n--\n-- \\`inventory_receive\\` read \\`variant_id\\` off each line, checked it belonged to\n-- the product, and then did not write it: every receipt landed on the\n-- product's default variant. A shirt received as 10 M and 9 G was 19 of\n-- \"Padrão\". The line's variant now reaches the ledger row, which is what the\n-- per-variant balance is computed from.\n--\n-- Patched in place, as 026 and 027 did.\n--\n-- The column itself was never in this plugin's chain (022 says so): pools got\n-- it by hand, a database built from zero did not. It is declared here, in the\n-- shape the pools have. The value is read off the line's JSON, which exists in\n-- every version of the function; \\`v_ln.variant_id\\` only exists where the\n-- parser was widened by hand.\n-- ============================================================================\n\nALTER TABLE public.plg_inventory_stock_movements\n ADD COLUMN IF NOT EXISTS variant_id uuid REFERENCES public.product_variants(id) ON DELETE SET NULL;\nCREATE INDEX IF NOT EXISTS plg_inventory_stock_movements_variant_idx\n ON public.plg_inventory_stock_movements (tenant_id, variant_id) WHERE variant_id IS NOT NULL;\n\nDO $patch$\nDECLARE\n v_fn regprocedure := 'public.inventory_receive(uuid, jsonb, jsonb, text)'::regprocedure;\n v_def text := pg_get_functiondef(v_fn);\n v_cols constant text := 'source_item_type, source_item_id)';\n v_vals constant text := 'nullif(v_line->>''source_item_id'', '''')::uuid)';\nBEGIN\n IF position('source_item_type, source_item_id, variant_id)' in v_def) > 0 THEN\n RETURN; -- already writes it (a replay, or a later rewrite)\n END IF;\n IF position(v_cols in v_def) = 0 OR position(v_vals in v_def) = 0 THEN\n RAISE EXCEPTION '028: inventory_receive no longer has the INSERT this extends; patch it by hand';\n END IF;\n v_def := overlay(v_def PLACING 'source_item_type, source_item_id, variant_id)'\n FROM position(v_cols in v_def) FOR length(v_cols));\n v_def := overlay(v_def PLACING 'nullif(v_line->>''source_item_id'', '''')::uuid, nullif(v_line->>''variant_id'', '''')::uuid)'\n FROM position(v_vals in v_def) FOR length(v_vals));\n EXECUTE v_def;\nEND $patch$;\n`\n\nexport const MIGRATION_029_A_REVERSAL_UNDOES_THE_SAME_VARIANT = `-- ============================================================================\n-- 029 — a reversal undoes the same variant\n--\n-- 028 made a receipt keep the variant it was for. \\`inventory_reverse\\` copies\n-- the row it undoes and left the variant out, so undoing \"6 of M\" tried to\n-- take 6 from the product's default variant — which has none, and the\n-- reversal was refused (\"would take variant … below zero\"). A receipt by size\n-- could be made and never corrected.\n--\n-- The reversal now carries the variant of the row it reverses.\n-- Patched in place, like 026–028.\n-- ============================================================================\n\nDO $patch$\nDECLARE\n v_fn regprocedure := 'public.inventory_reverse(text, text)'::regprocedure;\n v_def text := pg_get_functiondef(v_fn);\n v_new text;\n -- The function has two INSERTs (one per branch); both copy the row they undo.\n v_cols constant text := 'reverses_movement_id, idempotency_key, line_no, operation_id, metadata)';\n v_vals1 constant text := 'jsonb_build_object(''reversed_movement_id'', r.id))';\n v_vals2 constant text := '''reversed_kind'', r.kind))';\nBEGIN\n IF position('r.variant_id' in v_def) > 0 THEN\n RETURN; -- already carries it (a replay, or a later rewrite)\n END IF;\n v_new := replace(v_def, v_cols, 'reverses_movement_id, idempotency_key, line_no, operation_id, metadata, variant_id)');\n v_new := replace(v_new, v_vals1, 'jsonb_build_object(''reversed_movement_id'', r.id), r.variant_id)');\n v_new := replace(v_new, v_vals2, '''reversed_kind'', r.kind), r.variant_id)');\n -- Every INSERT that gained the column must have gained the value.\n IF (length(v_new) - length(replace(v_new, 'metadata, variant_id)', ''))) / length('metadata, variant_id)')\n <> (length(v_new) - length(replace(v_new, ', r.variant_id)', ''))) / length(', r.variant_id)')\n OR position('metadata, variant_id)' in v_new) = 0 THEN\n RAISE EXCEPTION '029: inventory_reverse no longer has the INSERTs this extends; patch it by hand';\n END IF;\n EXECUTE v_new;\nEND $patch$;\n`\n\nexport const MIGRATION_030_THE_MINIMUM_CAN_BE_SAVED = `-- ============================================================================\n-- 030 — the minimum can be saved\n--\n-- The product form saves its minimum with an upsert on\n-- (tenant_id, product_id, unit_id). The only unique index on those columns\n-- was the expression index (tenant_id, product_id, COALESCE(unit_id, zero)),\n-- which ON CONFLICT cannot infer from a column list, so every save came back\n-- 42P10 and the client never looked at the error: \"Quantidade Mínima\" was\n-- dropped in silence.\n--\n-- A plain column index with NULLS NOT DISTINCT is an arbiter for that\n-- conflict target and treats the tenant-wide row (unit_id NULL) as one row,\n-- which is what the expression index already enforced. Both are kept: they\n-- agree on every row, and dropping the old one is not this fix's business.\n--\n-- Hand-applied on cluster-squash-test on 2026-10-22 under this same name, so\n-- this file must be a no-op there.\n--\n-- Second part: the Insumo tab had its own minimum/maximum, stored on\n-- plg_inventory_product_ingredient_info, which no alert or restock list\n-- reads. The tab no longer offers them. Whatever was typed there is copied\n-- into the tenant-wide settings row when the product has none yet, so the\n-- number the person entered becomes the one that is acted on. The columns\n-- stay; nothing is dropped.\n-- ============================================================================\n\nCREATE UNIQUE INDEX IF NOT EXISTS plg_inventory_product_settings_upsert_key\n ON public.plg_inventory_product_settings (tenant_id, product_id, unit_id) NULLS NOT DISTINCT;\n\nINSERT INTO public.plg_inventory_product_settings (tenant_id, product_id, unit_id, min_quantity, max_quantity)\nSELECT i.tenant_id,\n i.product_id,\n NULL,\n GREATEST(COALESCE(i.minimum_quantity, 0), 0),\n CASE WHEN i.maximum_quantity >= 0 THEN i.maximum_quantity END\n FROM public.plg_inventory_product_ingredient_info i\n JOIN public.products p ON p.id = i.product_id AND p.tenant_id = i.tenant_id\n WHERE (COALESCE(i.minimum_quantity, 0) > 0 OR i.maximum_quantity IS NOT NULL)\n AND NOT EXISTS (\n SELECT 1\n FROM public.plg_inventory_product_settings s\n WHERE s.tenant_id = i.tenant_id\n AND s.product_id = i.product_id\n AND s.unit_id IS NULL)\nON CONFLICT (tenant_id, product_id, unit_id) DO NOTHING;\n`\n\nexport const MIGRATION_031_THE_BELL_RINGS_WHEN_STOCK_RUNS_LOW = `-- ============================================================================\n-- 031 — the bell rings when stock runs low\n--\n-- A product's minimum (plg_inventory_product_settings.min_quantity) only ever\n-- fed a count and a filter: \"estoque baixo\" was something you found by opening\n-- the Estoque screen or asking the assistant. Nothing told anyone.\n--\n-- Now the moment a product's balance CROSSES its minimum — from above it to at\n-- or below it — every active member who can read the stock gets a line in the\n-- bell (public.notifications, the table the shell's bell reads), the bell is\n-- poked over realtime, and \\`inventory.stock_low\\` is recorded in the event log\n-- so an automation can carry it further (WhatsApp, e-mail).\n--\n-- The rules are the ones v_inventory_low_stock already states, so the alert and\n-- the screen never disagree:\n-- - a minimum applies when it is > 0;\n-- - \"low\" is on_hand <= min_quantity;\n-- - a settings row with unit_id set watches that unit's positions; the row\n-- with unit_id NULL watches the product across the whole tenant.\n--\n-- EDGE, not level. The alert fires on the crossing, not on every movement while\n-- the product stays low — selling the tenth bottle of a product already at its\n-- minimum must not ring ten times. It re-arms by itself: once a receipt lifts\n-- the balance above the minimum, the next fall rings again. The same crossing\n-- is checked when the minimum itself changes: raising it above what is on the\n-- shelf is as much \"this is now low\" as selling down to it.\n--\n-- One notification per user per minimum, re-surfaced. The subject is the\n-- settings row (product + scope), so the unique index of packages/db 130 on\n-- (user_id, subject_type, subject_id, type) keeps one line per product per\n-- person; a new crossing marks it unread and moves it to the top instead of\n-- stacking copies.\n--\n-- PER TRANSACTION, not per statement. Every position write goes through\n-- app.inventory_apply_to_position, one position per call, so a transfer is two\n-- statements — the source goes down, then the destination goes up — and a\n-- recipe consumes ingredient by ingredient. Checked per statement, a transfer\n-- between two units of a product sitting near its minimum would dip the tenant\n-- total in between and ring for a balance that never changed. So:\n-- - a BEFORE row trigger records, on the first touch of a transaction, the\n-- balance of each scope it touches (the product across the tenant, and the\n-- unit) in a transaction-local setting — that is the balance BEFORE;\n-- - a DEFERRED constraint trigger runs at COMMIT, when every statement of\n-- the transaction has landed, compares each minimum's scope against what\n-- was recorded, rings on a crossing, and moves the record forward so the\n-- other rows of the same commit do not ring again.\n\n-- A failure here never takes the stock write with it: everything that rings\n-- runs inside an exception block that only warns, as the order bell does.\n-- ============================================================================\n\n-- The text of a quantity: \"2\", \"2,5\", \"0,125\" — no trailing zeros, Brazilian\n-- decimal comma. Helper of this file only.\nCREATE OR REPLACE FUNCTION app.inventory_quantity_text(p_value numeric)\nRETURNS text\nLANGUAGE sql\nIMMUTABLE\nSET search_path TO ''\nAS $$\n SELECT replace(trim_scale(coalesce(p_value, 0))::text, '.', ',');\n$$;\n\n-- Ring for one minimum that has just been crossed.\nCREATE OR REPLACE FUNCTION app.inventory_ring_the_bell_for_low_stock(\n p_setting_id uuid,\n p_tenant_id uuid,\n p_product_id uuid,\n p_unit_id uuid,\n p_on_hand numeric,\n p_min numeric\n)\nRETURNS void\nLANGUAGE plpgsql\nSECURITY DEFINER\nSET search_path TO ''\nAS $$\nDECLARE\n v_name text;\n v_measure text;\n v_unit text;\n v_title text;\n v_body text;\nBEGIN\n BEGIN\n SELECT pl.name INTO v_name FROM app.inventory_product_label(p_product_id) pl(name, sku);\n SELECT nullif(btrim(coalesce(p.unit, '')), '') INTO v_measure\n FROM public.products p WHERE p.id = p_product_id;\n IF p_unit_id IS NOT NULL THEN\n SELECT u.name INTO v_unit FROM app.units u WHERE u.id = p_unit_id;\n END IF;\n\n v_title := 'Estoque baixo: ' || coalesce(v_name, 'produto');\n v_body := concat_ws(' · ',\n 'Saldo ' || app.inventory_quantity_text(p_on_hand) || coalesce(' ' || v_measure, ''),\n 'mínimo ' || app.inventory_quantity_text(p_min) || coalesce(' ' || v_measure, ''),\n v_unit);\n\n -- Whoever can see the stock where it ran low. RLS on notifications shows a\n -- row only to its own user_id, so it is one row per person.\n INSERT INTO public.notifications (tenant_id, user_id, title, body, type, link, subject_type, subject_id)\n SELECT p_tenant_id, m.user_id, v_title, v_body, 'inventory.stock_low',\n '/inventory/products/detail/' || p_product_id::text,\n 'inventory_minimum', p_setting_id\n FROM (SELECT DISTINCT mm.user_id FROM app.memberships mm\n WHERE mm.tenant_id = p_tenant_id AND mm.active AND mm.user_id IS NOT NULL) m\n WHERE EXISTS (\n SELECT 1 FROM app.role_permissions rp\n WHERE rp.tenant_id = p_tenant_id\n AND rp.role_id = app.effective_role_id(p_tenant_id, m.user_id, p_unit_id)\n AND rp.permission IN ('inventory.read', 'inventory.manage'))\n ON CONFLICT (user_id, subject_type, subject_id, type) WHERE subject_id IS NOT NULL\n DO UPDATE SET read = false, created_at = now(), title = EXCLUDED.title, body = EXCLUDED.body;\n\n IF to_regproc('realtime.send') IS NOT NULL THEN\n PERFORM realtime.send(\n jsonb_build_object('type', 'inventory.stock_low', 'subjectId', p_setting_id),\n 'notification',\n -- Contract with \\`subscribeBell()\\` in packages/admin/src/lib/bell.ts.\n 'avisos:' || p_tenant_id::text,\n false);\n END IF;\n\n PERFORM public.plg_emit_event(\n 'inventory.stock_low',\n jsonb_build_object(\n 'productId', p_product_id,\n 'productName', v_name,\n 'unitId', p_unit_id,\n 'unitName', v_unit,\n 'onHand', p_on_hand,\n 'minQuantity', p_min,\n 'measure', v_measure),\n 'product', p_product_id::text, p_tenant_id);\n EXCEPTION WHEN OTHERS THEN\n RAISE WARNING 'inventory_ring_the_bell_for_low_stock: %', SQLERRM;\n END;\nEND;\n$$;\n\n-- The balance a minimum watches: the unit's positions, or the tenant's.\nCREATE OR REPLACE FUNCTION app.inventory_scope_on_hand(p_tenant_id uuid, p_product_id uuid, p_unit_id uuid)\nRETURNS numeric\nLANGUAGE sql\nSTABLE\nSECURITY DEFINER\nSET search_path TO ''\nAS $$\n SELECT coalesce(sum(p.quantity), 0)\n FROM public.plg_inventory_stock_positions p\n WHERE p.tenant_id = p_tenant_id\n AND p.product_id = p_product_id\n AND (p_unit_id IS NULL OR p.unit_id = p_unit_id);\n$$;\n\n-- The transaction-local setting holding a scope's balance before this\n-- transaction. md5 keeps the name a plain identifier of fixed length.\nCREATE OR REPLACE FUNCTION app.inventory_low_stock_key(p_tenant_id uuid, p_product_id uuid, p_unit_id uuid)\nRETURNS text\nLANGUAGE sql\nIMMUTABLE\nSET search_path TO ''\nAS $$\n SELECT 'fayz_low_stock.s' || md5(p_tenant_id::text || '|' || p_product_id::text || '|' || coalesce(p_unit_id::text, '*'));\n$$;\n\n-- BEFORE each position write: remember the balances as they were when this\n-- transaction first touched them. A key already set means \"already touched\".\nCREATE OR REPLACE FUNCTION app.trg_inventory_positions_low_stock_snapshot()\nRETURNS trigger\nLANGUAGE plpgsql\nSECURITY DEFINER\nSET search_path TO ''\nAS $$\nDECLARE\n r record;\n v_k text;\nBEGIN\n BEGIN\n FOR r IN\n SELECT DISTINCT x.tenant_id, x.product_id, x.unit_id\n FROM (SELECT NEW.tenant_id, NEW.product_id, NEW.unit_id WHERE TG_OP <> 'DELETE'\n UNION ALL\n SELECT OLD.tenant_id, OLD.product_id, OLD.unit_id WHERE TG_OP <> 'INSERT') AS x(tenant_id, product_id, unit_id)\n WHERE x.tenant_id IS NOT NULL AND x.product_id IS NOT NULL\n LOOP\n v_k := app.inventory_low_stock_key(r.tenant_id, r.product_id, NULL);\n IF coalesce(current_setting(v_k, true), '') = '' THEN\n PERFORM set_config(v_k, app.inventory_scope_on_hand(r.tenant_id, r.product_id, NULL)::text, true);\n END IF;\n IF r.unit_id IS NOT NULL THEN\n v_k := app.inventory_low_stock_key(r.tenant_id, r.product_id, r.unit_id);\n IF coalesce(current_setting(v_k, true), '') = '' THEN\n PERFORM set_config(v_k, app.inventory_scope_on_hand(r.tenant_id, r.product_id, r.unit_id)::text, true);\n END IF;\n END IF;\n END LOOP;\n EXCEPTION WHEN OTHERS THEN\n RAISE WARNING 'trg_inventory_positions_low_stock_snapshot: %', SQLERRM;\n END;\n RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;\nEND;\n$$;\n\n-- At COMMIT, once per written row: every minimum of the row's product whose\n-- scope this transaction touched is compared against the recorded balance.\nCREATE OR REPLACE FUNCTION app.trg_inventory_positions_low_stock_check()\nRETURNS trigger\nLANGUAGE plpgsql\nSECURITY DEFINER\nSET search_path TO ''\nAS $$\nDECLARE\n v_tenant uuid;\n v_product uuid;\n s record;\n v_k text;\n v_before text;\n v_now numeric;\nBEGIN\n -- Everything, the reads of OLD/NEW included, inside the block that only\n -- warns: this runs at COMMIT, and an error here would undo the sale.\n BEGIN\n IF TG_OP = 'DELETE' THEN\n v_tenant := OLD.tenant_id; v_product := OLD.product_id;\n ELSE\n v_tenant := NEW.tenant_id; v_product := NEW.product_id;\n END IF;\n FOR s IN\n SELECT st.id, st.tenant_id, st.product_id, st.unit_id, st.min_quantity\n FROM public.plg_inventory_product_settings st\n WHERE st.tenant_id = v_tenant AND st.product_id = v_product AND st.min_quantity > 0\n LOOP\n v_k := app.inventory_low_stock_key(s.tenant_id, s.product_id, s.unit_id);\n v_before := current_setting(v_k, true);\n CONTINUE WHEN coalesce(v_before, '') = '';\n v_now := app.inventory_scope_on_hand(s.tenant_id, s.product_id, s.unit_id);\n IF v_now <= s.min_quantity AND v_before::numeric > s.min_quantity THEN\n PERFORM app.inventory_ring_the_bell_for_low_stock(\n s.id, s.tenant_id, s.product_id, s.unit_id, v_now, s.min_quantity);\n END IF;\n -- Forward: the next row of this commit compares against now.\n PERFORM set_config(v_k, v_now::text, true);\n END LOOP;\n EXCEPTION WHEN OTHERS THEN\n RAISE WARNING 'trg_inventory_positions_low_stock_check: %', SQLERRM;\n END;\n RETURN NULL;\nEND;\n$$;\n\nDROP TRIGGER IF EXISTS plg_inventory_stock_positions_low_stock_snapshot ON public.plg_inventory_stock_positions;\nCREATE TRIGGER plg_inventory_stock_positions_low_stock_snapshot\n BEFORE INSERT OR UPDATE OR DELETE ON public.plg_inventory_stock_positions\n FOR EACH ROW EXECUTE FUNCTION app.trg_inventory_positions_low_stock_snapshot();\n\nDROP TRIGGER IF EXISTS plg_inventory_stock_positions_low_stock_check ON public.plg_inventory_stock_positions;\nCREATE CONSTRAINT TRIGGER plg_inventory_stock_positions_low_stock_check\n AFTER INSERT OR UPDATE OR DELETE ON public.plg_inventory_stock_positions\n DEFERRABLE INITIALLY DEFERRED\n FOR EACH ROW EXECUTE FUNCTION app.trg_inventory_positions_low_stock_check();\n\n-- The minimum moved: low now and not low under the old minimum is a crossing.\n-- A new minimum on a product that has never been stocked does not ring.\nCREATE OR REPLACE FUNCTION app.trg_inventory_minimum_check_low_stock()\nRETURNS trigger\nLANGUAGE plpgsql\nSECURITY DEFINER\nSET search_path TO ''\nAS $$\nDECLARE\n v_on_hand numeric;\n v_old_min numeric := 0;\nBEGIN\n BEGIN\n IF TG_OP = 'UPDATE' THEN v_old_min := OLD.min_quantity; END IF;\n IF coalesce(NEW.min_quantity, 0) <= 0 THEN RETURN NULL; END IF;\n -- A minimum set on a product nobody has stocked yet is the product being\n -- registered, not stock running out: the bell would ring for every new\n -- item before its first receipt. Only a product with positions rings.\n IF TG_OP = 'INSERT' AND NOT EXISTS (\n SELECT 1 FROM public.plg_inventory_stock_positions p\n WHERE p.tenant_id = NEW.tenant_id AND p.product_id = NEW.product_id) THEN\n RETURN NULL;\n END IF;\n v_on_hand := app.inventory_scope_on_hand(NEW.tenant_id, NEW.product_id, NEW.unit_id);\n IF v_on_hand <= NEW.min_quantity\n AND NOT (coalesce(v_old_min, 0) > 0 AND v_on_hand <= v_old_min) THEN\n PERFORM app.inventory_ring_the_bell_for_low_stock(\n NEW.id, NEW.tenant_id, NEW.product_id, NEW.unit_id, v_on_hand, NEW.min_quantity);\n END IF;\n EXCEPTION WHEN OTHERS THEN\n RAISE WARNING 'trg_inventory_minimum_check_low_stock: %', SQLERRM;\n END;\n RETURN NULL;\nEND;\n$$;\n\nDROP TRIGGER IF EXISTS plg_inventory_product_settings_low_stock ON public.plg_inventory_product_settings;\nCREATE TRIGGER plg_inventory_product_settings_low_stock\n AFTER INSERT OR UPDATE OF min_quantity ON public.plg_inventory_product_settings\n FOR EACH ROW EXECUTE FUNCTION app.trg_inventory_minimum_check_low_stock();\n\n-- Internal machinery: nobody calls these over PostgREST.\nREVOKE ALL ON FUNCTION app.inventory_quantity_text(numeric) FROM PUBLIC;\nREVOKE ALL ON FUNCTION app.inventory_low_stock_key(uuid, uuid, uuid) FROM PUBLIC;\nREVOKE ALL ON FUNCTION app.inventory_ring_the_bell_for_low_stock(uuid, uuid, uuid, uuid, numeric, numeric) FROM PUBLIC;\nREVOKE ALL ON FUNCTION app.inventory_scope_on_hand(uuid, uuid, uuid) FROM PUBLIC;\nREVOKE ALL ON FUNCTION app.trg_inventory_positions_low_stock_snapshot() FROM PUBLIC;\nREVOKE ALL ON FUNCTION app.trg_inventory_positions_low_stock_check() FROM PUBLIC;\nREVOKE ALL ON FUNCTION app.trg_inventory_minimum_check_low_stock() FROM PUBLIC;\n`\n\nexport const MIGRATIONS: Array<{ id: string; sql: string }> = [\n { id: \"000_baseline\", sql: MIGRATION_000_BASELINE },\n { id: \"001_the_usage_that_left_stock_can_be_audited\", sql: MIGRATION_001_THE_USAGE_THAT_LEFT_STOCK_CAN_BE_AUDITED },\n { id: \"002_a_auditoria_fecha_em_lote\", sql: MIGRATION_002_A_AUDITORIA_FECHA_EM_LOTE },\n { id: \"003_stock_count_closes_through_ledger\", sql: MIGRATION_003_STOCK_COUNT_CLOSES_THROUGH_LEDGER },\n { id: \"004_a_posicao_de_estoque_atravessa\", sql: MIGRATION_004_A_POSICAO_DE_ESTOQUE_ATRAVESSA },\n { id: \"005_a_posicao_e_consequencia_do_movimento\", sql: MIGRATION_005_A_POSICAO_E_CONSEQUENCIA_DO_MOVIMENTO },\n { id: \"006_a_producao_tem_ordem_e_estacao\", sql: MIGRATION_006_A_PRODUCAO_TEM_ORDEM_E_ESTACAO },\n { id: \"007_a_producao_atravessa\", sql: MIGRATION_007_A_PRODUCAO_ATRAVESSA },\n { id: \"008_o_grupo_da_receita_tambem_e_de_uma_casa\", sql: MIGRATION_008_O_GRUPO_DA_RECEITA_TAMBEM_E_DE_UMA_CASA },\n { id: \"009_a_direcao_do_movimento_nao_esta_no_numero\", sql: MIGRATION_009_A_DIRECAO_DO_MOVIMENTO_NAO_ESTA_NO_NUMERO },\n { id: \"010_a_entrada_de_notas_atravessa\", sql: MIGRATION_010_A_ENTRADA_DE_NOTAS_ATRAVESSA },\n { id: \"011_o_consumo_tem_duas_origens\", sql: MIGRATION_011_O_CONSUMO_TEM_DUAS_ORIGENS },\n { id: \"012_o_consumo_clinico_nao_precisa_da_tabela_de_itens_inteira\", sql: MIGRATION_012_O_CONSUMO_CLINICO_NAO_PRECISA_DA_TABELA_DE_ITENS_INTEIRA },\n { id: \"013_a_fila_de_auditoria_tem_uma_janela\", sql: MIGRATION_013_A_FILA_DE_AUDITORIA_TEM_UMA_JANELA },\n { id: \"014_a_sessao_se_busca_uma_a_uma\", sql: MIGRATION_014_A_SESSAO_SE_BUSCA_UMA_A_UMA },\n { id: \"015_lateral_sem_cerca_nao_e_lateral\", sql: MIGRATION_015_LATERAL_SEM_CERCA_NAO_E_LATERAL },\n { id: \"016_a_data_do_consumo_e_uma_coluna\", sql: MIGRATION_016_A_DATA_DO_CONSUMO_E_UMA_COLUNA },\n { id: \"017_aprovar_e_baixar_sao_dois_gestos\", sql: MIGRATION_017_APROVAR_E_BAIXAR_SAO_DOIS_GESTOS },\n { id: \"018_o_nome_nao_sai_da_vista\", sql: MIGRATION_018_O_NOME_NAO_SAI_DA_VISTA },\n { id: \"019_sem_saldo_nao_se_baixa\", sql: MIGRATION_019_SEM_SALDO_NAO_SE_BAIXA },\n { id: \"020_o_produto_e_varias_coisas_ao_mesmo_tempo\", sql: MIGRATION_020_O_PRODUTO_E_VARIAS_COISAS_AO_MESMO_TEMPO },\n { id: \"021_o_razao_diz_de_onde_veio\", sql: MIGRATION_021_O_RAZAO_DIZ_DE_ONDE_VEIO },\n { id: \"022_receber_estoque_volta_a_funcionar\", sql: MIGRATION_022_RECEBER_ESTOQUE_VOLTA_A_FUNCIONAR },\n { id: \"023_a_receita_se_escreve_com_permissao\", sql: MIGRATION_023_A_RECEITA_SE_ESCREVE_COM_PERMISSAO },\n { id: \"024_a_categoria_do_produto_e_do_catalogo\", sql: MIGRATION_024_A_CATEGORIA_DO_PRODUTO_E_DO_CATALOGO },\n { id: \"025_a_ficha_tecnica_por_estacao\", sql: MIGRATION_025_A_FICHA_TECNICA_POR_ESTACAO },\n { id: \"026_stock_cost_knows_its_currency\", sql: MIGRATION_026_STOCK_COST_KNOWS_ITS_CURRENCY },\n { id: \"027_a_receipt_names_the_line_it_came_from\", sql: MIGRATION_027_A_RECEIPT_NAMES_THE_LINE_IT_CAME_FROM },\n { id: \"028_a_receipt_keeps_the_variant\", sql: MIGRATION_028_A_RECEIPT_KEEPS_THE_VARIANT },\n { id: \"029_a_reversal_undoes_the_same_variant\", sql: MIGRATION_029_A_REVERSAL_UNDOES_THE_SAME_VARIANT },\n { id: \"030_the_minimum_can_be_saved\", sql: MIGRATION_030_THE_MINIMUM_CAN_BE_SAVED },\n { id: \"031_the_bell_rings_when_stock_runs_low\", sql: MIGRATION_031_THE_BELL_RINGS_WHEN_STOCK_RUNS_LOW },\n]\n"]}
|