@fayz-ai/plugin-workforce 0.3.14 → 0.3.15
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/WorkforcePage-A64KM6IC.js +2133 -0
- package/dist/WorkforcePage-A64KM6IC.js.map +1 -0
- package/dist/chunk-6GWD6GQK.js +177 -0
- package/dist/chunk-6GWD6GQK.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -5440
- package/dist/index.js.map +1 -1
- package/dist/migrations/main.browser.js +14 -0
- package/dist/migrations/main.browser.js.map +1 -0
- package/dist/migrations/main.js +3141 -0
- package/dist/migrations/main.js.map +1 -0
- package/package.json +10 -4
|
@@ -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;AA66D/B,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;AAmKlD,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;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAoG5D,IAAM,wDAAA,GAA2D,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;AAgKjE,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;AA2KrD,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;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAyczD,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;AA2D5D,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;AA2D/D,IAAM,UAAA,GAAiD;AAAA,EAC5D,EAAE,EAAA,EAAI,cAAA,EAAgB,GAAA,EAAK,sBAAA,EAAuB;AAAA,EAClD,EAAE,EAAA,EAAI,iCAAA,EAAmC,GAAA,EAAK,yCAAA,EAA0C;AAAA,EACxF,EAAE,EAAA,EAAI,2CAAA,EAA6C,GAAA,EAAK,mDAAA,EAAoD;AAAA,EAC5G,EAAE,EAAA,EAAI,gDAAA,EAAkD,GAAA,EAAK,wDAAA,EAAyD;AAAA,EACtH,EAAE,EAAA,EAAI,oCAAA,EAAsC,GAAA,EAAK,4CAAA,EAA6C;AAAA,EAC9F,EAAE,EAAA,EAAI,wCAAA,EAA0C,GAAA,EAAK,gDAAA,EAAiD;AAAA,EACtG,EAAE,EAAA,EAAI,2CAAA,EAA6C,GAAA,EAAK,mDAAA,EAAoD;AAAA,EAC5G,EAAE,EAAA,EAAI,8CAAA,EAAgD,GAAA,EAAK,sDAAA;AAC7D","file":"main.js","sourcesContent":["// AUTO-GENERATED from 000_baseline.sql, 001_a_prova_da_batida_tem_bytes.sql, 002_o_guarda_da_foto_olhava_o_nome_errado.sql, 003_o_ponto_em_aberto_para_de_mentir_a_duracao.sql, 004_a_jornada_implausivel_nao_soma.sql, 005_a_ficha_trabalhista_tem_onde_morar.sql, 006_o_custo_de_pessoal_e_um_ramo_do_plano.sql, 007_a_porta_de_servico_do_ponto_fecha_tambem.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 = `-- O RH ganha ponto e escala.\n--\n-- D9 · Gente & Jornada de trabalho é o último domínio inteiro do V1 sem NENHUMA\n-- contraparte no V2. O registro de features do app diz isso feature por feature:\n-- \\`sch.templates\\` P0 missing, \\`sch.calendar\\` P0 missing (\"Nenhum módulo do SDK é\n-- dono disso hoje — precisa de uma decisão antes de código\"), \\`sch.clock\\` P1\n-- missing (\"O que existe é o horário PREVISTO de trabalho. A batida realizada,\n-- com ajuste auditado, não existe\"), \\`sch.clock.adjust\\` P1 missing. O host\n-- previsto para os quatro é o mesmo: **plugin-workforce**. Este arquivo é a\n-- camada de dados dele.\n--\n-- ── O que foi medido na origem (Pertinho do Céu, licença 8, 07/09/2026) ──────\n--\n-- Da documentação do V1 (\\`docs/v1-guide/restaurante/escalas/FLOW.md\\` e\n-- \\`docs/v1-guide/agenda/ponto-eletronico/FLOW.md\\`), tudo verificado na tela:\n--\n-- 325 registros de ponto no período — e TODOS os 325 marcados\n-- \"Fora da escala\" (V1-043). A coluna existe justamente para cruzar\n-- ponto × escala e nunca varia: o ponto está ativo em \\`PC Serviços\\`\n-- enquanto a operação e as escalas são de \\`Pertinho do Céu\\`.\n-- 20 batimentos EM ABERTO, incluindo 5 de 5 hoje e 8 de 16 ontem; um\n-- deles com entrada às 23:29 e \"13h07 (parcial)\".\n-- R$ 47.747 o valor do período — SUBESTIMADO, porque o V1 só calcula valor no\n-- fechamento do par, e 20 pares nunca fecharam.\n-- 103 profissionais habilitados para arrastar numa vaga de template.\n-- 7 templates de escala no tenant.\n--\n-- Três dos números acima são sintoma do MESMO defeito de modelo, e é ele que\n-- este arquivo existe para não repetir: no V1 a batida é um par entrada/saída\n-- numa única linha, com o ajuste como colunas \\`*_adjusted_at\\` por cima. Um par\n-- que nunca fecha não tem duração, não tem valor e não tem como ser conferido\n-- contra nada.\n--\n-- ── Duas decisões de arquitetura que o repositório já tomou ─────────────────\n--\n-- 1. *\"Ponto é razão append-only, como todo saldo — nunca uma coluna que alguém\n-- edita.\"* (\\`docs/migration/how-to-port.json\\`, mv.sch.clock: *\"Batida é\n-- append-only e ajuste é registro separado com autor e motivo — apagar\n-- batida é o que a lei não permite.\"*)\n--\n-- Por isso \\`plg_workforce_time_entries\\` é append-only de verdade — trigger\n-- que recusa UPDATE e DELETE, e as policies de UPDATE/DELETE derrubadas\n-- depois do scaffold — e o ajuste do V1 (\\`checkin_adjusted_at\\`,\n-- \\`checkout_adjusted_at\\`, \\`adjustment_reason\\`, \\`adjusted_by_*\\`) vira LINHA em\n-- \\`plg_workforce_time_adjustments\\`, com autor, motivo e carimbo. A própria\n-- tela do V1 já enuncia a regra que o V2 passa a garantir no banco: *\"Os\n-- horários reais dos batimentos são preservados.\"*\n--\n-- 2. *\"Escala de funcionário NÃO mora na agenda — quem oferta horário é o RH, a\n-- agenda só consome.\"* (registro de features, domínio D3.)\n--\n-- Por isso a escala é deste plugin, e a ponte com a agenda é UMA coluna:\n-- \\`plg_workforce_schedule_assignments.schedule_row_id\\`, que aponta para a\n-- linha de \\`public.schedules\\` que a publicação materializa.\n--\n-- ── O que este arquivo NÃO cria, porque já existe ───────────────────────────\n--\n-- O PREVISTO já está no V2. \\`public.schedules\\` (spine) tem exatamente a forma\n-- de uma oferta de horário — \\`kind\\`, \\`assignee_id\\` → \\`public.people\\`,\n-- \\`day_of_week\\`, \\`specific_date\\`, \\`starts_at\\`, \\`ends_at\\`, \\`unit_id\\` — e\n-- \\`public.schedule_kinds\\` é o registro de espécies dela (hoje: working_hours,\n-- exception, block, unclassified). Criar uma tabela de escala prevista aqui\n-- seria a segunda tabela de horário do cluster, que diverge da primeira na\n-- próxima mudança. Em vez disso este arquivo **semeia a espécie \\`work_shift\\`** e\n-- usa \\`public.schedules\\`.\n--\n-- \\`public.people\\` também já resolve sozinha o pior defeito do modelo do V1: lá o\n-- ponto aponta para \\`person_type ENUM('professional','employee')\\` + \\`person_id\\`,\n-- uma FK polimórfica para duas tabelas de cadastro que a própria tela mistura\n-- (\"A aba mistura Profissional e Funcionário no mesmo filtro e na mesma lista, e\n-- a diferença entre os dois cadastros não é explicada em lugar nenhum\"). No V2\n-- os dois já são \\`public.people\\` com papel em \\`public.person_roles\\`, então o par\n-- vira UMA FK \\`person_id uuid → public.people(id)\\`.\n--\n-- NÃO porta \\`schedule_grids\\` / \\`schedule_grid_blocks\\`: apesar do nome são grade\n-- de disponibilidade de AGENDAMENTO, não escala de trabalho. Ficam registradas\n-- em \\`migration.excluded_tables\\` com o motivo, que é mais barato que descobrir\n-- isso de novo daqui a seis meses.\n--\n-- ── As três lacunas do V1 que o V2 resolve aqui ─────────────────────────────\n--\n-- (a) FECHAMENTO DE COMPETÊNCIA. O V1 não tem nenhum. Qualquer ajuste pode\n-- mudar, em silêncio, um mês de horas já entregue ao contador e já pago —\n-- e a única trava que existe é de tela: \\`payable_invoice_id\\` preenchido\n-- esconde o botão \"Ajustar\". Uma trava de tela é uma trava até a primeira\n-- chamada de API.\n-- → \\`plg_workforce_periods\\`, no MESMO padrão que o financeiro já provou em\n-- \\`plugin-financial/src/migrations/006\\`: competência é o MÊS (data no dia\n-- 1º, por constraint), é do TENANT e não da unidade (um mês meio fechado é\n-- a pior resposta possível para quem precisa dizer que acabou), a escrita\n-- é só por RPC (\\`workforce_period_close\\` / \\`workforce_period_reopen\\`, sem\n-- policy de INSERT/UPDATE — uma policy de UPDATE seria a porta dos fundos\n-- da trava), reabrir exige motivo, e a permissão de fechar é própria\n-- (\\`workforce.close_period\\`): quem bate ponto não é quem fecha o mês.\n-- A trava vira trigger sobre a batida E sobre o ajuste, e o ajuste ganha\n-- a segunda trava do V1, agora no banco: batida já vinculada a um título\n-- a pagar não aceita ajuste.\n--\n-- (b) JORNADA PREVISTA × REALIZADA. No V1 elas nunca se encontram: a coluna\n-- \"Escala\" da lista de ponto existe para isso e marcou 325 de 325 registros\n-- como \"Fora da escala\".\n-- → O PREVISTO mora em \\`public.schedules\\` com \\`kind = 'work_shift'\\` (não em\n-- tabela nova — ver acima); o REALIZADO mora em\n-- \\`plg_workforce_time_entries\\`; e o cruzamento é a view\n-- \\`public.v_workforce_day\\`, um FULL OUTER JOIN de propósito, para que as\n-- duas assimetrias apareçam em vez de sumirem: previsto sem realizado é\n-- FALTA, realizado sem previsto é FORA DA ESCALA. A publicação da escala\n-- materializa a linha de \\`public.schedules\\` e guarda o id em\n-- \\`plg_workforce_schedule_assignments.schedule_row_id\\`, que é o que torna\n-- o cruzamento uma chave e não uma heurística de nome e horário.\n--\n-- (c) INTERVALO / ALMOÇO. O V1 tem UM par entrada/saída por ciclo. Quem sai para\n-- almoçar e volta ou não registra o intervalo, ou bate a saída e a entrada e\n-- o dia vira dois registros que ninguém soma. Num restaurante com turno\n-- partido isso é a jornada inteira.\n-- → A batida deixa de ser um PAR numa linha e passa a ser um EVENTO:\n-- \\`kind ∈ (clock_in, break_start, break_end, clock_out)\\`, N por dia,\n-- agrupados por \\`(person_id, work_date)\\` e ordenados por \\`seq\\`. A mesma\n-- decisão que satisfaz o append-only resolve o intervalo de graça, e ainda\n-- responde \"quais pontos estão em aberto?\" sem coluna derivada: em aberto\n-- é o dia cujos eventos de abertura (clock_in, break_end) não empatam com\n-- os de fechamento (clock_out, break_start).\n-- \\`work_date\\` é coluna, e não \\`punched_at::date\\`, porque o V1 já convive\n-- com o turno que cruza a meia-noite — a própria caixa de ajuste avisa:\n-- *\"útil quando o funcionário esqueceu de bater a saída e o registro\n-- cruzou para o dia seguinte\"*. A jornada da noite de sábado é de sábado.\n--\n-- ── Uma decisão a mais, tomada na fronteira da migração ─────────────────────\n--\n-- No molde do V1, titular vs stand-by é POSICIONAL: são titulares os primeiros\n-- \\`required_count\\` de \\`schedule_template_assignments\\` por \\`priority\\`, e o resto\n-- é reserva. Ninguém grava isso; é uma regra que vive no código da tela. Na\n-- instância o V1 já tem \\`role\\` como coluna de verdade. Aqui o molde também tem:\n-- \\`plg_workforce_schedule_template_assignments.role\\` é coluna, e o writer da\n-- migração CALCULA a posição uma vez, contra o \\`required_count\\` da função,\n-- em vez de deixar a regra escondida em cada leitor que vier depois.\n--\n-- Idempotente. Nada aqui apaga dado.\n\n-- A RPC de fechamento consulta a view de cruzamento, que nasce três seções\n-- depois dela — as duas moram onde o assunto está, não onde o carregador\n-- preferiria. Sessão, e é o que os baselines gerados do cluster já fazem.\nSET check_function_bodies = false;\n\n-- ═══════════════════════════════════════════════════════════════════════════\n-- 0. O plugin existe: as permissões\n-- ═══════════════════════════════════════════════════════════════════════════\n--\n-- O scaffold desenha as policies em cima de \\`<plugin>.read/create/edit/delete\\`\n-- e \\`<plugin>.read_all\\`. Sem essas chaves no catálogo, a policy compila e nega\n-- tudo para todo mundo — uma tabela protegida por não existir para ninguém.\n--\n-- Duas chaves são separadas de propósito, e as duas são segregação de função:\n-- \\`workforce.adjust_time\\` porque corrigir a batida de outra pessoa é outra\n-- ordem de decisão que editar uma escala, e \\`workforce.close_period\\` porque\n-- quem aponta não é quem fecha o mês.\nSELECT app.seed_permissions('[\n {\"key\": \"workforce.read\", \"plugin\": \"workforce\", \"description\": \"Ver escala, ponto e competência.\"},\n {\"key\": \"workforce.create\", \"plugin\": \"workforce\", \"description\": \"Criar escala, template e batida.\"},\n {\"key\": \"workforce.edit\", \"plugin\": \"workforce\", \"description\": \"Editar escala e template.\"},\n {\"key\": \"workforce.delete\", \"plugin\": \"workforce\", \"description\": \"Excluir escala e template.\"},\n {\"key\": \"workforce.manage\", \"plugin\": \"workforce\", \"description\": \"Administrar o módulo de jornada.\"},\n {\"key\": \"workforce.read_all\", \"plugin\": \"workforce\", \"description\": \"Ver a jornada de todo mundo, e não só a própria.\"},\n {\"key\": \"workforce.adjust_time\", \"plugin\": \"workforce\", \"description\": \"Ajustar batida de ponto. Separada de workforce.edit: corrigir a hora de outra pessoa é prova, não edição.\"},\n {\"key\": \"workforce.close_period\", \"plugin\": \"workforce\", \"description\": \"Fechar e reabrir a competência de jornada. Separada de workforce.edit: quem aponta não é quem fecha.\"}\n]'::jsonb);\n\n-- ═══════════════════════════════════════════════════════════════════════════\n-- 1. O PREVISTO já existe — só falta a espécie\n-- ═══════════════════════════════════════════════════════════════════════════\n--\n-- \\`working_hours\\` é a semana recorrente do PROFISSIONAL, para a agenda saber que\n-- um horário pode existir. \\`work_shift\\` é outra coisa: é o turno de trabalho que\n-- o RH ofertou para uma pessoa num dia, e é contra ele que o ponto é conferido.\n-- Reaproveitar \\`working_hours\\` misturaria \"posso ser agendado\" com \"estou\n-- escalado\", que é justamente a confusão que o D3 mandou não fazer.\nINSERT INTO public.schedule_kinds (kind, plugin, description, status)\nVALUES ('work_shift', 'workforce',\n 'O turno de trabalho que o RH escalou para uma pessoa — o PREVISTO contra o qual o ponto é conferido. Distinto de working_hours, que diz apenas quando um horário PODE existir na agenda.',\n 'declared')\nON CONFLICT (kind) DO UPDATE\n SET plugin = EXCLUDED.plugin, description = EXCLUDED.description, updated_at = now();\n\n-- \\`public.schedules\\` foi scaffoldada em modo \\`shadow\\` (065): as policies de\n-- autorização existem, mas as anteriores nunca foram derrubadas, e RLS soma\n-- policies em OR — uma policy legada sobrevivente alarga tudo silenciosamente.\n-- A partir do momento em que a escala do RH mora aqui dentro, sombra não serve:\n-- \\`scaffold_set_mode\\` reemite com os MESMOS resource_type/plugin já registrados\n-- (\\`agenda.schedule\\` / \\`agenda\\`) e derruba o que sobrou. Não é \\`scaffold_table\\`:\n-- repetir o scaffold aqui reclassificaria uma tabela do spine para dentro deste\n-- plugin, e a agenda perderia a própria mesa.\nDO $$\nBEGIN\n IF to_regclass('public.schedules') IS NOT NULL\n AND EXISTS (SELECT 1 FROM app.scaffold_registry\n WHERE table_schema = 'public' AND table_name = 'schedules' AND mode = 'shadow') THEN\n PERFORM app.scaffold_set_mode('public.schedules'::regclass, 'enforce');\n END IF;\nEND $$;\n\n-- ═══════════════════════════════════════════════════════════════════════════\n-- 2. O REALIZADO: a batida, append-only\n-- ═══════════════════════════════════════════════════════════════════════════\n\nCREATE TABLE IF NOT EXISTS public.plg_workforce_time_entries (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n unit_id uuid REFERENCES app.units(id),\n -- UMA fk, onde o V1 tem person_type + person_id. \\`public.people\\` já é o\n -- cadastro único; profissional e funcionário são papéis em person_roles.\n person_id uuid NOT NULL REFERENCES public.people(id) ON DELETE RESTRICT,\n -- O nome COMO ESTAVA na hora da batida. Não é desnormalização por preguiça: a\n -- folha de ponto é prova, e prova não pode mudar porque alguém corrigiu um\n -- cadastro dois anos depois.\n person_name text,\n -- O evento, não o par. É esta coluna que resolve o intervalo (c): o almoço é\n -- break_start + break_end, e o dia aceita quantos pares de intervalo houver.\n kind text NOT NULL\n CHECK (kind IN ('clock_in', 'break_start', 'break_end', 'clock_out')),\n punched_at timestamptz NOT NULL,\n -- O DIA DA JORNADA, que não é \\`punched_at::date\\`. O turno da noite de sábado\n -- que sai às 02:10 é jornada de sábado; o V1 já convive com isso e a própria\n -- caixa de ajuste dele avisa que o registro \"cruzou para o dia seguinte\".\n work_date date NOT NULL,\n -- Ordena os eventos do dia quando dois caem no mesmo instante (totem com\n -- clique duplo, importação em lote). Sem ele o \"último evento do dia\" é\n -- indeterminado, e é ele que diz se o ponto está em aberto.\n seq integer NOT NULL DEFAULT 1,\n origin text NOT NULL DEFAULT 'totem'\n CHECK (origin IN ('totem', 'device', 'manual', 'import', 'migration')),\n -- A prova que o totem produz: foto, coordenada com precisão, semelhança\n -- facial, IP e aparelho. Fica em jsonb e não em dez colunas porque é um\n -- conjunto de evidência de UM canal de captura — o totem com AWS Rekognition\n -- do V1 — e o próximo canal (crachá, biometria local) traz outro conjunto.\n -- O que não pode é perder: no V1 são \\`photo_path\\`, \\`lat\\`, \\`lng\\`, \\`accuracy_m\\`,\n -- \\`ip\\`, \\`user_agent\\` e \\`face_score\\`, medidos em campo como\n -- \"-22.89342, -43.39140 · ± 14m\" e \"Match facial 100.0%\".\n evidence jsonb NOT NULL DEFAULT '{}'::jsonb,\n -- O turno que esta batida cumpre, quando há um. Nulo é uma resposta legítima\n -- e frequente — é literalmente 325 de 325 no tenant medido — e o V2 prefere\n -- o nulo honesto a um vínculo inventado.\n assignment_id uuid,\n -- Onde esta hora custa. O turno traz o padrão (ver plg_workforce_schedule_shifts);\n -- a batida carrega o que de fato foi classificado, porque a hora do cozinheiro\n -- e a do garçom caem em centros de custo diferentes no mesmo dia.\n -- Sem FK inline: as três colunas abaixo apontam para tabelas de OUTRO plugin,\n -- e a jornada tem de poder ser instalada num cluster que não ligou o\n -- financeiro. A FK é declarada no fim do arquivo, condicionada à existência do\n -- destino — presente vira integridade, ausente vira só um uuid guardado.\n chart_account_id uuid,\n cost_center_id uuid,\n -- O \\`payable_invoice_id\\` do V1. No V1 ele é uma trava DE TELA (preenchido,\n -- o botão \"Ajustar\" some); aqui ele é uma trava de banco, pela trigger da\n -- seção 4. Hora já paga não se corrige sem reabrir a competência.\n payable_invoice_id uuid,\n notes text,\n metadata jsonb NOT NULL DEFAULT '{}'::jsonb,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n -- A mesma pessoa não bate o mesmo evento duas vezes no mesmo instante. É o\n -- que torna o replay da migração e o clique duplo no totem inofensivos.\n UNIQUE (tenant_id, person_id, work_date, kind, punched_at, seq)\n);\n\nCOMMENT ON TABLE public.plg_workforce_time_entries IS\n 'A batida, append-only: um EVENTO (entrada, início/fim de intervalo, saída), nunca um par editável. '\n 'UPDATE e DELETE são recusados por trigger; a correção é uma linha em plg_workforce_time_adjustments.';\n\n-- \"O que foi batido neste período, nesta unidade?\" — a pergunta da aba Registros\n-- de Ponto, que o V1 responde com filtros De/Até/Unidade/Pessoa.\nCREATE INDEX IF NOT EXISTS plg_workforce_time_entries_period_idx\n ON public.plg_workforce_time_entries (tenant_id, unit_id, work_date, punched_at);\n\n-- \"Quais pontos estão em aberto?\" — 20 no tenant medido. Em aberto não é coluna\n-- (seria estado derivado numa tabela append-only): é o dia em que os eventos de\n-- ABERTURA não empatam com os de fechamento. Este índice serve a metade que a\n-- view varre, e responde sozinho \"quem entrou hoje e onde\".\nCREATE INDEX IF NOT EXISTS plg_workforce_time_entries_open_idx\n ON public.plg_workforce_time_entries (tenant_id, person_id, work_date, seq)\n WHERE kind IN ('clock_in', 'break_end');\n\n-- \"O que esta pessoa bateu?\" — a folha individual, e o que a folha de pagamento\n-- soma no fim do mês.\nCREATE INDEX IF NOT EXISTS plg_workforce_time_entries_person_idx\n ON public.plg_workforce_time_entries (tenant_id, person_id, work_date DESC);\n\nDROP TRIGGER IF EXISTS plg_workforce_time_entries_updated_at ON public.plg_workforce_time_entries;\nCREATE TRIGGER plg_workforce_time_entries_updated_at\n BEFORE UPDATE ON public.plg_workforce_time_entries\n FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nGRANT SELECT, INSERT ON public.plg_workforce_time_entries TO authenticated;\nGRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_workforce_time_entries TO service_role;\n\n-- O scaffold é quem sabe desenhar a política deste cluster (tenant, unidade,\n-- permissão do plugin, escotilhas de compartilhamento). Escrever a policy à mão\n-- criaria a segunda regra de autorização da jornada, que diverge da primeira na\n-- próxima mudança do scaffold.\n--\n-- \\`p_owner_scoping = false\\`: a batida não tem dono no sentido de \"criada por\n-- mim\". Quem enxerga ponto de quem é \\`workforce.read\\` por unidade.\nSELECT app.scaffold_table('public.plg_workforce_time_entries'::regclass,\n 'workforce.time_entry', 'workforce', false, 'enforce');\n\n-- ── append-only, das duas formas ────────────────────────────────────────────\n--\n-- O scaffold acabou de criar policies de UPDATE e DELETE, porque é isso que ele\n-- faz em toda tabela. Aqui elas não deveriam existir: apagar batida é o que a\n-- lei não permite. As duas defesas são deliberadamente redundantes — a policy\n-- some para quem entra por \\`authenticated\\`, e a trigger pega também service_role\n-- e qualquer SECURITY DEFINER esquecido.\nDROP POLICY IF EXISTS plg_workforce_time_entries_authz_update ON public.plg_workforce_time_entries;\nDROP POLICY IF EXISTS plg_workforce_time_entries_authz_delete ON public.plg_workforce_time_entries;\n\nCREATE OR REPLACE FUNCTION public.plg_workforce_time_entries_append_only()\n RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $$\nBEGIN\n -- A escotilha é a mesma que o resto do cluster usa: durante uma onda de\n -- migração o motor marca a sessão, e só ela pode reescrever a linha que ela\n -- mesma acabou de trazer. Fora disso não há caminho.\n IF coalesce(current_setting('migration.writer', true), '') = 'on' THEN\n RETURN CASE TG_OP WHEN 'DELETE' THEN OLD ELSE NEW END;\n END IF;\n RAISE EXCEPTION 'plg_workforce_time_entries é append-only (% recusado) — corrija pelo ajuste, que é uma linha com autor e motivo', TG_OP\n USING ERRCODE = '42501',\n HINT = 'INSERT em plg_workforce_time_adjustments (action correct|insert|void) exige workforce.adjust_time.';\nEND $$;\n\nDROP TRIGGER IF EXISTS plg_workforce_time_entries_append_only ON public.plg_workforce_time_entries;\nCREATE TRIGGER plg_workforce_time_entries_append_only\n BEFORE UPDATE OR DELETE ON public.plg_workforce_time_entries\n FOR EACH ROW EXECUTE FUNCTION public.plg_workforce_time_entries_append_only();\n\n-- ═══════════════════════════════════════════════════════════════════════════\n-- 3. O AJUSTE é uma linha, não uma coluna\n-- ═══════════════════════════════════════════════════════════════════════════\n--\n-- O V1 guarda o ajuste como quatro colunas na própria batida\n-- (\\`checkin_adjusted_at\\`, \\`checkout_adjusted_at\\`, \\`adjustment_reason\\`,\n-- \\`adjusted_by_*\\`) — o que já é melhor que sobrescrever, e é a parte boa do\n-- modelo dele: *\"Os horários reais dos batimentos são preservados.\"* O que ele\n-- não tem é HISTÓRICO: o segundo ajuste apaga o primeiro, e ninguém consegue\n-- dizer quantas vezes aquela hora mudou nem por quem.\n--\n-- Três ações, porque o V1 faz as três e chama todas de \"ajustar\":\n-- correct — a hora é outra (o caso comum)\n-- insert — faltou a batida (o \"esqueceu de bater a saída\" do V1; a batida\n-- nova entra em plg_workforce_time_entries com origin 'manual' e\n-- esta linha é quem carrega o autor e o motivo dela)\n-- void — esta batida não conta (o clique duplo, o totem testado). Não é\n-- DELETE: a linha fica, marcada, porque sumir com prova é o que o\n-- append-only existe para impedir.\nCREATE TABLE IF NOT EXISTS public.plg_workforce_time_adjustments (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n unit_id uuid REFERENCES app.units(id),\n time_entry_id uuid NOT NULL\n REFERENCES public.plg_workforce_time_entries(id) ON DELETE CASCADE,\n action text NOT NULL CHECK (action IN ('correct', 'insert', 'void')),\n -- A hora que passa a valer. Obrigatória em correct/insert e sem sentido em\n -- void — a constraint diz isso em vez de deixar para a tela lembrar.\n effective_at timestamptz,\n -- No V1 o motivo é OPCIONAL (\"Motivo (opcional)\"), e é por isso que ninguém\n -- sabe por que 20 pontos foram mexidos. Aqui é NOT NULL: um ajuste sem motivo\n -- registrado é indistinguível de uma fraude bem-educada.\n reason text NOT NULL,\n adjusted_by_user_id uuid,\n -- O nome de quem ajustou, congelado — mesma razão de \\`person_name\\`.\n adjusted_by_name text,\n metadata jsonb NOT NULL DEFAULT '{}'::jsonb,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n CONSTRAINT plg_workforce_time_adjustments_effective_check\n CHECK ((action = 'void' AND effective_at IS NULL) OR (action <> 'void' AND effective_at IS NOT NULL)),\n CONSTRAINT plg_workforce_time_adjustments_reason_check\n CHECK (btrim(reason) <> '')\n);\n\nCOMMENT ON TABLE public.plg_workforce_time_adjustments IS\n 'O ajuste de ponto como LINHA, com autor e motivo obrigatório. O V1 guarda isso em colunas da própria batida e perde o histórico no segundo ajuste; aqui cada correção fica, e a hora que vale é a da última não anulada.';\n\n-- \"Qual a hora que vale nesta batida?\" — a view da seção 7 pergunta isso uma vez\n-- por batida, e é o caminho quente da folha de ponto inteira.\nCREATE INDEX IF NOT EXISTS plg_workforce_time_adjustments_entry_idx\n ON public.plg_workforce_time_adjustments (time_entry_id, created_at DESC);\n\nDROP TRIGGER IF EXISTS plg_workforce_time_adjustments_updated_at ON public.plg_workforce_time_adjustments;\nCREATE TRIGGER plg_workforce_time_adjustments_updated_at\n BEFORE UPDATE ON public.plg_workforce_time_adjustments\n FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nGRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_workforce_time_adjustments TO authenticated, service_role;\n\nSELECT app.scaffold_table('public.plg_workforce_time_adjustments'::regclass,\n 'workforce.time_adjustment', 'workforce', false, 'enforce');\n\n-- ═══════════════════════════════════════════════════════════════════════════\n-- 4. A COMPETÊNCIA (lacuna a)\n-- ═══════════════════════════════════════════════════════════════════════════\n--\n-- Mesmo desenho de \\`plg_financial_periods\\` (plugin-financial/006), de\n-- propósito: a empresa fecha UM mês, e ter dois calendários de fechamento com\n-- regras diferentes — um do dinheiro e outro das horas — é como não ter\n-- fechamento nenhum, porque a pergunta \"fevereiro está fechado?\" passa a ter\n-- duas respostas.\nCREATE TABLE IF NOT EXISTS public.plg_workforce_periods (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n -- A competência é do TENANT, não da unidade: fechar o mês é um ato de\n -- empresa, e um mês meio fechado é a pior resposta para quem precisa dizer ao\n -- contador que acabou. A coluna existe porque o scaffold precisa dela.\n unit_id uuid REFERENCES app.units(id),\n -- Sempre o dia 1º, por constraint e não por convenção de quem escreve.\n competence_month date NOT NULL,\n status text NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'closed')),\n closed_at timestamptz,\n closed_by_user_id uuid,\n reopened_at timestamptz,\n reopened_by_user_id uuid,\n -- Reabrir sem dizer por quê é como não ter fechado. Obrigatório na RPC; a\n -- coluna aceita nulo porque um período nunca reaberto não tem um.\n reopen_reason text,\n -- O que o fechamento virou no financeiro, quando virou. A folha é uma conta a\n -- pagar como outra qualquer, e a classificação dela mora aqui porque é o\n -- fechamento — não a batida — que produz o lançamento.\n payable_invoice_id uuid,\n chart_account_id uuid,\n cost_center_id uuid,\n notes text,\n metadata jsonb NOT NULL DEFAULT '{}'::jsonb,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n CONSTRAINT plg_workforce_periods_first_of_month_check\n CHECK (competence_month = date_trunc('month', competence_month)::date),\n CONSTRAINT plg_workforce_periods_tenant_month_key UNIQUE (tenant_id, competence_month)\n);\n\nCOMMENT ON TABLE public.plg_workforce_periods IS\n 'Competência mensal da jornada e seu estado. Fechada = nenhuma batida nem ajuste entra com data naquele mês. Escrita só por workforce_period_close/reopen.';\n\n-- A trava consulta \"este mês está fechado?\" a cada batida: índice parcial.\nCREATE INDEX IF NOT EXISTS plg_workforce_periods_closed_idx\n ON public.plg_workforce_periods (tenant_id, competence_month) WHERE status = 'closed';\n\nDROP TRIGGER IF EXISTS plg_workforce_periods_updated_at ON public.plg_workforce_periods;\nCREATE TRIGGER plg_workforce_periods_updated_at\n BEFORE UPDATE ON public.plg_workforce_periods\n FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nGRANT SELECT ON public.plg_workforce_periods TO authenticated;\nGRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_workforce_periods TO service_role;\n\nSELECT app.scaffold_table('public.plg_workforce_periods'::regclass,\n 'workforce.period', 'workforce', false, 'enforce');\n\n-- ESCREVER É SÓ PELA RPC. Fechar e reabrir são ATOS, não edições de linha, e o\n-- ato é onde a permissão e o registro de QUEM moram. Uma policy de UPDATE aqui\n-- seria a porta dos fundos da trava — é a mesma decisão do financeiro/006.\nDROP POLICY IF EXISTS plg_workforce_periods_authz_insert ON public.plg_workforce_periods;\nDROP POLICY IF EXISTS plg_workforce_periods_authz_update ON public.plg_workforce_periods;\nDROP POLICY IF EXISTS plg_workforce_periods_authz_delete ON public.plg_workforce_periods;\n\nCREATE OR REPLACE FUNCTION public.plg_workforce_period_is_closed(p_tenant uuid, p_date date)\n RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO ''\nAS $$\n SELECT EXISTS (\n SELECT 1 FROM public.plg_workforce_periods p\n WHERE p.tenant_id = p_tenant\n AND p.status = 'closed'\n AND p.competence_month = date_trunc('month', p_date)::date);\n$$;\n\nCOMMENT ON FUNCTION public.plg_workforce_period_is_closed(uuid, date) IS\n 'O mês desta data está fechado para a jornada deste tenant?';\n\n-- ── a trava sobre a batida ──────────────────────────────────────────────────\nCREATE OR REPLACE FUNCTION public.trg_workforce_refuse_closed_period()\n RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $$\nDECLARE\n v_date date;\nBEGIN\n -- Uma onda de migração traz o passado inteiro, e o passado inteiro está em\n -- meses que o V1 já tratou como encerrados. Fechar por cima do que ainda está\n -- chegando seria fechar antes de existir.\n IF coalesce(current_setting('migration.writer', true), '') = 'on' THEN\n RETURN NEW;\n END IF;\n\n -- A data que conta é a da JORNADA, não a de criação da linha.\n IF TG_TABLE_NAME = 'plg_workforce_time_entries' THEN\n v_date := NEW.work_date;\n ELSE\n SELECT e.work_date INTO v_date\n FROM public.plg_workforce_time_entries e WHERE e.id = NEW.time_entry_id;\n END IF;\n\n IF v_date IS NOT NULL AND public.plg_workforce_period_is_closed(NEW.tenant_id, v_date) THEN\n RAISE EXCEPTION 'a competência de % está fechada — reabra o período para lançar nela', to_char(v_date, 'MM/YYYY')\n USING ERRCODE = '23514',\n HINT = 'workforce_period_reopen(date_trunc(''month'', data)::date, ''motivo'') exige workforce.close_period.';\n END IF;\n\n RETURN NEW;\nEND $$;\n\nDROP TRIGGER IF EXISTS plg_workforce_time_entries_refuse_closed_period ON public.plg_workforce_time_entries;\nCREATE TRIGGER plg_workforce_time_entries_refuse_closed_period\n BEFORE INSERT ON public.plg_workforce_time_entries\n FOR EACH ROW EXECUTE FUNCTION public.trg_workforce_refuse_closed_period();\n\nDROP TRIGGER IF EXISTS plg_workforce_time_adjustments_refuse_closed_period ON public.plg_workforce_time_adjustments;\nCREATE TRIGGER plg_workforce_time_adjustments_refuse_closed_period\n BEFORE INSERT OR UPDATE ON public.plg_workforce_time_adjustments\n FOR EACH ROW EXECUTE FUNCTION public.trg_workforce_refuse_closed_period();\n\n-- ── a segunda trava do V1, agora no banco ───────────────────────────────────\n--\n-- No V1, batida com \\`payable_invoice_id\\` preenchido não pode ser ajustada — e a\n-- regra vive no componente que esconde o botão. Aqui ela vive onde não dá para\n-- contornar. A linha migrada é coberta pela mesma trava: o V1 aponta para uma\n-- tabela de títulos cuja contraparte no V2 não é a mesma coisa (ver a nota do\n-- de-para), então a referência crua fica em \\`metadata.v1.payable_invoice_id\\` — e\n-- basta ela existir para a hora estar travada.\nCREATE OR REPLACE FUNCTION public.trg_workforce_refuse_settled_adjustment()\n RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $$\nDECLARE\n v_entry public.plg_workforce_time_entries%ROWTYPE;\nBEGIN\n IF coalesce(current_setting('migration.writer', true), '') = 'on' THEN\n RETURN NEW;\n END IF;\n SELECT * INTO v_entry FROM public.plg_workforce_time_entries WHERE id = NEW.time_entry_id;\n IF v_entry.payable_invoice_id IS NOT NULL\n OR (v_entry.metadata #>> '{v1,payable_invoice_id}') IS NOT NULL THEN\n RAISE EXCEPTION 'esta batida já virou título a pagar — a hora paga não se corrige sem estornar o título'\n USING ERRCODE = '23514';\n END IF;\n IF NOT app.has_permission('workforce.adjust_time', v_entry.unit_id) THEN\n RAISE EXCEPTION 'ajustar ponto exige workforce.adjust_time' USING ERRCODE = '42501';\n END IF;\n RETURN NEW;\nEND $$;\n\nDROP TRIGGER IF EXISTS plg_workforce_time_adjustments_refuse_settled ON public.plg_workforce_time_adjustments;\nCREATE TRIGGER plg_workforce_time_adjustments_refuse_settled\n BEFORE INSERT ON public.plg_workforce_time_adjustments\n FOR EACH ROW EXECUTE FUNCTION public.trg_workforce_refuse_settled_adjustment();\n\n-- ── fechar e reabrir ────────────────────────────────────────────────────────\nCREATE OR REPLACE FUNCTION public.workforce_period_close(p_month date, p_notes text DEFAULT NULL)\n RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $$\nDECLARE\n v_tenant uuid := app.current_tenant_id();\n v_month date := date_trunc('month', p_month)::date;\n v_open bigint;\n v_row public.plg_workforce_periods%ROWTYPE;\nBEGIN\n IF v_tenant IS NULL THEN\n RAISE EXCEPTION 'workforce_period_close: sem tenant na sessão' USING ERRCODE = '42501';\n END IF;\n IF NOT app.has_permission('workforce.close_period', NULL) THEN\n RAISE EXCEPTION 'workforce_period_close: exige workforce.close_period' USING ERRCODE = '42501';\n END IF;\n IF v_month > date_trunc('month', current_date)::date THEN\n RAISE EXCEPTION 'workforce_period_close: % ainda não terminou', to_char(v_month, 'MM/YYYY')\n USING ERRCODE = '22023';\n END IF;\n\n -- Ponto em aberto é jornada que ainda vai mudar. Fechar por cima produz um\n -- total de horas que muda depois de fechado, que é exatamente o que a trava\n -- existe para impedir — e é o defeito medido no V1: 20 batimentos em aberto\n -- fazem o valor do período (R$ 47.747) ser um número menor que o real.\n SELECT count(*) INTO v_open\n FROM public.v_workforce_day d\n WHERE d.tenant_id = v_tenant\n AND d.is_open\n AND date_trunc('month', d.work_date)::date <= v_month;\n IF v_open > 0 THEN\n RAISE EXCEPTION 'workforce_period_close: % ponto(s) em aberto em % ou antes — feche ou ajuste antes', v_open, to_char(v_month, 'MM/YYYY')\n USING ERRCODE = '23514',\n HINT = 'SELECT * FROM public.v_workforce_day WHERE is_open;';\n END IF;\n\n INSERT INTO public.plg_workforce_periods (tenant_id, competence_month, status, closed_at, closed_by_user_id, notes)\n VALUES (v_tenant, v_month, 'closed', now(), auth.uid(), p_notes)\n ON CONFLICT (tenant_id, competence_month) DO UPDATE\n SET status = 'closed', closed_at = now(), closed_by_user_id = auth.uid(),\n notes = coalesce(EXCLUDED.notes, public.plg_workforce_periods.notes), updated_at = now()\n RETURNING * INTO v_row;\n\n RETURN jsonb_build_object('period_id', v_row.id, 'competence_month', v_month,\n 'status', 'closed', 'closed_at', v_row.closed_at);\nEND $$;\n\nCREATE OR REPLACE FUNCTION public.workforce_period_reopen(p_month date, p_reason text)\n RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $$\nDECLARE\n v_tenant uuid := app.current_tenant_id();\n v_month date := date_trunc('month', p_month)::date;\n v_row public.plg_workforce_periods%ROWTYPE;\nBEGIN\n IF v_tenant IS NULL THEN\n RAISE EXCEPTION 'workforce_period_reopen: sem tenant na sessão' USING ERRCODE = '42501';\n END IF;\n IF NOT app.has_permission('workforce.close_period', NULL) THEN\n RAISE EXCEPTION 'workforce_period_reopen: exige workforce.close_period' USING ERRCODE = '42501';\n END IF;\n IF nullif(btrim(coalesce(p_reason, '')), '') IS NULL THEN\n RAISE EXCEPTION 'workforce_period_reopen: o motivo é obrigatório' USING ERRCODE = '22023';\n END IF;\n\n UPDATE public.plg_workforce_periods SET\n status = 'open', reopened_at = now(), reopened_by_user_id = auth.uid(),\n reopen_reason = p_reason, updated_at = now()\n WHERE tenant_id = v_tenant AND competence_month = v_month\n RETURNING * INTO v_row;\n\n IF v_row.id IS NULL THEN\n RAISE EXCEPTION 'workforce_period_reopen: % nunca foi fechado', to_char(v_month, 'MM/YYYY')\n USING ERRCODE = 'P0002';\n END IF;\n\n RETURN jsonb_build_object('period_id', v_row.id, 'competence_month', v_month,\n 'status', 'open', 'reopened_at', v_row.reopened_at, 'reason', p_reason);\nEND $$;\n\nREVOKE ALL ON FUNCTION public.workforce_period_close(date, text) FROM PUBLIC;\nREVOKE ALL ON FUNCTION public.workforce_period_reopen(date, text) FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.workforce_period_close(date, text) TO authenticated;\nGRANT EXECUTE ON FUNCTION public.workforce_period_reopen(date, text) TO authenticated;\n\n-- ═══════════════════════════════════════════════════════════════════════════\n-- 5. A ESCALA: o molde\n-- ═══════════════════════════════════════════════════════════════════════════\n\nCREATE TABLE IF NOT EXISTS public.plg_workforce_schedule_templates (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n -- No V1 o template é POR UNIDADE (o editor abre com um seletor de Unidade), e\n -- aqui também: \"Almoço Seg. a Sex.\" da matriz não é o da filial.\n unit_id uuid REFERENCES app.units(id),\n name text NOT NULL,\n is_active boolean NOT NULL DEFAULT true,\n notes text,\n metadata jsonb NOT NULL DEFAULT '{}'::jsonb,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\n\nCOMMENT ON TABLE public.plg_workforce_schedule_templates IS\n 'O molde da escala. Pré-requisito duro no V1: sem template não se cria escala nenhuma — \"Selecione um template de escala acima para começar.\"';\n\nCREATE TABLE IF NOT EXISTS public.plg_workforce_schedule_shifts (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n unit_id uuid REFERENCES app.units(id),\n template_id uuid NOT NULL\n REFERENCES public.plg_workforce_schedule_templates(id) ON DELETE CASCADE,\n label text NOT NULL,\n start_time time NOT NULL,\n end_time time NOT NULL,\n -- 0 = domingo, como \\`extract(dow)\\` e como \\`public.schedules.day_of_week\\`.\n -- A constraint existe porque um 7 solto num array é um turno que nunca\n -- acontece e que ninguém percebe faltando.\n weekdays smallint[] NOT NULL DEFAULT '{}'::smallint[],\n -- Turno que vira o dia (16:00–02:00) é normal em restaurante, e é a razão de\n -- \\`work_date\\` ser coluna na batida. Guardar a travessia aqui evita que cada\n -- leitor redescubra \\`end_time < start_time\\` à sua maneira.\n crosses_midnight boolean NOT NULL GENERATED ALWAYS AS (end_time <= start_time) STORED,\n sort_order integer NOT NULL DEFAULT 0,\n -- O padrão de onde a hora deste turno custa. A batida copia e pode divergir.\n -- FK condicional no fim do arquivo, como nas outras duas tabelas.\n chart_account_id uuid,\n cost_center_id uuid,\n metadata jsonb NOT NULL DEFAULT '{}'::jsonb,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n CONSTRAINT plg_workforce_schedule_shifts_weekdays_check\n CHECK (weekdays <@ ARRAY[0,1,2,3,4,5,6]::smallint[])\n);\n\nCREATE TABLE IF NOT EXISTS public.plg_workforce_schedule_requirements (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n unit_id uuid REFERENCES app.units(id),\n shift_id uuid NOT NULL\n REFERENCES public.plg_workforce_schedule_shifts(id) ON DELETE CASCADE,\n -- Texto, e não FK para um cadastro de profissão: no V1 isto é o nome livre que\n -- a tela mostra (\"Garçom (1/1)\", \"Cozinha (0/3)\"), e o V2 ainda não tem o\n -- cadastro de profissão que seria o destino honesto. Inventar a FK agora seria\n -- criar um cadastro a partir de dado sujo; o texto atravessa e fica auditável.\n profession_name text NOT NULL,\n required_count integer NOT NULL DEFAULT 1 CHECK (required_count > 0),\n metadata jsonb NOT NULL DEFAULT '{}'::jsonb,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n UNIQUE (shift_id, profession_name)\n);\n\nCOMMENT ON COLUMN public.plg_workforce_schedule_requirements.required_count IS\n 'A lotação da função no turno — o denominador do \"Garçom (1/1)\" da tela. É contra ele que a posição por prioridade vira titular ou stand-by.';\n\nCREATE TABLE IF NOT EXISTS public.plg_workforce_schedule_template_assignments (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n unit_id uuid REFERENCES app.units(id),\n template_id uuid NOT NULL\n REFERENCES public.plg_workforce_schedule_templates(id) ON DELETE CASCADE,\n shift_id uuid NOT NULL\n REFERENCES public.plg_workforce_schedule_shifts(id) ON DELETE CASCADE,\n profession_name text NOT NULL,\n person_id uuid NOT NULL REFERENCES public.people(id) ON DELETE CASCADE,\n -- COLUNA, e não posição. No V1 titular vs stand-by no molde é implícito — são\n -- titulares os primeiros \\`required_count\\` por \\`priority\\` — e essa regra só\n -- existe no código da tela. Aqui ela é calculada uma vez, na entrada, e fica\n -- escrita: a mesma verdade que a instância já grava.\n role text NOT NULL DEFAULT 'titular' CHECK (role IN ('titular', 'standby')),\n priority integer NOT NULL DEFAULT 0,\n metadata jsonb NOT NULL DEFAULT '{}'::jsonb,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n UNIQUE (shift_id, profession_name, person_id)\n);\n\n-- ═══════════════════════════════════════════════════════════════════════════\n-- 6. A ESCALA: a instância do dia\n-- ═══════════════════════════════════════════════════════════════════════════\n\nCREATE TABLE IF NOT EXISTS public.plg_workforce_schedules (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n unit_id uuid REFERENCES app.units(id),\n schedule_date date NOT NULL,\n -- SET NULL e não CASCADE: apagar o molde não pode apagar a escala de terça\n -- passada, que é registro de quem trabalhou. A cópia do turno já está no\n -- snapshot da atribuição, então a escala sobrevive sozinha ao molde.\n template_id uuid REFERENCES public.plg_workforce_schedule_templates(id) ON DELETE SET NULL,\n status text NOT NULL DEFAULT 'draft'\n CHECK (status IN ('draft', 'published', 'closed')),\n published_at timestamptz,\n published_by_user_id uuid,\n is_special_day boolean NOT NULL DEFAULT false,\n special_day_label text,\n notes text,\n metadata jsonb NOT NULL DEFAULT '{}'::jsonb,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n -- Publicada sem carimbo de quando é uma escala que ninguém consegue provar\n -- ter avisado — e avisar é a única coisa que a publicação faz.\n CONSTRAINT plg_workforce_schedules_published_check\n CHECK (status <> 'published' OR published_at IS NOT NULL)\n);\n\n-- \"Qual a escala deste dia?\" — é o clique do calendário, e é a consulta que a\n-- tela faz primeiro em toda sessão.\nCREATE INDEX IF NOT EXISTS plg_workforce_schedules_day_idx\n ON public.plg_workforce_schedules (tenant_id, unit_id, schedule_date DESC, status);\n\nCREATE TABLE IF NOT EXISTS public.plg_workforce_schedule_assignments (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n unit_id uuid REFERENCES app.units(id),\n schedule_id uuid NOT NULL\n REFERENCES public.plg_workforce_schedules(id) ON DELETE CASCADE,\n -- A referência ao turno do molde é conveniência, não dependência: o snapshot\n -- abaixo é quem manda. Por isso SET NULL.\n shift_id uuid REFERENCES public.plg_workforce_schedule_shifts(id) ON DELETE SET NULL,\n -- SNAPSHOT, copiado na criação da escala, exatamente como o V1 faz. Mudar o\n -- molde não pode reescrever a escala de anteontem: o que a pessoa foi chamada\n -- para cumprir é um fato daquele dia, e é contra ESTE horário que o ponto dela\n -- é conferido.\n shift_label text,\n shift_start_time time,\n shift_end_time time,\n profession_name text,\n person_id uuid NOT NULL REFERENCES public.people(id) ON DELETE RESTRICT,\n role text NOT NULL DEFAULT 'titular' CHECK (role IN ('titular', 'standby')),\n priority integer NOT NULL DEFAULT 0,\n status text NOT NULL DEFAULT 'pending'\n CHECK (status IN ('pending', 'notified', 'confirmed', 'declined', 'realized')),\n -- O convite que sai por link, sem sessão. Único no tenant porque é uma\n -- credencial: dois convites com o mesmo token são duas pessoas confirmando\n -- pelo mesmo endereço.\n response_token text,\n response_channel text,\n notified_at timestamptz,\n responded_at timestamptz,\n -- A PONTE COM A AGENDA, e a razão de a escala não morar lá. O RH oferta; ao\n -- publicar, a atribuição materializa uma linha de \\`public.schedules\\` com\n -- kind 'work_shift' e a agenda consome. A coluna é o que torna o cruzamento\n -- previsto × realizado uma chave em vez de um casamento por nome e hora.\n schedule_row_id uuid REFERENCES public.schedules(id) ON DELETE SET NULL,\n metadata jsonb NOT NULL DEFAULT '{}'::jsonb,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n -- A mesma pessoa não ocupa a mesma vaga duas vezes no mesmo dia. No V1 nada\n -- impede, e o resultado é a lotação \"2/1\" que a tela não sabe desenhar.\n UNIQUE (schedule_id, shift_id, profession_name, person_id)\n);\n\nCREATE UNIQUE INDEX IF NOT EXISTS plg_workforce_schedule_assignments_token_uq\n ON public.plg_workforce_schedule_assignments (tenant_id, response_token)\n WHERE response_token IS NOT NULL;\n\n-- \"Quem está escalado hoje, e quem ainda não respondeu?\" — os contadores\n-- \"N titulares · N pendentes\" do cartão da escala.\nCREATE INDEX IF NOT EXISTS plg_workforce_schedule_assignments_schedule_idx\n ON public.plg_workforce_schedule_assignments (schedule_id, role, status);\n\n-- \"O que ESTA pessoa tem pela frente?\" — a folha individual e o convite.\nCREATE INDEX IF NOT EXISTS plg_workforce_schedule_assignments_person_idx\n ON public.plg_workforce_schedule_assignments (tenant_id, person_id, status);\n\n-- O log da atribuição: quem mudou o quê, por qual canal. No V1 é\n-- \\`schedule_assignment_logs\\`, e é a única parte do modelo de escala dele que já\n-- nasceu append-only.\nCREATE TABLE IF NOT EXISTS public.plg_workforce_assignment_logs (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n unit_id uuid REFERENCES app.units(id),\n assignment_id uuid NOT NULL\n REFERENCES public.plg_workforce_schedule_assignments(id) ON DELETE CASCADE,\n from_status text,\n to_status text NOT NULL,\n channel text,\n note text,\n changed_by_user_id uuid,\n metadata jsonb NOT NULL DEFAULT '{}'::jsonb,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\n\nCREATE INDEX IF NOT EXISTS plg_workforce_assignment_logs_assignment_idx\n ON public.plg_workforce_assignment_logs (assignment_id, created_at DESC);\n\n-- ── triggers, grants e scaffold das seis tabelas de escala ──────────────────\n--\n-- Em laço porque são seis vezes a mesma disciplina: o gatilho de updated_at, o\n-- GRANT para os dois papéis que existem, e o scaffold desenhando a policy. Seis\n-- blocos copiados divergiriam no primeiro dia em que alguém editasse um só.\nDO $$\nDECLARE\n r record;\nBEGIN\n FOR r IN\n SELECT * FROM (VALUES\n ('plg_workforce_schedule_templates', 'workforce.schedule_template'),\n ('plg_workforce_schedule_shifts', 'workforce.schedule_shift'),\n ('plg_workforce_schedule_requirements', 'workforce.schedule_requirement'),\n ('plg_workforce_schedule_template_assignments', 'workforce.schedule_template_assignment'),\n ('plg_workforce_schedules', 'workforce.schedule'),\n ('plg_workforce_schedule_assignments', 'workforce.schedule_assignment'),\n ('plg_workforce_assignment_logs', 'workforce.assignment_log')\n ) AS t(tbl, resource)\n LOOP\n EXECUTE format('DROP TRIGGER IF EXISTS %I ON public.%I', r.tbl || '_updated_at', r.tbl);\n EXECUTE format('CREATE TRIGGER %I BEFORE UPDATE ON public.%I FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at()',\n r.tbl || '_updated_at', r.tbl);\n EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON public.%I TO authenticated, service_role', r.tbl);\n PERFORM app.scaffold_table(format('public.%I', r.tbl)::regclass, r.resource, 'workforce', false, 'enforce');\n END LOOP;\nEND $$;\n\n-- ═══════════════════════════════════════════════════════════════════════════\n-- 7. O CRUZAMENTO previsto × realizado (lacuna b)\n-- ═══════════════════════════════════════════════════════════════════════════\n\n-- A hora que VALE em cada batida: a última correção não anulada, ou o horário\n-- real. É a leitura que substitui as colunas \\`*_adjusted_at\\` do V1 sem perder a\n-- propriedade que o V1 acertou — o horário real continua ali, intocado.\n--\n-- \\`security_invoker\\` em todas as views desta seção: uma folha de ponto que\n-- ignora escopo é um vazamento com totais.\n-- REPLAY: \\`DROP\\` e depois \\`CREATE\\`, e não \\`CREATE OR REPLACE\\`.\n--\n-- As migrations 003/004/005 ACRESCENTAM colunas a esta view. Acrescentar,\n-- \\`CREATE OR REPLACE\\` faz; TIRAR, não faz — e é o que a baseline faria numa\n-- segunda passada, porque a versão dela é a estreita. O cluster devolve\n-- \\`42P16: cannot drop columns from view\\` e a corrente para aqui.\n--\n-- Conferido em \\`pg_depend\\` antes de derrubar: nada pende destas duas views,\n-- então o \\`DROP\\` é sem \\`CASCADE\\` — de propósito. Sem ele, o dia em que\n-- alguém construir sobre elas o \\`DROP\\` falha alto em vez de levar junto.\n-- Na ordem da dependência: \\`v_workforce_day\\` é construída SOBRE esta, então\n-- sai primeiro. Sem isso o \\`DROP\\` de baixo recusa — e recusar é o certo,\n-- foi assim que esta dependência apareceu.\nDROP VIEW IF EXISTS public.v_workforce_day;\nDROP VIEW IF EXISTS public.v_workforce_time_entries;\nCREATE VIEW public.v_workforce_time_entries\nWITH (security_invoker = 'true') AS\nSELECT e.id,\n e.tenant_id,\n e.unit_id,\n e.person_id,\n e.person_name,\n e.kind,\n e.work_date,\n e.seq,\n e.origin,\n e.punched_at,\n coalesce(c.effective_at, e.punched_at) AS effective_at,\n (c.id IS NOT NULL) AS adjusted,\n (v.id IS NOT NULL) AS voided,\n c.reason AS adjustment_reason,\n c.adjusted_by_name,\n e.assignment_id,\n e.payable_invoice_id,\n e.evidence,\n e.metadata\n FROM public.plg_workforce_time_entries e\n LEFT JOIN LATERAL (\n SELECT a.id, a.effective_at, a.reason, a.adjusted_by_name\n FROM public.plg_workforce_time_adjustments a\n WHERE a.time_entry_id = e.id AND a.action IN ('correct', 'insert')\n ORDER BY a.created_at DESC, a.id DESC\n LIMIT 1) c ON true\n LEFT JOIN LATERAL (\n SELECT a.id FROM public.plg_workforce_time_adjustments a\n WHERE a.time_entry_id = e.id AND a.action = 'void'\n LIMIT 1) v ON true;\n\nCOMMENT ON VIEW public.v_workforce_time_entries IS\n 'A batida com a hora que vale: a última correção não anulada, ou o horário real. O real (punched_at) nunca some — é o que torna o ponto auditável.';\n\n-- O dia de trabalho de uma pessoa, previsto ao lado de realizado.\n--\n-- FULL OUTER JOIN de propósito. As duas assimetrias são exatamente o que o V1\n-- não consegue mostrar, e são as duas perguntas do RH: previsto sem realizado é\n-- FALTA; realizado sem previsto é FORA DA ESCALA (que no tenant medido é o\n-- estado de 325 de 325 registros — não porque as pessoas trabalhem fora da\n-- escala, mas porque o ponto está ligado numa unidade e a escala em outra).\n--\n-- As horas trabalhadas saem de uma soma com sinal: clock_out e break_start\n-- FECHAM um trecho, clock_in e break_end ABREM. Quantos intervalos houver no\n-- dia, a conta é a mesma — é isso que o par único do V1 não consegue fazer.\n-- REPLAY: \\`DROP\\` e depois \\`CREATE\\`, e não \\`CREATE OR REPLACE\\`.\n--\n-- As migrations 003/004/005 ACRESCENTAM colunas a esta view. Acrescentar,\n-- \\`CREATE OR REPLACE\\` faz; TIRAR, não faz — e é o que a baseline faria numa\n-- segunda passada, porque a versão dela é a estreita. O cluster devolve\n-- \\`42P16: cannot drop columns from view\\` e a corrente para aqui.\n--\n-- Conferido em \\`pg_depend\\` antes de derrubar: nada pende destas duas views,\n-- então o \\`DROP\\` é sem \\`CASCADE\\` — de propósito. Sem ele, o dia em que\n-- alguém construir sobre elas o \\`DROP\\` falha alto em vez de levar junto.\nDROP VIEW IF EXISTS public.v_workforce_day;\nCREATE VIEW public.v_workforce_day\nWITH (security_invoker = 'true') AS\nWITH realized AS (\n SELECT e.tenant_id,\n e.unit_id,\n e.person_id,\n e.work_date,\n min(e.effective_at) FILTER (WHERE e.kind = 'clock_in') AS first_in,\n max(e.effective_at) FILTER (WHERE e.kind = 'clock_out') AS last_out,\n count(*) FILTER (WHERE e.kind = 'break_start') AS break_count,\n count(*) FILTER (WHERE e.kind IN ('clock_in', 'break_end'))\n <> count(*) FILTER (WHERE e.kind IN ('clock_out', 'break_start')) AS is_open,\n sum(CASE\n WHEN e.kind IN ('clock_out', 'break_start') THEN extract(epoch FROM e.effective_at)\n WHEN e.kind IN ('clock_in', 'break_end') THEN -extract(epoch FROM e.effective_at)\n END) AS worked_seconds\n FROM public.v_workforce_time_entries e\n WHERE NOT e.voided\n GROUP BY e.tenant_id, e.unit_id, e.person_id, e.work_date\n),\nplanned AS (\n SELECT a.tenant_id,\n a.unit_id,\n a.person_id,\n s.schedule_date AS work_date,\n a.id AS assignment_id,\n a.role,\n a.status AS assignment_status,\n a.shift_label,\n a.shift_start_time,\n a.shift_end_time,\n -- O turno que vira o dia é normal aqui; sem o +24h a jornada da noite\n -- daria negativa.\n (extract(epoch FROM a.shift_end_time - a.shift_start_time)\n + CASE WHEN a.shift_end_time <= a.shift_start_time THEN 86400 ELSE 0 END) AS planned_seconds\n FROM public.plg_workforce_schedule_assignments a\n JOIN public.plg_workforce_schedules s ON s.id = a.schedule_id\n WHERE s.status IN ('published', 'closed')\n AND a.role = 'titular'\n AND a.shift_start_time IS NOT NULL\n AND a.shift_end_time IS NOT NULL\n)\nSELECT coalesce(p.tenant_id, r.tenant_id) AS tenant_id,\n coalesce(p.unit_id, r.unit_id) AS unit_id,\n coalesce(p.person_id, r.person_id) AS person_id,\n coalesce(p.work_date, r.work_date) AS work_date,\n p.assignment_id,\n p.shift_label,\n p.shift_start_time,\n p.shift_end_time,\n p.assignment_status,\n (p.planned_seconds / 60)::integer AS planned_minutes,\n (r.worked_seconds / 60)::integer AS worked_minutes,\n ((r.worked_seconds - p.planned_seconds) / 60)::integer AS variance_minutes,\n r.first_in,\n r.last_out,\n r.break_count,\n coalesce(r.is_open, false) AS is_open,\n -- As duas assimetrias, nomeadas. São as perguntas do RH, e nenhuma delas\n -- tem resposta no V1.\n (r.person_id IS NULL) AS absent,\n (p.assignment_id IS NULL) AS outside_schedule\n FROM planned p\n FULL OUTER JOIN realized r\n ON r.tenant_id = p.tenant_id\n AND r.person_id = p.person_id\n AND r.work_date = p.work_date;\n\nCOMMENT ON VIEW public.v_workforce_day IS\n 'Jornada prevista × realizada por pessoa e dia. Previsto vem da escala publicada, realizado das batidas com ajuste aplicado. FULL OUTER JOIN: \\`absent\\` é escala sem ponto, \\`outside_schedule\\` é ponto sem escala — as duas perguntas que o V1 não responde.';\n\nGRANT SELECT ON public.v_workforce_time_entries TO authenticated, service_role;\nGRANT SELECT ON public.v_workforce_day TO authenticated, service_role;\n\n-- ═══════════════════════════════════════════════════════════════════════════\n-- 8. OS WRITERS da migração\n-- ═══════════════════════════════════════════════════════════════════════════\n\n-- O vocabulário do de-para é lista fechada de propósito: uma linha que nomeia um\n-- writer inexistente falha na hora, não na terceira hora da onda.\n--\n-- ACRÉSCIMO, e não reemissão. A versão anterior reescrevia a lista INTEIRA —\n-- \"a lista até aqui (046) mais os oito da jornada\". Uma lista escrita por\n-- extenso é um retrato: ela apaga todo writer que outro passo registrou e que\n-- o retrato não viu. Medido no cluster vivo, re-aplicar esta baseline dava\n--\n-- 23514: check constraint \"allowlist_writer_check\" is violated by some row\n--\n-- porque lá já havia writers que esta lista não nomeia. É o mesmo defeito que\n-- \\`agenda/008\\` e \\`financial/072\\` já pagaram, e a forma abaixo é a de\n-- \\`inventory/004\\`: leia o que existe, acrescente o que falta, nunca reemita.\nDO $$\nDECLARE\n v_def text;\n v_falta text[];\n v_meus text[] := ARRAY[\n 'wf_time_entry', 'wf_schedule_template', 'wf_schedule_shift',\n 'wf_schedule_requirement', 'wf_schedule_template_assignment',\n 'wf_schedule', 'wf_schedule_assignment', 'wf_assignment_log'];\nBEGIN\n SELECT pg_get_constraintdef(oid) INTO v_def\n FROM pg_constraint WHERE conname = 'allowlist_writer_check';\n\n IF v_def IS NULL THEN\n -- Ninguém escreveu a regra ainda: aqui ela nasce, com os writers desta\n -- jornada e os que a corrente já registrou até este ponto.\n ALTER TABLE migration.allowlist ADD CONSTRAINT allowlist_writer_check\n CHECK (writer IS NULL OR writer = ANY (ARRAY[\n 'person', 'unit', 'category', 'product', 'order', 'invoice', 'price_table',\n 'price_table_item', 'package', 'package_item', 'field_rule', 'holiday', 'facet',\n 'fin_account', 'fin_payment_method_type', 'fin_payment_method', 'fin_card_brand',\n 'fin_chart_account', 'fin_cost_center', 'inv_measurement_unit', 'inv_unit_conversion',\n 'inv_location', 'inv_movement', 'order_item', 'fin_movement', 'authz_role',\n 'authz_role_permission', 'person_registration',\n 'fin_recurring_bill', 'fin_recurring_bill_item', 'fin_recurring_bill_occurrence',\n 'wf_time_entry', 'wf_schedule_template', 'wf_schedule_shift', 'wf_schedule_requirement',\n 'wf_schedule_template_assignment', 'wf_schedule', 'wf_schedule_assignment',\n 'wf_assignment_log']));\n RETURN;\n END IF;\n\n SELECT array_agg(w) INTO v_falta\n FROM unnest(v_meus) AS w\n WHERE strpos(v_def, '''' || w || '''') = 0;\n\n IF v_falta IS NULL THEN RETURN; END IF;\n\n -- Entram na frente do primeiro elemento: a posição não importa para um\n -- \\`= ANY\\`, e ancorar em \\`ARRAY[\\` dispensa conhecer qualquer valor da lista\n -- — que é justamente o que não se pode presumir.\n ALTER TABLE migration.allowlist DROP CONSTRAINT allowlist_writer_check;\n EXECUTE 'ALTER TABLE migration.allowlist ADD CONSTRAINT allowlist_writer_check '\n || replace(v_def, 'ARRAY[',\n 'ARRAY[' || (SELECT string_agg(quote_literal(w) || '::text, ', '')\n FROM unnest(v_falta) AS w));\nEND $$;\n\n-- ── o de-para da pessoa polimórfica ─────────────────────────────────────────\n--\n-- Este helper é a tradução de \\`person_type\\` + \\`person_id\\` para a FK única do V2,\n-- e existe uma vez porque quatro writers precisam dela. O ledger é a única fonte\n-- que sabe o de-para, e ele guarda a TABELA de origem — então 'professional' e\n-- 'employee' resolvem pelo mesmo caminho, e o writer não precisa conhecer\n-- nenhuma das duas.\nCREATE OR REPLACE FUNCTION migration._wf_person(p_tenant uuid, p_meta jsonb)\n RETURNS uuid LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO ''\nAS $$\n SELECT l.target_id\n FROM migration.ledger l\n WHERE l.tenant_id = p_tenant\n AND l.source_table = CASE WHEN (p_meta #>> '{v1,person_type}') = 'professional'\n THEN 'professionals' ELSE 'employees' END\n AND l.source_numeric_id = nullif(p_meta #>> '{v1,person_id}', '')::bigint\n AND l.target_table = 'public.people'\n AND l.status = 'migrated'\n LIMIT 1;\n$$;\n\nCOMMENT ON FUNCTION migration._wf_person(uuid, jsonb) IS\n 'person_type + person_id do V1 → o uuid de public.people. Duas tabelas de cadastro no V1, um cadastro só no V2.';\n\n-- Helper de migração lê o ledger inteiro do tenant: não é função de aplicação.\nREVOKE ALL ON FUNCTION migration._wf_person(uuid, jsonb) FROM PUBLIC, anon, authenticated;\n\n-- ── a batida ────────────────────────────────────────────────────────────────\n--\n-- UMA linha do V1 vira ATÉ QUATRO linhas do V2: a entrada, a saída (quando\n-- existe), e o ajuste de cada uma (quando \\`*_adjusted_at\\` está preenchido). É a\n-- tradução do par-com-colunas-de-ajuste para eventos-com-ajuste-em-linha.\n--\n-- Os ids derivados são determinísticos (md5 do id de origem + o sufixo do papel)\n-- para que reoferecer a mesma linha numa segunda passada ATUALIZE em vez de\n-- duplicar o dia de trabalho de alguém. \\`target_id\\` devolvido é o da ENTRADA:\n-- é a linha do V1 que ela representa.\nCREATE OR REPLACE FUNCTION migration.upsert_wf_time_entry(\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_person uuid;\n v_unit uuid := (p_row ->> 'unit_id')::uuid;\n v_in timestamptz := (p_row ->> 'checkin_at')::timestamptz;\n v_out timestamptz := (p_row ->> 'checkout_at')::timestamptz;\n v_in_adj timestamptz := (p_row ->> 'checkin_adjusted_at')::timestamptz;\n v_out_adj timestamptz := (p_row ->> 'checkout_adjusted_at')::timestamptz;\n v_work_date date;\n v_out_id uuid;\n v_reason text := coalesce(nullif(btrim(coalesce(p_row ->> 'adjustment_reason', '')), ''),\n 'ajuste importado do V1 sem motivo registrado');\n v_evidence jsonb;\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\n IF p_id IS NULL OR v_in IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state',\n 'reason', 'uma batida precisa de id e de hora de entrada');\n END IF;\n\n -- Recusar é o certo, e é temporário: a pessoa entra na mesma onda, e a segunda\n -- passada oferece a batida de novo. Inventar a pessoa aqui seria pendurar\n -- horas de trabalho — e portanto dinheiro — em quem não existe.\n v_person := migration._wf_person(p_tenant, v_meta);\n IF v_person IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',\n 'reason', format('batida de %s #%s cuja pessoa ainda não migrou',\n coalesce(v_meta #>> '{v1,person_type}', '<sem tipo>'),\n coalesce(v_meta #>> '{v1,person_id}', '<sem id>')));\n END IF;\n\n -- A jornada é do dia da ENTRADA, sempre — mesmo quando a saída é do dia\n -- seguinte. É a regra que o V1 admite na caixa de ajuste e nunca grava.\n v_work_date := coalesce((p_row ->> 'work_date')::date, v_in::date);\n v_out_id := md5(p_id::text || ':clock_out')::uuid;\n\n -- Todo o conjunto de prova do totem numa chave só. Perder qualquer um destes\n -- campos é perder a defesa de uma batida contestada.\n v_evidence := jsonb_strip_nulls(jsonb_build_object(\n 'photo_path', p_row ->> 'checkin_photo_path',\n 'lat', (p_row ->> 'checkin_lat')::numeric,\n 'lng', (p_row ->> 'checkin_lng')::numeric,\n 'accuracy_m', (p_row ->> 'checkin_accuracy_m')::numeric,\n 'ip', p_row ->> 'checkin_ip',\n 'user_agent', p_row ->> 'checkin_user_agent',\n 'face_score', (p_row ->> 'checkin_face_score')::numeric));\n\n IF NOT EXISTS (SELECT 1 FROM public.plg_workforce_time_entries WHERE id = p_id) THEN\n INSERT INTO public.plg_workforce_time_entries\n (id, tenant_id, unit_id, person_id, person_name, kind, punched_at, work_date, seq,\n origin, evidence, assignment_id, notes, metadata)\n VALUES (p_id, p_tenant, v_unit, v_person, p_row ->> 'person_name', 'clock_in', v_in,\n v_work_date, 1, 'migration', v_evidence,\n (p_row ->> 'assignment_id')::uuid, p_row ->> 'notes', v_meta);\n v_created := true;\n ELSE\n UPDATE public.plg_workforce_time_entries SET\n unit_id = coalesce(v_unit, unit_id),\n person_id = v_person,\n person_name = coalesce(p_row ->> 'person_name', person_name),\n punched_at = v_in,\n work_date = v_work_date,\n evidence = migration._merge_meta(evidence, v_evidence),\n assignment_id = coalesce((p_row ->> 'assignment_id')::uuid, assignment_id),\n notes = CASE WHEN p_row ? 'notes' THEN p_row ->> 'notes' ELSE notes END,\n metadata = migration._merge_meta(metadata, v_meta)\n WHERE id = p_id;\n END IF;\n\n -- A saída só existe quando existe. \\`checkout_at IS NULL\\` é o \"ponto em aberto\"\n -- do V1 — 20 deles no tenant medido — e ele atravessa como ausência de evento,\n -- que é o que ele é. Fabricar uma saída para \"fechar\" o registro inventaria\n -- horas trabalhadas.\n IF v_out IS NOT NULL THEN\n IF NOT EXISTS (SELECT 1 FROM public.plg_workforce_time_entries WHERE id = v_out_id) THEN\n INSERT INTO public.plg_workforce_time_entries\n (id, tenant_id, unit_id, person_id, person_name, kind, punched_at, work_date, seq,\n origin, evidence, assignment_id, metadata)\n VALUES (v_out_id, p_tenant, v_unit, v_person, p_row ->> 'person_name', 'clock_out', v_out,\n v_work_date, 2, 'migration',\n jsonb_strip_nulls(jsonb_build_object(\n 'photo_path', p_row ->> 'checkout_photo_path',\n 'lat', (p_row ->> 'checkout_lat')::numeric,\n 'lng', (p_row ->> 'checkout_lng')::numeric,\n 'accuracy_m', (p_row ->> 'checkout_accuracy_m')::numeric,\n 'ip', p_row ->> 'checkout_ip',\n 'user_agent', p_row ->> 'checkout_user_agent',\n 'face_score', (p_row ->> 'checkout_face_score')::numeric)),\n (p_row ->> 'assignment_id')::uuid,\n migration._deep_set(v_meta, ARRAY['migration', 'derived_from'], to_jsonb(p_id::text)));\n ELSE\n UPDATE public.plg_workforce_time_entries SET\n punched_at = v_out, work_date = v_work_date, person_id = v_person\n WHERE id = v_out_id;\n END IF;\n END IF;\n\n -- O ajuste vira LINHA. No V1 ele é um par de colunas por cima da batida, e o\n -- histórico morre no segundo ajuste; aqui cada correção fica, com autor e\n -- motivo. O motivo é opcional no V1 e obrigatório aqui — quando vem vazio, o\n -- texto diz exatamente isso, em vez de fingir que alguém justificou.\n IF v_in_adj IS NOT NULL THEN\n INSERT INTO public.plg_workforce_time_adjustments\n (id, tenant_id, unit_id, time_entry_id, action, effective_at, reason,\n adjusted_by_user_id, adjusted_by_name, metadata)\n VALUES (md5(p_id::text || ':adjust_in')::uuid, p_tenant, v_unit, p_id, 'correct',\n v_in_adj, v_reason,\n (p_row ->> 'adjusted_by_user_id')::uuid, p_row ->> 'adjusted_by_name',\n migration._deep_set(v_meta, ARRAY['migration', 'from'], to_jsonb('checkin_adjusted_at'::text)))\n ON CONFLICT (id) DO UPDATE\n SET effective_at = EXCLUDED.effective_at, reason = EXCLUDED.reason,\n adjusted_by_name = EXCLUDED.adjusted_by_name, updated_at = now();\n END IF;\n\n IF v_out_adj IS NOT NULL AND v_out IS NOT NULL THEN\n INSERT INTO public.plg_workforce_time_adjustments\n (id, tenant_id, unit_id, time_entry_id, action, effective_at, reason,\n adjusted_by_user_id, adjusted_by_name, metadata)\n VALUES (md5(p_id::text || ':adjust_out')::uuid, p_tenant, v_unit, v_out_id, 'correct',\n v_out_adj, v_reason,\n (p_row ->> 'adjusted_by_user_id')::uuid, p_row ->> 'adjusted_by_name',\n migration._deep_set(v_meta, ARRAY['migration', 'from'], to_jsonb('checkout_adjusted_at'::text)))\n ON CONFLICT (id) DO UPDATE\n SET effective_at = EXCLUDED.effective_at, reason = EXCLUDED.reason,\n adjusted_by_name = EXCLUDED.adjusted_by_name, updated_at = now();\n END IF;\n\n IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_workforce_time_entries', p_id, '{}'::jsonb); END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id,\n 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END,\n 'entries', CASE WHEN v_out IS NULL THEN 1 ELSE 2 END,\n 'open', v_out IS NULL);\nEND $function$;\n\n-- ── o molde ─────────────────────────────────────────────────────────────────\nCREATE OR REPLACE FUNCTION migration.upsert_wf_schedule_template(\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_name text := nullif(btrim(coalesce(p_row ->> 'name', '')), '');\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 OR v_name IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state',\n 'reason', 'um template de escala precisa de id e de nome — é por ele que a tela o oferece');\n END IF;\n\n IF NOT EXISTS (SELECT 1 FROM public.plg_workforce_schedule_templates WHERE id = p_id) THEN\n INSERT INTO public.plg_workforce_schedule_templates (id, tenant_id, unit_id, name, is_active, notes, metadata)\n VALUES (p_id, p_tenant, (p_row ->> 'unit_id')::uuid, v_name,\n coalesce((p_row ->> 'is_active')::boolean, true), p_row ->> 'notes', v_meta);\n v_created := true;\n ELSE\n UPDATE public.plg_workforce_schedule_templates SET\n unit_id = CASE WHEN p_row ? 'unit_id' THEN (p_row ->> 'unit_id')::uuid ELSE unit_id END,\n name = v_name,\n is_active = CASE WHEN p_row ? 'is_active' THEN coalesce((p_row ->> 'is_active')::boolean, true) ELSE is_active END,\n notes = CASE WHEN p_row ? 'notes' THEN p_row ->> 'notes' ELSE notes END,\n metadata = migration._merge_meta(metadata, v_meta)\n WHERE id = p_id;\n END IF;\n\n IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_workforce_schedule_templates', p_id, '{}'::jsonb); END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id,\n 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);\nEND $function$;\n\nCREATE OR REPLACE FUNCTION migration.upsert_wf_schedule_shift(\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_template uuid := (p_row ->> 'template_id')::uuid;\n v_start time := (p_row ->> 'start_time')::time;\n v_end time := (p_row ->> 'end_time')::time;\n v_days smallint[];\n v_bad smallint[];\n v_unit 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 OR v_template IS NULL OR v_start IS NULL OR v_end IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state',\n 'reason', 'um turno precisa de id, do template, de início e de fim');\n END IF;\n SELECT unit_id INTO v_unit FROM public.plg_workforce_schedule_templates\n WHERE id = v_template AND tenant_id = p_tenant;\n IF NOT FOUND THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',\n 'reason', format('o template %s não existe neste tenant', v_template));\n END IF;\n\n -- Sem correção silenciosa nos dias da semana. Um 7 solto (ou um 1..7 em vez de\n -- 0..6) é um turno que nunca acontece, e um turno que nunca acontece é uma\n -- pessoa que ninguém chamou — some sem erro nenhum.\n SELECT coalesce(array_agg(x::smallint ORDER BY x), '{}'::smallint[])\n INTO v_days\n FROM jsonb_array_elements_text(coalesce(p_row -> 'weekdays', '[]'::jsonb)) AS t(x);\n SELECT coalesce(array_agg(d), '{}'::smallint[]) INTO v_bad\n FROM unnest(v_days) AS d WHERE d < 0 OR d > 6;\n IF array_length(v_bad, 1) > 0 THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',\n 'reason', format('dia da semana fora de 0..6 (domingo..sábado): %s', v_bad::text));\n END IF;\n\n IF NOT EXISTS (SELECT 1 FROM public.plg_workforce_schedule_shifts WHERE id = p_id) THEN\n INSERT INTO public.plg_workforce_schedule_shifts\n (id, tenant_id, unit_id, template_id, label, start_time, end_time, weekdays, sort_order, metadata)\n VALUES (p_id, p_tenant, v_unit, v_template,\n coalesce(nullif(btrim(coalesce(p_row ->> 'label', '')), ''), 'Turno'),\n v_start, v_end, v_days, coalesce((p_row ->> 'sort_order')::integer, 0), v_meta);\n v_created := true;\n ELSE\n UPDATE public.plg_workforce_schedule_shifts SET\n unit_id = v_unit,\n label = coalesce(nullif(btrim(coalesce(p_row ->> 'label', '')), ''), label),\n start_time = v_start,\n end_time = v_end,\n weekdays = v_days,\n sort_order = CASE WHEN p_row ? 'sort_order' THEN coalesce((p_row ->> 'sort_order')::integer, sort_order) ELSE sort_order END,\n metadata = migration._merge_meta(metadata, v_meta)\n WHERE id = p_id;\n END IF;\n\n IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_workforce_schedule_shifts', p_id, '{}'::jsonb); END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id,\n 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END,\n 'crosses_midnight', v_end <= v_start);\nEND $function$;\n\nCREATE OR REPLACE FUNCTION migration.upsert_wf_schedule_requirement(\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_shift uuid := (p_row ->> 'shift_id')::uuid;\n v_prof text := nullif(btrim(coalesce(p_row ->> 'profession_name', '')), '');\n v_count integer := coalesce((p_row ->> 'required_count')::integer, 1);\n v_unit uuid;\n v_id uuid;\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 v_shift IS NULL OR v_prof IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state',\n 'reason', 'uma vaga precisa de id, do turno e do nome da função');\n END IF;\n SELECT unit_id INTO v_unit FROM public.plg_workforce_schedule_shifts\n WHERE id = v_shift AND tenant_id = p_tenant;\n IF NOT FOUND THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',\n 'reason', format('o turno %s não existe neste tenant', v_shift));\n END IF;\n -- Lotação zero ou negativa é vaga que não existe; e a constraint recusaria a\n -- linha inteira mais tarde, sem dizer qual era.\n IF v_count <= 0 THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',\n 'reason', format('lotação de %L é %s — uma vaga precisa de pelo menos uma pessoa', v_prof, v_count));\n END IF;\n\n -- Devolve o id da linha que de fato ficou: quando duas vagas do V1 nomeiam a\n -- mesma função no mesmo turno, a segunda funde na primeira, e o ledger tem de\n -- apontar para onde o dado foi parar — não para o id que ela trazia.\n INSERT INTO public.plg_workforce_schedule_requirements\n (id, tenant_id, unit_id, shift_id, profession_name, required_count, metadata)\n VALUES (p_id, p_tenant, v_unit, v_shift, v_prof, v_count, v_meta)\n ON CONFLICT (shift_id, profession_name) DO UPDATE\n SET required_count = EXCLUDED.required_count,\n metadata = migration._merge_meta(public.plg_workforce_schedule_requirements.metadata, EXCLUDED.metadata),\n updated_at = now()\n RETURNING id INTO v_id;\n\n IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_workforce_schedule_requirements', v_id, '{}'::jsonb); END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', v_id,\n 'reason', CASE WHEN v_id = p_id THEN 'upserted' ELSE 'fundida na vaga que já existia para esta função neste turno' END);\nEND $function$;\n\n-- No molde do V1, titular vs stand-by é POSICIONAL — os primeiros\n-- \\`required_count\\` por \\`priority\\`. Este writer faz essa conta UMA vez, na\n-- entrada, e grava o resultado em \\`role\\`. Deixar a regra implícita no V2 seria\n-- repor um comportamento que só existe no código de uma tela, e que a próxima\n-- tela a ler a mesma tabela erraria em silêncio.\nCREATE OR REPLACE FUNCTION migration.upsert_wf_schedule_template_assignment(\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_shift uuid := (p_row ->> 'shift_id')::uuid;\n v_prof text := nullif(btrim(coalesce(p_row ->> 'profession_name', '')), '');\n v_priority integer := coalesce((p_row ->> 'priority')::integer, 0);\n v_person uuid;\n v_template uuid;\n v_unit uuid;\n v_required integer;\n v_rank integer;\n v_role 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 IF p_id IS NULL OR v_shift IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state',\n 'reason', 'uma alocação de molde precisa de id e do turno');\n END IF;\n\n -- No molde a pessoa é sempre \\`professionals\\` — o V1 não escala funcionário\n -- pelo template — mas o helper resolve pelo que o metadata disser, e não pelo\n -- que este writer supõe.\n v_person := migration._wf_person(p_tenant,\n CASE WHEN (v_meta #>> '{v1,person_type}') IS NULL\n THEN migration._deep_set(v_meta, ARRAY['v1', 'person_type'], to_jsonb('professional'::text))\n ELSE v_meta END);\n IF v_person IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',\n 'reason', format('alocação de molde cuja pessoa ainda não migrou (%s #%s)',\n coalesce(v_meta #>> '{v1,person_type}', 'professional'),\n coalesce(v_meta #>> '{v1,person_id}', '<sem id>')));\n END IF;\n\n SELECT s.template_id, s.unit_id INTO v_template, v_unit\n FROM public.plg_workforce_schedule_shifts s\n WHERE s.id = v_shift AND s.tenant_id = p_tenant;\n IF NOT FOUND THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',\n 'reason', format('o turno %s não existe neste tenant', v_shift));\n END IF;\n\n INSERT INTO public.plg_workforce_schedule_template_assignments\n (id, tenant_id, unit_id, template_id, shift_id, profession_name, person_id, role, priority, metadata)\n VALUES (p_id, p_tenant, v_unit, v_template, v_shift, coalesce(v_prof, '—'), v_person,\n 'titular', v_priority, v_meta)\n ON CONFLICT (shift_id, profession_name, person_id) DO UPDATE\n SET priority = EXCLUDED.priority,\n metadata = migration._merge_meta(public.plg_workforce_schedule_template_assignments.metadata, EXCLUDED.metadata),\n updated_at = now();\n\n -- A POSIÇÃO vira PAPEL. Recalculada a cada linha porque a ordem muda quando a\n -- próxima chega: a quarta pessoa de uma vaga de três rebaixa ninguém, mas\n -- entra como reserva, e a sétima também.\n SELECT r.required_count INTO v_required\n FROM public.plg_workforce_schedule_requirements r\n WHERE r.shift_id = v_shift AND r.profession_name = coalesce(v_prof, '—');\n\n SELECT rank INTO v_rank FROM (\n SELECT a.id, row_number() OVER (ORDER BY a.priority, a.created_at, a.id) AS rank\n FROM public.plg_workforce_schedule_template_assignments a\n WHERE a.shift_id = v_shift AND a.profession_name = coalesce(v_prof, '—')) s\n WHERE s.id = p_id;\n\n -- Sem vaga declarada não há denominador, e chutar um faria a pessoa virar\n -- titular ou reserva por acaso. \\`titular\\` é o que o V1 mostra nesse caso.\n v_role := CASE WHEN v_required IS NULL THEN 'titular'\n WHEN v_rank <= v_required THEN 'titular'\n ELSE 'standby' END;\n UPDATE public.plg_workforce_schedule_template_assignments SET role = v_role WHERE id = p_id;\n\n IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_workforce_schedule_template_assignments', p_id, '{}'::jsonb); END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'role', v_role,\n 'reason', format('posição %s de %s vaga(s) → %s', v_rank, coalesce(v_required::text, 'sem lotação declarada'), v_role));\nEND $function$;\n\n-- ── a instância ─────────────────────────────────────────────────────────────\nCREATE OR REPLACE FUNCTION migration.upsert_wf_schedule(\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_date date := (p_row ->> 'schedule_date')::date;\n v_status_v1 text := nullif(btrim(lower(coalesce(p_row ->> 'status', ''))), '');\n v_status text;\n v_published timestamptz := (p_row ->> 'published_at')::timestamptz;\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 OR v_date IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state',\n 'reason', 'uma escala precisa de id e do dia a que pertence');\n END IF;\n\n v_status := CASE v_status_v1\n WHEN 'draft' THEN 'draft'\n WHEN 'published' THEN 'published'\n WHEN 'closed' THEN 'closed'\n ELSE NULL END;\n IF v_status IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',\n 'reason', format('estado de escala desconhecido: %L (o V2 conhece draft, published e closed)', v_status_v1));\n END IF;\n\n -- Publicada sem carimbo é o rascunho do V1 que alguém marcou como publicado\n -- sem a ação que grava a data. A constraint recusaria; aqui a data de\n -- publicação vira a do próprio dia da escala, e o remendo fica escrito.\n IF v_status <> 'draft' AND v_published IS NULL THEN\n v_published := v_date::timestamptz;\n v_meta := migration._deep_set(v_meta, ARRAY['migration', 'published_at_inferred'], to_jsonb(true));\n END IF;\n\n IF NOT EXISTS (SELECT 1 FROM public.plg_workforce_schedules WHERE id = p_id) THEN\n INSERT INTO public.plg_workforce_schedules\n (id, tenant_id, unit_id, schedule_date, template_id, status, published_at,\n is_special_day, special_day_label, notes, metadata)\n VALUES (p_id, p_tenant, (p_row ->> 'unit_id')::uuid, v_date,\n (p_row ->> 'template_id')::uuid, v_status, v_published,\n coalesce((p_row ->> 'is_special_day')::boolean, false),\n p_row ->> 'special_day_label', p_row ->> 'notes', v_meta);\n v_created := true;\n ELSE\n UPDATE public.plg_workforce_schedules SET\n unit_id = CASE WHEN p_row ? 'unit_id' THEN (p_row ->> 'unit_id')::uuid ELSE unit_id END,\n schedule_date = v_date,\n template_id = CASE WHEN p_row ? 'template_id' THEN (p_row ->> 'template_id')::uuid ELSE template_id END,\n status = v_status,\n published_at = v_published,\n is_special_day = CASE WHEN p_row ? 'is_special_day' THEN coalesce((p_row ->> 'is_special_day')::boolean, false) ELSE is_special_day END,\n special_day_label = CASE WHEN p_row ? 'special_day_label' THEN p_row ->> 'special_day_label' ELSE special_day_label END,\n notes = CASE WHEN p_row ? 'notes' THEN p_row ->> 'notes' ELSE notes END,\n metadata = migration._merge_meta(metadata, v_meta)\n WHERE id = p_id;\n END IF;\n\n IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_workforce_schedules', p_id, '{}'::jsonb); END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id,\n 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);\nEND $function$;\n\nCREATE OR REPLACE FUNCTION migration.upsert_wf_schedule_assignment(\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_schedule uuid := (p_row ->> 'schedule_id')::uuid;\n v_role_v1 text := nullif(btrim(lower(coalesce(p_row ->> 'role', ''))), '');\n v_status_v1 text := nullif(btrim(lower(coalesce(p_row ->> 'status', ''))), '');\n v_role text;\n v_status text;\n v_person uuid;\n v_unit uuid;\n v_start time := (p_row ->> 'shift_start_time')::time;\n v_end time := (p_row ->> 'shift_end_time')::time;\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 OR v_schedule IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state',\n 'reason', 'uma atribuição precisa de id e da escala a que pertence');\n END IF;\n SELECT unit_id INTO v_unit FROM public.plg_workforce_schedules\n WHERE id = v_schedule AND tenant_id = p_tenant;\n IF NOT FOUND THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',\n 'reason', format('a escala %s não existe neste tenant', v_schedule));\n END IF;\n\n v_person := migration._wf_person(p_tenant,\n CASE WHEN (v_meta #>> '{v1,person_type}') IS NULL\n THEN migration._deep_set(v_meta, ARRAY['v1', 'person_type'], to_jsonb('professional'::text))\n ELSE v_meta END);\n IF v_person IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',\n 'reason', format('atribuição de escala cuja pessoa ainda não migrou (%s #%s)',\n coalesce(v_meta #>> '{v1,person_type}', 'professional'),\n coalesce(v_meta #>> '{v1,person_id}', '<sem id>')));\n END IF;\n\n -- Stand-by NÃO é folga: é o substituto acionável, e a própria tela do V1 diz\n -- (\"Substitutos em caso de falta\"). Confundir os dois na tradução apagaria a\n -- fila de quem cobre a falta.\n v_role := CASE v_role_v1\n WHEN 'titular' THEN 'titular'\n WHEN 'standby' THEN 'standby'\n WHEN 'stand_by' THEN 'standby'\n WHEN 'stand-by' THEN 'standby'\n ELSE NULL END;\n IF v_role IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',\n 'reason', format('papel na vaga desconhecido: %L (o V2 conhece titular e standby)', v_role_v1));\n END IF;\n\n v_status := CASE v_status_v1\n WHEN 'pending' THEN 'pending'\n WHEN 'notified' THEN 'notified'\n WHEN 'confirmed' THEN 'confirmed'\n WHEN 'declined' THEN 'declined'\n WHEN 'realized' THEN 'realized'\n ELSE NULL END;\n IF v_status IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',\n 'reason', format('estado de atribuição desconhecido: %L', v_status_v1));\n END IF;\n\n IF NOT EXISTS (SELECT 1 FROM public.plg_workforce_schedule_assignments WHERE id = p_id) THEN\n INSERT INTO public.plg_workforce_schedule_assignments\n (id, tenant_id, unit_id, schedule_id, shift_id, shift_label, shift_start_time, shift_end_time,\n profession_name, person_id, role, priority, status, response_token, response_channel,\n notified_at, responded_at, metadata)\n VALUES (p_id, p_tenant, v_unit, v_schedule, (p_row ->> 'shift_id')::uuid,\n p_row ->> 'shift_label', v_start, v_end,\n p_row ->> 'profession_name', v_person, v_role,\n coalesce((p_row ->> 'priority')::integer, 0), v_status,\n -- O token do V1 é uma credencial viva de um canal que o V2 ainda não\n -- opera. Ele atravessa para não perder o rastro de quem foi chamado\n -- por link, mas quem emitir o próximo convite emite outro.\n p_row ->> 'response_token', p_row ->> 'response_channel',\n (p_row ->> 'notified_at')::timestamptz, (p_row ->> 'responded_at')::timestamptz,\n v_meta);\n v_created := true;\n ELSE\n UPDATE public.plg_workforce_schedule_assignments SET\n unit_id = v_unit,\n shift_id = CASE WHEN p_row ? 'shift_id' THEN (p_row ->> 'shift_id')::uuid ELSE shift_id END,\n shift_label = CASE WHEN p_row ? 'shift_label' THEN p_row ->> 'shift_label' ELSE shift_label END,\n shift_start_time = coalesce(v_start, shift_start_time),\n shift_end_time = coalesce(v_end, shift_end_time),\n profession_name = CASE WHEN p_row ? 'profession_name' THEN p_row ->> 'profession_name' ELSE profession_name END,\n person_id = v_person,\n role = v_role,\n priority = CASE WHEN p_row ? 'priority' THEN coalesce((p_row ->> 'priority')::integer, priority) ELSE priority END,\n status = v_status,\n response_token = CASE WHEN p_row ? 'response_token' THEN p_row ->> 'response_token' ELSE response_token END,\n response_channel = CASE WHEN p_row ? 'response_channel' THEN p_row ->> 'response_channel' ELSE response_channel END,\n notified_at = CASE WHEN p_row ? 'notified_at' THEN (p_row ->> 'notified_at')::timestamptz ELSE notified_at END,\n responded_at = CASE WHEN p_row ? 'responded_at' THEN (p_row ->> 'responded_at')::timestamptz ELSE responded_at END,\n metadata = migration._merge_meta(metadata, v_meta)\n WHERE id = p_id;\n END IF;\n\n IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_workforce_schedule_assignments', p_id, '{}'::jsonb); END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'role', v_role,\n 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);\nEND $function$;\n\nCREATE OR REPLACE FUNCTION migration.upsert_wf_assignment_log(\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_assignment uuid := (p_row ->> 'assignment_id')::uuid;\n v_to text := nullif(btrim(lower(coalesce(p_row ->> 'to_status', ''))), '');\n v_unit uuid;\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 v_assignment IS NULL OR v_to IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state',\n 'reason', 'uma linha de histórico precisa de id, da atribuição e do estado para o qual foi');\n END IF;\n SELECT unit_id INTO v_unit FROM public.plg_workforce_schedule_assignments\n WHERE id = v_assignment AND tenant_id = p_tenant;\n IF NOT FOUND THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',\n 'reason', format('a atribuição %s não existe neste tenant', v_assignment));\n END IF;\n\n -- O log é histórico: reoferecer a mesma linha é replay, e replay não reescreve\n -- o que já foi registrado.\n IF EXISTS (SELECT 1 FROM public.plg_workforce_assignment_logs WHERE id = p_id) THEN\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id,\n 'reason', 'already in the ledger (append-only)');\n END IF;\n\n INSERT INTO public.plg_workforce_assignment_logs\n (id, tenant_id, unit_id, assignment_id, from_status, to_status, channel, note,\n changed_by_user_id, metadata, created_at)\n VALUES (p_id, p_tenant, v_unit, v_assignment,\n nullif(btrim(lower(coalesce(p_row ->> 'from_status', ''))), ''), v_to,\n p_row ->> 'channel', p_row ->> 'note',\n (p_row ->> 'changed_by_user_id')::uuid, v_meta,\n coalesce((p_row ->> 'created_at')::timestamptz, now()));\n\n IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_workforce_assignment_logs', p_id, '{}'::jsonb); END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', 'created');\nEND $function$;\n\n-- ═══════════════════════════════════════════════════════════════════════════\n-- 9. O DE-PARA\n-- ═══════════════════════════════════════════════════════════════════════════\n\n-- \\`_allow\\` é o construtor oficial: a allowlist tem gatilho de congelamento, e um\n-- INSERT direto por cima de uma linha \\`frozen\\` seria recusado. São oito\n-- argumentos — (source, target, domain, writer, writer_args, financial[],\n-- columns, notes) — e \\`layer\\` é fixo em 'core-A' lá dentro.\n\n-- A allowlist é CONGELADA (\\`trg_allowlist_frozen\\`): mudança de core-A é\n-- arquivo de migration, não escrita solta — e este é o arquivo. Sem a\n-- escotilha, a PRIMEIRA passada passa (a linha ainda não existia) e a\n-- SEGUNDA falha: é assim que uma migration deixa de ser replay-safe sem\n-- ninguém ver. \\`true\\` no terceiro argumento: vale só nesta transação.\nSELECT set_config('migration.allowlist_unlock', 'on', true);\n\nSELECT migration._allow(\n 'time_clock_entries', 'public.plg_workforce_time_entries', 'workforce',\n 'wf_time_entry', '{}'::jsonb, ARRAY[]::text[],\n jsonb_build_object(\n 'uuid', 'id',\n 'unit_id', 'unit_id@companies:app.units',\n -- O par polimórfico. Nenhuma das duas vira coluna: juntas elas viram\n -- \\`person_id\\`, resolvido pelo ledger em migration._wf_person.\n 'person_type', 'jsonb:v1.person_type',\n 'person_id', 'jsonb:v1.person_id',\n 'person_name', 'person_name',\n 'checkin_at', 'checkin_at',\n 'checkout_at', 'checkout_at',\n 'checkin_photo_path', 'checkin_photo_path',\n 'checkin_lat', 'checkin_lat',\n 'checkin_lng', 'checkin_lng',\n 'checkin_accuracy_m', 'checkin_accuracy_m',\n 'checkin_ip', 'checkin_ip',\n 'checkin_user_agent', 'checkin_user_agent',\n 'checkin_face_score', 'checkin_face_score',\n 'checkout_photo_path', 'checkout_photo_path',\n 'checkout_lat', 'checkout_lat',\n 'checkout_lng', 'checkout_lng',\n 'checkout_accuracy_m', 'checkout_accuracy_m',\n 'checkout_ip', 'checkout_ip',\n 'checkout_user_agent', 'checkout_user_agent',\n 'checkout_face_score', 'checkout_face_score',\n 'schedule_assignment_id','assignment_id@schedule_assignments',\n -- O ajuste do V1 não vira coluna: vira LINHA em\n -- plg_workforce_time_adjustments, com autor e motivo.\n 'checkin_adjusted_at', 'checkin_adjusted_at',\n 'checkout_adjusted_at', 'checkout_adjusted_at',\n 'adjustment_reason', 'adjustment_reason',\n 'adjusted_by_user_id', 'adjusted_by_user_id',\n 'adjusted_by_name', 'adjusted_by_name',\n -- Derivadas e não portadas: \\`manually_adjusted\\` é a existência de um ajuste,\n -- \\`adjusted_by\\`/\\`adjusted_at\\` são redundantes com a linha do ajuste, e\n -- \\`outside_schedule\\` é o resultado do cruzamento — que no V1 é \\`true\\` em 325\n -- de 325 registros por um defeito de configuração, e que no V2 é a coluna\n -- \\`outside_schedule\\` da view v_workforce_day, calculada e não guardada.\n 'manually_adjusted', '-',\n 'adjusted_by', '-',\n 'adjusted_at', '-',\n 'outside_schedule', 'jsonb:v1.outside_schedule',\n 'notes', 'notes',\n -- NÃO vira FK: no V1 aponta para a tabela de títulos do V1, cuja contraparte\n -- no V2 não é a mesma coisa (mesma recusa da 046). A referência crua é o que\n -- TRAVA o ajuste da linha migrada — a trigger da seção 4 lê exatamente aqui.\n 'payable_invoice_id', 'jsonb:v1.payable_invoice_id'\n ),\n 'A batida. Uma linha do V1 (par entrada/saída + colunas de ajuste) vira até 4 do V2: dois eventos append-only e duas linhas de ajuste. checkout_at nulo atravessa como ausência de evento — é o ponto em aberto, 20 deles no tenant medido.'\n);\n\nSELECT migration._allow(\n 'schedule_templates', 'public.plg_workforce_schedule_templates', 'workforce',\n 'wf_schedule_template', '{}'::jsonb, ARRAY[]::text[],\n jsonb_build_object(\n 'uuid', 'id',\n 'unit_id', 'unit_id@companies:app.units',\n 'name', 'name',\n 'active', 'is_active'\n ),\n 'O molde da escala. Pré-requisito duro: sem template o V1 não deixa criar escala nenhuma.'\n);\n\nSELECT migration._allow(\n 'schedule_template_shifts', 'public.plg_workforce_schedule_shifts', 'workforce',\n 'wf_schedule_shift', '{}'::jsonb, ARRAY[]::text[],\n jsonb_build_object(\n 'uuid', 'id',\n 'template_id', 'template_id@schedule_templates',\n 'label', 'label',\n 'start_time', 'start_time',\n 'end_time', 'end_time',\n 'weekdays', 'weekdays',\n 'sort_order', 'sort_order'\n ),\n 'O turno do molde. weekdays é 0..6 (domingo..sábado); qualquer valor fora disso é quarentena, porque um turno que nunca acontece some sem erro.'\n);\n\nSELECT migration._allow(\n 'schedule_template_requirements', 'public.plg_workforce_schedule_requirements', 'workforce',\n 'wf_schedule_requirement', '{}'::jsonb, ARRAY[]::text[],\n jsonb_build_object(\n 'uuid', 'id',\n 'shift_id', 'shift_id@schedule_template_shifts',\n 'profession_name', 'profession_name',\n 'required_count', 'required_count'\n ),\n 'A lotação da função no turno — o \"(1/1)\" da tela, e o denominador que transforma posição em titular/stand-by.'\n);\n\nSELECT migration._allow(\n 'schedule_template_assignments', 'public.plg_workforce_schedule_template_assignments', 'workforce',\n 'wf_schedule_template_assignment', '{}'::jsonb, ARRAY[]::text[],\n jsonb_build_object(\n 'uuid', 'id',\n 'template_id', 'template_id@schedule_templates',\n 'shift_id', 'shift_id@schedule_template_shifts',\n 'profession_name', 'profession_name',\n 'professional_id', 'jsonb:v1.person_id',\n 'priority', 'priority'\n ),\n 'A alocação do molde. No V1 titular × stand-by é POSICIONAL (os primeiros required_count por priority) e vive só no código da tela; o writer faz a conta uma vez e grava em \\`role\\`.'\n);\n\nSELECT migration._allow(\n 'schedules', 'public.plg_workforce_schedules', 'workforce',\n 'wf_schedule', '{}'::jsonb, ARRAY[]::text[],\n jsonb_build_object(\n 'uuid', 'id',\n 'unit_id', 'unit_id@companies:app.units',\n 'schedule_date', 'schedule_date',\n 'template_id', 'template_id@schedule_templates',\n 'status', 'status',\n 'published_at', 'published_at',\n 'is_special_day', 'is_special_day',\n 'special_day_label', 'special_day_label',\n 'notes', 'notes'\n ),\n 'A escala do dia. Homônima da \\`public.schedules\\` do V2, que é OUTRA coisa (a oferta de horário do spine) — é a razão do prefixo plg_workforce_.'\n);\n\nSELECT migration._allow(\n 'schedule_assignments', 'public.plg_workforce_schedule_assignments', 'workforce',\n 'wf_schedule_assignment', '{}'::jsonb, ARRAY[]::text[],\n jsonb_build_object(\n 'uuid', 'id',\n 'schedule_id', 'schedule_id@schedules',\n 'shift_id', 'shift_id@schedule_template_shifts',\n -- O SNAPSHOT do V1 atravessa como snapshot. Reconstruí-lo a partir do turno\n -- atual do molde reescreveria a escala de anteontem quando o molde mudasse.\n 'shift_label', 'shift_label',\n 'shift_start_time', 'shift_start_time',\n 'shift_end_time', 'shift_end_time',\n 'profession_name', 'profession_name',\n 'professional_id', 'jsonb:v1.person_id',\n 'role', 'role',\n 'priority', 'priority',\n 'status', 'status',\n 'response_token', 'response_token',\n 'response_channel', 'response_channel',\n 'notified_at', 'notified_at',\n 'responded_at', 'responded_at'\n ),\n 'A atribuição do dia. Stand-by não é folga: é o substituto acionável (\"Substitutos em caso de falta\"), e confundir os dois apagaria a fila de quem cobre a falta.'\n);\n\nSELECT migration._allow(\n 'schedule_assignment_logs', 'public.plg_workforce_assignment_logs', 'workforce',\n 'wf_assignment_log', '{}'::jsonb, ARRAY[]::text[],\n jsonb_build_object(\n 'uuid', 'id',\n 'assignment_id', 'assignment_id@schedule_assignments',\n 'from_status', 'from_status',\n 'to_status', 'to_status',\n 'channel', 'channel',\n 'note', 'note',\n 'changed_by_user_id','changed_by_user_id',\n 'created_at', 'created_at'\n ),\n 'O histórico da atribuição: a única parte do modelo de escala do V1 que já nasceu append-only, e que atravessa como é.'\n);\n\n-- As duas que NÃO atravessam, e a razão escrita onde a próxima onda vai\n-- procurar. \"Grid\" aqui é grade de disponibilidade de AGENDAMENTO — quando um\n-- horário pode ser marcado —, não escala de trabalho. Portá-las para dentro do\n-- RH criaria a terceira tabela de horário do cluster e colocaria a agenda dentro\n-- do plugin que o D3 mandou justamente separar dela.\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) VALUES\n ('schedule_grids',\n 'Apesar do nome, é grade de disponibilidade de AGENDAMENTO (quando um horário pode ser marcado), não escala de trabalho. O destino natural é a agenda (public.schedules, kind working_hours), não o RH.'),\n ('schedule_grid_blocks',\n 'Bloco da grade de agendamento — mesma razão de schedule_grids. Escala de trabalho é plg_workforce_schedule_*.')\nON CONFLICT (source_table) WHERE tenant_id IS NULL DO UPDATE SET reason = EXCLUDED.reason;\n\n-- ═══════════════════════════════════════════════════════════════════════════\n-- 10. A amarração com o financeiro, quando o financeiro existe\n-- ═══════════════════════════════════════════════════════════════════════════\n--\n-- Hora de trabalho vira dinheiro, e o V2 já tem onde: \\`plg_financial_invoices\\`\n-- para o título, \\`plg_financial_chart_of_accounts\\` e \\`plg_financial_cost_centers\\`\n-- para a classificação. O que a jornada NÃO pode fazer é exigir o financeiro:\n-- o plugin é instalável sozinho, e uma FK inline recusaria o arquivo inteiro num\n-- cluster que não ligou o módulo. Declarar a FK aqui, condicionada ao destino,\n-- é o que dá integridade onde ela é possível sem transformar preferência em\n-- dependência.\n--\n-- Todas ON DELETE SET NULL: apagar um centro de custo não pode apagar a batida\n-- de ninguém. O vínculo some, a hora fica.\nDO $$\nDECLARE\n r record;\nBEGIN\n FOR r IN\n SELECT * FROM (VALUES\n ('plg_workforce_time_entries', 'chart_account_id', 'plg_financial_chart_of_accounts'),\n ('plg_workforce_time_entries', 'cost_center_id', 'plg_financial_cost_centers'),\n ('plg_workforce_time_entries', 'payable_invoice_id', 'plg_financial_invoices'),\n ('plg_workforce_schedule_shifts', 'chart_account_id', 'plg_financial_chart_of_accounts'),\n ('plg_workforce_schedule_shifts', 'cost_center_id', 'plg_financial_cost_centers'),\n ('plg_workforce_periods', 'chart_account_id', 'plg_financial_chart_of_accounts'),\n ('plg_workforce_periods', 'cost_center_id', 'plg_financial_cost_centers'),\n ('plg_workforce_periods', 'payable_invoice_id', 'plg_financial_invoices')\n ) AS t(tbl, col, target)\n LOOP\n CONTINUE WHEN to_regclass('public.' || r.target) IS NULL;\n CONTINUE WHEN EXISTS (SELECT 1 FROM pg_constraint\n WHERE conname = r.tbl || '_' || r.col || '_fkey'\n AND conrelid = ('public.' || r.tbl)::regclass);\n EXECUTE format(\n 'ALTER TABLE public.%I ADD CONSTRAINT %I FOREIGN KEY (%I) REFERENCES public.%I(id) ON DELETE SET NULL',\n r.tbl, r.tbl || '_' || r.col || '_fkey', r.col, r.target);\n END LOOP;\nEND $$;\n\nSELECT set_config('migration.allowlist_unlock', 'off', true);\n`\n\nexport const MIGRATION_001_A_PROVA_DA_BATIDA_TEM_BYTES = `-- ---------------------------------------------------------------------------\n-- 001_a_prova_da_batida_tem_bytes.sql — a foto do ponto atravessa, e não só o\n-- caminho dela.\n--\n-- A 000 escreveu, no comentário de \\`evidence\\`:\n--\n-- \"O que não pode é perder: no V1 são \\`photo_path\\`, \\`lat\\`, \\`lng\\`,\n-- \\`accuracy_m\\`, \\`ip\\`, \\`user_agent\\` e \\`face_score\\`.\"\n--\n-- Sete campos. Seis atravessaram. O sétimo — o único que uma pessoa reconhece —\n-- atravessou como TEXTO: \\`8/2026-07-05/professional-15-1783278419303.jpg\\`,\n-- que é um objeto num bucket PRIVADO de outro projeto Supabase, o do V1.\n--\n-- Medido em 18/09/2026 no tenant Pertinho do Céu: 1.320 das 1.361 batidas têm\n-- esse caminho preenchido, e nenhuma das 1.320 imagens é alcançável daqui. No\n-- dia em que o V1 sair do ar elas deixam de existir em qualquer lugar.\n--\n-- É pior que uma coluna vazia, porque parece cheia. \\`evidence.photo_path\\` diz\n-- que houve captura e sugere que a captura está guardada. A tela de Registros de\n-- Ponto, por isso mesmo, escondia a seção de evidência inteira — o comentário\n-- dela dizia que a evidência estava \"hoje vazia\", o que já não era verdade.\n--\n-- Este arquivo declara para o motor de arquivos da 054 (\\`migration.asset_kinds\\`)\n-- que a foto de batida é uma espécie com bytes para transportar: de que bucket\n-- ela sai, para qual vai, o que falta atravessar, e quem reescreve o ponteiro.\n-- Mover os bytes é do fayz-etl (\\`fayz_etl/blobs.py\\`); a regra é daqui.\n--\n-- Idempotente e replay-safe.\n-- ---------------------------------------------------------------------------\n\n-- ─────────────────────────────────────────────────────────────────────────\n-- §1 O bucket, PRIVADO\n--\n-- Não é escolha de estilo: é biometria facial de um funcionário, com hora e\n-- coordenada ao lado. O V1 já a guarda em bucket privado e serve por URL\n-- assinada de curta duração (edge function \\`time-clock-photo-url\\`), e o V2 não\n-- pode rebaixar isso — um bucket público transformaria \"a cara e o horário de\n-- quem trabalha aqui\" em algo que basta um caminho para ver.\n--\n-- A leitura é por pasta de tenant, o mesmo escopo que o resto da plataforma usa.\n-- Com ele, \\`createSignedUrl\\` do lado do app funciona para quem é do tenant e\n-- falha para quem não é, sem RPC no meio.\n-- ─────────────────────────────────────────────────────────────────────────\n\nINSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)\nVALUES ('workforce-punch-photos', 'workforce-punch-photos', false, 5242880,\n ARRAY['image/jpeg', 'image/png', 'image/webp'])\nON CONFLICT (id) DO UPDATE SET\n public = EXCLUDED.public,\n file_size_limit = EXCLUDED.file_size_limit,\n allowed_mime_types = EXCLUDED.allowed_mime_types;\n\nDROP POLICY IF EXISTS plg_workforce_punch_photos_read ON storage.objects;\nDROP POLICY IF EXISTS plg_workforce_punch_photos_insert ON storage.objects;\nDROP POLICY IF EXISTS plg_workforce_punch_photos_update ON storage.objects;\nDROP POLICY IF EXISTS plg_workforce_punch_photos_delete ON storage.objects;\n\n-- Sem \\`anon\\`. A prova de uma jornada não se lê deslogado.\nCREATE POLICY plg_workforce_punch_photos_read ON storage.objects\n FOR SELECT TO authenticated\n USING (\n bucket_id = 'workforce-punch-photos'\n AND (storage.foldername(name))[1] IN (SELECT public.user_tenant_ids()::text)\n );\n\nCREATE POLICY plg_workforce_punch_photos_insert ON storage.objects\n FOR INSERT TO authenticated\n WITH CHECK (\n bucket_id = 'workforce-punch-photos'\n AND (storage.foldername(name))[1] IN (SELECT public.user_tenant_ids()::text)\n );\n\nCREATE POLICY plg_workforce_punch_photos_update ON storage.objects\n FOR UPDATE TO authenticated\n USING (\n bucket_id = 'workforce-punch-photos'\n AND (storage.foldername(name))[1] IN (SELECT public.user_tenant_ids()::text)\n )\n WITH CHECK (\n bucket_id = 'workforce-punch-photos'\n AND (storage.foldername(name))[1] IN (SELECT public.user_tenant_ids()::text)\n );\n\n-- DELETE fica de fora de propósito: apagar a foto de uma batida é apagar prova.\n-- Quem precisar (pedido de exclusão LGPD) passa pelo \\`service_role\\`, que é um\n-- ato deliberado e registrado, não um clique.\n\n-- ─────────────────────────────────────────────────────────────────────────\n-- §2 A espécie\n-- ─────────────────────────────────────────────────────────────────────────\n\nSELECT public.register_kind('document', 'punch_photo', 'workforce', ARRAY['active'], NULL,\n 'A foto que o totem capturou na batida — prova de quem bateu o ponto, junto com GPS e score facial');\n\n-- ─────────────────────────────────────────────────────────────────────────\n-- §3 O que falta, e o ponteiro\n--\n-- Todo o resto deste arquivo depende da 054 do base. Num pool que ainda não a\n-- aplicou, o plugin instala sem o transporte de bytes em vez de recusar a\n-- instalação: o ponto funciona sem foto, e a foto sem o ponto não é nada.\n-- ─────────────────────────────────────────────────────────────────────────\n\nDO $do$\nBEGIN\nIF to_regproc('migration.register_asset_kind(text,text,text,text,text,text)') IS NULL THEN\n RAISE NOTICE 'plugin-workforce 001: migration.register_asset_kind ausente (base < 054); a foto da batida fica sem transporte';\n RETURN;\nEND IF;\n\nCREATE OR REPLACE VIEW migration.v_asset_backlog_punch_photo AS\nSELECT e.tenant_id,\n 'time_entry'::text AS subject_type,\n e.id AS subject_id,\n e.person_id,\n e.evidence ->> 'photo_path' AS source_path,\n -- O bucket do V1 é privado: não há URL para baixar sem chave, e o\n -- transporte usa a chave de serviço da origem.\n NULL::text AS source_url,\n -- Uma foto por batida, nomeada pela batida. O caminho do V1 tem epoch no\n -- nome e não é reconstruível a partir do destino; o do V2 é derivável de\n -- qualquer lado, o que é o que faz o rerun sobrescrever em vez de dobrar.\n e.tenant_id::text || '/' || e.id::text || '.jpg' AS target_path,\n coalesce(e.person_name, 'sem nome') || ' · '\n || to_char(e.punched_at, 'DD/MM/YYYY HH24:MI') AS title\n FROM public.plg_workforce_time_entries e\n -- \\`photo_path\\` é a procedência (o caminho no V1, que fica); \\`photo\\` é o\n -- ponteiro do V2. Ter o primeiro e não o segundo É a definição de \"falta\n -- atravessar\", e escrever o segundo tira a linha desta lista.\n WHERE e.evidence ? 'photo_path'\n AND NOT (e.evidence ? 'photo');\n\nCOMMENT ON VIEW migration.v_asset_backlog_punch_photo IS\n 'Batidas cuja foto ainda só existe no bucket privado do V1 (001). A condição É o ponteiro: escrever evidence.photo apaga a linha desta lista.';\n\nCREATE OR REPLACE FUNCTION migration._point_punch_photo(p_tenant uuid, p_document uuid, p_row jsonb)\nRETURNS void\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = ''\nAS $fn$\nDECLARE v_entry uuid := (p_row ->> 'subject_id')::uuid;\nBEGIN\n IF v_entry IS NULL THEN\n RAISE EXCEPTION '_point_punch_photo: preciso de subject_id (a batida)' USING ERRCODE = '22023';\n END IF;\n -- UPDATE numa tabela append-only: passa porque \\`attach_asset\\` liga\n -- \\`migration.writer\\`, que é a mesma escotilha que o writer da batida usa.\n -- \\`photo_path\\` NÃO é tocado — ele é de onde a imagem veio, e continua sendo.\n UPDATE public.plg_workforce_time_entries\n SET evidence = migration._deep_set(coalesce(evidence, '{}'::jsonb), ARRAY['photo'],\n jsonb_build_object(\n 'bucket', p_row ->> 'bucket',\n 'path', p_row ->> 'target_path',\n 'document_id', p_document)),\n updated_at = now()\n WHERE id = v_entry AND tenant_id = p_tenant;\nEND $fn$;\n\nPERFORM migration.register_asset_kind(\n 'punch_photo', 'time-clock-photos', 'workforce-punch-photos',\n 'v_asset_backlog_punch_photo', 'migration._point_punch_photo',\n 'A foto do totem. Bucket privado nas duas pontas: é biometria facial com hora e coordenada ao lado, e o V1 já a tratava assim.');\nEND $do$;\n`\n\nexport const MIGRATION_002_O_GUARDA_DA_FOTO_OLHAVA_O_NOME_ERRADO = `-- ---------------------------------------------------------------------------\n-- 002_o_guarda_da_foto_olhava_o_nome_errado.sql — o §3 da 001 nunca rodou.\n--\n-- A 001 protege o próprio corpo com um guard, porque tudo nela depois do §2\n-- depende da 054 do base:\n--\n-- IF to_regproc('migration.register_asset_kind(text,text,text,text,text,text)')\n-- IS NULL THEN RETURN; END IF;\n--\n-- **\\`to_regproc\\` recebe um NOME, não uma assinatura.** Com a lista de tipos\n-- entre parênteses ela devolve NULL — e não levanta erro, que é exatamente o que\n-- torna isso invisível: \\`to_regproc\\` foi feita para responder \"não existe\" em vez\n-- de estourar. Quem lê a linha lê uma checagem; o Postgres lê uma pergunta\n-- malformada e responde \"não existe\" às duas.\n--\n-- Resultado medido no cluster em 19/09/2026, logo depois de aplicar a 001: o\n-- bucket \\`workforce-punch-photos\\` existe (§1, fora do bloco), a espécie\n-- \\`punch_photo\\` está registrada (§2, fora do bloco), e do §3 não existe NADA —\n-- nem a view de backlog, nem \\`_point_punch_photo\\`, nem a linha em\n-- \\`migration.asset_kinds\\`. A migration \"passou\". A foto continuou sem\n-- transporte, e o \\`mig-assets\\` teria relatado só os 63 avatares como se fosse o\n-- backlog inteiro.\n--\n-- Quem quer assinatura é \\`to_regprocedure\\`. É a única diferença deste arquivo.\n--\n-- ── Por que um arquivo novo, e não uma correção na 001 ─────────────────────\n--\n-- A 001 já está no ledger deste cluster. \\`fayz db apply\\` trata checksum\n-- diferente num arquivo aplicado como PARADA — e está certo: um app que já rodou\n-- a 001 não pode descobrir que os bytes dela mudaram. Corrigir para frente custa\n-- este arquivo; editar a 001 custaria o apply de todo app que já a tomou.\n--\n-- Idempotente e replay-safe.\n-- ---------------------------------------------------------------------------\n\nDO $do$\nBEGIN\n-- \\`to_regprocedure\\`, que é quem entende \\`(text,text,…)\\`. A mesma armadilha está\n-- na 152 do base (\\`to_regproc('app.scaffold_table(text,text,text,boolean,text)')\\`),\n-- e é por isso que a checagem aqui é pelo NOME quando basta o nome: um guard que\n-- mente é pior que nenhum guard, porque o arquivo sai dizendo que aplicou.\nIF to_regprocedure('migration.register_asset_kind(text,text,text,text,text,text)') IS NULL THEN\n RAISE NOTICE 'plugin-workforce 002: migration.register_asset_kind ausente (base < 054); a foto da batida fica sem transporte';\n RETURN;\nEND IF;\n\nCREATE OR REPLACE VIEW migration.v_asset_backlog_punch_photo AS\nSELECT e.tenant_id,\n 'time_entry'::text AS subject_type,\n e.id AS subject_id,\n e.person_id,\n e.evidence ->> 'photo_path' AS source_path,\n -- O bucket do V1 é privado: não há URL para baixar sem chave, e o\n -- transporte usa a chave de serviço da origem.\n NULL::text AS source_url,\n -- Uma foto por batida, nomeada pela batida. O caminho do V1 tem epoch no\n -- nome e não é reconstruível a partir do destino; o do V2 é derivável de\n -- qualquer lado, o que é o que faz o rerun sobrescrever em vez de dobrar.\n e.tenant_id::text || '/' || e.id::text || '.jpg' AS target_path,\n coalesce(e.person_name, 'sem nome') || ' · '\n || to_char(e.punched_at, 'DD/MM/YYYY HH24:MI') AS title\n FROM public.plg_workforce_time_entries e\n -- \\`photo_path\\` é a procedência (o caminho no V1, que fica); \\`photo\\` é o\n -- ponteiro do V2. Ter o primeiro e não o segundo É a definição de \"falta\n -- atravessar\", e escrever o segundo tira a linha desta lista.\n WHERE e.evidence ? 'photo_path'\n AND NOT (e.evidence ? 'photo');\n\nCOMMENT ON VIEW migration.v_asset_backlog_punch_photo IS\n 'Batidas cuja foto ainda só existe no bucket privado do V1 (001/002). A condição É o ponteiro: escrever evidence.photo apaga a linha desta lista.';\n\nCREATE OR REPLACE FUNCTION migration._point_punch_photo(p_tenant uuid, p_document uuid, p_row jsonb)\nRETURNS void\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = ''\nAS $fn$\nDECLARE v_entry uuid := (p_row ->> 'subject_id')::uuid;\nBEGIN\n IF v_entry IS NULL THEN\n RAISE EXCEPTION '_point_punch_photo: preciso de subject_id (a batida)' USING ERRCODE = '22023';\n END IF;\n -- UPDATE numa tabela append-only: passa porque \\`attach_asset\\` liga\n -- \\`migration.writer\\`, que é a mesma escotilha que o writer da batida usa.\n -- \\`photo_path\\` NÃO é tocado — ele é de onde a imagem veio, e continua sendo.\n UPDATE public.plg_workforce_time_entries\n SET evidence = migration._deep_set(coalesce(evidence, '{}'::jsonb), ARRAY['photo'],\n jsonb_build_object(\n 'bucket', p_row ->> 'bucket',\n 'path', p_row ->> 'target_path',\n 'document_id', p_document)),\n updated_at = now()\n WHERE id = v_entry AND tenant_id = p_tenant;\nEND $fn$;\n\nPERFORM migration.register_asset_kind(\n 'punch_photo', 'time-clock-photos', 'workforce-punch-photos',\n 'v_asset_backlog_punch_photo', 'migration._point_punch_photo',\n 'A foto do totem. Bucket privado nas duas pontas: é biometria facial com hora e coordenada ao lado, e o V1 já a tratava assim.');\nEND $do$;\n`\n\nexport const MIGRATION_003_O_PONTO_EM_ABERTO_PARA_DE_MENTIR_A_DURACAO = `-- ---------------------------------------------------------------------------\n-- 003_o_ponto_em_aberto_para_de_mentir_a_duracao.sql — \\`-497155h02\\`.\n--\n-- A tela de Registros de Ponto mostra isso na coluna Duração de todo ponto em\n-- aberto. Medido em 19/09/2026 no tenant Pertinho do Céu: **19 dias negativos,\n-- e os 19 estão abertos** — nenhum dia fechado dá negativo. O número não é\n-- aleatório e não é overflow.\n--\n-- ── De onde ele sai ────────────────────────────────────────────────────────\n--\n-- A 000 soma as horas com sinal, e o desenho é bom: \\`clock_out\\` e \\`break_start\\`\n-- FECHAM um trecho e entram positivos, \\`clock_in\\` e \\`break_end\\` ABREM e entram\n-- negativos. Quantos intervalos o dia tiver, a conta é a mesma — é isso que o\n-- par único do V1 não consegue fazer.\n--\n-- Ela só assume uma coisa que o dia em aberto não cumpre: que toda abertura tem\n-- o seu fechamento. Um dia com uma batida só —\n--\n-- clock_in 18:09\n--\n-- — soma \\`-extract(epoch FROM '2026-09-18 18:09')\\`, que é −1.789.790. segundos\n-- desde 1970. Dividido por 60 vira −29.829.302 minutos, e o formatador de horas\n-- faz o resto: \\`-497155h02\\`. O epoch vazou para a tela.\n--\n-- ── O conserto da conta ────────────────────────────────────────────────────\n--\n-- Devolver à soma a abertura que ficou sem par. O que sobra é exatamente a\n-- soma dos trechos FECHADOS, que é a única coisa que se pode afirmar de um dia\n-- que ainda não terminou:\n--\n-- entrada 09:00, saída 12:00, entrada 13:00 → 3h00 fechadas, aberto desde 13:00\n-- entrada 18:09 → 0h00 fechadas, aberto desde 18:09\n--\n-- Zero não é um defeito aqui: é a resposta certa para \"quanto já fechou?\" antes\n-- de qualquer fechamento. O que faltava para a tela era a OUTRA metade da\n-- pergunta, e é a coluna nova.\n--\n-- ── \\`open_since\\`, e por que uma coluna e não uma conta na tela ─────────────\n--\n-- Quem confere ponto quer saber há quanto tempo a pessoa está dentro. A tela\n-- não consegue descobrir isso sozinha: ela recebe o dia agregado, não as\n-- batidas, e \"a última abertura sem par\" é justamente o que a agregação sabe e\n-- ela não. Sem a coluna, a alternativa seria a tela buscar as batidas de cada\n-- linha — 299 consultas para desenhar uma tabela.\n--\n-- O relógio corre do lado de quem olha, não do banco: a coluna é o INSTANTE, e\n-- o \"6h58 em curso\" é calculado no render. Um intervalo calculado aqui\n-- envelheceria entre a consulta e a pintura, e envelheceria de novo a cada\n-- segundo que a aba ficasse aberta.\n--\n-- ── O que NÃO muda ────────────────────────────────────────────────────────\n--\n-- Dia em aberto continua **fora** do total do dia e do período. A regra é a do\n-- V1 e está certa: contar uma jornada que não terminou inventa hora que ninguém\n-- trabalhou. O que muda é que agora o número que ele não soma também não é\n-- absurdo quando alguém olha a linha.\n--\n-- Idempotente e replay-safe.\n-- ---------------------------------------------------------------------------\n\nCREATE OR REPLACE VIEW public.v_workforce_day\nWITH (security_invoker = 'true') AS\nWITH bruto AS (\n SELECT e.tenant_id,\n e.unit_id,\n e.person_id,\n e.work_date,\n min(e.effective_at) FILTER (WHERE e.kind = 'clock_in') AS first_in,\n max(e.effective_at) FILTER (WHERE e.kind = 'clock_out') AS last_out,\n count(*) FILTER (WHERE e.kind = 'break_start') AS break_count,\n count(*) FILTER (WHERE e.kind IN ('clock_in', 'break_end')) AS aberturas,\n count(*) FILTER (WHERE e.kind IN ('clock_out', 'break_start')) AS fechamentos,\n -- A última ABERTURA do dia. Quando as contagens não batem, é ela que\n -- ficou sem par — os eventos de um dia chegam em ordem, e uma abertura\n -- anterior já teria sido fechada pela seguinte.\n max(e.effective_at) FILTER (WHERE e.kind IN ('clock_in', 'break_end')) AS ultima_abertura,\n sum(CASE\n WHEN e.kind IN ('clock_out', 'break_start') THEN extract(epoch FROM e.effective_at)\n WHEN e.kind IN ('clock_in', 'break_end') THEN -extract(epoch FROM e.effective_at)\n END) AS soma_com_sinal\n FROM public.v_workforce_time_entries e\n WHERE NOT e.voided\n GROUP BY e.tenant_id, e.unit_id, e.person_id, e.work_date\n),\nrealized AS (\n SELECT b.tenant_id,\n b.unit_id,\n b.person_id,\n b.work_date,\n b.first_in,\n b.last_out,\n b.break_count,\n (b.aberturas <> b.fechamentos) AS is_open,\n CASE WHEN b.aberturas <> b.fechamentos THEN b.ultima_abertura END AS open_since,\n -- Devolve à soma a abertura sem par. Sem isto o que sobra é o epoch\n -- dela, com sinal trocado, e foi o que a tela vinha mostrando.\n (b.soma_com_sinal\n + CASE WHEN b.aberturas <> b.fechamentos\n THEN extract(epoch FROM b.ultima_abertura)\n ELSE 0 END) AS worked_seconds\n FROM bruto b\n),\nplanned AS (\n SELECT a.tenant_id,\n a.unit_id,\n a.person_id,\n s.schedule_date AS work_date,\n a.id AS assignment_id,\n a.role,\n a.status AS assignment_status,\n a.shift_label,\n a.shift_start_time,\n a.shift_end_time,\n -- O turno que vira o dia é normal aqui; sem o +24h a jornada da noite\n -- daria negativa.\n (extract(epoch FROM a.shift_end_time - a.shift_start_time)\n + CASE WHEN a.shift_end_time <= a.shift_start_time THEN 86400 ELSE 0 END) AS planned_seconds\n FROM public.plg_workforce_schedule_assignments a\n JOIN public.plg_workforce_schedules s ON s.id = a.schedule_id\n WHERE s.status IN ('published', 'closed')\n AND a.role = 'titular'\n AND a.shift_start_time IS NOT NULL\n AND a.shift_end_time IS NOT NULL\n)\nSELECT coalesce(p.tenant_id, r.tenant_id) AS tenant_id,\n coalesce(p.unit_id, r.unit_id) AS unit_id,\n coalesce(p.person_id, r.person_id) AS person_id,\n coalesce(p.work_date, r.work_date) AS work_date,\n p.assignment_id,\n p.shift_label,\n p.shift_start_time,\n p.shift_end_time,\n p.assignment_status,\n (p.planned_seconds / 60)::integer AS planned_minutes,\n (r.worked_seconds / 60)::integer AS worked_minutes,\n ((r.worked_seconds - p.planned_seconds) / 60)::integer AS variance_minutes,\n r.first_in,\n r.last_out,\n r.break_count,\n coalesce(r.is_open, false) AS is_open,\n (r.person_id IS NULL) AS absent,\n (p.assignment_id IS NULL) AS outside_schedule,\n -- Coluna nova, no fim: \\`CREATE OR REPLACE VIEW\\` só aceita acrescentar\n -- aqui, e é de propósito — quem lê por posição não se move.\n r.open_since\n FROM planned p\n FULL OUTER JOIN realized r\n ON r.tenant_id = p.tenant_id\n AND r.person_id = p.person_id\n AND r.work_date = p.work_date;\n\nCOMMENT ON VIEW public.v_workforce_day IS\n 'Jornada prevista × realizada por pessoa e dia. Previsto vem da escala publicada, realizado das batidas com ajuste aplicado. FULL OUTER JOIN: \\`absent\\` é escala sem ponto, \\`outside_schedule\\` é ponto sem escala. \\`worked_minutes\\` conta só os trechos FECHADOS; \\`open_since\\` é a abertura que ainda não tem par (003).';\n\nCOMMENT ON COLUMN public.v_workforce_day.open_since IS\n 'O instante da abertura sem par, quando o ponto está aberto (003). É o INSTANTE e não a duração: o relógio corre do lado de quem olha a tela, e um intervalo calculado aqui envelheceria entre a consulta e a pintura.';\n\nGRANT SELECT ON public.v_workforce_day TO authenticated, service_role;\n`\n\nexport const MIGRATION_004_A_JORNADA_IMPLAUSIVEL_NAO_SOMA = `-- ---------------------------------------------------------------------------\n-- 004_a_jornada_implausivel_nao_soma.sql — 45h45 num dia.\n--\n-- Medido em 19/09/2026 no tenant Pertinho do Céu, na tela de Registros de Ponto:\n--\n-- Steffany Fidelis da Costa 15:41 → 13:26 45h45\n-- CRISTIANE CARDOSO OLIVEIRA 17:14 → 09:23 16h10\n-- jannyne gomes dos santos 09:07 → 08:58 15h56\n--\n-- e o cabeçalho do dia somando **109h45** para oito pessoas.\n--\n-- Nenhum desses é erro de conta: a 003 já consertou a soma, e esses dias estão\n-- FECHADOS. Eles são a medição correta de um par errado — alguém entrou, foi\n-- embora sem bater a saída, e a saída caiu horas ou dias depois. O V1 grava\n-- \\`checkin_at\\` e \\`checkout_at\\` como um par só, então o buraco atravessou inteiro.\n--\n-- ── Por que não um teto de 8h ──────────────────────────────────────────────\n--\n-- Trocar 45h45 por 8h faria o total parecer certo. É o pior resultado possível:\n--\n-- · 8h ali é um número que ninguém trabalhou e ninguém digitou — é a regra\n-- \"nada de default inventado\" quebrada no lugar onde ela mais custa, que é\n-- base de cálculo de folha;\n-- · e o dia deixaria de parecer estranho, então a batida furada nunca seria\n-- corrigida. O erro sai da tela e fica no dado, que é o contrário de\n-- resolver.\n--\n-- ── O que esta migration faz ───────────────────────────────────────────────\n--\n-- Estende a regra que a tela JÁ tem. Ponto em aberto não soma, porque contar\n-- uma jornada que não terminou inventa hora. Um dia de 45h é o mesmo problema\n-- com outra cara — um par em que não se pode confiar —, então ele também não\n-- soma. O valor medido continua à vista, inteiro, com o motivo escrito ao lado.\n--\n-- \\`review_reason\\` nomeia por que o dia não entra na conta:\n--\n-- 'open' ainda não terminou\n-- 'span_too_long' fechou, e fechou com duração que não é uma jornada\n--\n-- NULL é o dia que conta. Uma coluna só, e quem soma filtra por ela — em vez de\n-- cada relatório redescobrir a regra e descobrir diferente.\n--\n-- ── O limite, e por que dezesseis horas ───────────────────────────────────\n--\n-- A jornada mais longa do vocabulário do V1 é a escala 12x36: doze horas. Com\n-- duas de extra, quatorze. Dezesseis deixa margem para o turno que vira a noite\n-- e ainda separa com folga o caso real, que não é uma jornada comprida e sim\n-- uma saída que ficou pendurada — os três medidos acima têm 16h, 16h e 46h.\n--\n-- É o MESMO dezesseis que a tela usa para dizer \"sem saída há 31h\" num ponto\n-- aberto, e é de propósito: um número, uma justificativa, os dois lados da\n-- mesma pergunta.\n--\n-- Idempotente e replay-safe.\n-- ---------------------------------------------------------------------------\n\nCREATE OR REPLACE FUNCTION public.workforce_max_shift_minutes()\nRETURNS integer LANGUAGE sql IMMUTABLE PARALLEL SAFE\nAS $$ SELECT 16 * 60 $$;\n\nCOMMENT ON FUNCTION public.workforce_max_shift_minutes() IS\n 'Acima disto uma jornada fechada deixa de ser jornada e vira saída não batida (004). 12x36 é a mais longa do vocabulário do V1; dezesseis dá margem ao turno que vira a noite. Função e não constante literal para que a tela, a view e um relatório futuro citem o mesmo número.';\n\nCREATE OR REPLACE VIEW public.v_workforce_day\nWITH (security_invoker = 'true') AS\nWITH bruto AS (\n SELECT e.tenant_id,\n e.unit_id,\n e.person_id,\n e.work_date,\n min(e.effective_at) FILTER (WHERE e.kind = 'clock_in') AS first_in,\n max(e.effective_at) FILTER (WHERE e.kind = 'clock_out') AS last_out,\n count(*) FILTER (WHERE e.kind = 'break_start') AS break_count,\n count(*) FILTER (WHERE e.kind IN ('clock_in', 'break_end')) AS aberturas,\n count(*) FILTER (WHERE e.kind IN ('clock_out', 'break_start')) AS fechamentos,\n -- A última ABERTURA do dia. Quando as contagens não batem, é ela que\n -- ficou sem par — os eventos de um dia chegam em ordem, e uma abertura\n -- anterior já teria sido fechada pela seguinte.\n max(e.effective_at) FILTER (WHERE e.kind IN ('clock_in', 'break_end')) AS ultima_abertura,\n sum(CASE\n WHEN e.kind IN ('clock_out', 'break_start') THEN extract(epoch FROM e.effective_at)\n WHEN e.kind IN ('clock_in', 'break_end') THEN -extract(epoch FROM e.effective_at)\n END) AS soma_com_sinal\n FROM public.v_workforce_time_entries e\n WHERE NOT e.voided\n GROUP BY e.tenant_id, e.unit_id, e.person_id, e.work_date\n),\nrealized AS (\n SELECT b.tenant_id,\n b.unit_id,\n b.person_id,\n b.work_date,\n b.first_in,\n b.last_out,\n b.break_count,\n (b.aberturas <> b.fechamentos) AS is_open,\n CASE WHEN b.aberturas <> b.fechamentos THEN b.ultima_abertura END AS open_since,\n -- Devolve à soma a abertura sem par (003). Sem isto o que sobra é o\n -- epoch dela, com sinal trocado.\n (b.soma_com_sinal\n + CASE WHEN b.aberturas <> b.fechamentos\n THEN extract(epoch FROM b.ultima_abertura)\n ELSE 0 END) AS worked_seconds\n FROM bruto b\n),\nplanned AS (\n SELECT a.tenant_id,\n a.unit_id,\n a.person_id,\n s.schedule_date AS work_date,\n a.id AS assignment_id,\n a.role,\n a.status AS assignment_status,\n a.shift_label,\n a.shift_start_time,\n a.shift_end_time,\n -- O turno que vira o dia é normal aqui; sem o +24h a jornada da noite\n -- daria negativa.\n (extract(epoch FROM a.shift_end_time - a.shift_start_time)\n + CASE WHEN a.shift_end_time <= a.shift_start_time THEN 86400 ELSE 0 END) AS planned_seconds\n FROM public.plg_workforce_schedule_assignments a\n JOIN public.plg_workforce_schedules s ON s.id = a.schedule_id\n WHERE s.status IN ('published', 'closed')\n AND a.role = 'titular'\n AND a.shift_start_time IS NOT NULL\n AND a.shift_end_time IS NOT NULL\n)\nSELECT coalesce(p.tenant_id, r.tenant_id) AS tenant_id,\n coalesce(p.unit_id, r.unit_id) AS unit_id,\n coalesce(p.person_id, r.person_id) AS person_id,\n coalesce(p.work_date, r.work_date) AS work_date,\n p.assignment_id,\n p.shift_label,\n p.shift_start_time,\n p.shift_end_time,\n p.assignment_status,\n (p.planned_seconds / 60)::integer AS planned_minutes,\n -- O MEDIDO, sempre. Não é aqui que se decide se ele conta — a coluna\n -- mente se for corrigida, e um número corrigido em silêncio é pior que\n -- um número estranho.\n (r.worked_seconds / 60)::integer AS worked_minutes,\n ((r.worked_seconds - p.planned_seconds) / 60)::integer AS variance_minutes,\n r.first_in,\n r.last_out,\n r.break_count,\n coalesce(r.is_open, false) AS is_open,\n (r.person_id IS NULL) AS absent,\n (p.assignment_id IS NULL) AS outside_schedule,\n r.open_since,\n -- Por que este dia não entra na soma. NULL = entra.\n CASE\n WHEN r.person_id IS NULL THEN NULL -- escala sem ponto: não há o que somar\n WHEN r.is_open THEN 'open'\n WHEN (r.worked_seconds / 60) > public.workforce_max_shift_minutes()\n THEN 'span_too_long'\n END AS review_reason\n FROM planned p\n FULL OUTER JOIN realized r\n ON r.tenant_id = p.tenant_id\n AND r.person_id = p.person_id\n AND r.work_date = p.work_date;\n\nCOMMENT ON VIEW public.v_workforce_day IS\n 'Jornada prevista × realizada por pessoa e dia. \\`worked_minutes\\` é sempre o MEDIDO; \\`review_reason\\` diz por que um dia não entra na soma — \\`open\\` (não terminou) ou \\`span_too_long\\` (fechou com duração que não é jornada, 004). Quem soma filtra por ela.';\n\nCOMMENT ON COLUMN public.v_workforce_day.review_reason IS\n 'NULL = o dia conta. \\`open\\` = ponto em aberto. \\`span_too_long\\` = acima de workforce_max_shift_minutes(), que na prática é saída não batida e não jornada longa (004).';\n\nGRANT SELECT ON public.v_workforce_day TO authenticated, service_role;\n`\n\nexport const MIGRATION_005_A_FICHA_TRABALHISTA_TEM_ONDE_MORAR = `-- ---------------------------------------------------------------------------\n-- 005_a_ficha_trabalhista_tem_onde_morar.sql — o RH do V1 ganha destino.\n--\n-- Escrito a partir de \\`~/dev/beautyplace/docs/v1-guide/rh\\`, que documenta o\n-- módulo do V1 andando nele: a Visão Geral (oito cartões, três listas) e a Ficha\n-- de RH da pessoa (dezesseis vistas). O FLOW de cada um diz \\`v2: status:\n-- missing\\` — e dizia certo: não havia nada disso aqui.\n--\n-- ── O que este arquivo NÃO traz, e por quê ────────────────────────────────\n--\n-- Não são as dezesseis vistas. Medido na origem em 19/09/2026, nas 886 pessoas\n-- dos três tenants em porte:\n--\n-- as 12 tabelas de lista da Ficha 0 linhas — no V1 INTEIRO, não só\n-- (férias, faltas, advertências, nesses tenants\n-- treinamentos, avaliações, exames,\n-- EPIs, pendências, dependentes,\n-- histórico salarial, 13º, benefícios)\n-- admission_date, dismissal_date, 0 preenchidos\n-- job_title, pis_pasep, CTPS, eSocial\n-- base_salary 25 de 422\n-- contract_type 2\n--\n-- O próprio FLOW da Visão Geral avisa: *\"Num tenant recém-migrado ele mostra\n-- headcount cheio e todo o resto zerado — e isso não é defeito, é ficha vazia.\"*\n-- Aqui TODO tenant é assim. Desenhar dezesseis vistas para portar zero linha\n-- seria construir um formulário de cada vez que alguém leu um FLOW.\n--\n-- Então vem o núcleo que sustenta o conceito e já é útil no primeiro dia:\n--\n-- employment o vínculo — é dele que saem headcount, admissões,\n-- demissões, turnover e experiência vencendo. Sem ele\n-- nenhum número de RH existe, e é a vista 1 do V1\n-- vacations férias. Obrigação legal, recorrente, e a única lista do\n-- V1 que alimenta um cartão\n-- absences faltas e afastamentos\n-- pending_items pendências — a outra lista que aparece no painel\n--\n-- As outras doze entram quando alguém for usá-las. O que elas precisam já está\n-- de pé: a forma da tabela, o escopo, a permissão e a view do painel.\n--\n-- ── A permissão, que o FLOW manda resolver ANTES ──────────────────────────\n--\n-- V1-069: no V1 **não há permissão separada para RH**. Quem edita cadastro vê\n-- salário, CPF, dependentes e CID de atestado. O FLOW da Ficha fecha dizendo que\n-- portar só vale \"desde que a questão da permissão separada seja resolvida\n-- antes\" — então ela é resolvida aqui, e não depois.\n--\n-- \\`workforce_hr.*\\` é uma família própria: quem tem \\`workforce.read\\` vê escala e\n-- ponto e NÃO vê salário. São duas perguntas diferentes e agora têm duas\n-- respostas.\n--\n-- ── O que o V2 faz e o V1 não ────────────────────────────────────────────\n--\n-- O FLOW da vista 10 registra, como defeito: *\"Não conversa com o ponto\n-- eletrônico nem com a escala: uma falta lançada aqui não aparece lá.\"* No V1\n-- são dois sistemas sobre a mesma pessoa que não se olham — alguém de atestado\n-- aparece como FALTA na folha de ponto, e quem confere precisa lembrar de cabeça.\n--\n-- A §7 conserta: \\`v_workforce_day\\` passa a saber do afastamento. Um dia coberto\n-- por atestado deixa de ser \"Falta\" e passa a dizer o que foi. É a razão de o RH\n-- morar no mesmo plugin do ponto, e não ao lado dele.\n--\n-- Idempotente e replay-safe.\n-- ---------------------------------------------------------------------------\n\n-- ═══════════════════════════════════════════════════════════════════════════\n-- 1. A PERMISSÃO SEPARADA (V1-069)\n-- ═══════════════════════════════════════════════════════════════════════════\n\nINSERT INTO app.permissions (key, plugin, description) VALUES\n ('workforce_hr.read', 'workforce_hr', 'Ver a ficha trabalhista: vínculo, salário, férias, afastamentos e pendências.'),\n ('workforce_hr.create', 'workforce_hr', 'Lançar na ficha trabalhista.'),\n ('workforce_hr.edit', 'workforce_hr', 'Corrigir lançamento da ficha trabalhista.'),\n ('workforce_hr.delete', 'workforce_hr', 'Excluir lançamento da ficha trabalhista.')\nON CONFLICT (key) DO UPDATE SET plugin = EXCLUDED.plugin, description = EXCLUDED.description;\n\n-- ═══════════════════════════════════════════════════════════════════════════\n-- 2. O VÍNCULO\n-- ═══════════════════════════════════════════════════════════════════════════\n--\n-- Uma linha por pessoa, e não colunas em \\`public.people\\`. Três razões, e a\n-- terceira é a que decide:\n--\n-- · \\`people\\` é do core e atende cliente, fornecedor e profissional; quarenta\n-- colunas de CLT ali seriam nulas para a esmagadora maioria das linhas;\n-- · o V1 tem DUAS tabelas (professionals, employees) com os mesmos campos, e\n-- o V2 tem uma pessoa só — a separação é papel, não cadastro;\n-- · e é a tabela que carrega o salário. Ela precisa de uma policy PRÓPRIA,\n-- e uma coluna não tem policy: RLS é por tabela. Guardar salário em\n-- \\`people\\` seria reproduzir o V1-069 no schema.\n\nCREATE TABLE IF NOT EXISTS public.plg_workforce_employment (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n unit_id uuid,\n person_id uuid NOT NULL REFERENCES public.people(id) ON DELETE CASCADE,\n\n -- ── vínculo (vista 1 do V1: Contrato) ────────────────────────────────────\n -- Vocabulário exato do FLOW. Lista fechada porque é ela que decide encargo, e\n -- \"CLT \" com espaço vira um tipo de contrato novo em silêncio.\n contract_kind text CHECK (contract_kind IN\n ('clt', 'pj', 'estagio', 'jovem_aprendiz', 'autonomo', 'socio', 'temporario', 'intermitente')),\n admission_date date,\n dismissal_date date,\n dismissal_reason text,\n -- O V1 tem os dois: o fim da PRIMEIRA experiência e o fim FINAL. É o segundo\n -- que alimenta o cartão \"Experiência vencendo\".\n probation_first_end date,\n probation_final_end date,\n job_title text,\n department text,\n schedule_regime text CHECK (schedule_regime IN\n ('mensalista', 'horista', 'diarista', 'escala_12x36', 'escala_6x1', 'escala_5x2')),\n weekly_hours numeric(5,2),\n monthly_hours numeric(6,2),\n esocial_registration text,\n\n -- ── remuneração (vista 2) ────────────────────────────────────────────────\n -- Valor de REFERÊNCIA, e o V1 diz isso com todas as letras: nada aqui vira\n -- conta a pagar, comissão ou desconto. O comentário fica porque a coluna\n -- chamada \\`base_salary\\` convida a supor o contrário.\n base_salary numeric(14,2),\n pay_frequency text CHECK (pay_frequency IN ('mensal', 'quinzenal', 'semanal', 'diario', 'por_hora')),\n -- Os quinze auxílios e adicionais da vista 2 em jsonb, e não em quinze\n -- colunas. Medido: ZERO deles preenchido nas 886 pessoas. Quinze colunas\n -- nulas seriam quinze decisões de schema tomadas sem um caso de uso; a chave\n -- que a casa usar aparece aqui e vira coluna quando pagar a passagem.\n allowances jsonb NOT NULL DEFAULT '{}'::jsonb,\n\n notes text,\n metadata jsonb NOT NULL DEFAULT '{}'::jsonb,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n -- Um vínculo por pessoa. Recontratação reabre o mesmo, com a data nova — o\n -- histórico de quem saiu e voltou é uma vista de lista que ainda não existe,\n -- e inventar a segunda linha agora criaria dois headcounts para uma pessoa.\n CONSTRAINT plg_workforce_employment_person UNIQUE (tenant_id, person_id),\n -- Demissão antes da admissão não é dado ruim, é dado impossível.\n CONSTRAINT plg_workforce_employment_dates\n CHECK (dismissal_date IS NULL OR admission_date IS NULL OR dismissal_date >= admission_date)\n);\n\nCOMMENT ON TABLE public.plg_workforce_employment IS\n 'O vínculo trabalhista de uma pessoa (005). É dele que saem headcount, admissões, demissões, turnover e experiência vencendo. Tabela própria e não colunas em people: RLS é por tabela, e é o salário que precisa da policy.';\nCOMMENT ON COLUMN public.plg_workforce_employment.base_salary IS\n 'Valor de REFERÊNCIA. Não vira conta a pagar, comissão nem desconto — o V1 também não fazia isso, e o nome da coluna convida a supor que faz.';\nCOMMENT ON COLUMN public.plg_workforce_employment.allowances IS\n 'Auxílios e adicionais (VT, VR, VA, plano, creche, noturno, insalubridade…). Em jsonb porque os quinze campos da vista 2 do V1 estão vazios nas 886 pessoas medidas: vira coluna quando alguém pagar a passagem.';\n\nCREATE INDEX IF NOT EXISTS plg_workforce_employment_tenant_person\n ON public.plg_workforce_employment (tenant_id, person_id);\nCREATE INDEX IF NOT EXISTS plg_workforce_employment_ativo\n ON public.plg_workforce_employment (tenant_id) WHERE dismissal_date IS NULL;\n\n-- ═══════════════════════════════════════════════════════════════════════════\n-- 3. FÉRIAS (vista 8)\n-- ═══════════════════════════════════════════════════════════════════════════\n\nCREATE TABLE IF NOT EXISTS public.plg_workforce_vacations (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n unit_id uuid,\n person_id uuid NOT NULL REFERENCES public.people(id) ON DELETE CASCADE,\n -- O período AQUISITIVO: o que a pessoa ganhou. É o \\`accrual_end\\` que alimenta\n -- \"Férias vencendo (60d)\".\n accrual_start date NOT NULL,\n accrual_end date NOT NULL,\n -- O período de GOZO: quando ela tirou. Nulo é o estado normal de quem ainda\n -- não programou.\n taken_start date,\n taken_end date,\n days_taken integer,\n days_sold integer,\n advance_13th boolean NOT NULL DEFAULT false,\n -- Digitado, como no V1. O sistema NÃO calcula o terço — e escrever isso aqui\n -- é o que impede alguém de supor que a coluna vazia é um bug.\n constitutional_third numeric(14,2),\n status text NOT NULL DEFAULT 'pendente'\n CHECK (status IN ('pendente', 'programada', 'em_gozo', 'concluida', 'vencida')),\n notes text,\n metadata jsonb NOT NULL DEFAULT '{}'::jsonb,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n CONSTRAINT plg_workforce_vacations_accrual CHECK (accrual_end >= accrual_start),\n CONSTRAINT plg_workforce_vacations_taken\n CHECK (taken_end IS NULL OR taken_start IS NULL OR taken_end >= taken_start)\n);\n\nCOMMENT ON TABLE public.plg_workforce_vacations IS\n 'Período aquisitivo e de gozo de férias (005). Nada é calculado, inclusive o terço constitucional — é digitado, como no V1.';\n\nCREATE INDEX IF NOT EXISTS plg_workforce_vacations_vencendo\n ON public.plg_workforce_vacations (tenant_id, accrual_end) WHERE status <> 'concluida';\n\n-- ═══════════════════════════════════════════════════════════════════════════\n-- 4. FALTAS E AFASTAMENTOS (vista 10)\n-- ═══════════════════════════════════════════════════════════════════════════\n\nCREATE TABLE IF NOT EXISTS public.plg_workforce_absences (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n unit_id uuid,\n person_id uuid NOT NULL REFERENCES public.people(id) ON DELETE CASCADE,\n kind text NOT NULL CHECK (kind IN\n ('falta_justificada', 'falta_injustificada', 'atestado', 'licenca_maternidade',\n 'licenca_paternidade', 'licenca_nao_remunerada', 'suspensao', 'ferias', 'afastamento_inss')),\n starts_on date NOT NULL,\n -- Nulo = afastamento em aberto, que é um estado real (INSS sem alta marcada).\n ends_on date,\n days integer,\n paid boolean NOT NULL DEFAULT true,\n -- Dado médico. Mora aqui e não em \\`people\\` pelo mesmo motivo do salário: é a\n -- policy de \\`workforce_hr\\` que o protege, e V1-069 é exatamente sobre isto.\n icd_code text,\n doctor text,\n notes text,\n metadata jsonb NOT NULL DEFAULT '{}'::jsonb,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n CONSTRAINT plg_workforce_absences_span CHECK (ends_on IS NULL OR ends_on >= starts_on)\n);\n\nCOMMENT ON TABLE public.plg_workforce_absences IS\n 'Falta e afastamento (005). Diferente do V1, CONVERSA com o ponto: v_workforce_day lê daqui, e um dia coberto por atestado deixa de aparecer como falta.';\nCOMMENT ON COLUMN public.plg_workforce_absences.icd_code IS\n 'CID do atestado. Dado de saúde: é a policy de workforce_hr que o separa de quem edita cadastro — o V1 não separava (V1-069).';\n\nCREATE INDEX IF NOT EXISTS plg_workforce_absences_periodo\n ON public.plg_workforce_absences (tenant_id, person_id, starts_on, ends_on);\n\n-- ═══════════════════════════════════════════════════════════════════════════\n-- 5. PENDÊNCIAS (vista 16)\n-- ═══════════════════════════════════════════════════════════════════════════\n\nCREATE TABLE IF NOT EXISTS public.plg_workforce_pending_items (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n unit_id uuid,\n person_id uuid NOT NULL REFERENCES public.people(id) ON DELETE CASCADE,\n title text NOT NULL,\n category text CHECK (category IN ('documento', 'exame', 'epi', 'treinamento', 'assinatura', 'outro')),\n priority text NOT NULL DEFAULT 'normal' CHECK (priority IN ('baixa', 'normal', 'alta', 'urgente')),\n due_on date,\n resolved_at timestamptz,\n description text,\n metadata jsonb NOT NULL DEFAULT '{}'::jsonb,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\n\nCOMMENT ON TABLE public.plg_workforce_pending_items IS\n 'O que falta na pasta de alguém: documento, exame, EPI, assinatura (005). É a lista que o painel cobra — \"Pendências críticas\" conta as não resolvidas de prioridade alta ou urgente.';\n\nCREATE INDEX IF NOT EXISTS plg_workforce_pending_abertas\n ON public.plg_workforce_pending_items (tenant_id, priority, due_on) WHERE resolved_at IS NULL;\n\n-- ═══════════════════════════════════════════════════════════════════════════\n-- 6. ESCOPO, CARIMBO E PERMISSÃO\n-- ═══════════════════════════════════════════════════════════════════════════\n\nDO $$\nDECLARE r record;\nBEGIN\n FOR r IN SELECT * FROM (VALUES\n ('plg_workforce_employment', 'workforce_hr.employment'),\n ('plg_workforce_vacations', 'workforce_hr.vacation'),\n ('plg_workforce_absences', 'workforce_hr.absence'),\n ('plg_workforce_pending_items', 'workforce_hr.pending_item')\n ) AS t(tbl, resource)\n LOOP\n EXECUTE format('ALTER TABLE public.%I ENABLE ROW LEVEL SECURITY', r.tbl);\n EXECUTE format('ALTER TABLE public.%I FORCE ROW LEVEL SECURITY', r.tbl);\n EXECUTE format('DROP TRIGGER IF EXISTS %I ON public.%I', r.tbl || '_updated_at', r.tbl);\n EXECUTE format('CREATE TRIGGER %I BEFORE UPDATE ON public.%I FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at()',\n r.tbl || '_updated_at', r.tbl);\n -- \\`workforce_hr\\` e não \\`workforce\\`: é o nome do plugin que o scaffold usa\n -- para derivar \\`<plugin>.read/create/edit/delete\\`, então passar outro nome\n -- aqui É a separação de permissão. Quem tem workforce.read vê escala e\n -- ponto e não vê salário.\n PERFORM app.scaffold_table(format('public.%I', r.tbl)::regclass, r.resource, 'workforce_hr', false, 'enforce');\n END LOOP;\nEND $$;\n\n-- ═══════════════════════════════════════════════════════════════════════════\n-- 7. O PONTO PASSA A SABER DO AFASTAMENTO\n-- ═══════════════════════════════════════════════════════════════════════════\n--\n-- O defeito que o FLOW da vista 10 registra: *\"Não conversa com o ponto\n-- eletrônico nem com a escala.\"* Duas telas sobre a mesma pessoa, no mesmo\n-- sistema, que não se olham — quem confere ponto vê FALTA em cima de um\n-- atestado que o RH lançou na semana passada, e precisa lembrar de cabeça.\n--\n-- A view não muda de forma: ganha uma coluna no fim. \\`absent\\` continua sendo\n-- escala sem ponto, porque é isso que ele é; o que muda é que agora dá para\n-- dizer POR QUE, quando alguém sabe.\n\nCREATE OR REPLACE VIEW public.v_workforce_day\nWITH (security_invoker = 'true') AS\nWITH bruto AS (\n SELECT e.tenant_id, e.unit_id, e.person_id, e.work_date,\n min(e.effective_at) FILTER (WHERE e.kind = 'clock_in') AS first_in,\n max(e.effective_at) FILTER (WHERE e.kind = 'clock_out') AS last_out,\n count(*) FILTER (WHERE e.kind = 'break_start') AS break_count,\n count(*) FILTER (WHERE e.kind IN ('clock_in', 'break_end')) AS aberturas,\n count(*) FILTER (WHERE e.kind IN ('clock_out', 'break_start')) AS fechamentos,\n max(e.effective_at) FILTER (WHERE e.kind IN ('clock_in', 'break_end')) AS ultima_abertura,\n sum(CASE\n WHEN e.kind IN ('clock_out', 'break_start') THEN extract(epoch FROM e.effective_at)\n WHEN e.kind IN ('clock_in', 'break_end') THEN -extract(epoch FROM e.effective_at)\n END) AS soma_com_sinal\n FROM public.v_workforce_time_entries e\n WHERE NOT e.voided\n GROUP BY e.tenant_id, e.unit_id, e.person_id, e.work_date\n),\nrealized AS (\n SELECT b.tenant_id, b.unit_id, b.person_id, b.work_date,\n b.first_in, b.last_out, b.break_count,\n (b.aberturas <> b.fechamentos) AS is_open,\n CASE WHEN b.aberturas <> b.fechamentos THEN b.ultima_abertura END AS open_since,\n -- Devolve à soma a abertura sem par (003).\n (b.soma_com_sinal\n + CASE WHEN b.aberturas <> b.fechamentos\n THEN extract(epoch FROM b.ultima_abertura)\n ELSE 0 END) AS worked_seconds\n FROM bruto b\n),\nplanned AS (\n SELECT a.tenant_id, a.unit_id, a.person_id,\n s.schedule_date AS work_date,\n a.id AS assignment_id, a.role, a.status AS assignment_status,\n a.shift_label, a.shift_start_time, a.shift_end_time,\n (extract(epoch FROM a.shift_end_time - a.shift_start_time)\n + CASE WHEN a.shift_end_time <= a.shift_start_time THEN 86400 ELSE 0 END) AS planned_seconds\n FROM public.plg_workforce_schedule_assignments a\n JOIN public.plg_workforce_schedules s ON s.id = a.schedule_id\n WHERE s.status IN ('published', 'closed')\n AND a.role = 'titular'\n AND a.shift_start_time IS NOT NULL\n AND a.shift_end_time IS NOT NULL\n)\nSELECT coalesce(p.tenant_id, r.tenant_id) AS tenant_id,\n coalesce(p.unit_id, r.unit_id) AS unit_id,\n coalesce(p.person_id, r.person_id) AS person_id,\n coalesce(p.work_date, r.work_date) AS work_date,\n p.assignment_id,\n p.shift_label,\n p.shift_start_time,\n p.shift_end_time,\n p.assignment_status,\n (p.planned_seconds / 60)::integer AS planned_minutes,\n (r.worked_seconds / 60)::integer AS worked_minutes,\n ((r.worked_seconds - p.planned_seconds) / 60)::integer AS variance_minutes,\n r.first_in,\n r.last_out,\n r.break_count,\n coalesce(r.is_open, false) AS is_open,\n (r.person_id IS NULL) AS absent,\n (p.assignment_id IS NULL) AS outside_schedule,\n r.open_since,\n CASE\n WHEN r.person_id IS NULL THEN NULL\n WHEN r.is_open THEN 'open'\n WHEN (r.worked_seconds / 60) > public.workforce_max_shift_minutes()\n THEN 'span_too_long'\n END AS review_reason,\n -- Coluna nova, no fim. \\`security_invoker\\` na view + a policy de\n -- \\`workforce_hr\\` na tabela: quem não tem a permissão simplesmente não\n -- enxerga o afastamento, e a coluna volta nula. A folha de ponto não\n -- vira a porta dos fundos do CID.\n (SELECT a.kind\n FROM public.plg_workforce_absences a\n WHERE a.tenant_id = coalesce(p.tenant_id, r.tenant_id)\n AND a.person_id = coalesce(p.person_id, r.person_id)\n AND coalesce(p.work_date, r.work_date) BETWEEN a.starts_on\n AND coalesce(a.ends_on, coalesce(p.work_date, r.work_date))\n ORDER BY a.starts_on DESC\n LIMIT 1) AS absence_kind\n FROM planned p\n FULL OUTER JOIN realized r\n ON r.tenant_id = p.tenant_id\n AND r.person_id = p.person_id\n AND r.work_date = p.work_date;\n\nCOMMENT ON COLUMN public.v_workforce_day.absence_kind IS\n 'O afastamento que cobre este dia, quando o RH lançou um (005). É o conserto do defeito que o V1 tem: lá a falta lançada no RH não aparece no ponto, e quem confere vê FALTA em cima de um atestado.';\n\nGRANT SELECT ON public.v_workforce_day TO authenticated, service_role;\n\n-- ═══════════════════════════════════════════════════════════════════════════\n-- 8. A VISÃO GERAL — os oito números\n-- ═══════════════════════════════════════════════════════════════════════════\n--\n-- Os mesmos oito cartões do V1, com três diferenças, e as três são conserto de\n-- defeito registrado no FLOW:\n--\n-- V1-072 \"Experiência vencendo\" só olhava PROFISSIONAIS, porque o V1 tem\n-- duas tabelas. O V2 tem uma pessoa, então olha todo mundo.\n-- V1-073 \"Férias a vencer\" mostrava a data e NÃO o nome. Aqui a view devolve\n-- person_id e nome, e a tela linka para a ficha.\n-- V1-074 aniversariante do dia 1º caía no mês anterior, por comparação de\n-- data em UTC. \\`extract(month …)\\` não tem esse problema.\n\nCREATE OR REPLACE VIEW public.v_workforce_hr_overview\nWITH (security_invoker = 'true') AS\nWITH base AS (\n SELECT e.tenant_id, e.person_id, e.admission_date, e.dismissal_date, e.probation_final_end,\n p.name, p.date_of_birth, p.is_active\n FROM public.plg_workforce_employment e\n JOIN public.people p ON p.id = e.person_id\n)\nSELECT b.tenant_id,\n count(*) FILTER (WHERE b.dismissal_date IS NULL AND coalesce(b.is_active, true)) AS headcount,\n count(*) FILTER (WHERE b.admission_date >= current_date - 30) AS admissions_30d,\n count(*) FILTER (WHERE b.dismissal_date >= current_date - 30) AS dismissals_30d,\n count(*) FILTER (WHERE b.probation_final_end BETWEEN current_date AND current_date + 60) AS probation_ending,\n count(*) FILTER (WHERE b.dismissal_date IS NULL\n AND extract(month FROM b.date_of_birth) = extract(month FROM current_date)) AS birthdays_this_month,\n (SELECT count(*) FROM public.plg_workforce_vacations v\n WHERE v.tenant_id = b.tenant_id AND v.status <> 'concluida'\n AND v.accrual_end BETWEEN current_date AND current_date + 60) AS vacations_due_60d,\n (SELECT count(*) FROM public.plg_workforce_pending_items i\n WHERE i.tenant_id = b.tenant_id AND i.resolved_at IS NULL\n AND i.priority IN ('alta', 'urgente')) AS critical_pending\n FROM base b\n GROUP BY b.tenant_id;\n\nCOMMENT ON VIEW public.v_workforce_hr_overview IS\n 'Os oito números do painel de RH do V1 (005). Turnover não está aqui de propósito: é demissões ÷ headcount, e uma divisão que a tela faz é uma divisão que a tela pode formatar — a view devolve as duas parcelas.';\n\n-- Quem vence, COM NOME — o V1-073 é justamente a lista sem nome.\nCREATE OR REPLACE VIEW public.v_workforce_hr_upcoming\nWITH (security_invoker = 'true') AS\nSELECT v.tenant_id, 'vacation'::text AS kind, v.person_id, p.name AS person_name,\n v.accrual_end AS due_on, v.status AS detail\n FROM public.plg_workforce_vacations v\n JOIN public.people p ON p.id = v.person_id\n WHERE v.status <> 'concluida' AND v.accrual_end BETWEEN current_date AND current_date + 60\nUNION ALL\nSELECT e.tenant_id, 'probation', e.person_id, p.name,\n e.probation_final_end, e.job_title\n FROM public.plg_workforce_employment e\n JOIN public.people p ON p.id = e.person_id\n WHERE e.probation_final_end BETWEEN current_date AND current_date + 60\nUNION ALL\nSELECT i.tenant_id, 'pending', i.person_id, p.name, i.due_on, i.title\n FROM public.plg_workforce_pending_items i\n JOIN public.people p ON p.id = i.person_id\n WHERE i.resolved_at IS NULL AND i.priority IN ('alta', 'urgente');\n\nCOMMENT ON VIEW public.v_workforce_hr_upcoming IS\n 'O que vence nos próximos 60 dias, com o NOME de quem (005). O V1 lista férias a vencer só com a data — V1-073 —, o que obriga a procurar a pessoa em outra tela.';\n\nGRANT SELECT ON public.v_workforce_hr_overview TO authenticated, service_role;\nGRANT SELECT ON public.v_workforce_hr_upcoming TO authenticated, service_role;\n`\n\nexport const MIGRATION_006_O_CUSTO_DE_PESSOAL_E_UM_RAMO_DO_PLANO = `-- O custo de pessoal é um RAMO DO PLANO, e quem o aponta é a casa.\n--\n-- ── O defeito, medido ───────────────────────────────────────────────────────\n--\n-- A tela \"Encargos e honorários\" (RH → Custos) lê o razão do Financeiro e soma\n-- toda linha de conta a pagar que tenha conta contábil. Toda. Medido no cluster\n-- de Pertinho do Céu em 19/09/2026, o que ela chamava de custo de pessoal\n-- incluía:\n--\n-- 6.2.1 Aluguel R$ 21.061,99\n-- 6.2.3 IPTU R$ 26.613,73\n-- 6.5.5 ICMS R$ 30.964,92\n-- 6.4.1 Equipamentos R$ 82.595,87\n-- 6.3.1 Energia/Light R$ 27.185,62\n--\n-- — ao lado das que são, de fato, pessoal:\n--\n-- 5 DESPESAS COM PESSOAL (26 contas: Salário, FGTS, INSS, Comissão,\n-- Hora Extra, Adiantamento, IRRF Folha, Vale Alimentação, FREELANCER\n-- com oito filhas…)\n--\n-- Um total que soma aluguel e ICMS sob o título \"custo de pessoal\" não está\n-- impreciso: está errado, e erra para cima, que é o lado que faz alguém decidir\n-- contratar menos.\n--\n-- ── Por que uma configuração, e não uma regra ───────────────────────────────\n--\n-- Não existe como o código saber qual ramo é pessoal. O plano de contas é uma\n-- DECLARAÇÃO da casa (ver \\`040_o_demonstrativo_mostra_o_plano_inteiro\\`), e cada\n-- casa numera o dela: aqui pessoal é o ramo \\`5\\`; o template que o SDK semeia\n-- para um tenant novo põe folha em \\`4.03\\`. Deduzir pelo NOME da conta seria\n-- adivinhar em português — e \\`8.2.4 Rescisão\\` e \\`8.2.6 Pro-labore\\`, que também\n-- são gente, moram fora do ramo 5 neste mesmo plano, por escolha de quem montou.\n--\n-- Então a chave guarda a escolha, e a ausência dela é uma ausência honesta: sem\n-- nada escolhido a tela mostra TODAS as despesas classificadas e diz que é o que\n-- está fazendo. Um padrão inventado ('5') faria a tela mentir com convicção no\n-- primeiro tenant cujo plano não usa esse número.\n--\n-- ── A forma ────────────────────────────────────────────────────────────────\n--\n-- Um array de CÓDIGOS, não de ids. O código é o que carrega a hierarquia: quem\n-- escolhe '5' escolhe o ramo inteiro — '5.4' e '5.4.1' entram com ele, e uma\n-- conta nova criada amanhã dentro do ramo entra sozinha. Com id seria preciso\n-- re-marcar a configuração a cada conta nova, e ninguém lembra.\n--\n-- \\`tenant_setting_set\\` cobra \\`settings.manage\\` e recusa chave não registrada —\n-- é por isso que este arquivo existe. Idempotente: \\`seed_setting_keys\\` faz\n-- upsert por chave.\n\nSELECT app.seed_setting_keys($$[\n {\n \"key\": \"workforce.payroll_account_codes\", \"type\": \"array\", \"default\": null,\n \"reader\": \"plugin:workforce\",\n \"description\": \"Os ramos do plano de contas que são custo de pessoal, por código ('5', '8.2.4'). Um código traz toda a descendência dele. Vazio ou nulo = a tela mostra todas as despesas classificadas e avisa que não há ramo escolhido.\"\n }\n]$$::jsonb);\n`\n\nexport const MIGRATION_007_A_PORTA_DE_SERVICO_DO_PONTO_FECHA_TAMBEM = `-- ---------------------------------------------------------------------------\n-- 007_a_porta_de_servico_do_ponto_fecha_tambem.sql\n--\n-- ── o buraco, e por que ele estava invisível ────────────────────────────\n--\n-- As funções que este plugin cria em \\`migration\\` nasceram executáveis por\n-- PUBLIC — é o padrão do Postgres, e ninguém revogou. Elas são\n-- \\`SECURITY DEFINER\\` e escrevem dados de QUALQUER inquilino: uma delas\n-- alcançável pela chave anônima é a base inteira alcançável pela chave\n-- anônima.\n--\n-- Isto não aparecia porque a \\`000_baseline\\` falhava e levava junto os outros\n-- seis arquivos deste plugin — as funções nunca chegavam a existir na bancada.\n-- Com a baseline consertada (#443) o passo inteiro passou a aplicar, e as duas\n-- suítes que guardam esta fronteira ficaram vermelhas na hora:\n--\n-- 090_migration_ledger/001 \"no migration function is executable by anon\n-- or authenticated\" — have 9, want 0\n-- crm/009 \"nenhuma função do schema migration é chamável\n-- por anon\" — have 9, want 0\n--\n-- Ou seja: a regra sempre esteve escrita e medida; o que faltava era o plugin\n-- chegar ao banco para ser medido. É o argumento do #338 de novo — um plugin\n-- fora da corrente é um plugin cujas tabelas (e cujas portas) são coincidência.\n--\n-- ── por extenso, e não varrendo o catálogo ──────────────────────────────\n--\n-- A tentação é \\`REVOKE\\` num laço sobre \\`pg_proc\\`. Já foi tentado neste repo e\n-- derrubou 35 suítes: varrer revoga de funções que outros passos criaram e\n-- precisam de execução. Revogar em massa é fácil de escrever e impossível de\n-- revisar. A lista abaixo é a das funções DESTE plugin, nomeada uma a uma.\n-- ---------------------------------------------------------------------------\n\nDO $$\nDECLARE\n v_fn text;\n v_args text;\n v_n integer := 0;\nBEGIN\n -- Cada writer do de-para, com a assinatura que a corrente lhe deu. Nomear em\n -- vez de varrer é o que torna esta lista revisável.\n FOR v_fn, v_args IN\n SELECT p.proname, pg_get_function_identity_arguments(p.oid)\n FROM pg_proc p\n JOIN pg_namespace n ON n.oid = p.pronamespace\n WHERE n.nspname = 'migration'\n AND (p.proname LIKE 'upsert_wf\\\\_%' OR p.proname IN ('_wf_person', '_point_punch_photo'))\n LOOP\n EXECUTE format('REVOKE ALL ON FUNCTION migration.%I(%s) FROM PUBLIC, anon, authenticated', v_fn, v_args);\n -- \\`service_role\\` fica: é quem a importação usa, e é a única identidade que\n -- tem razão para atravessar inquilinos.\n EXECUTE format('GRANT EXECUTE ON FUNCTION migration.%I(%s) TO service_role', v_fn, v_args);\n v_n := v_n + 1;\n END LOOP;\n\n RAISE NOTICE 'workforce/007: % função(ões) de migration fechadas a anon e authenticated', v_n;\nEND $$;\n`\n\nexport const MIGRATIONS: Array<{ id: string; sql: string }> = [\n { id: \"000_baseline\", sql: MIGRATION_000_BASELINE },\n { id: \"001_a_prova_da_batida_tem_bytes\", sql: MIGRATION_001_A_PROVA_DA_BATIDA_TEM_BYTES },\n { id: \"002_o_guarda_da_foto_olhava_o_nome_errado\", sql: MIGRATION_002_O_GUARDA_DA_FOTO_OLHAVA_O_NOME_ERRADO },\n { id: \"003_o_ponto_em_aberto_para_de_mentir_a_duracao\", sql: MIGRATION_003_O_PONTO_EM_ABERTO_PARA_DE_MENTIR_A_DURACAO },\n { id: \"004_a_jornada_implausivel_nao_soma\", sql: MIGRATION_004_A_JORNADA_IMPLAUSIVEL_NAO_SOMA },\n { id: \"005_a_ficha_trabalhista_tem_onde_morar\", sql: MIGRATION_005_A_FICHA_TRABALHISTA_TEM_ONDE_MORAR },\n { id: \"006_o_custo_de_pessoal_e_um_ramo_do_plano\", sql: MIGRATION_006_O_CUSTO_DE_PESSOAL_E_UM_RAMO_DO_PLANO },\n { id: \"007_a_porta_de_servico_do_ponto_fecha_tambem\", sql: MIGRATION_007_A_PORTA_DE_SERVICO_DO_PONTO_FECHA_TAMBEM },\n]\n"]}
|
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"status": "beta",
|
|
5
5
|
"dependencies": []
|
|
6
6
|
},
|
|
7
|
-
"version": "0.3.
|
|
7
|
+
"version": "0.3.15",
|
|
8
8
|
"description": "Fayz SDK — plugin-workforce plugin",
|
|
9
9
|
"type": "module",
|
|
10
10
|
"sideEffects": false,
|
|
@@ -17,6 +17,12 @@
|
|
|
17
17
|
"import": "./dist/index.js"
|
|
18
18
|
}
|
|
19
19
|
},
|
|
20
|
+
"imports": {
|
|
21
|
+
"#migrations/*": {
|
|
22
|
+
"browser": "./dist/migrations/*.browser.js",
|
|
23
|
+
"default": "./dist/migrations/*.js"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
20
26
|
"files": [
|
|
21
27
|
"dist"
|
|
22
28
|
],
|
|
@@ -27,9 +33,9 @@
|
|
|
27
33
|
"zustand": "^4.5.0 || ^5.0.0"
|
|
28
34
|
},
|
|
29
35
|
"dependencies": {
|
|
30
|
-
"@fayz-ai/admin": "^0.
|
|
31
|
-
"@fayz-ai/core": "^0.
|
|
32
|
-
"@fayz-ai/ui": "^0.
|
|
36
|
+
"@fayz-ai/admin": "^0.63.0",
|
|
37
|
+
"@fayz-ai/core": "^0.63.0",
|
|
38
|
+
"@fayz-ai/ui": "^0.63.0"
|
|
33
39
|
},
|
|
34
40
|
"devDependencies": {
|
|
35
41
|
"@types/node": "^20.0.0",
|