Compare commits
665 Commits
a2fe1fcbcc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b0657bd76 | ||
|
|
f75e082e67 | ||
|
|
f1273798cd | ||
|
|
bb814a46ff | ||
|
|
be7256ce4c | ||
|
|
d37f10f1c3 | ||
|
|
b98ee8a6fb | ||
|
|
df0de97a68 | ||
|
|
49a0a953e5 | ||
|
|
64eb34cdff | ||
|
|
cd0c08f348 | ||
|
|
6a5364e053 | ||
|
|
ec78fc148d | ||
|
|
9d9be17542 | ||
|
|
1d1bbfe612 | ||
|
|
b1257b6983 | ||
|
|
687decca28 | ||
|
|
307afbf3c0 | ||
|
|
fecd2415f6 | ||
|
|
e36318f7a5 | ||
|
|
feddca19d6 | ||
|
|
95378ff1da | ||
|
|
c8529b8a99 | ||
|
|
7a66d7849d | ||
|
|
9ad09c32b0 | ||
|
|
6b63df8c3d | ||
|
|
72d3130c88 | ||
|
|
f6518b4d7e | ||
|
|
bf6ee2bb2c | ||
|
|
077f898283 | ||
|
|
779539d1b5 | ||
|
|
34a65f9c4a | ||
|
|
97cce8c755 | ||
|
|
fe98fadf61 | ||
|
|
32c7026558 | ||
|
|
76866a7c76 | ||
|
|
f19ca02e05 | ||
|
|
1f5eaf0386 | ||
|
|
a82f09ea50 | ||
|
|
a5144a925c | ||
|
|
2bdf4ef6a0 | ||
|
|
3ba9f2821e | ||
|
|
0104e87750 | ||
|
|
1f818096db | ||
|
|
bb873e8a7a | ||
|
|
d4ef4d55e0 | ||
|
|
fc8963da99 | ||
|
|
c520803c84 | ||
|
|
7349f3180d | ||
|
|
2414b6328e | ||
|
|
5605012245 | ||
|
|
52849777dd | ||
|
|
6f060896bf | ||
|
|
3e0b531110 | ||
|
|
8cc02759b8 | ||
|
|
40b3205274 | ||
|
|
15470426eb | ||
|
|
b22bb11b31 | ||
|
|
134c94fc6c | ||
|
|
f1a2b300f7 | ||
|
|
396170b438 | ||
|
|
eb186cac3c | ||
|
|
4acf9d7f85 | ||
|
|
e596723ba5 | ||
|
|
d7ec91b0f1 | ||
|
|
3e5ced1655 | ||
|
|
aabfc1afe7 | ||
|
|
45b698beb5 | ||
|
|
de6336ba42 | ||
|
|
c876767755 | ||
|
|
d1fc3d8720 | ||
|
|
a78ceaba51 | ||
|
|
6c15a7b1cf | ||
|
|
45ddb444a7 | ||
|
|
9df3262d30 | ||
|
|
5d9609b5ee | ||
|
|
622f133f05 | ||
|
|
482f12256e | ||
|
|
86b8e59c95 | ||
|
|
1b8038d8e8 | ||
|
|
a2d13cf83b | ||
|
|
6f6aa6e90a | ||
|
|
0513ea23a4 | ||
|
|
72aa28e6c4 | ||
|
|
a7cf44249d | ||
|
|
0e6ebe7bc6 | ||
|
|
dced0c66a4 | ||
|
|
2ced576204 | ||
|
|
61a0cb244f | ||
|
|
aeea670064 | ||
|
|
b0836e1c93 | ||
|
|
a32946be44 | ||
|
|
01a85c475c | ||
|
|
43b2edcbb5 | ||
|
|
d770c0c3a9 | ||
|
|
a5db0fe71e | ||
|
|
c44fd89ed1 | ||
|
|
6c395709cf | ||
|
|
0754d0b101 | ||
|
|
2435096f32 | ||
|
|
25952cf226 | ||
|
|
eb1ee85d24 | ||
|
|
1e34a67384 | ||
|
|
a1cfab6fe9 | ||
|
|
a46e31e710 | ||
|
|
032b10752e | ||
|
|
e7d63a3859 | ||
|
|
2b47bd8b10 | ||
|
|
2f74d5ecb9 | ||
|
|
f8abadfc18 | ||
|
|
164b775206 | ||
|
|
b7211468b2 | ||
|
|
fb6cccc8b1 | ||
|
|
ae02164b78 | ||
|
|
a5063cc816 | ||
|
|
89267a9f41 | ||
|
|
e599daf4d9 | ||
|
|
e09913af5a | ||
|
|
416daa36d2 | ||
|
|
b7f280141f | ||
|
|
2b8d99f69d | ||
|
|
18072c9c60 | ||
|
|
1d0d4afdbf | ||
|
|
f5cee25299 | ||
|
|
6351aa6054 | ||
|
|
a7cbd1a6f7 | ||
|
|
9c7b7c54e5 | ||
|
|
48c2a4bfe1 | ||
|
|
4c5ee6143c | ||
|
|
faffdca592 | ||
|
|
15e25ca50b | ||
|
|
c71e61da77 | ||
|
|
0f2ed5cc16 | ||
|
|
1d674e587c | ||
|
|
713ba17e37 | ||
|
|
43abb8ef25 | ||
|
|
27af984f28 | ||
|
|
aab842d6d3 | ||
|
|
a9256dbed7 | ||
|
|
200a2efeb8 | ||
|
|
76a80badff | ||
|
|
095db7375c | ||
|
|
299cae8a4e | ||
|
|
baf5c4158f | ||
|
|
01df46f79f | ||
|
|
92b690aef1 | ||
|
|
08bc2b6a89 | ||
|
|
ad3d6261af | ||
|
|
f04b31cec7 | ||
|
|
5f898d4209 | ||
|
|
807ed86ef6 | ||
|
|
525ed6a61d | ||
|
|
b308380201 | ||
|
|
7da51b4ec8 | ||
|
|
5764d439c3 | ||
|
|
5f372b462a | ||
|
|
67af54b46e | ||
|
|
5a699de1ca | ||
|
|
1b473a7873 | ||
|
|
9223f8da7c | ||
|
|
8c9b645196 | ||
|
|
2aa4bce089 | ||
|
|
46c62ebefa | ||
|
|
152e6d4328 | ||
|
|
33fff5acba | ||
|
|
2ae1c867b5 | ||
|
|
c990110646 | ||
|
|
5872583fbb | ||
|
|
c8db3915ea | ||
|
|
547e7d66a9 | ||
|
|
bfeca0ac32 | ||
|
|
40d563801a | ||
|
|
e271908109 | ||
|
|
72f75fe754 | ||
|
|
6cb352629a | ||
|
|
d53bb73055 | ||
|
|
ff51035494 | ||
|
|
0ed4f88da2 | ||
|
|
caeba27846 | ||
|
|
a2e254b934 | ||
|
|
8b14466da2 | ||
|
|
5a039ae369 | ||
|
|
aab6b9275b | ||
|
|
26a1086623 | ||
|
|
c00831a72a | ||
|
|
3a120dd400 | ||
|
|
4dc0a7cca5 | ||
|
|
4930a89970 | ||
|
|
72f0f182a6 | ||
|
|
5173554281 | ||
|
|
c2b693c97e | ||
|
|
051094813e | ||
|
|
edf3f95854 | ||
|
|
80887d6098 | ||
|
|
5d5964a327 | ||
|
|
80f80fb707 | ||
|
|
bfc138251a | ||
|
|
7dab5fb9c6 | ||
|
|
8d4c85cc52 | ||
|
|
fc17754996 | ||
|
|
0371624afb | ||
|
|
eed1c4619d | ||
|
|
170398ab6f | ||
|
|
d4e95dcd47 | ||
|
|
e1fedf7231 | ||
|
|
9a2975b154 | ||
|
|
271a995455 | ||
|
|
056178b433 | ||
|
|
2285c9def1 | ||
|
|
6afc9e3c0d | ||
|
|
b06d28e7f6 | ||
|
|
7b90f210b9 | ||
|
|
c75d2bde5a | ||
|
|
9e6b88f60e | ||
|
|
dc6afdd021 | ||
|
|
978cd5953e | ||
|
|
b869c31ba3 | ||
|
|
67fc22237b | ||
|
|
d9f2983ea7 | ||
|
|
3120612e35 | ||
|
|
2a93ece4ba | ||
|
|
b26fa13044 | ||
|
|
7ff46af192 | ||
|
|
6d4b6284ad | ||
|
|
d8456fb9a3 | ||
|
|
b41d9629e1 | ||
|
|
10477a7c8f | ||
|
|
8f6302b446 | ||
|
|
87e924d1d8 | ||
|
|
7fab01e5cb | ||
|
|
4911088dc1 | ||
|
|
086ff512b6 | ||
|
|
96e33834bd | ||
|
|
765b095035 | ||
|
|
358b90516b | ||
|
|
dd0dc26232 | ||
|
|
1dea752a29 | ||
|
|
9f3edd60ae | ||
|
|
0b92294586 | ||
|
|
a52ef29a84 | ||
|
|
97deb93ee7 | ||
|
|
b67186a25b | ||
|
|
258782e3c3 | ||
|
|
acc95d8ee0 | ||
|
|
e9b82fbe9d | ||
|
|
c3bcb4b99d | ||
|
|
cfaf4657ce | ||
|
|
7966f8d505 | ||
|
|
839a7f0abc | ||
|
|
0f751d82cc | ||
|
|
aa8161f764 | ||
|
|
31740b3949 | ||
|
|
e99cf20887 | ||
|
|
cc5542833f | ||
|
|
0568d8ae87 | ||
|
|
c2180d3691 | ||
|
|
42036c23ab | ||
|
|
7bcbcb4008 | ||
|
|
0047f49d2c | ||
|
|
5cc1117f75 | ||
|
|
de3ec7d97a | ||
|
|
89a937fb32 | ||
|
|
830b29ce49 | ||
|
|
269f9984ef | ||
|
|
9e5c23f37d | ||
|
|
36cd4341a7 | ||
|
|
c34dfce6c3 | ||
|
|
84ed406c8e | ||
|
|
f4e1f9d218 | ||
|
|
8eb2c2de95 | ||
|
|
bdf676e05a | ||
|
|
6c7e11db4d | ||
|
|
a53b03265d | ||
|
|
560ffa2cdf | ||
|
|
d89546bec7 | ||
|
|
818dfa3882 | ||
|
|
772107d25b | ||
|
|
c61371005a | ||
|
|
7a0bd67fc0 | ||
|
|
efc420b4ce | ||
|
|
fd2b2908f3 | ||
|
|
eb1fd50add | ||
|
|
a60506a645 | ||
|
|
8b9b4d60ad | ||
|
|
a90eace4d0 | ||
|
|
7c2ae84e32 | ||
|
|
63d692b322 | ||
|
|
1a3ca8704e | ||
|
|
d6ebcb6233 | ||
|
|
48805b5988 | ||
|
|
005daade55 | ||
|
|
27e12dd544 | ||
|
|
5f03080374 | ||
|
|
efaf16dffb | ||
|
|
e4000374ca | ||
|
|
fee4219703 | ||
|
|
6ca9a58a8c | ||
|
|
d86c120969 | ||
|
|
85609f99cd | ||
|
|
29821bd541 | ||
|
|
1fdafd34d1 | ||
|
|
9584953467 | ||
|
|
52097ca59b | ||
|
|
1d6184dd2f | ||
|
|
88a473e7eb | ||
|
|
08ababc2c7 | ||
|
|
59ad77839a | ||
|
|
a594431eb6 | ||
|
|
58d02598da | ||
|
|
395bd4949e | ||
|
|
a6546ac858 | ||
|
|
233e5e6e72 | ||
|
|
b06a5b2d12 | ||
|
|
3ef67c6beb | ||
|
|
4a304e02f3 | ||
|
|
0d08d2d135 | ||
|
|
f9cb1b11ce | ||
|
|
1122f84007 | ||
|
|
2cdb2e3d0b | ||
|
|
f00dda2abd | ||
|
|
3b7b2477cf | ||
|
|
e762ee4b68 | ||
|
|
5d086c7f27 | ||
|
|
3eba80bb31 | ||
|
|
2a0d1862df | ||
|
|
7f70785b79 | ||
|
|
9dcd00d9b2 | ||
|
|
5a28c7e90f | ||
|
|
3c2efae951 | ||
|
|
c06d3d442a | ||
|
|
c76eb94724 | ||
|
|
06dc6a62b9 | ||
|
|
5463efcfc2 | ||
|
|
3fdbeed813 | ||
|
|
a18ef6c405 | ||
|
|
eae6a471e8 | ||
|
|
a61bd05a5c | ||
|
|
8109b3ec76 | ||
|
|
9d78bc4317 | ||
|
|
5c3c979f77 | ||
|
|
b52fe01d07 | ||
|
|
81da9bf71c | ||
|
|
1d04ac8cb7 | ||
|
|
27465cfeac | ||
|
|
fb5da1e3cd | ||
|
|
f661724c72 | ||
|
|
d127e19b45 | ||
|
|
d022e529d9 | ||
|
|
894eea7ce2 | ||
|
|
b395600a1c | ||
|
|
612394c987 | ||
|
|
d6d6249857 | ||
|
|
3440e4b7c6 | ||
|
|
5295aefd8f | ||
|
|
4025789ba0 | ||
|
|
5b6e53c863 | ||
|
|
b70fff01e1 | ||
|
|
07f9bcf79b | ||
|
|
1420a5c445 | ||
|
|
2bfb1015ea | ||
|
|
ace82de88c | ||
|
|
1b1e9fdb9e | ||
|
|
95e0e2d9bd | ||
|
|
cdc9f864b2 | ||
|
|
a00c891277 | ||
|
|
f45883233c | ||
|
|
d5e79cdc10 | ||
|
|
1a8a96d94e | ||
|
|
53fd6114e7 | ||
|
|
1314f4581d | ||
|
|
b2f483d67c | ||
|
|
48dd7718e2 | ||
|
|
ecca8e357f | ||
|
|
f41426c5b9 | ||
|
|
ebbadb3002 | ||
|
|
4f1b7c2df6 | ||
|
|
b4b59cc3c9 | ||
|
|
638b223d3b | ||
|
|
f463600585 | ||
|
|
bf4464ba37 | ||
|
|
65c4d8801c | ||
|
|
ef0c096e48 | ||
|
|
c506b53dec | ||
|
|
d93b500901 | ||
|
|
5c8768c556 | ||
|
|
3a15164605 | ||
|
|
194850e3cf | ||
|
|
f1cea2fb35 | ||
|
|
d15d9e4303 | ||
|
|
7f8a80fecb | ||
|
|
38a79a4b04 | ||
|
|
5a5e310a83 | ||
|
|
cb56a38680 | ||
|
|
750c7068e2 | ||
|
|
44e5b391f9 | ||
|
|
8ef57a4bb1 | ||
|
|
c86f1bbbe5 | ||
|
|
afe19f2105 | ||
|
|
73ee48e7c9 | ||
|
|
7727745b73 | ||
|
|
ad553b1082 | ||
|
|
429084e0bf | ||
|
|
79fbfec61f | ||
|
|
d4fb1eebbf | ||
|
|
2e4d957a47 | ||
|
|
e5928b965f | ||
|
|
0600b87a29 | ||
|
|
3d1b6e7ec5 | ||
|
|
d7bee9e854 | ||
|
|
6343386488 | ||
|
|
afe0fd1206 | ||
|
|
ac1db177e1 | ||
|
|
7c31269691 | ||
|
|
2142a66bc0 | ||
|
|
821e768b7e | ||
|
|
2645db40a2 | ||
|
|
60eb2adef3 | ||
|
|
e3bec557b6 | ||
|
|
6a1640ff6d | ||
|
|
10f5d44965 | ||
|
|
a4d615d74e | ||
|
|
f5ac8d07d7 | ||
|
|
50539741ce | ||
|
|
7a891c5aaa | ||
|
|
3bef640979 | ||
|
|
1f20eb3d2a | ||
|
|
df53ab956f | ||
|
|
5ff271a7b1 | ||
|
|
8831176ec4 | ||
|
|
d77cc252bb | ||
|
|
091f98e1f9 | ||
|
|
25f568f225 | ||
|
|
4e54ecc32f | ||
|
|
ab7ff3eea5 | ||
|
|
f8fc6be370 | ||
|
|
b27f68b8d5 | ||
|
|
d9bdbd8e18 | ||
|
|
281941c7ee | ||
|
|
7eb9dd02a7 | ||
|
|
3a520564a7 | ||
|
|
6f2bea9773 | ||
|
|
e50631c46a | ||
|
|
76c68e0311 | ||
|
|
04862e8a28 | ||
|
|
cdc47554ed | ||
|
|
77b84ac11b | ||
|
|
b92a396934 | ||
|
|
8225061dfa | ||
|
|
fe4cceeffa | ||
|
|
a99f9aa5ee | ||
|
|
ca60500c07 | ||
|
|
d17cadabf0 | ||
|
|
df74d702af | ||
|
|
ada22a583f | ||
|
|
009562913c | ||
|
|
0593b70354 | ||
|
|
26fe41e7d4 | ||
|
|
2802fcf738 | ||
|
|
153b980e2b | ||
|
|
6cad69cb86 | ||
|
|
27badff570 | ||
|
|
a63fbe1558 | ||
|
|
49013c64fb | ||
|
|
ba6f39375a | ||
|
|
cbed74e5eb | ||
|
|
2730c455f5 | ||
|
|
669ba0fd8a | ||
|
|
8e172132e7 | ||
|
|
d3c5c25865 | ||
|
|
f8586611c9 | ||
|
|
28220f0732 | ||
|
|
edcc325483 | ||
|
|
37f1f7e8a3 | ||
|
|
0f10c490cd | ||
|
|
e166fae57b | ||
|
|
488243cd75 | ||
|
|
6cf826268b | ||
|
|
c8deef1482 | ||
|
|
55ac05667c | ||
|
|
4da123c2d3 | ||
|
|
8c6718e352 | ||
|
|
9d58f5f61e | ||
|
|
06df9745a0 | ||
|
|
3aa11eaffc | ||
|
|
c2590a99ff | ||
|
|
215e393bdb | ||
|
|
1780b383b9 | ||
|
|
a6ff3054bc | ||
|
|
b3a86cd4b9 | ||
|
|
23ac3284cb | ||
|
|
83c2b42aad | ||
|
|
22e217a16c | ||
|
|
3310b12754 | ||
|
|
eac337c058 | ||
|
|
655b767127 | ||
|
|
9ebf89bde2 | ||
|
|
191a9c82be | ||
|
|
00981a502a | ||
|
|
d75198be9f | ||
|
|
d009a1ef50 | ||
|
|
9001b6fc51 | ||
|
|
a24ef15a02 | ||
|
|
7fdab094fc | ||
|
|
c2646f59c4 | ||
|
|
152ed86c3a | ||
|
|
21754c1660 | ||
|
|
145b424760 | ||
|
|
a68bf2eae7 | ||
|
|
bc7c771f20 | ||
|
|
1ed414c6fb | ||
|
|
7d27db69c6 | ||
|
|
d891002c84 | ||
|
|
e0eacc2530 | ||
|
|
c637f82ae2 | ||
|
|
7cafab1b9f | ||
|
|
c96f27b96c | ||
|
|
406cac1362 | ||
|
|
13fd0712d9 | ||
|
|
1414ef2c1c | ||
|
|
42e8fe3d21 | ||
|
|
bad73fcea8 | ||
|
|
94249ba67d | ||
|
|
2abd859a29 | ||
|
|
98cb42d2e5 | ||
|
|
878d05685c | ||
|
|
bd2c037a97 | ||
|
|
44636e47fb | ||
|
|
06c49ecec6 | ||
|
|
37deaedf0d | ||
|
|
30f7f18472 | ||
|
|
66e9749853 | ||
|
|
c9be68a575 | ||
|
|
19d692afe7 | ||
|
|
0351dcd497 | ||
|
|
03fd3d7c1c | ||
|
|
f4c9ed3d24 | ||
|
|
ef885c66dc | ||
|
|
148aa5cba8 | ||
|
|
661c8ae227 | ||
|
|
a24a1ddf1a | ||
|
|
f05cacec22 | ||
|
|
9239ee2822 | ||
|
|
4733885211 | ||
|
|
8e708bf2c4 | ||
|
|
caf240daec | ||
|
|
4bed8ab2c5 | ||
|
|
50c209b8d3 | ||
|
|
65a1c4b17e | ||
|
|
91d3a3f9d1 | ||
|
|
70f855d91b | ||
|
|
85eddba546 | ||
|
|
48d3e48e61 | ||
|
|
f07e1bcce1 | ||
|
|
e7c6960de9 | ||
|
|
ad64b0b4c9 | ||
|
|
cd763fa1d7 | ||
|
|
f40f44aafd | ||
|
|
63bf271725 | ||
|
|
974b8a5152 | ||
|
|
0a32ed2da7 | ||
|
|
e4681a58c6 | ||
|
|
135cbd3a5c | ||
|
|
3182ca3c39 | ||
|
|
677e460438 | ||
|
|
c7b794f604 | ||
|
|
64c61dcca8 | ||
|
|
649b75d4a1 | ||
|
|
8aa817b1a0 | ||
|
|
80d1cc5639 | ||
|
|
2db789d7dd | ||
|
|
7a02382623 | ||
|
|
169e97af02 | ||
|
|
3c959771ae | ||
|
|
449f29fc7f | ||
|
|
3c2fb22346 | ||
|
|
3a41370189 | ||
|
|
d6513ff7ab | ||
|
|
457d9b7dbf | ||
|
|
c85a9bbf82 | ||
|
|
5b399fbdda | ||
|
|
b5416d242c | ||
|
|
fdbbd2852a | ||
|
|
be109c9c79 | ||
|
|
78d633f63f | ||
|
|
95cb73d91a | ||
|
|
0d85063b5e | ||
|
|
765a0a4c82 | ||
|
|
daf1235e20 | ||
|
|
3d4f003aba | ||
|
|
6c6fb8d2a4 | ||
|
|
1b1bebdcd8 | ||
|
|
e0d1998811 | ||
|
|
bc3f584851 | ||
|
|
105909470f | ||
|
|
6e67fc5ce3 | ||
|
|
fd9d4e775b | ||
|
|
2de5491693 | ||
|
|
671820427a | ||
|
|
b07f771d98 | ||
|
|
01a46e33e2 | ||
|
|
2d9779047b | ||
|
|
cba9a6da6b | ||
|
|
15eac309ee | ||
|
|
7d37f5713c | ||
|
|
cd2584d6ee | ||
|
|
dcbe8305d0 | ||
|
|
798458c834 | ||
|
|
30a1141997 | ||
|
|
a0644a7e5c | ||
|
|
8b5472bf4e | ||
|
|
d6bda9740f | ||
|
|
8d082cd9cc | ||
|
|
89dd77aff2 | ||
|
|
1c68fd0555 | ||
|
|
b0070afc1b | ||
|
|
9e39e41b0d | ||
|
|
f4c41de91c | ||
|
|
913311653f | ||
|
|
1c1f517847 | ||
|
|
b2592d70f8 | ||
|
|
03f14c2c40 | ||
|
|
eee2dcd615 | ||
|
|
6b7b44264a | ||
|
|
6c6a59ceef | ||
|
|
b7817b752c | ||
|
|
d5e954d45c | ||
|
|
2ba9b9d03d | ||
|
|
028c71452d | ||
|
|
66e04caf21 | ||
|
|
19c1cbdf15 | ||
|
|
3b7dba32a4 | ||
|
|
f02dc382b7 | ||
|
|
a713ec2fd3 | ||
|
|
586f05d567 | ||
|
|
3cc393454d | ||
|
|
d6bd43b76e | ||
|
|
e54ffe7309 | ||
|
|
28bf6b5071 | ||
|
|
6b4df48090 | ||
|
|
4e0b74d7ae | ||
|
|
4c6bad04c5 | ||
|
|
32d48ea44d | ||
|
|
e37eab9f23 | ||
|
|
2f8db6d592 | ||
|
|
21e42e7b48 | ||
|
|
d53fd53b80 | ||
|
|
328599d539 | ||
|
|
875828c588 | ||
|
|
efef7859cd | ||
|
|
9794a98de9 | ||
|
|
ee80673579 | ||
|
|
1da27ed6bf | ||
|
|
bdcbd86db2 | ||
|
|
4213c44e51 | ||
|
|
b8d064b180 | ||
|
|
c5d21e0519 | ||
|
|
f990f29019 | ||
|
|
f7fcd03bfc | ||
|
|
555dd5421f | ||
|
|
875548c547 | ||
|
|
ec0a07fbe9 | ||
|
|
b187192c58 | ||
|
|
bbf2476f01 | ||
|
|
9401afb21d | ||
|
|
df43737b1b |
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Python bytecode
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# Editor / OS noise
|
||||||
|
.DS_Store
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Odoo runtime
|
||||||
|
*.pyc-tmp
|
||||||
|
|
||||||
|
# Local-only diagnostic logs from test runs
|
||||||
|
_test_*.log
|
||||||
95
AGENTS.md
Normal file
95
AGENTS.md
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
# Odoo Modules — Codex Instructions
|
||||||
|
|
||||||
|
## Project
|
||||||
|
27 custom Odoo 19 modules for Fusion Central (Westin Healthcare + NEXA Systems).
|
||||||
|
|
||||||
|
## Critical Rules — Odoo 19
|
||||||
|
1. **NEVER code from memory** — Always read a reference file from Docker first:
|
||||||
|
```bash
|
||||||
|
docker exec odoo-dev-app cat /usr/lib/python3/dist-packages/odoo/addons/<module>/static/src/<path>
|
||||||
|
```
|
||||||
|
2. **Frontend JS**: Use `Interaction` class from `@web/public/interaction`, registered via `registry.category("public.interactions")`. NOT IIFE/DOMContentLoaded.
|
||||||
|
3. **Backend OWL**: Use standalone `rpc()` from `@web/core/network/rpc`. NOT `useService("rpc")`. `static props = []` not `{}`.
|
||||||
|
4. **HTTP routes**: `type="jsonrpc"` — NOT `type="json"` (deprecated).
|
||||||
|
5. **res.config.settings**: Only boolean/integer/float/char/selection/many2one/datetime. NO Date fields.
|
||||||
|
6. **res.groups**: NO `users` field, NO `category_id` field.
|
||||||
|
7. **Search views**: NO `group expand="0"` syntax.
|
||||||
|
8. **SCSS imports**: `@import "./partial"` is FORBIDDEN in Odoo 19 custom SCSS. It prints a warning and silently falls back to the old cached bundle. Register every SCSS file (including `_partial.scss` tokens) as a separate entry in `web.assets_backend`. Put tokens first; Odoo concatenates bundle files so SCSS variables/mixins from the first file are visible to every later file.
|
||||||
|
|
||||||
|
## Card Styling — Copy Odoo's Kanban Pattern
|
||||||
|
Don't rely on `var(--bs-border-color)` or `var(--bs-body-bg)` for card surfaces — they drift between themes/addons and often render **invisible**. Odoo's own kanban (`.o_kanban_record`) uses **explicit hex** values:
|
||||||
|
```css
|
||||||
|
background-color: white;
|
||||||
|
border: 1px solid #d8dadd;
|
||||||
|
```
|
||||||
|
For custom OWL dashboards / client actions use the same approach:
|
||||||
|
- Define a `_tokens.scss` partial with explicit hex values wrapped in a CSS custom property:
|
||||||
|
```scss
|
||||||
|
$fp-card: var(--fp-card-bg, #ffffff);
|
||||||
|
$fp-border: var(--fp-border-color, #d8dadd);
|
||||||
|
```
|
||||||
|
- Reference those tokens everywhere (never `var(--bs-border-color)` directly)
|
||||||
|
- Three-layer contrast: **page** (grayest) → **container/column** (mid) → **card** (brightest). That's what makes cards pop.
|
||||||
|
- Reference implementation: `fusion_plating_shopfloor/static/src/scss/_fp_shopfloor_tokens.scss`.
|
||||||
|
|
||||||
|
## Dark Mode — Branch on `$o-webclient-color-scheme` at SCSS Compile Time
|
||||||
|
Odoo 19 does NOT flip dark mode via a runtime DOM class. It compiles TWO asset bundles:
|
||||||
|
- `web.assets_backend` — compiled with `$o-webclient-color-scheme: bright`
|
||||||
|
- `web.assets_web_dark` — compiled with `$o-webclient-color-scheme: dark` (dark variant primary variables loaded first)
|
||||||
|
|
||||||
|
Your SCSS file is compiled into BOTH bundles. To make the dark bundle have different colors, **branch at compile time** using the SCSS variable Odoo sets:
|
||||||
|
|
||||||
|
```scss
|
||||||
|
$o-webclient-color-scheme: bright !default;
|
||||||
|
|
||||||
|
$_my-page-hex: #f3f4f6;
|
||||||
|
$_my-card-hex: #ffffff;
|
||||||
|
|
||||||
|
@if $o-webclient-color-scheme == dark {
|
||||||
|
$_my-page-hex: #1a1d21 !global;
|
||||||
|
$_my-card-hex: #22262d !global;
|
||||||
|
}
|
||||||
|
|
||||||
|
$my-page: var(--my-page-bg, $_my-page-hex);
|
||||||
|
$my-card: var(--my-card-bg, $_my-card-hex);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Do NOT use** `.o_dark_mode` class selectors, `[data-bs-theme="dark"]`, or `@media (prefers-color-scheme: dark)` — none of those fire reliably in Odoo 19. The user toggles dark mode via the user profile, which sets a `color_scheme` cookie and reloads the page; Odoo then serves the dark bundle. Your SCSS `@if` handles the rest at compile time.
|
||||||
|
|
||||||
|
Verify by inspecting the attachments — you should see two files with different URLs for the two bundles:
|
||||||
|
```python
|
||||||
|
env['ir.qweb']._get_asset_bundle('web.assets_backend').css() # light
|
||||||
|
env['ir.qweb']._get_asset_bundle('web.assets_web_dark').css() # dark
|
||||||
|
```
|
||||||
|
|
||||||
|
## Asset Bundle Cache Busting
|
||||||
|
Odoo content-hashes the compiled bundle URL (`/web/assets/<hash>/...`). When CSS changes but the hash doesn't update, the browser serves the old bundle. Fixes in order of escalation:
|
||||||
|
1. Bump the module `version` in `__manifest__.py`
|
||||||
|
2. `DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%';` then restart odoo
|
||||||
|
3. Call `env['ir.qweb']._get_asset_bundle('web.assets_backend').css()` in odoo-shell to force regeneration
|
||||||
|
4. Hard-refresh browser with cache clear (DevTools → right-click refresh → *Empty Cache and Hard Reload*); on mobile clear website data
|
||||||
|
|
||||||
|
## Naming
|
||||||
|
- New fields: `x_fc_*` prefix
|
||||||
|
- Legacy fields: `x_studio_*`
|
||||||
|
- Canadian English for all user-facing text
|
||||||
|
- Currency: `$` sign with Monetary fields + currency_id
|
||||||
|
|
||||||
|
## Cursor-Managed Modules
|
||||||
|
- **fusion_clock** is currently being modified in Cursor — always read files fresh before editing, don't assume you know the current state
|
||||||
|
- **fusion_repairs** — status and deferred work: [`fusion_repairs/cloud.md`](fusion_repairs/cloud.md) (bundles 1–11 shipped at `19.0.2.2.4`; not production-deployed)
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
- Local dev: `docker exec odoo-dev-app odoo -d fusion-dev -u <module> --stop-after-init`
|
||||||
|
- Local URL: http://localhost:8069
|
||||||
|
- Test before deploying. Edit existing files — don't create unnecessary new ones.
|
||||||
|
|
||||||
|
## Supabase Knowledge Base
|
||||||
|
Before starting unfamiliar work, check Supabase for context:
|
||||||
|
```bash
|
||||||
|
PGPASSWORD='a09e12e0995dc29446631fa458f3d4b3' psql -h 100.74.28.73 -p 5433 -U postgres -d postgres
|
||||||
|
```
|
||||||
|
- `fusionapps.decisions` — past architecture decisions
|
||||||
|
- `fusionapps.issues` — known issues and fixes
|
||||||
|
- `fusionapps.code_snippets` — reference code
|
||||||
|
- `fusionapps.quick_commands` — deployment and admin commands
|
||||||
142
CLAUDE.md
142
CLAUDE.md
@@ -12,9 +12,26 @@
|
|||||||
3. **Backend OWL**: Use standalone `rpc()` from `@web/core/network/rpc`. NOT `useService("rpc")`. `static props = []` not `{}`.
|
3. **Backend OWL**: Use standalone `rpc()` from `@web/core/network/rpc`. NOT `useService("rpc")`. `static props = []` not `{}`.
|
||||||
4. **HTTP routes**: `type="jsonrpc"` — NOT `type="json"` (deprecated).
|
4. **HTTP routes**: `type="jsonrpc"` — NOT `type="json"` (deprecated).
|
||||||
5. **res.config.settings**: Only boolean/integer/float/char/selection/many2one/datetime. NO Date fields.
|
5. **res.config.settings**: Only boolean/integer/float/char/selection/many2one/datetime. NO Date fields.
|
||||||
|
**`config_parameter=` Boolean fields don't round-trip `False` as a string.** Odoo's `set_values()` calls `IrConfigParameter.set_param(key, value)`, and `set_param` deletes the row when `value` is falsy (False / None / empty). So writing `False` to a Boolean config field means the param no longer exists in `ir_config_parameter`; a subsequent `get_param(key)` returns the *default* (Python `False`), not `'False'`. Test like `self.assertFalse(ICP.get_param('...'))` — never `assertEqual(..., 'False')`. (Integer/Float/Char go through `repr(value)` / strip, so they DO persist as strings — `'90'`, `'0'`, etc.) Source: `odoo/addons/base/models/res_config.py::set_values` and `ir_config_parameter.py::set_param`.
|
||||||
6. **res.groups**: NO `users` field, NO `category_id` field.
|
6. **res.groups**: NO `users` field, NO `category_id` field.
|
||||||
|
**res.users**: field was renamed `groups_id` → `group_ids` (also `all_group_ids` for implied). The plural form is gone; using `groups_id` raises `ValueError: Invalid field 'groups_id' in 'res.users'`.
|
||||||
|
**`ir.ui.view`**: same rename — view-level visibility gating uses `group_ids`, not `groups_id`. A record like `<field name="groups_id" eval="[(4, ref('base.group_system'))]"/>` on an `ir.ui.view` raises `ValueError: Invalid field 'groups_id' in 'ir.ui.view'` at module install. (The XML *attribute* `groups="base.group_system"` on form elements like `<page>`, `<button>`, `<field>` is unrelated and still works.)
|
||||||
|
**`ir.rule` `groups` field is additive, not restrictive.** A rule with `groups=[some_group]` applies ONLY to users in that group — it does NOT restrict non-members. So `domain_force=[(1,'=',1)]` + `groups=[base.group_system]` does NOT mean "only admins see rows"; it means "admins see all rows (and the rule is silent on everyone else)". Non-admins are gated by the ACL (`ir.model.access.csv`), not the rule. To truly restrict by group at the rule layer, pair a global rule (`groups=[]`, `domain_force=[(0,'=',1)]` = block-all baseline) with a group-scoped allow rule. Default to letting the ACL do the gating; use rules for row-level filters that ACLs cannot express.
|
||||||
7. **Search views**: NO `group expand="0"` syntax.
|
7. **Search views**: NO `group expand="0"` syntax.
|
||||||
8. **SCSS imports**: `@import "./partial"` is FORBIDDEN in Odoo 19 custom SCSS. It prints a warning and silently falls back to the old cached bundle. Register every SCSS file (including `_partial.scss` tokens) as a separate entry in `web.assets_backend`. Put tokens first; Odoo concatenates bundle files so SCSS variables/mixins from the first file are visible to every later file.
|
8. **SCSS imports**: `@import "./partial"` is FORBIDDEN in Odoo 19 custom SCSS. It prints a warning and silently falls back to the old cached bundle. Register every SCSS file (including `_partial.scss` tokens) as a separate entry in `web.assets_backend`. Put tokens first; Odoo concatenates bundle files so SCSS variables/mixins from the first file are visible to every later file.
|
||||||
|
9. **SQL constraints & indexes**: Odoo 19 dropped `_sql_constraints = [(name, def, msg), ...]` and the `init()`/raw-SQL pattern. Both still parse but only emit a warning and are silently ignored. Use declarative class attributes instead:
|
||||||
|
```python
|
||||||
|
_check_qty_positive = models.Constraint('CHECK (qty > 0)', 'Quantity must be positive.')
|
||||||
|
_user_time_idx = models.Index('(user_id, event_time DESC)')
|
||||||
|
```
|
||||||
|
The attribute name after the leading underscore becomes the SQL object name suffix (`{table}_{suffix}`). `models.Index` accepts `DESC`, `WHERE` predicates, and `USING btree (...)`. Sources: `odoo/orm/model_classes.py` (warns at registry build), `odoo/orm/table_objects.py` (Constraint + Index classes).
|
||||||
|
10. **`res.users._login` is an instance method in Odoo 19**, not a classmethod as in earlier versions. Signature is `def _login(self, credential, user_agent_env)` — there is no `db` parameter. Override it like any normal instance method (`super()._login(credential, user_agent_env)`). When called via `authenticate()` on an empty recordset, `self` carries the right env. Older recipes that build a separate `api.Environment` from `odoo.modules.registry.Registry(db)` no longer apply. Source: `odoo/addons/base/models/res_users.py:760`.
|
||||||
|
11. **Inherited `ir.ui.view` records cannot have `groups`/`group_ids` on the record itself.** Odoo 19 raises `ParseError: Inherited view cannot have 'groups' defined on the record. Use 'groups' attributes inside the view definition` at install time. Move the gate to the inner XML nodes — every `<button>`, `<page>`, `<field>`, `<xpath>`, `<group>` etc. supports a `groups="base.group_system"` attribute. For an inherited form with a smart button + admin tab, put `groups=` on the button and the page individually; leave the `<record model="ir.ui.view">` clean.
|
||||||
|
12. **`mail.template` QWeb/inline_template `ctx` IS `self.env.context`** — not a nested dict you can pass. `MailRenderMixin._render_eval_context()` sets `ctx = self.env.context`, so `ctx.get('foo')` in subject/body resolves to `env.context.get('foo')`. To pass dynamic data to a template, spread keys directly into the context: `tmpl.with_context(**my_data).send_mail(res_id, ...)`. Calling `tmpl.with_context(ctx=my_data)` puts the dict at `env.context['ctx']`, and the template's `ctx.get('foo')` becomes `env.context.get('foo')` → `None` (looks like a silent rendering bug — subject ends up blank).
|
||||||
|
13. **`ir.cron` dropped `numbercall`** in Odoo 19. Old recipes set `<field name="numbercall">-1</field>` for "run forever"; that now raises `ValueError: Invalid field 'numbercall' in 'ir.cron'` at install time. Just omit the field — recurring crons keep running as long as `active=True`. Source: `odoo/addons/base/models/ir_cron.py` field list.
|
||||||
|
14. **`cr.commit()` / `cr.rollback()` raise AssertionError inside `TransactionCase`** — they are NOT silent no-ops in Odoo 19. The test cursor explicitly refuses both ("Cannot commit or rollback a cursor from inside a test, this will lead to a broken cursor when trying to rollback the test. Please rollback to a specific savepoint instead..."). For cron/worker code that needs per-row isolation so one bad row doesn't roll back the whole batch, use `with self.env.cr.savepoint(): ...` inside the loop instead of `cr.commit()`. Savepoints work in both prod (under the outer cron transaction) and tests (under the outer test transaction). The cron transaction commits the whole batch when the method returns; in tests everything rolls back cleanly. Source: `odoo/sql_db.py::TestCursor.commit` and `Cursor.savepoint()`.
|
||||||
|
|
||||||
|
15. **There is NO `sale.subscription` model in Odoo 19** (Enterprise `sale_subscription`). A subscription is a **`sale.order`** with `is_subscription=True`, `plan_id` → **`sale.subscription.plan`** (the recurrence), plus `subscription_state` / `next_invoice_date` / `recurring_monthly`. Any Many2one or relation that targets "a subscription" must point at `sale.order` (filter `domain=[('is_subscription','=',True)]`) — **not** `sale.subscription`, which does not exist and fails at install. The surviving `sale.subscription.*` records are only the plan + wizards/reports (`sale.subscription.plan`, `sale.subscription.report`, `sale.subscription.change.customer.wizard`, `sale.subscription.close.reason.wizard`). Verified on live `nexamain` (odoo-nexa, 19.0): `SELECT model FROM ir_model WHERE model LIKE 'sale.subscription%'`.
|
||||||
|
|
||||||
## Card Styling — Copy Odoo's Kanban Pattern
|
## Card Styling — Copy Odoo's Kanban Pattern
|
||||||
Don't rely on `var(--bs-border-color)` or `var(--bs-body-bg)` for card surfaces — they drift between themes/addons and often render **invisible**. Odoo's own kanban (`.o_kanban_record`) uses **explicit hex** values:
|
Don't rely on `var(--bs-border-color)` or `var(--bs-body-bg)` for card surfaces — they drift between themes/addons and often render **invisible**. Odoo's own kanban (`.o_kanban_record`) uses **explicit hex** values:
|
||||||
@@ -77,12 +94,38 @@ Odoo content-hashes the compiled bundle URL (`/web/assets/<hash>/...`). When CSS
|
|||||||
|
|
||||||
## Cursor-Managed Modules
|
## Cursor-Managed Modules
|
||||||
- **fusion_clock** is currently being modified in Cursor — always read files fresh before editing, don't assume you know the current state
|
- **fusion_clock** is currently being modified in Cursor — always read files fresh before editing, don't assume you know the current state
|
||||||
|
- **fusion_repairs** — read [`fusion_repairs/cloud.md`](fusion_repairs/cloud.md) before feature work. **Version `19.0.2.2.4`.** Bundles 1–11 shipped in repo (intake, portals, dashboard, pricing, flowcharts, parts/PO). **Not production-deployed** to Westin as of 2026-05-27. Local: `docker exec odoo-modsdev-app odoo -d fusion-dev -u fusion_repairs --stop-after-init`. Outstanding: RingCentral SMS, C2 history sidebar UI, office follow-up crons (config keys only), `tests/`, more flowchart content, sales-rep dashboard tile in `fusion_authorizer_portal`.
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
- Local dev: `docker exec odoo-dev-app odoo -d fusion-dev -u <module> --stop-after-init`
|
- Local dev: `docker exec odoo-modsdev-app odoo -d fusion-dev -u <module> --stop-after-init`
|
||||||
- Local URL: http://localhost:8069
|
- Local URL: http://localhost:8082
|
||||||
|
- **Running module tests requires ephemeral ports.** The dev container's main Odoo process holds 8069 and 8072; a `docker exec ... odoo --test-enable` will die with `Address already in use` unless you also pass `--http-port=0 --gevent-port=0`. This is because Odoo 19 forces `http_spawn()` when `--test-enable` is set, even when `--no-http` is passed. Canonical test invocation:
|
||||||
|
```bash
|
||||||
|
docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /<module> \
|
||||||
|
-u <module> --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60
|
||||||
|
```
|
||||||
|
- **`fusion_centralize_billing` tests run on odoo-trial (VM 316).** Local dev is Community and cannot install this module. Use `bash scripts/fcb_test_on_trial.sh` from the repo root. The script uses `--http-port 8070` to avoid the port 8069 conflict with the live odoo-trial-app container. Pass = `FCB_EXIT=0`. Takes ~1-2 min.
|
||||||
|
- **Python deps not bundled with `odoo:19` image:** `user_agents` (used by `fusion_login_audit`), and likely others. Install ephemerally with `docker exec -u 0 odoo-modsdev-app pip install <pkg> --break-system-packages`. The install is LOST when the container is recreated (e.g. `docker compose up -d` after a compose edit). When this happens, the symptom is `ModuleNotFoundError` deep in the auth or report code. Re-run the pip install. A persistent fix would be a custom Dockerfile or a startup hook on the compose service — not done yet.
|
||||||
- Test before deploying. Edit existing files — don't create unnecessary new ones.
|
- Test before deploying. Edit existing files — don't create unnecessary new ones.
|
||||||
|
|
||||||
|
## PDF Preview — Prefer fusion_pdf_preview Over Downloads/New-Tab
|
||||||
|
When a Python action opens an attachment, route it through `fusion_pdf_preview` instead of returning `ir.actions.act_url` with `download=true` or `target=new`. The preview dialog gives operators preview + print + download in one place and writes an audit log; non-PDF attachments fall back to the legacy download path automatically.
|
||||||
|
|
||||||
|
The drop-in replacement is the new helper on `ir.attachment`:
|
||||||
|
```python
|
||||||
|
return att.action_fusion_preview(title='My Doc')
|
||||||
|
# vs. the old pattern:
|
||||||
|
# return {'type': 'ir.actions.act_url',
|
||||||
|
# 'url': '/web/content/%s?download=true' % att.id,
|
||||||
|
# 'target': 'new'}
|
||||||
|
```
|
||||||
|
|
||||||
|
The helper auto-detects mimetype: PDFs go to the dialog, everything else (ZPL, CSV, XML, images) stays on download. So a callsite that today serves CSV today and a PDF tomorrow doesn't need a code change — same call, different routing.
|
||||||
|
|
||||||
|
If you need to invoke the client action directly (rare — only when you don't have a recordset handy), the tag is `fusion_pdf_preview.open_attachment` and the params are `{attachment_id, title, model_name, record_ids, report_name}`. See `fusion_pdf_preview/static/src/js/open_attachment_action.js`.
|
||||||
|
|
||||||
|
Existing reports (`ir.actions.report` of type `qweb-pdf`) are intercepted automatically by `fusion_pdf_preview/static/src/js/pdf_preview.js`; the helper above is for the *other* pattern — attachments opened by custom buttons.
|
||||||
|
|
||||||
## Supabase Knowledge Base
|
## Supabase Knowledge Base
|
||||||
Before starting unfamiliar work, check Supabase for context:
|
Before starting unfamiliar work, check Supabase for context:
|
||||||
```bash
|
```bash
|
||||||
@@ -92,3 +135,98 @@ PGPASSWORD='a09e12e0995dc29446631fa458f3d4b3' psql -h 100.74.28.73 -p 5433 -U po
|
|||||||
- `fusionapps.issues` — known issues and fixes
|
- `fusionapps.issues` — known issues and fixes
|
||||||
- `fusionapps.code_snippets` — reference code
|
- `fusionapps.code_snippets` — reference code
|
||||||
- `fusionapps.quick_commands` — deployment and admin commands
|
- `fusionapps.quick_commands` — deployment and admin commands
|
||||||
|
|
||||||
|
## Fusion Helpdesk — Customer Follow-up + Embedded Inbox (deployment + handoff)
|
||||||
|
|
||||||
|
Two modules: **`fusion_helpdesk`** (client — runs on each client deployment, e.g. entech)
|
||||||
|
and **`fusion_helpdesk_central`** (runs on the central Odoo = nexa). The client forwards
|
||||||
|
tickets to central over **XML-RPC**; central find-or-creates the customer partner +
|
||||||
|
follower; the client shows a server-side-scoped "My Tickets" inbox + systray unread badge.
|
||||||
|
|
||||||
|
### Where each runs / how to deploy
|
||||||
|
- **Central = nexa** (`erp.nexasystems.ca`, VM 315 on pve-worker1, Docker, DB `nexamain`).
|
||||||
|
Source on host: `/opt/odoo/custom-addons/fusion_helpdesk_central`. Upgrade (brief downtime):
|
||||||
|
```bash
|
||||||
|
ssh pve-worker1 "qm guest exec 315 --timeout 590 -- bash -c 'docker stop odoo-nexa-app; docker run --rm --network odoo_odoo-network -v odoo_odoo-data:/var/lib/odoo -v /opt/odoo/custom-addons:/mnt/extra-addons -v /opt/odoo/enterprise-addons:/mnt/enterprise-addons -v /opt/odoo/odoo.conf:/etc/odoo/odoo.conf odoo-nexa:19 odoo -d nexamain -u fusion_helpdesk_central --stop-after-init --http-port=0 --gevent-port=0 > /tmp/up.log 2>&1; docker start odoo-nexa-app'"
|
||||||
|
```
|
||||||
|
Use `;` (not `&&`) before `docker start` so the app ALWAYS restarts even if the upgrade
|
||||||
|
fails. nexa `odoo.conf` has `log_level=warn`, so test/INFO lines are suppressed — verify
|
||||||
|
the result via DB query, not the upgrade log.
|
||||||
|
- **Client = entech** (LXC 111 on pve-worker5, **native systemd `odoo.service`**, DB `admin`,
|
||||||
|
config `/etc/odoo/odoo.conf`, source `/mnt/extra-addons/custom/fusion_helpdesk`). No host
|
||||||
|
bind mount — get files in with `scp` to pve-worker5 then `pct push 111 <file> <dest>`.
|
||||||
|
Upgrade as the `odoo` user (NOT root):
|
||||||
|
```bash
|
||||||
|
pct exec 111 -- bash -lc "systemctl stop odoo; runuser -u odoo -- /usr/bin/odoo --config /etc/odoo/odoo.conf -d admin -u fusion_helpdesk --stop-after-init --http-port=0 --gevent-port=0 --logfile=/tmp/up.log; systemctl start odoo"
|
||||||
|
```
|
||||||
|
**Backup dir MUST live OUTSIDE the addons path** (e.g. `/root/`). A dir named `*.bak.*`
|
||||||
|
*inside* `/mnt/extra-addons/custom` makes Odoo try to load it as a module →
|
||||||
|
`FileNotFoundError: Invalid module name: fusion_helpdesk.bak.predeploy` → whole registry
|
||||||
|
load fails. (Learned the hard way; auto-rollback restored it.) Current rollback copy:
|
||||||
|
`/root/fh_bak_predeploy`.
|
||||||
|
|
||||||
|
### REQUIRED prerequisite on the central service account (easy to miss)
|
||||||
|
The keystone passes `partner_email`, so central find-or-creates the partner. The XML-RPC
|
||||||
|
service account (**`support@nexasystems.ca`, uid 33** on nexa) MUST have the **Contact
|
||||||
|
Creation** group (`base.group_partner_manager`). Without it, `helpdesk.ticket.create`
|
||||||
|
faults with *"not allowed to create 'Contact' (res.partner)"* for any reporter who isn't
|
||||||
|
already a contact. Granted on nexa 2026-05-27. **Every new client deployment needs this
|
||||||
|
grant on the central account.**
|
||||||
|
|
||||||
|
### Testing lesson
|
||||||
|
Client logic (scope domain, seen model, vals, `_norm_email`) is unit-tested in
|
||||||
|
`fusion_helpdesk/tests/` and runs on local Community (`-d modsdev`). **Smoke tests must
|
||||||
|
call the controller endpoints, not re-implement their logic** — the Phase 6 smoke test
|
||||||
|
replicated `build_scope_domain` directly and so missed a `NameError` (`_norm_email`
|
||||||
|
referenced but never imported) that broke every inbox endpoint. Run
|
||||||
|
`docker exec odoo-modsdev-app python3 -m pyflakes <file>` after editing controllers — it
|
||||||
|
catches undefined names instantly.
|
||||||
|
|
||||||
|
### Two non-obvious gotchas the first ship hit (fixed 2026-05-27 afternoon)
|
||||||
|
1. **`group_reporter_admin` had zero members on install** — `res.groups` doesn't auto-grant
|
||||||
|
to the deployment admin, so the "All (deployment)" toggle never appeared and admins were
|
||||||
|
stuck with the per-user `partner_email` filter. Fix lives in
|
||||||
|
`fusion_helpdesk/security/fusion_helpdesk_groups.xml`: extend `base.group_system.implied_ids`
|
||||||
|
with `(4, ref('fusion_helpdesk.group_reporter_admin'))`. The (4, id) tuple is additive — it
|
||||||
|
never replaces base's existing implied groups. Verified live: all six entech
|
||||||
|
`base.group_system` members now return True for
|
||||||
|
`has_group('fusion_helpdesk.group_reporter_admin')` after the upgrade.
|
||||||
|
2. **Historical tickets had NULL `x_fc_client_label` + NULL `partner_email`** — anything
|
||||||
|
created before the customer-followup ship was invisible in "My Tickets" because the scope
|
||||||
|
filter requires both fields. The reporter identity was preserved only in the description
|
||||||
|
HTML (the diag block's "User" row). Backfill recipe (50 ENTECH + 1 WESTIN, all in one
|
||||||
|
transaction):
|
||||||
|
```sql
|
||||||
|
UPDATE helpdesk_ticket
|
||||||
|
SET x_fc_client_label = substring(name from '^\[([A-Z]+)\]'),
|
||||||
|
partner_email = lower(substring(
|
||||||
|
substring(description from 'User</td><td[^>]*><code>([^<]+)</code>')
|
||||||
|
from ', ([^)]+)\)')),
|
||||||
|
partner_name = regexp_replace(
|
||||||
|
substring(description from 'User</td><td[^>]*><code>([^<]+)</code>'),
|
||||||
|
' \(#\d+, [^)]+\)$', '')
|
||||||
|
WHERE name ~ '^\[[A-Z]+\]'
|
||||||
|
AND description ~ 'User</td>'
|
||||||
|
AND x_fc_client_label IS NULL;
|
||||||
|
```
|
||||||
|
Safe: SQL UPDATE bypasses the central `helpdesk.ticket.create` override, so no duplicate
|
||||||
|
ack emails. Per-deployment label inferred from the `[XXX]` name prefix the old code was
|
||||||
|
already adding. Note: users whose `login != email` (e.g. uid=2 on entech has login
|
||||||
|
`gsinghpal@outlook.com` and email `gs@nexasystems.ca`) get tagged with their *login* in
|
||||||
|
backfill — they won't see their old tickets in "Mine", only in "All". New tickets are
|
||||||
|
tagged with the profile email (`user.email` first, `user.login` fallback).
|
||||||
|
|
||||||
|
### STATUS (handoff 2026-05-27 afternoon)
|
||||||
|
- **Merged to `main`** as squash commit `6c15a7b1` (initial ship). Today's followup is the
|
||||||
|
group/backfill fix described above — committed separately.
|
||||||
|
- **Deployed live**: nexa `fusion_helpdesk_central` **19.0.1.1.0**; entech `fusion_helpdesk`
|
||||||
|
**19.0.1.5.0** (bumped from 19.0.1.4.1 for the implied_ids fix). Both services healthy.
|
||||||
|
- **Historical entech tickets backfilled** on nexa (51 rows: 50 ENTECH + 1 WESTIN).
|
||||||
|
- **Smoke-tested live end-to-end** (entech→nexa): partner resolved + follower + `ENTECH`
|
||||||
|
label, branded ack email queued, support reply visible in thread, inbox scope finds own
|
||||||
|
ticket, no cross-deployment leak. The "Mine" view for non-admins and the "All" view for
|
||||||
|
the entech owner both populate as expected.
|
||||||
|
- **Browser confirmation**: hard-refresh entech (DevTools → Empty Cache and Hard Reload),
|
||||||
|
open the systray helpdesk dialog. The Mine/All toggle appears for the owner; "All" shows
|
||||||
|
all 50 ENTECH tickets, "Mine" shows the count matching the owner's profile email.
|
||||||
|
Tracebacks live in `/var/log/odoo/odoo-server.log` on entech (LXC 111 / pve-worker5).
|
||||||
|
|||||||
166
docs/superpowers/2026-05-27-fusion-billing-session-handoff.md
Normal file
166
docs/superpowers/2026-05-27-fusion-billing-session-handoff.md
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
# fusion_centralize_billing — Session Handoff (2026-05-27)
|
||||||
|
|
||||||
|
Resume point for the centralized-billing initiative. Read this first, then continue
|
||||||
|
from **"Decision pending"** below.
|
||||||
|
|
||||||
|
## Where we are
|
||||||
|
|
||||||
|
- **Sub-project #1 (core billing engine): DONE and on `main`** (tip `d770c0c3`, pushed to
|
||||||
|
GitHub + Gitea).
|
||||||
|
- 11/11 plan tasks, TDD, Opus code-reviewed; all Critical/High bugs fixed
|
||||||
|
(cross-billing cron → match by `plan_id`; `/usage` authz vs IDOR; input validation →
|
||||||
|
4xx not 500; correct billing-period window; idempotency scoped to `(sub, metric, key)`;
|
||||||
|
webhook sign-exact-bytes + event-id + SSRF guard).
|
||||||
|
- **39 tests green on Odoo 19 Enterprise.**
|
||||||
|
- Note: the 14 billing commits were rebased off the old login-audit/helpdesk stack and
|
||||||
|
landed cleanly on `main`.
|
||||||
|
|
||||||
|
- **`fusion_login_audit`: also landed on `main`** (2026-05-27). Its 19 commits were rebased
|
||||||
|
onto `main` and the `feat/fusion-login-audit` branch was deleted. This also restored
|
||||||
|
Odoo-19 rules #9–14 in `CLAUDE.md`, which had gone missing on `main` when billing landed
|
||||||
|
alone (they were authored alongside login_audit and never existed on the old base).
|
||||||
|
- A concurrent `feat/helpdesk-customer-followup` session still carries pre-landing copies
|
||||||
|
of the billing + login_audit commits; when it merges, replay its helpdesk-only commits
|
||||||
|
onto `main`.
|
||||||
|
|
||||||
|
- **Reference docs (on `main`):**
|
||||||
|
- Spec: `docs/superpowers/specs/2026-05-27-nexa-billing-centralized-design.md`
|
||||||
|
- Core plan: `docs/superpowers/plans/2026-05-27-fusion-centralize-billing-core.md`
|
||||||
|
|
||||||
|
## Next: sub-project #2 — NexaCloud adapter + dual-run reconciliation
|
||||||
|
|
||||||
|
Per spec §12, each sub-project is its own spec → plan → build cycle. #2 decomposes into
|
||||||
|
four chunks (dependency order):
|
||||||
|
|
||||||
|
| Chunk | What | Risk |
|
||||||
|
|-------|------|------|
|
||||||
|
| **2a — Mapping + importer** | Read `nexacloud` DB → create `res.partner` + `account.link`, `product.template` + subscription plans, one subscription `sale.order` per deployment | **Low** — read-only on NexaCloud, writes only into Odoo |
|
||||||
|
| **2b — Usage metering wiring** | NexaCloud `usage_metering.py` pushes CPU-seconds → Odoo `/usage`; verify aggregation → draft invoice w/ quota + overage + HST | Edits NexaCloud code |
|
||||||
|
| **2c — Control loop** | NexaCloud consumes Odoo's outbound webhooks (`invoice.payment_failed` → suspend via existing `network_isolation`/`throttle_checker`; `subscription.terminated` → deprovision) | Edits NexaCloud code |
|
||||||
|
| **2d — Dual-run reconciliation** | `fusion.billing.reconciliation` diffs Odoo-computed vs NexaCloud-actual per customer/period for ≥ 1 cycle before any flip | Safety gate before flipping real billing |
|
||||||
|
|
||||||
|
The core engine already built the *receiving* side (`/usage`, webhook engine, charge math).
|
||||||
|
#2 is about **connecting NexaCloud to it and proving the numbers match before flipping.**
|
||||||
|
|
||||||
|
## Decision pending (resume here)
|
||||||
|
|
||||||
|
We were in the `superpowers:brainstorming` flow for #2 and stopped at: **which slice to
|
||||||
|
start with?**
|
||||||
|
|
||||||
|
- **(recommended) 2a — Mapping + importer** — lowest risk, foundation for everything else.
|
||||||
|
- 2d — Reconciliation first (front-load the trust mechanism).
|
||||||
|
- Full #2 design as one spec, then one plan.
|
||||||
|
- Just write the #2 plan, no code this session.
|
||||||
|
|
||||||
|
## Open questions to resolve before building #2
|
||||||
|
|
||||||
|
- **Spec §15 Q2 — NexaCloud billing granularity:** confirm **one subscription per
|
||||||
|
deployment** (spec leans this way) vs one subscription per customer with deployment line
|
||||||
|
items.
|
||||||
|
- **Access / environments needed:**
|
||||||
|
- Read access to the `nexacloud` DB schema (LXC 102 / its Postgres on LXC 201) to design
|
||||||
|
the importer mapping.
|
||||||
|
- A NexaCloud staging or safe path for 2b/2c (they edit live NexaCloud code).
|
||||||
|
- Test target for the Odoo side stays the odoo-trial Enterprise sandbox.
|
||||||
|
- **Resolved already:** Stripe is one account (`acct_1ShlA9IkwUB1dVox`) for everything — no
|
||||||
|
account migration (spec §11 / §15 Q1). Branch strategy — land on `main`, branch new work
|
||||||
|
off `main`.
|
||||||
|
|
||||||
|
## How to run / test
|
||||||
|
|
||||||
|
- **Billing tests:** `bash scripts/fcb_test_on_trial.sh` from repo root → pass = `FCB_EXIT=0`
|
||||||
|
(~1–2 min). Syncs the module to the odoo-trial Enterprise sandbox (Proxmox VM 316, db
|
||||||
|
`trial`) and runs `--test-enable`. Local dev Odoo is Community and **cannot** install this
|
||||||
|
module.
|
||||||
|
|
||||||
|
## Branch hygiene (lesson from this session)
|
||||||
|
|
||||||
|
Cut each new feature branch from `main`, and land it before starting the next. For any
|
||||||
|
cross-branch git surgery, use a **throwaway `git worktree`** — never switch the shared
|
||||||
|
working dir's branch, because a concurrent session may be working on it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UPDATE — sub-project #2 complete (2026-05-27, later session)
|
||||||
|
|
||||||
|
All four chunks of #2 are now built. The brainstorm "which slice" question resolved to
|
||||||
|
2a-first; everything else followed.
|
||||||
|
|
||||||
|
**Done + on `main` in `Odoo-Modules` (fully tested on odoo-trial, suite `FCB_EXIT=0`):**
|
||||||
|
- **2a — importer** (`fusion.billing.import.wizard`): read-only `psycopg2` reader split
|
||||||
|
from pure-Odoo writes; users→partners+links, plans→`cpu_seconds` charge catalog
|
||||||
|
(`plan_id` NULL), deployments→one **draft shadow** `sale.order` each with the flat price.
|
||||||
|
Shadow-safe by construction (draft + no token + NULL `plan_id`). Idempotent, dry-run,
|
||||||
|
Test-Connection guard, README runbook.
|
||||||
|
- **2d — reconciliation** (`fusion.billing.reconciliation`): `_compute_reconciliation` +
|
||||||
|
`_reconcile_rows` (Odoo flat+overage vs NexaCloud actual, status match/delta), reader for
|
||||||
|
NexaCloud usage+invoice actuals, "Run Reconciliation" button. **Upsert key is
|
||||||
|
`(service, external_subscription_id, period)`** — per subscription, so a customer with
|
||||||
|
two deployments doesn't collide.
|
||||||
|
- **/usage enabler**: `_api_record_usage` resolves a subscription by the source app's own
|
||||||
|
id (`x_fc_nexacloud_subscription_id`) so NexaCloud can push against shadow subs.
|
||||||
|
- Core-engine bug fixed in passing: `charge.price_per_unit` is now `Float(16,6)` and
|
||||||
|
`_compute_billable` keeps 6-dp precision (was `Monetary`/cent-rounded → would under-bill
|
||||||
|
sub-cent rates and drift from NexaCloud's 4-dp amounts).
|
||||||
|
|
||||||
|
**Code-complete in `Nexa-Cloud` (feature-flagged, NOT deployed, NOT integration-tested):**
|
||||||
|
- **2b — usage push**: `services/odoo_billing_client.py` + a hook in `usage_metering.py`
|
||||||
|
posting cpu-seconds to Odoo `/usage`. **2c — control loop**:
|
||||||
|
`routers/odoo_billing.py` (`POST /api/v1/billing/webhooks/central`, HMAC-verified) +
|
||||||
|
`services/odoo_billing_integration.py` (suspend/restore/deprovision). All INERT unless
|
||||||
|
`ODOO_BILLING_ENABLED`. Implemented as NEW modules + edits to clean files only —
|
||||||
|
NexaCloud `main` had concurrent **Cursor uncommitted WIP** (`routers/billing.py`,
|
||||||
|
`scheduler.py`, `stripe_service.py`, `models/billing.py`, …) which was deliberately not
|
||||||
|
touched. Commits: `94542ec` + `956abb2` (only my files staged).
|
||||||
|
|
||||||
|
**Deployment status (2026-05-27):**
|
||||||
|
- **odoo-nexa (production `nexamain`): DEPLOYED** — `fusion_centralize_billing` (core + 2a
|
||||||
|
+ 2d) **fresh-installed** (#1 had never actually been deployed here; `DIR_ABSENT` before).
|
||||||
|
`ir_module_module.state = installed`, `odoo-nexa-app` healthy. **INERT**: no
|
||||||
|
`nexacloud_dsn`, all charges `plan_id` NULL (rating cron no-op), no webhooks queued
|
||||||
|
(dispatch cron no-op), inbound API 401s with no key configured. Synced to
|
||||||
|
`/opt/odoo/custom-addons` + `-i` via the restart-safe recipe.
|
||||||
|
- **NexaCloud (prod, `vps.nexasystems.ca` / 192.168.1.250): DEPLOYED — INERT.** Did NOT
|
||||||
|
use `./deploy.sh` (it `rsync --delete`s the working tree → would have shipped the
|
||||||
|
concurrent **uncommitted Cursor WIP** (7 files) AND wiped the gitignored prod `.env`
|
||||||
|
files). Instead deployed **surgically**: rsync of ONLY my 6 committed billing files (no
|
||||||
|
`--delete`; `.env` + Cursor's files untouched), `docker compose build backend`,
|
||||||
|
**boot-tested in a throwaway container** (`run --rm --no-deps backend python -c "import
|
||||||
|
app.main"` → BOOT_OK) before swapping, then `up -d backend`. `nexacloud-api` healthy,
|
||||||
|
`/health` OK. Feature OFF: `ODOO_BILLING_ENABLED` unset → `/billing/webhooks/central`
|
||||||
|
returns 404 and no usage is pushed. Activate later by setting `ODOO_BILLING_*` in
|
||||||
|
`/opt/nexacloud/.env` (+ compose env passthrough) once the Odoo side is wired.
|
||||||
|
**NOTE:** Cursor's 7-file WIP remains uncommitted locally and was never deployed — when
|
||||||
|
Cursor finishes, a normal `./deploy.sh` will ship it (and re-sync `.env`).
|
||||||
|
|
||||||
|
**Dual-run stand-up results (2026-05-27) — STOPPED here for review, NOT flipped:**
|
||||||
|
- Read-only role `odoo_billing_ro` created on nexacloud Postgres (192.168.1.50); DSN set in
|
||||||
|
`ir.config_parameter` `fusion_billing.nexacloud_dsn` on nexamain. Test Connection OK
|
||||||
|
(read 7 users / 232 plans / 87 subscriptions).
|
||||||
|
- **Shadow import committed on nexamain**: 7 partners, 232 plan catalogs, 87 draft shadow
|
||||||
|
subscriptions; 0 skipped, 0 failed. (NOTE: importer takes ALL plans/subs regardless of
|
||||||
|
active status → ~464 NC-* products now in the prod ERP catalog. Consider filtering to
|
||||||
|
`is_active` plans / active subscriptions, or prune the shadow records — all reversible.)
|
||||||
|
- **Reconciliation pass**: 9 (sub,period) rows had real billing activity → **2 match, 7
|
||||||
|
delta**, 0 failed. The 7 deltas, MUST resolve before flipping:
|
||||||
|
1. **One-off / non-subscription invoices** (3 rows: $877.99, $872.66, $32.20) — nexacloud
|
||||||
|
invoices with NULL subscription_id (fees/manual/credits); not modeled per-subscription.
|
||||||
|
2. **List-price ≠ actual-invoiced** (4 rows: Odoo $200/$50 vs actual ~$9.1x) — likely
|
||||||
|
proration or NexaCloud invoicing ≠ plan list price.
|
||||||
|
- **2d bug surfaced (analysis-only, not safety):** `_reconcile_rows` with an empty
|
||||||
|
`subscription_external_id` matches NULL-field orders instead of skipping → spurious
|
||||||
|
delta rows for the one-off invoices. Add `if not sub_ext: skip`.
|
||||||
|
|
||||||
|
**Remaining before go-live (gated on infra / ops you do):**
|
||||||
|
1. Grant the read-only DSN (`fusion_billing.nexacloud_dsn`) — see the module README — then
|
||||||
|
Test Connection → dry-run import → review → real import.
|
||||||
|
2. Run a dual-run cycle (Run Reconciliation), confirm all rows `match`.
|
||||||
|
3. **2c needs the Odoo side to actually EMIT** `invoice.payment_failed` /
|
||||||
|
`payment_succeeded` / `subscription.terminated` webhooks with `deployment_id` in the
|
||||||
|
payload — that emission isn't wired yet (it belongs to the live billing flow). The
|
||||||
|
NexaCloud receiver is built to that contract; confirm the payload shape when wiring it.
|
||||||
|
4. Integration-test + deploy the NexaCloud changes (no test harness in that repo).
|
||||||
|
5. The flip: set `charge.plan_id`, attach Stripe tokens, confirm the shadow subs.
|
||||||
|
|
||||||
|
Specs/plans: `specs/2026-05-27-nexacloud-billing-importer-design.md`,
|
||||||
|
`specs/2026-05-27-nexacloud-reconciliation-design.md`, and the matching plans.
|
||||||
2797
docs/superpowers/plans/2026-05-12-nexa-coa-setup.md
Normal file
2797
docs/superpowers/plans/2026-05-12-nexa-coa-setup.md
Normal file
File diff suppressed because it is too large
Load Diff
2801
docs/superpowers/plans/2026-05-13-nfc-clock-kiosk-plan.md
Normal file
2801
docs/superpowers/plans/2026-05-13-nfc-clock-kiosk-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
2703
docs/superpowers/plans/2026-05-26-fusion-login-audit.md
Normal file
2703
docs/superpowers/plans/2026-05-26-fusion-login-audit.md
Normal file
File diff suppressed because it is too large
Load Diff
1104
docs/superpowers/plans/2026-05-27-fusion-centralize-billing-core.md
Normal file
1104
docs/superpowers/plans/2026-05-27-fusion-centralize-billing-core.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,477 @@
|
|||||||
|
# Fusion Helpdesk — Customer Follow-up & Embedded Inbox Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Attach real customer identity to every helpdesk ticket and give client-deployment staff an in-app ticket inbox (read replies + follow up without leaving their Odoo), while external customers use the native Enterprise portal + magic link.
|
||||||
|
|
||||||
|
**Architecture:** Keystone = pass `partner_email`/`partner_name`/`x_fc_client_label` in the ticket-create payload; native helpdesk then creates the partner + subscribes the follower. Client module (`fusion_helpdesk`) gains read/reply RPC endpoints + a tabbed dialog + unread badge, all scoped server-side by the logged-in user's identity. Central module (`fusion_helpdesk_central`) adds the `x_fc_client_label` field + a branded acknowledgement email.
|
||||||
|
|
||||||
|
**Tech Stack:** Odoo 19 (Enterprise on central, Community on client deployments), Python 3.11, OWL 2, XML-RPC client→central, `helpdesk` (Enterprise), `portal.mixin`, `mail.thread.cc`.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-05-27-fusion-helpdesk-customer-followup-design.md`
|
||||||
|
|
||||||
|
**Testability note:** `fusion_helpdesk` depends only on base/web/mail → installable + testable on local Community (`odoo-modsdev-app`, DB `modsdev`). Pure logic (scope-domain, message filtering, vals builder, unread math) is extracted into `fusion_helpdesk/utils.py` and unit-tested with no live remote. `fusion_helpdesk_central` depends on `helpdesk` (Enterprise) → install/test on the deploy target (odoo-nexa) or odoo-trial.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
**`fusion_helpdesk` (client)**
|
||||||
|
- `utils.py` *(new)* — pure helpers: `build_scope_domain`, `is_public_message`, `build_ticket_vals`, `compute_unread_count`. No Odoo env needed → trivially unit-testable.
|
||||||
|
- `controllers/main.py` *(modify)* — keystone payload in `submit()`; new endpoints `my_tickets`, `ticket_detail`, `ticket_reply`, `unread_count`; a mockable `_rpc(model, method, args, kw)` seam.
|
||||||
|
- `models/__init__.py`, `models/fusion_helpdesk_ticket_seen.py` *(new)* — `fusion.helpdesk.ticket.seen` read-tracking model.
|
||||||
|
- `security/ir.model.access.csv` *(modify)* — ACL for the seen model.
|
||||||
|
- `security/fusion_helpdesk_groups.xml` *(new)* — `group_reporter_admin`.
|
||||||
|
- `static/src/js/fusion_helpdesk_dialog.js` *(modify)* — tabs (New / My Tickets), list, thread, reply.
|
||||||
|
- `static/src/xml/fusion_helpdesk_dialog.xml` *(modify)* — tab markup + list/thread/reply templates + confirmed-email field.
|
||||||
|
- `static/src/js/fusion_helpdesk_systray.js` *(modify)* — unread badge.
|
||||||
|
- `static/src/xml/fusion_helpdesk_systray.xml` *(modify)* — badge markup.
|
||||||
|
- `static/src/scss/fusion_helpdesk.scss` *(modify)* — tab/list/thread/badge styles.
|
||||||
|
- `tests/__init__.py`, `tests/test_utils.py`, `tests/test_seen.py` *(new)*.
|
||||||
|
- `__manifest__.py` *(modify)* — version bump, register groups XML + tests dir + new model.
|
||||||
|
|
||||||
|
**`fusion_helpdesk_central` (central)**
|
||||||
|
- `models/__init__.py`, `models/helpdesk_ticket.py` *(new)* — inherit `helpdesk.ticket`, add `x_fc_client_label`.
|
||||||
|
- `views/helpdesk_ticket_views.xml` *(new)* — list column + search filter for `x_fc_client_label`.
|
||||||
|
- `data/mail_template_ack.xml` *(new)* — branded acknowledgement template.
|
||||||
|
- `data/helpdesk_ack_automation.xml` *(new)* OR create-override in `helpdesk_ticket.py` — send ack on create.
|
||||||
|
- `tests/__init__.py`, `tests/test_identity.py` *(new)* — partner resolution + follower + label.
|
||||||
|
- `__manifest__.py` *(modify)* — version bump, register models/views/data/tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — Keystone identity
|
||||||
|
|
||||||
|
### Task 1: Pure `build_ticket_vals` helper (client)
|
||||||
|
|
||||||
|
**Files:** Create `fusion_helpdesk/utils.py`; Test `fusion_helpdesk/tests/test_utils.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing test**
|
||||||
|
```python
|
||||||
|
# fusion_helpdesk/tests/test_utils.py
|
||||||
|
from odoo.tests import TransactionCase, tagged
|
||||||
|
from odoo.addons.fusion_helpdesk.utils import build_ticket_vals
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install', 'fusion_helpdesk')
|
||||||
|
class TestBuildTicketVals(TransactionCase):
|
||||||
|
def test_identity_fields_present(self):
|
||||||
|
vals = build_ticket_vals(
|
||||||
|
kind='bug', subject='X', body_html='<p>b</p>',
|
||||||
|
team_id=1, client_label='ENTECH',
|
||||||
|
reporter_name='John Doe', reporter_email='john@entech.com',
|
||||||
|
company_name='ENTECH Inc',
|
||||||
|
)
|
||||||
|
self.assertEqual(vals['partner_email'], 'john@entech.com')
|
||||||
|
self.assertEqual(vals['partner_name'], 'John Doe')
|
||||||
|
self.assertEqual(vals['x_fc_client_label'], 'ENTECH')
|
||||||
|
self.assertEqual(vals['partner_company_name'], 'ENTECH Inc')
|
||||||
|
self.assertEqual(vals['team_id'], 1)
|
||||||
|
self.assertIn('X', vals['name'])
|
||||||
|
|
||||||
|
def test_no_email_omits_partner_email(self):
|
||||||
|
vals = build_ticket_vals(
|
||||||
|
kind='feature', subject='Y', body_html='<p>b</p>',
|
||||||
|
team_id=False, client_label='', reporter_name='Jane',
|
||||||
|
reporter_email='', company_name='',
|
||||||
|
)
|
||||||
|
self.assertNotIn('partner_email', vals) # never send empty email
|
||||||
|
self.assertNotIn('team_id', vals) # omit falsy team
|
||||||
|
self.assertEqual(vals['partner_name'], 'Jane')
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run — expect ImportError/FAIL**
|
||||||
|
Run: `docker exec odoo-modsdev-app odoo -d modsdev --test-enable --test-tags /fusion_helpdesk -u fusion_helpdesk --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -30`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement `build_ticket_vals`**
|
||||||
|
```python
|
||||||
|
# fusion_helpdesk/utils.py
|
||||||
|
"""Pure helpers for fusion_helpdesk — no Odoo env, unit-testable in isolation."""
|
||||||
|
|
||||||
|
def build_ticket_vals(kind, subject, body_html, team_id, client_label,
|
||||||
|
reporter_name, reporter_email, company_name):
|
||||||
|
"""Construct helpdesk.ticket create vals. Identity fields drive native
|
||||||
|
partner find-or-create + follower subscription on the central Odoo."""
|
||||||
|
kind_label = 'Bug Report' if kind == 'bug' else 'Feature Request'
|
||||||
|
prefix = ('[%s] ' % client_label) if client_label else ''
|
||||||
|
vals = {
|
||||||
|
'name': '%s%s: %s' % (prefix, kind_label, subject or '(untitled)'),
|
||||||
|
'description': body_html,
|
||||||
|
'partner_name': reporter_name or '',
|
||||||
|
}
|
||||||
|
if team_id:
|
||||||
|
vals['team_id'] = team_id
|
||||||
|
if reporter_email:
|
||||||
|
vals['partner_email'] = reporter_email
|
||||||
|
if company_name:
|
||||||
|
vals['partner_company_name'] = company_name
|
||||||
|
if client_label:
|
||||||
|
vals['x_fc_client_label'] = client_label
|
||||||
|
return vals
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run — expect PASS** (same command as Step 2)
|
||||||
|
- [ ] **Step 5: Commit** `git add fusion_helpdesk/utils.py fusion_helpdesk/tests/ && git commit -m "feat(fusion_helpdesk): pure build_ticket_vals helper (identity keystone)"`
|
||||||
|
|
||||||
|
### Task 2: Wire keystone into `submit()` (client)
|
||||||
|
|
||||||
|
**Files:** Modify `fusion_helpdesk/controllers/main.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1:** In `submit()`, accept new arg `reply_email=None`. Replace the inline `ticket_vals` block with:
|
||||||
|
```python
|
||||||
|
from odoo.addons.fusion_helpdesk.utils import build_ticket_vals
|
||||||
|
# ...
|
||||||
|
user = request.env.user
|
||||||
|
reporter_email = (reply_email or user.email or user.login or '').strip()
|
||||||
|
body_html = '\n'.join(body_parts)
|
||||||
|
ticket_vals = build_ticket_vals(
|
||||||
|
kind=kind, subject=subject, body_html=body_html,
|
||||||
|
team_id=cfg['team_id'], client_label=cfg['client_label'],
|
||||||
|
reporter_name=user.name, reporter_email=reporter_email,
|
||||||
|
company_name=request.env.company.name,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
- [ ] **Step 2:** Keep the existing create + attachment + return logic. Verify `_build_diag_block` still appends.
|
||||||
|
- [ ] **Step 3: Manual sanity** — `docker exec odoo-modsdev-app odoo -d modsdev -u fusion_helpdesk --stop-after-init 2>&1 | tail -20` (module upgrades clean).
|
||||||
|
- [ ] **Step 4: Commit** `git commit -am "feat(fusion_helpdesk): send partner identity in ticket payload"`
|
||||||
|
|
||||||
|
### Task 3: `x_fc_client_label` field on central
|
||||||
|
|
||||||
|
**Files:** Create `fusion_helpdesk_central/models/__init__.py`, `models/helpdesk_ticket.py`; Modify `__init__.py`, `__manifest__.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing test** (runs on Enterprise env)
|
||||||
|
```python
|
||||||
|
# fusion_helpdesk_central/tests/test_identity.py
|
||||||
|
from odoo.tests import TransactionCase, tagged
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install', 'fusion_helpdesk_central')
|
||||||
|
class TestTicketIdentity(TransactionCase):
|
||||||
|
def test_label_field_and_partner_resolution(self):
|
||||||
|
team = self.env['helpdesk.team'].search([], limit=1)
|
||||||
|
t = self.env['helpdesk.ticket'].create({
|
||||||
|
'name': 'T1', 'team_id': team.id,
|
||||||
|
'partner_email': 'newperson@example.com',
|
||||||
|
'partner_name': 'New Person',
|
||||||
|
'x_fc_client_label': 'ENTECH',
|
||||||
|
})
|
||||||
|
self.assertEqual(t.x_fc_client_label, 'ENTECH')
|
||||||
|
self.assertTrue(t.partner_id, "native create should resolve partner from email")
|
||||||
|
self.assertIn(t.partner_id, t.message_partner_ids, "customer should be a follower")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Implement field**
|
||||||
|
```python
|
||||||
|
# fusion_helpdesk_central/models/helpdesk_ticket.py
|
||||||
|
from odoo import fields, models
|
||||||
|
|
||||||
|
class HelpdeskTicket(models.Model):
|
||||||
|
_inherit = 'helpdesk.ticket'
|
||||||
|
|
||||||
|
x_fc_client_label = fields.Char(
|
||||||
|
string='Client Deployment', index=True, copy=False,
|
||||||
|
help='Deployment tag (e.g. ENTECH) set by the in-app reporter. '
|
||||||
|
'Scopes the embedded "My Tickets" inbox per client.',
|
||||||
|
)
|
||||||
|
```
|
||||||
|
```python
|
||||||
|
# fusion_helpdesk_central/models/__init__.py
|
||||||
|
from . import helpdesk_ticket
|
||||||
|
```
|
||||||
|
- [ ] **Step 3:** `fusion_helpdesk_central/__init__.py` → add `from . import models`. `__manifest__.py` → `version` bump to `19.0.1.1.0`, add `'models'` import is implicit; add `views/helpdesk_ticket_views.xml` to `data`, add `tests` discovery (automatic).
|
||||||
|
- [ ] **Step 4: Run on Enterprise** (deferred to Phase 6 deploy; can't run on local Community).
|
||||||
|
- [ ] **Step 5: Commit** `git commit -am "feat(fusion_helpdesk_central): x_fc_client_label on helpdesk.ticket"`
|
||||||
|
|
||||||
|
### Task 4: Backend list/search exposure (central)
|
||||||
|
|
||||||
|
**Files:** Create `fusion_helpdesk_central/views/helpdesk_ticket_views.xml`
|
||||||
|
- [ ] **Step 1:** Inherit the helpdesk ticket list + search to add `x_fc_client_label` (column `optional="show"`, search field + a group-by). Use `group_ids` not `groups_id` if gating (none needed here).
|
||||||
|
```xml
|
||||||
|
<odoo>
|
||||||
|
<record id="fhc_ticket_list_label" model="ir.ui.view">
|
||||||
|
<field name="name">fhc.helpdesk.ticket.list.label</field>
|
||||||
|
<field name="model">helpdesk.ticket</field>
|
||||||
|
<field name="inherit_id" ref="helpdesk.helpdesk_ticket_view_tree"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<field name="partner_id" position="after">
|
||||||
|
<field name="x_fc_client_label" optional="show"/>
|
||||||
|
</field>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
<record id="fhc_ticket_search_label" model="ir.ui.view">
|
||||||
|
<field name="name">fhc.helpdesk.ticket.search.label</field>
|
||||||
|
<field name="model">helpdesk.ticket</field>
|
||||||
|
<field name="inherit_id" ref="helpdesk.helpdesk_tickets_view_search"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<field name="partner_id" position="after">
|
||||||
|
<field name="x_fc_client_label"/>
|
||||||
|
<filter string="Client Deployment" name="group_client_label"
|
||||||
|
context="{'group_by': 'x_fc_client_label'}"/>
|
||||||
|
</field>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
|
```
|
||||||
|
> NOTE at execution: verify the exact `inherit_id` external IDs by reading the live views (`helpdesk.helpdesk_ticket_view_tree`, `helpdesk.helpdesk_tickets_view_search`) on odoo-nexa — names differ across versions. Adjust before install.
|
||||||
|
- [ ] **Step 2: Commit** `git commit -am "feat(fusion_helpdesk_central): expose client label in ticket views"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — Read APIs + scoping (client)
|
||||||
|
|
||||||
|
### Task 5: Pure scoping + message-filter + unread helpers
|
||||||
|
|
||||||
|
**Files:** Modify `fusion_helpdesk/utils.py`; Modify `fusion_helpdesk/tests/test_utils.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests**
|
||||||
|
```python
|
||||||
|
from odoo.addons.fusion_helpdesk.utils import (
|
||||||
|
build_scope_domain, is_public_message, compute_unread_count)
|
||||||
|
|
||||||
|
def test_regular_scope_binds_email_and_label(self):
|
||||||
|
dom = build_scope_domain(label='ENTECH', email='john@entech.com', is_admin=False)
|
||||||
|
self.assertIn(('x_fc_client_label', '=', 'ENTECH'), dom)
|
||||||
|
self.assertIn(('partner_email', '=ilike', 'john@entech.com'), dom)
|
||||||
|
|
||||||
|
def test_admin_scope_binds_label_only(self):
|
||||||
|
dom = build_scope_domain(label='ENTECH', email='a@entech.com', is_admin=True)
|
||||||
|
self.assertIn(('x_fc_client_label', '=', 'ENTECH'), dom)
|
||||||
|
self.assertFalse(any(t[0] == 'partner_email' for t in dom))
|
||||||
|
|
||||||
|
def test_admin_still_bounded_by_label(self):
|
||||||
|
# label is ALWAYS present — no cross-deployment leakage
|
||||||
|
self.assertTrue(build_scope_domain('ENTECH', 'a@x', True))
|
||||||
|
|
||||||
|
def test_internal_note_is_not_public(self):
|
||||||
|
self.assertFalse(is_public_message({'subtype_is_internal': True}))
|
||||||
|
self.assertTrue(is_public_message({'subtype_is_internal': False}))
|
||||||
|
|
||||||
|
def test_unread_count(self):
|
||||||
|
tickets = [{'id': 1, 'last_support_msg_id': 10},
|
||||||
|
{'id': 2, 'last_support_msg_id': 5},
|
||||||
|
{'id': 3, 'last_support_msg_id': 0}]
|
||||||
|
seen = {1: 10, 2: 3} # ticket 2 has newer support msg; 1 is read; 3 none
|
||||||
|
self.assertEqual(compute_unread_count(tickets, seen), 1)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run — FAIL**
|
||||||
|
- [ ] **Step 3: Implement**
|
||||||
|
```python
|
||||||
|
def build_scope_domain(label, email, is_admin):
|
||||||
|
"""Server-side ticket scope. label is ALWAYS bound (defense in depth)."""
|
||||||
|
domain = [('x_fc_client_label', '=', label or '__none__')]
|
||||||
|
if not is_admin:
|
||||||
|
domain.append(('partner_email', '=ilike', email or '__none__'))
|
||||||
|
return domain
|
||||||
|
|
||||||
|
def is_public_message(msg):
|
||||||
|
"""True when a message is customer-visible (not an internal note)."""
|
||||||
|
return not msg.get('subtype_is_internal', False)
|
||||||
|
|
||||||
|
def compute_unread_count(tickets, seen_by_id):
|
||||||
|
"""Count tickets whose latest support message id exceeds the user's
|
||||||
|
last-seen id for that ticket (0/absent = unseen baseline)."""
|
||||||
|
n = 0
|
||||||
|
for t in tickets:
|
||||||
|
last = t.get('last_support_msg_id') or 0
|
||||||
|
if last and last > (seen_by_id.get(t['id']) or 0):
|
||||||
|
n += 1
|
||||||
|
return n
|
||||||
|
```
|
||||||
|
- [ ] **Step 4: Run — PASS**; **Step 5: Commit**
|
||||||
|
|
||||||
|
### Task 6: `fusion.helpdesk.ticket.seen` model + ACL
|
||||||
|
|
||||||
|
**Files:** Create `fusion_helpdesk/models/__init__.py`, `models/fusion_helpdesk_ticket_seen.py`; Modify `__init__.py`, `security/ir.model.access.csv`, `__manifest__.py`; Test `fusion_helpdesk/tests/test_seen.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Failing test**
|
||||||
|
```python
|
||||||
|
# tests/test_seen.py
|
||||||
|
from odoo.tests import TransactionCase, tagged
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install', 'fusion_helpdesk')
|
||||||
|
class TestSeen(TransactionCase):
|
||||||
|
def test_mark_seen_upserts(self):
|
||||||
|
Seen = self.env['fusion.helpdesk.ticket.seen']
|
||||||
|
Seen._mark_seen(central_ticket_id=42, last_message_id=100)
|
||||||
|
Seen._mark_seen(central_ticket_id=42, last_message_id=120)
|
||||||
|
rec = Seen.search([('user_id', '=', self.env.uid),
|
||||||
|
('central_ticket_id', '=', 42)])
|
||||||
|
self.assertEqual(len(rec), 1)
|
||||||
|
self.assertEqual(rec.last_seen_message_id, 120)
|
||||||
|
|
||||||
|
def test_seen_map(self):
|
||||||
|
Seen = self.env['fusion.helpdesk.ticket.seen']
|
||||||
|
Seen._mark_seen(1, 10); Seen._mark_seen(2, 20)
|
||||||
|
self.assertEqual(Seen._seen_map([1, 2, 3]), {1: 10, 2: 20})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run — FAIL**
|
||||||
|
- [ ] **Step 3: Implement model**
|
||||||
|
```python
|
||||||
|
# models/fusion_helpdesk_ticket_seen.py
|
||||||
|
from odoo import api, fields, models
|
||||||
|
|
||||||
|
class FusionHelpdeskTicketSeen(models.Model):
|
||||||
|
_name = 'fusion.helpdesk.ticket.seen'
|
||||||
|
_description = 'Fusion Helpdesk — per-user read tracking (metadata only)'
|
||||||
|
|
||||||
|
user_id = fields.Many2one('res.users', required=True, index=True,
|
||||||
|
default=lambda s: s.env.uid, ondelete='cascade')
|
||||||
|
central_ticket_id = fields.Integer(required=True, index=True)
|
||||||
|
last_seen_message_id = fields.Integer(default=0)
|
||||||
|
|
||||||
|
_user_ticket_uniq = models.Constraint(
|
||||||
|
'UNIQUE(user_id, central_ticket_id)',
|
||||||
|
'One seen-row per user per ticket.')
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _mark_seen(self, central_ticket_id, last_message_id):
|
||||||
|
rec = self.search([('user_id', '=', self.env.uid),
|
||||||
|
('central_ticket_id', '=', central_ticket_id)], limit=1)
|
||||||
|
if rec:
|
||||||
|
if last_message_id > rec.last_seen_message_id:
|
||||||
|
rec.last_seen_message_id = last_message_id
|
||||||
|
else:
|
||||||
|
self.create({'central_ticket_id': central_ticket_id,
|
||||||
|
'last_seen_message_id': last_message_id})
|
||||||
|
return True
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _seen_map(self, central_ticket_ids):
|
||||||
|
rows = self.search([('user_id', '=', self.env.uid),
|
||||||
|
('central_ticket_id', 'in', central_ticket_ids)])
|
||||||
|
return {r.central_ticket_id: r.last_seen_message_id for r in rows}
|
||||||
|
```
|
||||||
|
- [ ] **Step 4:** ACL CSV row:
|
||||||
|
```csv
|
||||||
|
access_fhd_seen_user,fusion.helpdesk.ticket.seen.user,model_fusion_helpdesk_ticket_seen,base.group_user,1,1,1,1
|
||||||
|
```
|
||||||
|
`models/__init__.py` → `from . import fusion_helpdesk_ticket_seen`; `__init__.py` → `from . import models`; manifest registers nothing extra (models auto).
|
||||||
|
- [ ] **Step 5: Run — PASS**; **Step 6: Commit**
|
||||||
|
|
||||||
|
### Task 7: Admin group
|
||||||
|
|
||||||
|
**Files:** Create `fusion_helpdesk/security/fusion_helpdesk_groups.xml`; Modify `__manifest__.py` (add to `data`, FIRST so the group exists before ACLs reference it if needed)
|
||||||
|
- [ ] **Step 1:**
|
||||||
|
```xml
|
||||||
|
<odoo>
|
||||||
|
<record id="group_reporter_admin" model="res.groups">
|
||||||
|
<field name="name">Helpdesk Reporter Admin</field>
|
||||||
|
<field name="comment">Can view all tickets filed from this deployment in the in-app inbox.</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
|
```
|
||||||
|
> Odoo 19: NO `users`/`category_id` fields on res.groups. Keep the record minimal.
|
||||||
|
- [ ] **Step 2:** Upgrade clean; **Step 3: Commit**
|
||||||
|
|
||||||
|
### Task 8: Read endpoints (`my_tickets`, `ticket_detail`, `unread_count`)
|
||||||
|
|
||||||
|
**Files:** Modify `fusion_helpdesk/controllers/main.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Add a mockable RPC seam + identity helper:
|
||||||
|
```python
|
||||||
|
def _identity(self):
|
||||||
|
user = request.env.user
|
||||||
|
cfg = self._read_config()
|
||||||
|
return {
|
||||||
|
'email': (user.email or user.login or '').strip(),
|
||||||
|
'label': cfg['client_label'],
|
||||||
|
'is_admin': user.has_group('fusion_helpdesk.group_reporter_admin'),
|
||||||
|
'cfg': cfg,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _rpc(self, cfg, model, method, args, kw=None):
|
||||||
|
uid, proxy = self._authenticate(cfg) # existing
|
||||||
|
return proxy.execute_kw(cfg['db'], uid, cfg['password'], model, method, args, kw or {})
|
||||||
|
```
|
||||||
|
- [ ] **Step 2:** Implement endpoints (all `type='jsonrpc'`, `auth='user'`). `my_tickets` builds the scoped domain via `build_scope_domain`, `search_read` fields `[id, name, stage_id, write_date]`, plus a per-ticket latest public support message id (read `message_ids` or a dedicated query), then computes `has_unread` via the seen map. `ticket_detail` re-resolves the ticket through the scoped domain (reject if absent), reads public messages only (filter via `is_public_message` using each message's subtype internal flag fetched from central), and calls `_mark_seen`. `unread_count` returns `compute_unread_count(...)`.
|
||||||
|
> Execution detail: fetch message subtype "internal" flag from central by reading `mail.message` fields `[author_id, date, body, message_type, subtype_id]` and resolving `subtype_id.internal` via a second read or by filtering `message_type='comment'` + excluding notes. Confirm the cleanest field set against the live `mail.message` model during execution.
|
||||||
|
- [ ] **Step 3:** Manual: upgrade module; **Step 4: Commit**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — Reply endpoint (client)
|
||||||
|
|
||||||
|
### Task 9: `ticket_reply`
|
||||||
|
|
||||||
|
**Files:** Modify `fusion_helpdesk/controllers/main.py`
|
||||||
|
- [ ] **Step 1:** Endpoint `/fusion_helpdesk/ticket/<int:ticket_id>/reply`, `auth='user'`. Re-resolve ticket via scoped domain (reject if not in scope). Resolve author partner on central by the replier's email (find-or-create via `res.partner` search/create through bot, or pass `author_id` resolved from `partner_email`). Post:
|
||||||
|
```python
|
||||||
|
self._rpc(cfg, 'helpdesk.ticket', 'message_post', [ticket_id], {
|
||||||
|
'body': body_html, # already-safe HTML (escape user text)
|
||||||
|
'message_type': 'comment',
|
||||||
|
'subtype_xmlid': 'mail.mt_comment',
|
||||||
|
'author_id': author_partner_id,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
- [ ] **Step 2:** Escape the user's text to HTML server-side (reuse `_html_escape`). Mark seen after posting.
|
||||||
|
- [ ] **Step 3:** Manual upgrade; **Step 4: Commit**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4 — Client UI (dialog tabs, thread, badge)
|
||||||
|
|
||||||
|
### Task 10: Dialog tabs + My Tickets list + thread + reply + confirmed email
|
||||||
|
|
||||||
|
**Files:** Modify `static/src/js/fusion_helpdesk_dialog.js`, `static/src/xml/fusion_helpdesk_dialog.xml`, `static/src/scss/fusion_helpdesk.scss`
|
||||||
|
- [ ] **Step 1:** Add to state: `tab:'new'|'list'|'thread'`, `tickets:[]`, `loadingList`, `current:{id,subject,messages,canReply}`, `replyBody`, `replyEmail` (default from a new `/fusion_helpdesk/whoami` or seeded via session user email — read `user.email` via `useService('user')`/`session`), `scope:'mine'|'all'`, `isAdmin`.
|
||||||
|
- [ ] **Step 2:** Methods: `openList()` → rpc `/fusion_helpdesk/my_tickets` (with `scope`); `openTicket(id)` → rpc detail, switch to thread, refresh list badge; `sendReply()` → rpc reply then reload thread; `setScope()` (admin toggle). Add confirmed **Your email** input on the New tab bound to `state.replyEmail`, passed as `reply_email` in submit payload.
|
||||||
|
- [ ] **Step 3:** Template: a tab header (New | My Tickets); New pane = existing form + email field; List pane = table (ref, subject, stage chip, unread dot) + admin Mine/All toggle; Thread pane = messages (author, date, body, attachments) + reply box + Back. Use `Markup`-safe rendering: render message bodies with `t-out` (OWL) since central returns sanitized HTML.
|
||||||
|
- [ ] **Step 4:** SCSS for tabs/list/thread (follow Odoo kanban hex pattern + dark-mode `$o-webclient-color-scheme` branch per CLAUDE.md).
|
||||||
|
- [ ] **Step 5:** Manual QA locally (dialog opens, tabs switch). **Step 6: Commit**
|
||||||
|
|
||||||
|
### Task 11: Systray unread badge
|
||||||
|
|
||||||
|
**Files:** Modify `static/src/js/fusion_helpdesk_systray.js`, `static/src/xml/fusion_helpdesk_systray.xml`, SCSS
|
||||||
|
- [ ] **Step 1:** On setup, call `/fusion_helpdesk/unread_count`; store `state.unread`. Poll on an interval (e.g. 120s) and on dialog close. Show a badge bubble when `unread > 0`.
|
||||||
|
- [ ] **Step 2:** Badge markup over the icon. **Step 3: Commit**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5 — Central acknowledgement email
|
||||||
|
|
||||||
|
### Task 12: Branded acknowledgement template + send-on-create
|
||||||
|
|
||||||
|
**Files:** Create `fusion_helpdesk_central/data/mail_template_ack.xml`; Modify `models/helpdesk_ticket.py`, `__manifest__.py`
|
||||||
|
- [ ] **Step 1:** `mail.template` on `helpdesk.ticket` with subject "We received your request [{{ object.ticket_ref }}]" and a body using the company email layout + a prominent button to `{{ object.get_base_url() }}{{ object.access_url }}` (magic link). Canadian English.
|
||||||
|
- [ ] **Step 2:** Send on create via a create-override (central inherit), gated:
|
||||||
|
```python
|
||||||
|
@api.model_create_multi
|
||||||
|
def create(self, vals_list):
|
||||||
|
tickets = super().create(vals_list)
|
||||||
|
tmpl = self.env.ref('fusion_helpdesk_central.mail_template_ticket_ack', raise_if_not_found=False)
|
||||||
|
for t in tickets:
|
||||||
|
if tmpl and t.partner_email and t.x_fc_client_label: # in-app channel only → avoid double-ack with native web form
|
||||||
|
tmpl.send_mail(t.id, force_send=False)
|
||||||
|
return tickets
|
||||||
|
```
|
||||||
|
> Decision: gate on `x_fc_client_label` so only in-app-channel tickets get OUR ack; external web/email customers rely on native confirmation (verify native behavior during deploy; widen the gate if native sends nothing).
|
||||||
|
- [ ] **Step 3:** Register template data in manifest; **Step 4: Commit**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6 — Review, fix, deploy, smoke test
|
||||||
|
|
||||||
|
### Task 13: Code review + fix
|
||||||
|
- [ ] Run the code-review skill / pr-review-toolkit `code-reviewer` + `silent-failure-hunter` over the diff. Fix HIGH/MEDIUM findings. Re-run client tests locally. Commit fixes.
|
||||||
|
|
||||||
|
### Task 14: Deploy + test central on odoo-nexa
|
||||||
|
- [ ] Copy/confirm `fusion_helpdesk_central` source is visible to odoo-nexa (`/opt/odoo/custom-addons`).
|
||||||
|
- [ ] Run module tests on nexa: `-u fusion_helpdesk_central --test-enable --test-tags /fusion_helpdesk_central --stop-after-init` (ephemeral http port). Fix failures.
|
||||||
|
- [ ] Upgrade live: `-u fusion_helpdesk_central --stop-after-init` then restart `odoo-nexa-app`.
|
||||||
|
|
||||||
|
### Task 15: Deploy client on odoo-entech
|
||||||
|
- [ ] Look up entech access (memory: DB `admin`; confirm container/SSH via Supabase quick_commands). Confirm entech's `fusion_helpdesk.client_label` (e.g. ENTECH) + remote config points at nexa.
|
||||||
|
- [ ] Ensure `fusion_helpdesk` source present on entech; upgrade `-u fusion_helpdesk --stop-after-init`; restart.
|
||||||
|
|
||||||
|
### Task 16: Smoke test (one ticket)
|
||||||
|
- [ ] From entech: file ONE test ticket via the dialog (or simulate the controller path).
|
||||||
|
- [ ] On nexa: confirm the new ticket has `partner_id` resolved, `partner_email`/`partner_name`/`x_fc_client_label` set, customer is a follower, ack email queued/sent.
|
||||||
|
- [ ] Reply as agent on nexa → confirm notification email to the reporter w/ magic link; confirm the entech dialog "My Tickets" shows the ticket + reply and the badge increments.
|
||||||
|
- [ ] Confirm pre-existing identity-less tickets are untouched (the "lots already submitted" set) and do NOT leak across deployments in the inbox query.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review (run before execution)
|
||||||
|
- **Spec coverage:** keystone (T1-3), label field+views (T3-4), scoping (T5,8,9), seen/badge (T6,10,11), admin group (T7), ack email (T12), portal/native (config — verified live, no code), tests (T1,5,6 local + T3 enterprise), deploy+smoke (T14-16). ✓
|
||||||
|
- **Placeholders:** none — code shown for all Python/XML; JS tasks specify state/methods/markup concretely. JS is manually QA'd (OWL unit tests out of scope).
|
||||||
|
- **Type consistency:** `build_scope_domain(label,email,is_admin)`, `is_public_message(msg)`, `compute_unread_count(tickets,seen)`, `_mark_seen(central_ticket_id,last_message_id)`, `_seen_map(ids)`, `x_fc_client_label` — names consistent across tasks. ✓
|
||||||
956
docs/superpowers/plans/2026-05-27-nexacloud-billing-importer.md
Normal file
956
docs/superpowers/plans/2026-05-27-nexacloud-billing-importer.md
Normal file
@@ -0,0 +1,956 @@
|
|||||||
|
# NexaCloud → Odoo Billing Importer (Sub-project #2a) — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Build a one-time, re-runnable, read-only importer that backfills NexaCloud customers/plans/deployments into Odoo as a shadow copy (drafts, no charge) for dual-run reconciliation.
|
||||||
|
|
||||||
|
**Architecture:** A `fusion.billing.import.wizard` transient model. `_read_nexacloud_rows()` opens a read-only `psycopg2` connection (DSN from `ir.config_parameter`) and returns plain row dicts — the only code touching NexaCloud. `_import_rows(data, dry_run)` is pure Odoo: it upserts the `nexacloud` service, a `cpu_seconds` metric, Monthly/Yearly recurrences, partners+links (reusing `_resolve_or_create_partner`), a per-plan catalog (product + CPU-overage product + `fusion.billing.charge` with `plan_id` left NULL), and one **draft** shadow `sale.order` per deployment with the flat price set explicitly on the line. Shadow-safety holds by construction: draft + no payment token + charge `plan_id` NULL.
|
||||||
|
|
||||||
|
**Tech Stack:** Odoo 19 Enterprise (Python 3.12), `sale_subscription`, `account_accountant`, `payment_stripe`, `psycopg2`. Tests: `odoo.tests.common.TransactionCase` on odoo-trial.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-05-27-nexacloud-billing-importer-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conventions for every task
|
||||||
|
|
||||||
|
- **Never code Odoo internals from memory** (repo CLAUDE.md rule #1). The uncertain internals (`recurring_invoice`, `is_subscription` on a draft order, `sale.subscription.plan` fields, `price_unit` stickiness, `sale.subscription.plan` `billing_period_unit` values) are *verified by the tests themselves* on odoo-trial — when a test fails because an assumption is wrong, fix the source, do not weaken the assertion.
|
||||||
|
- **Models, not UI:** all logic lives in `_import_rows` / `_do_import` / `_import_*` model methods; the wizard button only calls them. This keeps everything testable under `TransactionCase`.
|
||||||
|
- **Money:** CAD, prices are `Float`/`Monetary`. CPU overage: `price_per_unit=0.0075`, `unit_batch=3600`.
|
||||||
|
- **New fields on native models:** `x_fc_*` prefix.
|
||||||
|
- **Registering tests:** append `from . import test_importer` to `tests/__init__.py` in the task that creates it; commit `__init__.py` alongside so the package always imports.
|
||||||
|
|
||||||
|
## Test environment
|
||||||
|
|
||||||
|
Tests run on **odoo-trial** (Proxmox VM 316, Odoo 19 Enterprise, db `trial`) — local dev is Community and cannot install this module. One runner:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/fcb_test_on_trial.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
- It re-syncs the module to the sandbox and runs `-u fusion_centralize_billing --test-enable --test-tags /fusion_centralize_billing`.
|
||||||
|
- **Pass condition:** output contains `FCB_EXIT=0`.
|
||||||
|
- The script runs the **whole** FCB suite (it cannot target one test); every "run the test" step below means "run the suite, ~1–2 min".
|
||||||
|
- **Never** run `--test-enable` against production `nexamain`.
|
||||||
|
|
||||||
|
## File structure (this plan)
|
||||||
|
|
||||||
|
```
|
||||||
|
fusion_centralize_billing/
|
||||||
|
__init__.py # + from . import wizards
|
||||||
|
models/
|
||||||
|
__init__.py # + from . import res_partner
|
||||||
|
sale_order.py # + x_fc_* fields on the existing SaleOrder inherit
|
||||||
|
res_partner.py # NEW: x_fc_stripe_customer_id
|
||||||
|
wizards/
|
||||||
|
__init__.py # NEW
|
||||||
|
import_wizard.py # NEW: the importer (read + import logic)
|
||||||
|
views/
|
||||||
|
import_wizard_views.xml # NEW: wizard form + action + menu
|
||||||
|
security/
|
||||||
|
ir.model.access.csv # + wizard ACL line
|
||||||
|
__manifest__.py # + views file
|
||||||
|
tests/
|
||||||
|
__init__.py # + from . import test_importer
|
||||||
|
test_importer.py # NEW
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Scaffolding — x_fc fields, partner inherit, wizard skeleton, security, manifest
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `fusion_centralize_billing/models/sale_order.py`
|
||||||
|
- Create: `fusion_centralize_billing/models/res_partner.py`
|
||||||
|
- Modify: `fusion_centralize_billing/models/__init__.py`
|
||||||
|
- Create: `fusion_centralize_billing/wizards/__init__.py`
|
||||||
|
- Create: `fusion_centralize_billing/wizards/import_wizard.py`
|
||||||
|
- Create: `fusion_centralize_billing/views/import_wizard_views.xml`
|
||||||
|
- Modify: `fusion_centralize_billing/__init__.py`
|
||||||
|
- Modify: `fusion_centralize_billing/security/ir.model.access.csv`
|
||||||
|
- Modify: `fusion_centralize_billing/__manifest__.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `x_fc_*` fields to the existing `sale.order` inherit**
|
||||||
|
|
||||||
|
In `models/sale_order.py`, add these fields to the `SaleOrder` class (keep `_fc_rate_usage`):
|
||||||
|
```python
|
||||||
|
x_fc_nexacloud_subscription_id = fields.Char(
|
||||||
|
index=True, copy=False,
|
||||||
|
help="Source NexaCloud subscription id — the importer's idempotency key.")
|
||||||
|
x_fc_nexacloud_deployment_id = fields.Char(index=True, copy=False)
|
||||||
|
x_fc_billing_service_id = fields.Many2one(
|
||||||
|
"fusion.billing.service", index=True, copy=False, ondelete="set null")
|
||||||
|
x_fc_shadow = fields.Boolean(
|
||||||
|
default=False, copy=False,
|
||||||
|
help="Imported in shadow mode: Odoo computes but must not charge/post/email.")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create the `res.partner` inherit**
|
||||||
|
|
||||||
|
`fusion_centralize_billing/models/res_partner.py`:
|
||||||
|
```python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1
|
||||||
|
from odoo import fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class ResPartner(models.Model):
|
||||||
|
_inherit = "res.partner"
|
||||||
|
|
||||||
|
x_fc_stripe_customer_id = fields.Char(
|
||||||
|
index=True, copy=False,
|
||||||
|
help="Existing Stripe customer id imported from a source app, reused at flip.")
|
||||||
|
```
|
||||||
|
Append to `models/__init__.py`: `from . import res_partner`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create the wizard skeleton**
|
||||||
|
|
||||||
|
`fusion_centralize_billing/wizards/__init__.py`:
|
||||||
|
```python
|
||||||
|
from . import import_wizard
|
||||||
|
```
|
||||||
|
|
||||||
|
`fusion_centralize_billing/wizards/import_wizard.py`:
|
||||||
|
```python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from odoo import api, fields, models
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
NEXACLOUD_CODE = "nexacloud"
|
||||||
|
CPU_METRIC_CODE = "cpu_seconds"
|
||||||
|
CPU_RATE_PER_CORE_HOUR = 0.0075 # NexaCloud CPU rate, CAD per core-hour
|
||||||
|
CPU_SECONDS_PER_CORE_HOUR = 3600.0 # one core-hour = 3600 cpu-seconds
|
||||||
|
|
||||||
|
|
||||||
|
class FusionBillingImportWizard(models.TransientModel):
|
||||||
|
_name = "fusion.billing.import.wizard"
|
||||||
|
_description = "Fusion Billing — NexaCloud Importer"
|
||||||
|
|
||||||
|
dry_run = fields.Boolean(
|
||||||
|
default=True,
|
||||||
|
help="Read and report what would be imported, without writing anything.")
|
||||||
|
result_summary = fields.Text(readonly=True)
|
||||||
|
|
||||||
|
def action_run_import(self):
|
||||||
|
self.ensure_one()
|
||||||
|
data = self._read_nexacloud_rows()
|
||||||
|
summary = self._import_rows(data, dry_run=self.dry_run)
|
||||||
|
self.result_summary = json.dumps(summary, indent=2, default=str)
|
||||||
|
return {
|
||||||
|
"type": "ir.actions.act_window",
|
||||||
|
"res_model": self._name,
|
||||||
|
"res_id": self.id,
|
||||||
|
"view_mode": "form",
|
||||||
|
"target": "new",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ----- read side (the ONLY code that touches NexaCloud) ------------------
|
||||||
|
def _read_nexacloud_rows(self):
|
||||||
|
"""Open a READ-ONLY psycopg2 connection to the nexacloud Postgres (DSN in
|
||||||
|
ir.config_parameter 'fusion_billing.nexacloud_dsn') and return rows as dicts.
|
||||||
|
Raises UserError on a missing DSN or a failed connection."""
|
||||||
|
import psycopg2
|
||||||
|
import psycopg2.extras
|
||||||
|
dsn = self.env["ir.config_parameter"].sudo().get_param("fusion_billing.nexacloud_dsn")
|
||||||
|
if not dsn:
|
||||||
|
raise UserError(
|
||||||
|
"NexaCloud DSN not configured. Set the 'fusion_billing.nexacloud_dsn' "
|
||||||
|
"system parameter to a read-only Postgres connection string.")
|
||||||
|
try:
|
||||||
|
conn = psycopg2.connect(dsn)
|
||||||
|
except Exception as e: # noqa: BLE001 - surface as a user error
|
||||||
|
raise UserError("Could not connect to the NexaCloud database: %s" % e)
|
||||||
|
try:
|
||||||
|
conn.set_session(readonly=True)
|
||||||
|
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
|
data = {}
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, email, full_name, company, billing_email, billing_address, "
|
||||||
|
"billing_city, billing_state, billing_postal_code, billing_country, "
|
||||||
|
"tax_id, stripe_customer_id FROM users")
|
||||||
|
data["users"] = [dict(r) for r in cur.fetchall()]
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, name, price_monthly, price_yearly, cpu_seconds_quota, "
|
||||||
|
"is_active FROM plans")
|
||||||
|
data["plans"] = [dict(r) for r in cur.fetchall()]
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, user_id, deployment_id, plan_id, status, billing_cycle, "
|
||||||
|
"current_period_start, current_period_end FROM subscriptions")
|
||||||
|
data["subscriptions"] = [dict(r) for r in cur.fetchall()]
|
||||||
|
return data
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# ----- import side (pure Odoo; unit-tested) ------------------------------
|
||||||
|
@api.model
|
||||||
|
def _import_rows(self, data, dry_run=False):
|
||||||
|
"""Upsert NexaCloud rows into Odoo. Idempotent. With dry_run=True the writes
|
||||||
|
happen inside a savepoint that is rolled back, so nothing persists."""
|
||||||
|
if not dry_run:
|
||||||
|
return self._do_import(data)
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
class _Rollback(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
with self.env.cr.savepoint():
|
||||||
|
result.update(self._do_import(data))
|
||||||
|
raise _Rollback()
|
||||||
|
except _Rollback:
|
||||||
|
pass
|
||||||
|
result["dry_run"] = True
|
||||||
|
return result
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _do_import(self, data):
|
||||||
|
return {"created": {}, "updated": {}, "skipped": [], "failed": []}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add the wizard view + action + menu**
|
||||||
|
|
||||||
|
`fusion_centralize_billing/views/import_wizard_views.xml`:
|
||||||
|
```xml
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<record id="view_fusion_billing_import_wizard_form" model="ir.ui.view">
|
||||||
|
<field name="name">fusion.billing.import.wizard.form</field>
|
||||||
|
<field name="model">fusion.billing.import.wizard</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Import from NexaCloud">
|
||||||
|
<group>
|
||||||
|
<field name="dry_run"/>
|
||||||
|
</group>
|
||||||
|
<group string="Result" invisible="not result_summary">
|
||||||
|
<field name="result_summary" nolabel="1" widget="text"/>
|
||||||
|
</group>
|
||||||
|
<footer>
|
||||||
|
<button name="action_run_import" type="object" string="Run Import"
|
||||||
|
class="btn-primary"/>
|
||||||
|
<button string="Close" class="btn-secondary" special="cancel"/>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_fusion_billing_import_wizard" model="ir.actions.act_window">
|
||||||
|
<field name="name">Import from NexaCloud</field>
|
||||||
|
<field name="res_model">fusion.billing.import.wizard</field>
|
||||||
|
<field name="view_mode">form</field>
|
||||||
|
<field name="target">new</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<menuitem id="menu_fusion_billing_root" name="Fusion Billing"
|
||||||
|
parent="account.menu_finance" sequence="90"/>
|
||||||
|
<menuitem id="menu_fusion_billing_import" name="Import from NexaCloud"
|
||||||
|
parent="menu_fusion_billing_root"
|
||||||
|
action="action_fusion_billing_import_wizard" sequence="10"
|
||||||
|
groups="base.group_system"/>
|
||||||
|
</odoo>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Wire module imports, security, manifest**
|
||||||
|
|
||||||
|
Append to `fusion_centralize_billing/__init__.py`: `from . import wizards`.
|
||||||
|
(Confirm it already has `from . import models` and `from . import controllers`; add the wizards line.)
|
||||||
|
|
||||||
|
Append to `security/ir.model.access.csv`:
|
||||||
|
```
|
||||||
|
access_fusion_billing_import_wizard,fusion.billing.import.wizard,model_fusion_billing_import_wizard,base.group_system,1,1,1,1
|
||||||
|
```
|
||||||
|
|
||||||
|
In `__manifest__.py`, add the view to `data` (after the cron):
|
||||||
|
```python
|
||||||
|
"data": [
|
||||||
|
"security/ir.model.access.csv",
|
||||||
|
"data/ir_cron.xml",
|
||||||
|
"views/import_wizard_views.xml",
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Verify the module upgrades cleanly on odoo-trial**
|
||||||
|
|
||||||
|
Run: `bash scripts/fcb_test_on_trial.sh`
|
||||||
|
Expected: `FCB_EXIT=0` (the 39 existing tests still pass; new model/fields/view load with no traceback).
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add fusion_centralize_billing/models/sale_order.py fusion_centralize_billing/models/res_partner.py fusion_centralize_billing/models/__init__.py fusion_centralize_billing/wizards/ fusion_centralize_billing/views/import_wizard_views.xml fusion_centralize_billing/__init__.py fusion_centralize_billing/security/ir.model.access.csv fusion_centralize_billing/__manifest__.py
|
||||||
|
git commit -m "feat(billing): importer scaffold — x_fc fields, wizard, security, view"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Identity import (users → partners + links)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `fusion_centralize_billing/wizards/import_wizard.py`
|
||||||
|
- Create: `fusion_centralize_billing/tests/test_importer.py`
|
||||||
|
- Modify: `fusion_centralize_billing/tests/__init__.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Register + write the failing test**
|
||||||
|
|
||||||
|
Append to `tests/__init__.py`: `from . import test_importer`.
|
||||||
|
|
||||||
|
`fusion_centralize_billing/tests/test_importer.py`:
|
||||||
|
```python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from odoo.tests.common import TransactionCase, tagged
|
||||||
|
|
||||||
|
|
||||||
|
def _fixture():
|
||||||
|
"""Two users, one plan, two subscriptions (monthly + yearly) — the canonical
|
||||||
|
NexaCloud row dicts the importer consumes."""
|
||||||
|
return {
|
||||||
|
"users": [
|
||||||
|
{"id": "u-1", "email": "ar@acme.test", "full_name": "Acme Inc",
|
||||||
|
"company": "Acme", "billing_email": "billing@acme.test",
|
||||||
|
"billing_address": "1 Main St", "billing_city": "Toronto",
|
||||||
|
"billing_state": "ON", "billing_postal_code": "M1M1M1",
|
||||||
|
"billing_country": "CA", "tax_id": "123456789RT0001",
|
||||||
|
"stripe_customer_id": "cus_ACME"},
|
||||||
|
{"id": "u-2", "email": "ops@globex.test", "full_name": "Globex",
|
||||||
|
"company": "Globex", "billing_email": None, "billing_address": None,
|
||||||
|
"billing_city": None, "billing_state": None, "billing_postal_code": None,
|
||||||
|
"billing_country": None, "tax_id": None, "stripe_customer_id": "cus_GLBX"},
|
||||||
|
],
|
||||||
|
"plans": [
|
||||||
|
{"id": "p-1", "name": "Starter", "price_monthly": 20.0,
|
||||||
|
"price_yearly": 200.0, "cpu_seconds_quota": 18000.0, "is_active": True},
|
||||||
|
],
|
||||||
|
"subscriptions": [
|
||||||
|
{"id": "s-1", "user_id": "u-1", "deployment_id": "d-1", "plan_id": "p-1",
|
||||||
|
"status": "active", "billing_cycle": "monthly",
|
||||||
|
"current_period_start": "2026-05-01", "current_period_end": "2026-06-01"},
|
||||||
|
{"id": "s-2", "user_id": "u-2", "deployment_id": "d-2", "plan_id": "p-1",
|
||||||
|
"status": "active", "billing_cycle": "yearly",
|
||||||
|
"current_period_start": "2026-05-01", "current_period_end": "2027-05-01"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestImporterIdentity(TransactionCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.Wizard = self.env['fusion.billing.import.wizard'].sudo()
|
||||||
|
self.Link = self.env['fusion.billing.account.link'].sudo()
|
||||||
|
|
||||||
|
def test_imports_users_as_partners_and_links(self):
|
||||||
|
self.Wizard._import_rows({'users': _fixture()['users']})
|
||||||
|
svc = self.env['fusion.billing.service'].search([('code', '=', 'nexacloud')])
|
||||||
|
self.assertTrue(svc, "importer must find-or-create the nexacloud service")
|
||||||
|
link1 = self.Link.search([('service_id', '=', svc.id), ('external_id', '=', 'u-1')])
|
||||||
|
self.assertEqual(len(link1), 1)
|
||||||
|
self.assertEqual(link1.partner_id.email, 'billing@acme.test') # billing_email wins
|
||||||
|
self.assertEqual(link1.partner_id.city, 'Toronto')
|
||||||
|
self.assertEqual(link1.partner_id.vat, '123456789RT0001')
|
||||||
|
self.assertEqual(link1.partner_id.x_fc_stripe_customer_id, 'cus_ACME')
|
||||||
|
self.assertEqual(link1.partner_id.country_id.code, 'CA')
|
||||||
|
link2 = self.Link.search([('service_id', '=', svc.id), ('external_id', '=', 'u-2')])
|
||||||
|
self.assertEqual(link2.partner_id.email, 'ops@globex.test') # falls back to email
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run it, expect failure**
|
||||||
|
|
||||||
|
Run: `bash scripts/fcb_test_on_trial.sh`
|
||||||
|
Expected: FAIL — `_do_import` returns the empty stub; no partners/links created.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement service/metric/recurrence helpers + user import**
|
||||||
|
|
||||||
|
Replace the stub `_do_import` and add helpers in `wizards/import_wizard.py`:
|
||||||
|
```python
|
||||||
|
@api.model
|
||||||
|
def _fc_service(self):
|
||||||
|
Service = self.env['fusion.billing.service']
|
||||||
|
svc = Service.search([('code', '=', NEXACLOUD_CODE)], limit=1)
|
||||||
|
return svc or Service.create({'name': 'NexaCloud', 'code': NEXACLOUD_CODE})
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _fc_cpu_metric(self):
|
||||||
|
Metric = self.env['fusion.billing.metric']
|
||||||
|
m = Metric.search([('code', '=', CPU_METRIC_CODE)], limit=1)
|
||||||
|
return m or Metric.create({
|
||||||
|
'name': 'CPU seconds', 'code': CPU_METRIC_CODE,
|
||||||
|
'aggregation': 'sum', 'unit_label': 'CPU-seconds'})
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _fc_recurrence_plan(self, unit):
|
||||||
|
Plan = self.env['sale.subscription.plan']
|
||||||
|
plan = Plan.search([('billing_period_value', '=', 1),
|
||||||
|
('billing_period_unit', '=', unit)], limit=1)
|
||||||
|
if plan:
|
||||||
|
return plan
|
||||||
|
label = 'Monthly' if unit == 'month' else 'Yearly'
|
||||||
|
return Plan.create({'name': label, 'billing_period_value': 1,
|
||||||
|
'billing_period_unit': unit})
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _fc_resolve_country(self, value):
|
||||||
|
Country = self.env['res.country']
|
||||||
|
if not value:
|
||||||
|
return Country.browse()
|
||||||
|
v = value.strip()
|
||||||
|
return Country.search(['|', ('code', '=ilike', v), ('name', '=ilike', v)], limit=1)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _bump(summary, created, key):
|
||||||
|
bucket = 'created' if created else 'updated'
|
||||||
|
summary[bucket][key] = summary[bucket].get(key, 0) + 1
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _import_user(self, service, urow):
|
||||||
|
Link = self.env['fusion.billing.account.link']
|
||||||
|
ext = str(urow['id'])
|
||||||
|
email = (urow.get('billing_email') or urow.get('email') or '').strip().lower() or None
|
||||||
|
name = urow.get('full_name') or urow.get('company') or email or ext
|
||||||
|
existed = bool(Link.search(
|
||||||
|
[('service_id', '=', service.id), ('external_id', '=', ext)], limit=1))
|
||||||
|
link = Link._resolve_or_create_partner(service, ext, name=name, email=email)
|
||||||
|
vals = {}
|
||||||
|
if urow.get('billing_address'):
|
||||||
|
vals['street'] = urow['billing_address']
|
||||||
|
if urow.get('billing_city'):
|
||||||
|
vals['city'] = urow['billing_city']
|
||||||
|
if urow.get('billing_postal_code'):
|
||||||
|
vals['zip'] = urow['billing_postal_code']
|
||||||
|
if urow.get('tax_id'):
|
||||||
|
vals['vat'] = urow['tax_id']
|
||||||
|
if urow.get('stripe_customer_id'):
|
||||||
|
vals['x_fc_stripe_customer_id'] = urow['stripe_customer_id']
|
||||||
|
country = self._fc_resolve_country(urow.get('billing_country'))
|
||||||
|
if country:
|
||||||
|
vals['country_id'] = country.id
|
||||||
|
if vals:
|
||||||
|
link.partner_id.write(vals)
|
||||||
|
return link, not existed
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _do_import(self, data):
|
||||||
|
service = self._fc_service()
|
||||||
|
summary = {'created': {}, 'updated': {}, 'skipped': [], 'failed': []}
|
||||||
|
partner_by_user = {}
|
||||||
|
for u in data.get('users', []):
|
||||||
|
try:
|
||||||
|
with self.env.cr.savepoint():
|
||||||
|
link, created = self._import_user(service, u)
|
||||||
|
partner_by_user[str(u['id'])] = link.partner_id
|
||||||
|
self._bump(summary, created, 'partners')
|
||||||
|
except Exception as e: # noqa: BLE001 - per-row isolation
|
||||||
|
summary['failed'].append(
|
||||||
|
{'kind': 'user', 'id': str(u.get('id')), 'error': str(e)})
|
||||||
|
return summary
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** `partner_by_user` and (Task 3) `plan_ctx_by_id` are **method-local** dicts — never set them as attributes on `self` (Odoo recordsets reject arbitrary attribute assignment). Tasks 3 and 4 add their loops to this same `_do_import` method, so the locals stay in scope.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run it, expect pass**
|
||||||
|
|
||||||
|
Run: `bash scripts/fcb_test_on_trial.sh`
|
||||||
|
Expected: `FCB_EXIT=0`; `TestImporterIdentity` passes. If `country_id.code` assertion fails, fix `_fc_resolve_country` (don't weaken the assertion).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add fusion_centralize_billing/wizards/import_wizard.py fusion_centralize_billing/tests/test_importer.py fusion_centralize_billing/tests/__init__.py
|
||||||
|
git commit -m "feat(billing): importer identity (NexaCloud users -> partners + links)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Catalog import (plans → metric + products + charge, plan_id NULL)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `fusion_centralize_billing/wizards/import_wizard.py`
|
||||||
|
- Modify: `fusion_centralize_billing/tests/test_importer.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test** (append to `test_importer.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestImporterCatalog(TransactionCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.Wizard = self.env['fusion.billing.import.wizard'].sudo()
|
||||||
|
|
||||||
|
def test_imports_plan_as_charge_with_null_plan_id(self):
|
||||||
|
self.Wizard._import_rows({'plans': _fixture()['plans']})
|
||||||
|
metric = self.env['fusion.billing.metric'].search([('code', '=', 'cpu_seconds')])
|
||||||
|
self.assertTrue(metric)
|
||||||
|
charge = self.env['fusion.billing.charge'].search([('plan_code', '=', 'p-1')])
|
||||||
|
self.assertEqual(len(charge), 1)
|
||||||
|
self.assertEqual(charge.metric_id, metric)
|
||||||
|
self.assertEqual(charge.included_quota, 18000.0) # = plan.cpu_seconds_quota
|
||||||
|
self.assertEqual(charge.unit_batch, 3600.0) # one core-hour
|
||||||
|
self.assertAlmostEqual(charge.price_per_unit, 0.0075) # CAD per core-hour
|
||||||
|
self.assertEqual(charge.charge_model, 'standard')
|
||||||
|
self.assertFalse(charge.plan_id, "shadow: charge.plan_id must be NULL so the "
|
||||||
|
"rating cron never auto-mutates order lines")
|
||||||
|
self.assertTrue(charge.product_id, "charge needs an overage product")
|
||||||
|
self.assertTrue(charge.product_id.recurring_invoice is False
|
||||||
|
or charge.product_id.recurring_invoice in (False, None))
|
||||||
|
|
||||||
|
def test_charge_math_matches_nexacloud(self):
|
||||||
|
# 18000 quota + 2 core-hours overage (7200s) -> 2 batches * $0.0075 = $0.015
|
||||||
|
self.Wizard._import_rows({'plans': _fixture()['plans']})
|
||||||
|
charge = self.env['fusion.billing.charge'].search([('plan_code', '=', 'p-1')])
|
||||||
|
_overage, amount = charge._compute_billable(18000.0 + 7200.0)
|
||||||
|
self.assertAlmostEqual(amount, 0.015, places=4)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run it, expect failure**
|
||||||
|
|
||||||
|
Run: `bash scripts/fcb_test_on_trial.sh`
|
||||||
|
Expected: FAIL — no charge created (catalog import not implemented).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement catalog import**
|
||||||
|
|
||||||
|
Add to `wizards/import_wizard.py`:
|
||||||
|
```python
|
||||||
|
@api.model
|
||||||
|
def _import_plan(self, metric, prow):
|
||||||
|
Product = self.env['product.product']
|
||||||
|
Charge = self.env['fusion.billing.charge']
|
||||||
|
plan_code = str(prow['id'])
|
||||||
|
name = prow.get('name') or plan_code
|
||||||
|
price_monthly = float(prow.get('price_monthly') or 0.0)
|
||||||
|
price_yearly = float(prow.get('price_yearly') or 0.0)
|
||||||
|
|
||||||
|
sub_code = 'NC-PLAN-%s' % plan_code
|
||||||
|
sub_product = Product.search([('default_code', '=', sub_code)], limit=1)
|
||||||
|
created = False
|
||||||
|
if not sub_product:
|
||||||
|
sub_product = Product.create({
|
||||||
|
'name': 'NexaCloud %s' % name, 'default_code': sub_code,
|
||||||
|
'type': 'service', 'recurring_invoice': True,
|
||||||
|
'list_price': price_monthly})
|
||||||
|
created = True
|
||||||
|
|
||||||
|
ov_code = 'NC-CPU-OVG-%s' % plan_code
|
||||||
|
ov_product = Product.search([('default_code', '=', ov_code)], limit=1)
|
||||||
|
if not ov_product:
|
||||||
|
ov_product = Product.create({
|
||||||
|
'name': 'NexaCloud CPU overage (%s)' % name, 'default_code': ov_code,
|
||||||
|
'type': 'service', 'list_price': 0.0})
|
||||||
|
|
||||||
|
charge_vals = {
|
||||||
|
'name': 'NexaCloud CPU overage — %s' % name,
|
||||||
|
'plan_code': plan_code, 'metric_id': metric.id, 'product_id': ov_product.id,
|
||||||
|
'included_quota': float(prow.get('cpu_seconds_quota') or 0.0),
|
||||||
|
'price_per_unit': CPU_RATE_PER_CORE_HOUR, 'unit_batch': CPU_SECONDS_PER_CORE_HOUR,
|
||||||
|
'charge_model': 'standard',
|
||||||
|
# plan_id intentionally omitted (NULL) — shadow safety guarantee #3
|
||||||
|
}
|
||||||
|
charge = Charge.search(
|
||||||
|
[('plan_code', '=', plan_code), ('metric_id', '=', metric.id)], limit=1)
|
||||||
|
if charge:
|
||||||
|
charge.write(charge_vals)
|
||||||
|
else:
|
||||||
|
charge = Charge.create(charge_vals)
|
||||||
|
created = True
|
||||||
|
return {'sub_product': sub_product, 'overage_product': ov_product,
|
||||||
|
'charge': charge, 'price_monthly': price_monthly,
|
||||||
|
'price_yearly': price_yearly}, created
|
||||||
|
```
|
||||||
|
In `_do_import`, after the users loop, add the plans loop:
|
||||||
|
```python
|
||||||
|
metric = self._fc_cpu_metric()
|
||||||
|
plan_ctx_by_id = {}
|
||||||
|
for p in data.get('plans', []):
|
||||||
|
try:
|
||||||
|
with self.env.cr.savepoint():
|
||||||
|
ctx, created = self._import_plan(metric, p)
|
||||||
|
plan_ctx_by_id[str(p['id'])] = ctx
|
||||||
|
self._bump(summary, created, 'plans')
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
summary['failed'].append(
|
||||||
|
{'kind': 'plan', 'id': str(p.get('id')), 'error': str(e)})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run it, expect pass**
|
||||||
|
|
||||||
|
Run: `bash scripts/fcb_test_on_trial.sh`
|
||||||
|
Expected: `FCB_EXIT=0`; both catalog tests pass. If `product.product` rejects `recurring_invoice` or `type='service'`, read the field on odoo-trial and fix the source.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add fusion_centralize_billing/wizards/import_wizard.py fusion_centralize_billing/tests/test_importer.py
|
||||||
|
git commit -m "feat(billing): importer catalog (plans -> products + CPU charge, plan_id NULL)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Subscription import (deployments → draft shadow sale.order)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `fusion_centralize_billing/wizards/import_wizard.py`
|
||||||
|
- Modify: `fusion_centralize_billing/tests/test_importer.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test** (append to `test_importer.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestImporterSubscriptions(TransactionCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.Wizard = self.env['fusion.billing.import.wizard'].sudo()
|
||||||
|
|
||||||
|
def test_imports_one_draft_shadow_subscription_per_deployment(self):
|
||||||
|
self.Wizard._import_rows(_fixture())
|
||||||
|
SaleOrder = self.env['sale.order']
|
||||||
|
sub1 = SaleOrder.search([('x_fc_nexacloud_subscription_id', '=', 's-1')])
|
||||||
|
self.assertEqual(len(sub1), 1)
|
||||||
|
self.assertTrue(sub1.is_subscription)
|
||||||
|
self.assertTrue(sub1.x_fc_shadow)
|
||||||
|
self.assertEqual(sub1.x_fc_nexacloud_deployment_id, 'd-1')
|
||||||
|
self.assertNotEqual(sub1.subscription_state, '3_progress') # left in draft
|
||||||
|
# monthly flat price set explicitly on the plan product line
|
||||||
|
plan_line = sub1.order_line.filtered(
|
||||||
|
lambda l: l.product_id.default_code == 'NC-PLAN-p-1')
|
||||||
|
self.assertEqual(len(plan_line), 1)
|
||||||
|
self.assertAlmostEqual(plan_line.price_unit, 20.0) # price_monthly
|
||||||
|
# the yearly subscription gets the yearly price + yearly recurrence
|
||||||
|
sub2 = SaleOrder.search([('x_fc_nexacloud_subscription_id', '=', 's-2')])
|
||||||
|
line2 = sub2.order_line.filtered(lambda l: l.product_id.default_code == 'NC-PLAN-p-1')
|
||||||
|
self.assertAlmostEqual(line2.price_unit, 200.0) # price_yearly
|
||||||
|
self.assertEqual(sub2.plan_id.billing_period_unit, 'year')
|
||||||
|
|
||||||
|
def test_subscription_skipped_when_user_or_plan_unresolved(self):
|
||||||
|
data = _fixture()
|
||||||
|
data['subscriptions'].append(
|
||||||
|
{"id": "s-3", "user_id": "u-missing", "deployment_id": "d-3", "plan_id": "p-1",
|
||||||
|
"status": "active", "billing_cycle": "monthly",
|
||||||
|
"current_period_start": "2026-05-01", "current_period_end": "2026-06-01"})
|
||||||
|
summary = self.Wizard._import_rows(data)
|
||||||
|
self.assertFalse(self.env['sale.order'].search(
|
||||||
|
[('x_fc_nexacloud_subscription_id', '=', 's-3')]))
|
||||||
|
self.assertTrue(any(s.get('id') == 's-3' for s in summary['skipped']))
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run it, expect failure**
|
||||||
|
|
||||||
|
Run: `bash scripts/fcb_test_on_trial.sh`
|
||||||
|
Expected: FAIL — no subscriptions created (subscription import not implemented).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement subscription import**
|
||||||
|
|
||||||
|
Add to `wizards/import_wizard.py`:
|
||||||
|
```python
|
||||||
|
@api.model
|
||||||
|
def _import_subscription(self, service, partner, plan_ctx, recurrence_plans, srow):
|
||||||
|
SaleOrder = self.env['sale.order']
|
||||||
|
SaleOrderLine = self.env['sale.order.line']
|
||||||
|
sub_ext = str(srow['id'])
|
||||||
|
cycle = (srow.get('billing_cycle') or 'monthly').lower()
|
||||||
|
rec_plan = recurrence_plans['yearly'] if cycle == 'yearly' else recurrence_plans['monthly']
|
||||||
|
price = plan_ctx['price_yearly'] if cycle == 'yearly' else plan_ctx['price_monthly']
|
||||||
|
product = plan_ctx['sub_product']
|
||||||
|
order_vals = {
|
||||||
|
'partner_id': partner.id, 'plan_id': rec_plan.id,
|
||||||
|
'x_fc_nexacloud_subscription_id': sub_ext,
|
||||||
|
'x_fc_nexacloud_deployment_id': str(srow.get('deployment_id') or ''),
|
||||||
|
'x_fc_billing_service_id': service.id, 'x_fc_shadow': True,
|
||||||
|
}
|
||||||
|
existing = SaleOrder.search(
|
||||||
|
[('x_fc_nexacloud_subscription_id', '=', sub_ext)], limit=1)
|
||||||
|
if existing:
|
||||||
|
existing.write(order_vals)
|
||||||
|
line = existing.order_line.filtered(lambda l: l.product_id == product)
|
||||||
|
line_vals = {'product_uom_qty': 1, 'price_unit': price}
|
||||||
|
if line:
|
||||||
|
line.write(line_vals)
|
||||||
|
else:
|
||||||
|
SaleOrderLine.create(dict(order_id=existing.id, product_id=product.id, **line_vals))
|
||||||
|
order = existing
|
||||||
|
created = False
|
||||||
|
else:
|
||||||
|
order_vals['order_line'] = [(0, 0, {
|
||||||
|
'product_id': product.id, 'product_uom_qty': 1, 'price_unit': price})]
|
||||||
|
order = SaleOrder.create(order_vals)
|
||||||
|
created = True
|
||||||
|
# guarantee the explicit price stuck (a pricelist compute may have overwritten it)
|
||||||
|
line = order.order_line.filtered(lambda l: l.product_id == product)
|
||||||
|
if line and line.price_unit != price:
|
||||||
|
line.price_unit = price
|
||||||
|
return order, created
|
||||||
|
```
|
||||||
|
In `_do_import`, before `return summary`, add the recurrences + subscriptions loop:
|
||||||
|
```python
|
||||||
|
recurrence_plans = {'monthly': self._fc_recurrence_plan('month'),
|
||||||
|
'yearly': self._fc_recurrence_plan('year')}
|
||||||
|
for s in data.get('subscriptions', []):
|
||||||
|
partner = partner_by_user.get(str(s.get('user_id') or ''))
|
||||||
|
ctx = plan_ctx_by_id.get(str(s.get('plan_id') or ''))
|
||||||
|
if not partner or not ctx:
|
||||||
|
summary['skipped'].append({
|
||||||
|
'kind': 'subscription', 'id': str(s.get('id')),
|
||||||
|
'reason': 'unresolved %s' % ('user' if not partner else 'plan')})
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with self.env.cr.savepoint():
|
||||||
|
_order, created = self._import_subscription(
|
||||||
|
service, partner, ctx, recurrence_plans, s)
|
||||||
|
self._bump(summary, created, 'subscriptions')
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
summary['failed'].append(
|
||||||
|
{'kind': 'subscription', 'id': str(s.get('id')), 'error': str(e)})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run it, expect pass**
|
||||||
|
|
||||||
|
Run: `bash scripts/fcb_test_on_trial.sh`
|
||||||
|
Expected: `FCB_EXIT=0`. If `is_subscription` is False on the draft order, that disproves the design assumption — read `sale_order.py` in `sale_subscription` on odoo-trial and adjust how the subscription is created (e.g. set the field driving `is_subscription`), never weaken the assertion. If `billing_period_unit` rejects `'year'`, read the selection values and fix `_fc_recurrence_plan`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add fusion_centralize_billing/wizards/import_wizard.py fusion_centralize_billing/tests/test_importer.py
|
||||||
|
git commit -m "feat(billing): importer subscriptions (one draft shadow sale.order per deployment)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Idempotency + dry-run
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `fusion_centralize_billing/tests/test_importer.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test** (append to `test_importer.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestImporterIdempotencyDryRun(TransactionCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.Wizard = self.env['fusion.billing.import.wizard'].sudo()
|
||||||
|
|
||||||
|
def _counts(self):
|
||||||
|
return (
|
||||||
|
self.env['fusion.billing.account.link'].search_count([]),
|
||||||
|
self.env['fusion.billing.charge'].search_count([]),
|
||||||
|
self.env['sale.order'].search_count([('x_fc_shadow', '=', True)]),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_rerun_updates_not_duplicates(self):
|
||||||
|
self.Wizard._import_rows(_fixture())
|
||||||
|
before = self._counts()
|
||||||
|
# change a value and re-run; counts stay the same, value updates
|
||||||
|
data = _fixture()
|
||||||
|
data['plans'][0]['cpu_seconds_quota'] = 99999.0
|
||||||
|
self.Wizard._import_rows(data)
|
||||||
|
self.assertEqual(self._counts(), before, "re-run must upsert, not duplicate")
|
||||||
|
charge = self.env['fusion.billing.charge'].search([('plan_code', '=', 'p-1')])
|
||||||
|
self.assertEqual(charge.included_quota, 99999.0)
|
||||||
|
|
||||||
|
def test_dry_run_writes_nothing(self):
|
||||||
|
summary = self.Wizard._import_rows(_fixture(), dry_run=True)
|
||||||
|
self.assertTrue(summary.get('dry_run'))
|
||||||
|
self.assertEqual(self._counts(), (0, 0, 0), "dry-run must not persist anything")
|
||||||
|
# the nexacloud service is created inside the rolled-back savepoint too
|
||||||
|
self.assertFalse(self.env['fusion.billing.service'].search([('code', '=', 'nexacloud')]))
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run it, expect pass**
|
||||||
|
|
||||||
|
Run: `bash scripts/fcb_test_on_trial.sh`
|
||||||
|
Expected: `FCB_EXIT=0` — idempotency and dry-run already hold from Tasks 2–4 + the savepoint in `_import_rows`. If the dry-run leaves a `nexacloud` service behind, the savepoint isn't wrapping `_fc_service` — confirm `_do_import` (which creates the service) runs entirely inside the `with self.env.cr.savepoint()` block.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add fusion_centralize_billing/tests/test_importer.py
|
||||||
|
git commit -m "test(billing): importer idempotency + dry-run"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Shadow-mode safety assertions
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `fusion_centralize_billing/tests/test_importer.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test** (append to `test_importer.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestImporterShadowSafety(TransactionCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.Wizard = self.env['fusion.billing.import.wizard'].sudo()
|
||||||
|
|
||||||
|
def test_import_creates_no_invoice_and_no_payment_token(self):
|
||||||
|
self.Wizard._import_rows(_fixture())
|
||||||
|
subs = self.env['sale.order'].search([('x_fc_shadow', '=', True)])
|
||||||
|
self.assertTrue(subs)
|
||||||
|
partners = subs.mapped('partner_id')
|
||||||
|
# no posted/draft customer invoice for any imported partner
|
||||||
|
invoices = self.env['account.move'].search([
|
||||||
|
('partner_id', 'in', partners.ids), ('move_type', '=', 'out_invoice')])
|
||||||
|
self.assertFalse(invoices, "shadow import must not create any invoice")
|
||||||
|
# no Stripe payment token -> charging is physically impossible
|
||||||
|
tokens = self.env['payment.token'].search([('partner_id', 'in', partners.ids)])
|
||||||
|
self.assertFalse(tokens, "shadow import must not attach a payment token")
|
||||||
|
# every imported charge has a NULL plan_id so the rating cron skips it
|
||||||
|
charges = self.env['fusion.billing.charge'].search([('plan_code', 'like', 'p-%')])
|
||||||
|
self.assertTrue(charges)
|
||||||
|
self.assertFalse(any(charges.mapped('plan_id')))
|
||||||
|
|
||||||
|
def test_rating_cron_leaves_shadow_subscriptions_untouched(self):
|
||||||
|
self.Wizard._import_rows(_fixture())
|
||||||
|
subs = self.env['sale.order'].search([('x_fc_shadow', '=', True)])
|
||||||
|
lines_before = sum(len(s.order_line) for s in subs)
|
||||||
|
self.env['fusion.billing.usage']._cron_rate_open_periods()
|
||||||
|
subs.invalidate_recordset()
|
||||||
|
lines_after = sum(len(s.order_line) for s in subs)
|
||||||
|
self.assertEqual(lines_before, lines_after,
|
||||||
|
"charges with NULL plan_id must keep the rating cron a no-op")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run it, expect pass**
|
||||||
|
|
||||||
|
Run: `bash scripts/fcb_test_on_trial.sh`
|
||||||
|
Expected: `FCB_EXIT=0` — the safety properties hold by construction (draft, no token, NULL plan_id). If `payment.token` is not a valid model name in this build, read the `payment` model names on odoo-trial and use the correct one (don't drop the assertion). If an invoice *is* found, the draft-import guarantee is broken — investigate whether `sale.order.create` auto-invoices, and stop confirming/posting.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add fusion_centralize_billing/tests/test_importer.py
|
||||||
|
git commit -m "test(billing): importer shadow-mode safety (no invoice/token, cron no-op)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Error handling — malformed rows isolated
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `fusion_centralize_billing/tests/test_importer.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test** (append to `test_importer.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestImporterErrorIsolation(TransactionCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.Wizard = self.env['fusion.billing.import.wizard'].sudo()
|
||||||
|
|
||||||
|
def test_one_bad_user_does_not_abort_the_batch(self):
|
||||||
|
data = _fixture()
|
||||||
|
# a row with no id -> str(urow['id']) raises KeyError, must be caught per-row
|
||||||
|
data['users'].insert(0, {"email": "broken@x.test"})
|
||||||
|
summary = self.Wizard._import_rows(data)
|
||||||
|
# the two good users still import
|
||||||
|
self.assertEqual(
|
||||||
|
self.env['fusion.billing.account.link'].search_count([]), 2)
|
||||||
|
self.assertTrue(summary['failed'], "the bad row must be recorded in failed[]")
|
||||||
|
self.assertTrue(any(f['kind'] == 'user' for f in summary['failed']))
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run it, expect pass**
|
||||||
|
|
||||||
|
Run: `bash scripts/fcb_test_on_trial.sh`
|
||||||
|
Expected: `FCB_EXIT=0` — the per-row `try/except` + `savepoint` already isolates failures. If the whole batch aborts, the `savepoint` is missing around `_import_user` or the broad `except` is too narrow — fix so one bad row never poisons the cursor.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add fusion_centralize_billing/tests/test_importer.py
|
||||||
|
git commit -m "test(billing): importer per-row error isolation"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: Read path — DSN guard
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `fusion_centralize_billing/tests/test_importer.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test** (append to `test_importer.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestImporterReadGuard(TransactionCase):
|
||||||
|
|
||||||
|
def test_missing_dsn_raises_usererror(self):
|
||||||
|
# ensure no DSN is configured in the test DB
|
||||||
|
self.env['ir.config_parameter'].sudo().set_param('fusion_billing.nexacloud_dsn', '')
|
||||||
|
wiz = self.env['fusion.billing.import.wizard'].sudo().create({'dry_run': True})
|
||||||
|
with self.assertRaises(UserError):
|
||||||
|
wiz._read_nexacloud_rows()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run it, expect pass**
|
||||||
|
|
||||||
|
Run: `bash scripts/fcb_test_on_trial.sh`
|
||||||
|
Expected: `FCB_EXIT=0` — `_read_nexacloud_rows` raises `UserError` when the DSN param is empty (implemented in Task 1). If `psycopg2` import fails on odoo-trial, confirm it ships with the image (it does — Odoo depends on it).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add fusion_centralize_billing/tests/test_importer.py
|
||||||
|
git commit -m "test(billing): importer read-path DSN guard"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 9: Full suite + static checks
|
||||||
|
|
||||||
|
**Files:** none (verification task)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Full test run**
|
||||||
|
|
||||||
|
Run: `bash scripts/fcb_test_on_trial.sh`
|
||||||
|
Expected: `FCB_EXIT=0`, no `FAIL`/`ERROR` lines for `fusion_centralize_billing`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: No `_sql_constraints` regressions**
|
||||||
|
|
||||||
|
Run: `grep -rn "_sql_constraints" fusion_centralize_billing/ || echo "clean"`
|
||||||
|
Expected: `clean`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: No bare `sale.subscription` model references**
|
||||||
|
|
||||||
|
Run: `grep -rnE "sale\.subscription[^.]" fusion_centralize_billing/ || echo "clean"`
|
||||||
|
Expected: `clean` (only `sale.subscription.plan` is valid).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Pyflakes the new Python**
|
||||||
|
|
||||||
|
Run: `docker exec odoo-modsdev-app python3 -m pyflakes fusion_centralize_billing/wizards/import_wizard.py fusion_centralize_billing/models/res_partner.py 2>&1 | tail -20 || true`
|
||||||
|
Expected: no undefined names (catches the kind of `_norm_email` NameError the helpdesk smoke test missed).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit (if any fixes)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A fusion_centralize_billing/
|
||||||
|
git commit -m "test(billing): 2a importer full suite green + static checks"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Done = 2a importer complete
|
||||||
|
|
||||||
|
A NexaCloud backfill produces, idempotently: unified partners + links, a `cpu_seconds` charge catalog (`plan_id` NULL), and one draft shadow `sale.order` per deployment carrying the exact NexaCloud flat price — with zero customer-visible billing in Odoo (no invoice, no token, rating cron a no-op). The `psycopg2` read path is ready; the live run is gated only on the read-only DSN grant.
|
||||||
|
|
||||||
|
## Next (not this plan)
|
||||||
|
|
||||||
|
- 2b: NexaCloud `usage_metering.py` pushes cpu-seconds (= core-hours × 3600) to `POST /usage`.
|
||||||
|
- 2c: NexaCloud consumes `invoice.payment_failed` / `subscription.terminated` webhooks → throttle/deprovision.
|
||||||
|
- 2d: `fusion.billing.reconciliation` diffs Odoo-computed (flat + `charge._compute_billable`) vs NexaCloud actuals per period; flip when within tolerance (set `charge.plan_id`, attach tokens, confirm subs).
|
||||||
637
docs/superpowers/plans/2026-05-27-nexacloud-invoice-ledger.md
Normal file
637
docs/superpowers/plans/2026-05-27-nexacloud-invoice-ledger.md
Normal file
@@ -0,0 +1,637 @@
|
|||||||
|
# NexaCloud → Odoo Invoice Ledger — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax.
|
||||||
|
|
||||||
|
**Goal:** Ingest NexaCloud's real (Stripe-billed) invoices into Odoo as posted `account.move` customer invoices with reconciled payments + HST, so Odoo is the accounting system of record — all history + ongoing, revenue split by service family, draft-first on the live books.
|
||||||
|
|
||||||
|
**Architecture:** A new ingester in `fusion_centralize_billing` mirroring the importer's read/write split: `_read_nexacloud_invoices` (read-only psycopg2 via the existing DSN) → `_ingest_invoices` (pure Odoo: create `account.move` drafts idempotently, map lines to per-family income accounts, derive tax, reconcile Stripe payments) → `_post_ingested` (bulk-post after review). Reuses the `account.link` partner mapping. Native Odoo accounting does the rest.
|
||||||
|
|
||||||
|
**Tech Stack:** Odoo 19 Enterprise, `account_accountant`, `psycopg2`. Tests: `TransactionCase` on odoo-trial (`bash scripts/fcb_test_on_trial.sh`, pass = `FCB_EXIT=0`).
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-05-27-nexacloud-invoice-ledger-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
- **Never code accounting internals from memory** (CLAUDE rule #1). Reference confirmed on trial: `account.move` has `invoice_line_ids`/`invoice_date`/`action_post`; `account.payment.register` exists; `account_type='income'`/`'asset_receivable'` valid; sale taxes are Canadian (find HST 13% by `amount=13` / name). Where a step says "read reference", confirm before relying on it.
|
||||||
|
- **Models, not UI:** logic in model methods; the wizard only calls them. Testable under `TransactionCase`.
|
||||||
|
- **New fields on native models:** `x_fc_*`. Declarative `models.Constraint` only.
|
||||||
|
- Tests run on **odoo-trial** (`bash scripts/fcb_test_on_trial.sh`, full suite, ~1–2 min). Register each new `tests/test_*.py` in `tests/__init__.py` in the same task.
|
||||||
|
|
||||||
|
## File structure
|
||||||
|
```
|
||||||
|
fusion_centralize_billing/
|
||||||
|
models/
|
||||||
|
account_move.py # NEW: account.move inherit (x_fc_nexacloud_invoice_id, x_fc_stripe_invoice_id)
|
||||||
|
__init__.py # + account_move
|
||||||
|
wizards/
|
||||||
|
invoice_ledger.py # NEW: the ingester (read + ingest + post + family/tax/payment helpers)
|
||||||
|
__init__.py # + invoice_ledger
|
||||||
|
views/
|
||||||
|
invoice_ledger_views.xml # NEW: wizard form + action + menu + cron
|
||||||
|
security/ir.model.access.csv # + ledger wizard ACL
|
||||||
|
__manifest__.py # + views/invoice_ledger_views.xml
|
||||||
|
tests/
|
||||||
|
test_invoice_ledger.py # NEW
|
||||||
|
__init__.py # + test_invoice_ledger
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Scaffold — account.move fields + ledger wizard skeleton
|
||||||
|
|
||||||
|
**Files:** create `models/account_move.py`, `wizards/invoice_ledger.py`, `views/invoice_ledger_views.xml`; modify `models/__init__.py`, `wizards/__init__.py`, `security/ir.model.access.csv`, `__manifest__.py`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: account.move inherit** — `models/account_move.py`:
|
||||||
|
```python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1
|
||||||
|
from odoo import fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class AccountMove(models.Model):
|
||||||
|
_inherit = "account.move"
|
||||||
|
|
||||||
|
x_fc_nexacloud_invoice_id = fields.Char(
|
||||||
|
index=True, copy=False, help="Source NexaCloud invoice id — ledger idempotency key.")
|
||||||
|
x_fc_stripe_invoice_id = fields.Char(index=True, copy=False)
|
||||||
|
|
||||||
|
_fc_nc_invoice_uniq = models.Constraint(
|
||||||
|
"unique(x_fc_nexacloud_invoice_id)",
|
||||||
|
"One Odoo invoice per NexaCloud invoice id.")
|
||||||
|
```
|
||||||
|
Add `from . import account_move` to `models/__init__.py`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: ledger wizard skeleton** — `wizards/invoice_ledger.py`:
|
||||||
|
```python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from odoo import api, fields, models
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class FusionBillingInvoiceLedgerWizard(models.TransientModel):
|
||||||
|
_name = "fusion.billing.invoice.ledger.wizard"
|
||||||
|
_description = "Fusion Billing — NexaCloud Invoice Ledger Ingester"
|
||||||
|
|
||||||
|
dry_run = fields.Boolean(default=True)
|
||||||
|
auto_post = fields.Boolean(
|
||||||
|
default=False, help="Post invoices immediately (else leave draft for review).")
|
||||||
|
result_summary = fields.Text(readonly=True)
|
||||||
|
|
||||||
|
def _ingest_invoices(self, data, post=False):
|
||||||
|
return {"created": 0, "updated": 0, "posted": 0, "skipped": [], "failed": [], "by_family": {}}
|
||||||
|
```
|
||||||
|
Add `from . import invoice_ledger` to `wizards/__init__.py`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: view + action + menu** — `views/invoice_ledger_views.xml`:
|
||||||
|
```xml
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<record id="view_fc_invoice_ledger_wizard_form" model="ir.ui.view">
|
||||||
|
<field name="name">fusion.billing.invoice.ledger.wizard.form</field>
|
||||||
|
<field name="model">fusion.billing.invoice.ledger.wizard</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Ingest NexaCloud Invoices">
|
||||||
|
<group>
|
||||||
|
<field name="dry_run"/>
|
||||||
|
<field name="auto_post"/>
|
||||||
|
</group>
|
||||||
|
<group string="Result" invisible="not result_summary">
|
||||||
|
<field name="result_summary" nolabel="1" widget="text"/>
|
||||||
|
</group>
|
||||||
|
<footer>
|
||||||
|
<button name="action_run" type="object" string="Run" class="btn-primary"/>
|
||||||
|
<button string="Close" class="btn-secondary" special="cancel"/>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
<record id="action_fc_invoice_ledger_wizard" model="ir.actions.act_window">
|
||||||
|
<field name="name">Ingest NexaCloud Invoices</field>
|
||||||
|
<field name="res_model">fusion.billing.invoice.ledger.wizard</field>
|
||||||
|
<field name="view_mode">form</field>
|
||||||
|
<field name="target">new</field>
|
||||||
|
</record>
|
||||||
|
<menuitem id="menu_fc_invoice_ledger" name="Ingest NexaCloud Invoices"
|
||||||
|
parent="menu_fusion_billing_root"
|
||||||
|
action="action_fc_invoice_ledger_wizard" sequence="20"
|
||||||
|
groups="base.group_system"/>
|
||||||
|
</odoo>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: security + manifest** — append to `security/ir.model.access.csv`:
|
||||||
|
```
|
||||||
|
access_fc_invoice_ledger_wizard,fusion.billing.invoice.ledger.wizard,model_fusion_billing_invoice_ledger_wizard,base.group_system,1,1,1,1
|
||||||
|
```
|
||||||
|
Add `"views/invoice_ledger_views.xml"` to `__manifest__.py` `data`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: verify upgrade** — `bash scripts/fcb_test_on_trial.sh` → `FCB_EXIT=0` (existing tests pass; new model/fields/view load).
|
||||||
|
|
||||||
|
- [ ] **Step 6: commit** — `feat(billing): invoice-ledger scaffold (account.move x_fc fields + wizard)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Service-family classification + income account
|
||||||
|
|
||||||
|
**Files:** modify `wizards/invoice_ledger.py`; create `tests/test_invoice_ledger.py` (+ register in `tests/__init__.py`).
|
||||||
|
|
||||||
|
- [ ] **Step 1: failing test** — `tests/test_invoice_ledger.py`:
|
||||||
|
```python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from odoo.tests.common import TransactionCase, tagged
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestLedgerFamily(TransactionCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.W = self.env['fusion.billing.invoice.ledger.wizard'].sudo()
|
||||||
|
|
||||||
|
def test_family_classification(self):
|
||||||
|
f = self.W._fc_family_for
|
||||||
|
self.assertEqual(f('Odoo ERP Hosting (2026-05-01 to 2026-06-01)'), 'hosting')
|
||||||
|
self.assertEqual(f('WordPress Website Hosting - Managed (at $50.00 / month)'), 'hosting')
|
||||||
|
self.assertEqual(f('Managed Odoo - Standard (at $49.99 / month)'), 'managed')
|
||||||
|
self.assertEqual(f('Daily Backup Protection'), 'addons')
|
||||||
|
self.assertEqual(f('Remaining time on Daily Backup Protection after 27 May 2026'), 'addons')
|
||||||
|
self.assertEqual(f('Something Unmapped'), 'other')
|
||||||
|
|
||||||
|
def test_income_account_per_family_distinct(self):
|
||||||
|
a_host = self.W._fc_income_account('hosting')
|
||||||
|
a_add = self.W._fc_income_account('addons')
|
||||||
|
self.assertEqual(a_host.account_type, 'income')
|
||||||
|
self.assertNotEqual(a_host, a_add) # split by family
|
||||||
|
self.assertEqual(self.W._fc_income_account('hosting'), a_host) # idempotent
|
||||||
|
```
|
||||||
|
Append `from . import test_invoice_ledger` to `tests/__init__.py`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: run** → FAIL (`_fc_family_for` missing).
|
||||||
|
|
||||||
|
- [ ] **Step 3: implement** — in `wizards/invoice_ledger.py`:
|
||||||
|
```python
|
||||||
|
_FAMILY_KEYWORDS = [
|
||||||
|
('hosting', ['odoo erp hosting', 'wordpress website hosting']),
|
||||||
|
('managed', ['managed']),
|
||||||
|
('addons', ['daily backup', 'whatsapp', 'forms builder', 'white label']),
|
||||||
|
]
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _fc_family_for(self, description):
|
||||||
|
import re
|
||||||
|
d = (description or '').lower()
|
||||||
|
m = re.match(r'remaining time on (.+?)(?: after| from |\s*\()', d)
|
||||||
|
if m:
|
||||||
|
d = m.group(1) # classify proration by the prorated item
|
||||||
|
for fam, kws in self._FAMILY_KEYWORDS:
|
||||||
|
if any(k in d for k in kws):
|
||||||
|
return fam
|
||||||
|
return 'other'
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _fc_income_account(self, family):
|
||||||
|
Account = self.env['account.account']
|
||||||
|
code = 'NCR-' + family.upper()[:6]
|
||||||
|
acc = Account.search([('code', '=', code)], limit=1)
|
||||||
|
if not acc:
|
||||||
|
acc = Account.create({
|
||||||
|
'code': code, 'name': 'NexaCloud %s Revenue' % family.title(),
|
||||||
|
'account_type': 'income'})
|
||||||
|
return acc
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: run** → PASS. (If `account.account.create` needs more required fields on this build, read `account_account.py` on trial and add them — don't weaken the test.)
|
||||||
|
|
||||||
|
- [ ] **Step 5: commit** — `feat(billing): ledger service-family classification + per-family income accounts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Tax derivation (match NexaCloud's invoice.tax)
|
||||||
|
|
||||||
|
**Files:** modify `wizards/invoice_ledger.py`, `tests/test_invoice_ledger.py`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: failing test** (append):
|
||||||
|
```python
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestLedgerTax(TransactionCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.W = self.env['fusion.billing.invoice.ledger.wizard'].sudo()
|
||||||
|
|
||||||
|
def test_tax_for_13pct_is_a_13_percent_sale_tax(self):
|
||||||
|
tax = self.W._fc_tax_for(100.0, 13.0)
|
||||||
|
self.assertTrue(tax, "expected an HST/13% sale tax on the Canadian COA")
|
||||||
|
self.assertEqual(tax.type_tax_use, 'sale')
|
||||||
|
# the chosen tax computes 13.00 on 100.00
|
||||||
|
res = tax.compute_all(100.0)
|
||||||
|
self.assertAlmostEqual(res['total_included'] - res['total_excluded'], 13.0, places=2)
|
||||||
|
|
||||||
|
def test_tax_for_zero_is_zero_or_empty(self):
|
||||||
|
tax = self.W._fc_tax_for(100.0, 0.0)
|
||||||
|
if tax:
|
||||||
|
res = tax.compute_all(100.0)
|
||||||
|
self.assertAlmostEqual(res['total_included'] - res['total_excluded'], 0.0, places=2)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: run** → FAIL.
|
||||||
|
|
||||||
|
- [ ] **Step 3: implement**:
|
||||||
|
```python
|
||||||
|
@api.model
|
||||||
|
def _fc_tax_for(self, subtotal, tax_amount):
|
||||||
|
"""Map a NexaCloud invoice's (subtotal, tax_amount) to the Odoo sale tax whose
|
||||||
|
computed tax equals it. Picks by effective percent; falls back to a 0% sale tax."""
|
||||||
|
Tax = self.env['account.tax']
|
||||||
|
sub = float(subtotal or 0.0)
|
||||||
|
tax_amt = float(tax_amount or 0.0)
|
||||||
|
if sub <= 0 or tax_amt <= 0:
|
||||||
|
return Tax.search([('type_tax_use', '=', 'sale'), ('amount', '=', 0.0)], limit=1)
|
||||||
|
rate = round(100.0 * tax_amt / sub)
|
||||||
|
tax = Tax.search([('type_tax_use', '=', 'sale'), ('amount_type', '=', 'percent'),
|
||||||
|
('amount', '=', float(rate))], limit=1)
|
||||||
|
if not tax:
|
||||||
|
tax = Tax.search([('type_tax_use', '=', 'sale'), ('name', 'ilike', '%s' % rate)], limit=1)
|
||||||
|
return tax
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: run** → PASS. (Read reference if no 13% sale tax exists: `docker exec odoo-trial-app ... grep -i hst` the l10n_ca data; on nexamain confirm the HST 13% record from `nexa_coa_setup`.)
|
||||||
|
|
||||||
|
- [ ] **Step 5: commit** — `feat(billing): ledger tax derivation matching source invoice tax`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Ingest invoices → draft account.move (idempotent)
|
||||||
|
|
||||||
|
**Read reference first:**
|
||||||
|
```bash
|
||||||
|
ssh pve-worker1 "qm guest exec 316 -- bash -lc 'docker exec odoo-trial-app bash -lc \"grep -nE \\\"def action_post|invoice_line_ids|move_type\\\" /mnt/enterprise-addons/account_accountant/../account/models/account_move.py | head\"'"
|
||||||
|
```
|
||||||
|
Confirm `account.move.create({'move_type':'out_invoice','partner_id':..,'invoice_line_ids':[(0,0,{'name','quantity','price_unit','account_id','tax_ids'})]})` and `move.amount_untaxed/amount_tax/amount_total`.
|
||||||
|
|
||||||
|
**Files:** modify `wizards/invoice_ledger.py`, `tests/test_invoice_ledger.py`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: failing test** (append) — uses a fixture invoice dict shaped like `_read_nexacloud_invoices` output:
|
||||||
|
```python
|
||||||
|
def _inv_fixture():
|
||||||
|
return [{
|
||||||
|
'id': 'inv-1', 'stripe_invoice_id': 'in_test1', 'invoice_number': 'NEX-0001',
|
||||||
|
'user_external_id': 'u-1', 'partner_name': 'Acme', 'partner_email': 'ar@acme.test',
|
||||||
|
'invoice_date': '2026-05-01', 'currency': 'CAD', 'status': 'open',
|
||||||
|
'subtotal': 100.0, 'tax': 13.0, 'amount_paid': 0.0, 'paid_at': None,
|
||||||
|
'items': [{'description': 'Odoo ERP Hosting (2026-05-01 to 2026-06-01)',
|
||||||
|
'quantity': 1.0, 'unit_price': 100.0, 'amount': 100.0}],
|
||||||
|
}]
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestLedgerIngest(TransactionCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.W = self.env['fusion.billing.invoice.ledger.wizard'].sudo()
|
||||||
|
self.svc = self.env['fusion.billing.service'].sudo().create(
|
||||||
|
{'name': 'NexaCloud', 'code': 'nexacloud'})
|
||||||
|
|
||||||
|
def test_ingest_creates_draft_invoice_with_right_totals(self):
|
||||||
|
self.W._ingest_invoices(_inv_fixture(), post=False)
|
||||||
|
mv = self.env['account.move'].search([('x_fc_nexacloud_invoice_id', '=', 'inv-1')])
|
||||||
|
self.assertEqual(len(mv), 1)
|
||||||
|
self.assertEqual(mv.move_type, 'out_invoice')
|
||||||
|
self.assertEqual(mv.state, 'draft')
|
||||||
|
self.assertAlmostEqual(mv.amount_untaxed, 100.0, places=2)
|
||||||
|
self.assertAlmostEqual(mv.amount_tax, 13.0, places=2) # equals source tax
|
||||||
|
self.assertAlmostEqual(mv.amount_total, 113.0, places=2)
|
||||||
|
self.assertEqual(mv.partner_id.email, 'ar@acme.test')
|
||||||
|
line = mv.invoice_line_ids
|
||||||
|
self.assertEqual(line.account_id, self.W._fc_income_account('hosting'))
|
||||||
|
|
||||||
|
def test_ingest_is_idempotent(self):
|
||||||
|
self.W._ingest_invoices(_inv_fixture(), post=False)
|
||||||
|
self.W._ingest_invoices(_inv_fixture(), post=False)
|
||||||
|
self.assertEqual(self.env['account.move'].search_count(
|
||||||
|
[('x_fc_nexacloud_invoice_id', '=', 'inv-1')]), 1)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: run** → FAIL.
|
||||||
|
|
||||||
|
- [ ] **Step 3: implement** the partner resolver + `_ingest_invoices`:
|
||||||
|
```python
|
||||||
|
@api.model
|
||||||
|
def _fc_partner_for(self, inv):
|
||||||
|
"""Resolve the unified partner for an invoice via the nexacloud account.link
|
||||||
|
(by user_external_id); create partner+link if missing (covers NULL-subscription
|
||||||
|
invoices, which still carry a user)."""
|
||||||
|
service = self.env['fusion.billing.service'].search([('code', '=', 'nexacloud')], limit=1)
|
||||||
|
link = self.env['fusion.billing.account.link']._resolve_or_create_partner(
|
||||||
|
service, str(inv.get('user_external_id')),
|
||||||
|
name=inv.get('partner_name'), email=inv.get('partner_email'))
|
||||||
|
return link.partner_id
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _ingest_invoices(self, data, post=False):
|
||||||
|
Move = self.env['account.move']
|
||||||
|
cad = self.env.ref('base.CAD', raise_if_not_found=False) or self.env.company.currency_id
|
||||||
|
summary = {'created': 0, 'updated': 0, 'posted': 0, 'skipped': [], 'failed': [], 'by_family': {}}
|
||||||
|
for inv in data:
|
||||||
|
nc_id = str(inv.get('id') or '')
|
||||||
|
try:
|
||||||
|
with self.env.cr.savepoint():
|
||||||
|
existing = Move.search([('x_fc_nexacloud_invoice_id', '=', nc_id)], limit=1)
|
||||||
|
if existing:
|
||||||
|
if existing.state != 'draft':
|
||||||
|
summary['skipped'].append({'id': nc_id, 'reason': 'already posted'})
|
||||||
|
continue
|
||||||
|
existing.invoice_line_ids.unlink() # draft: replace lines
|
||||||
|
move = existing
|
||||||
|
else:
|
||||||
|
move = Move.create({
|
||||||
|
'move_type': 'out_invoice',
|
||||||
|
'partner_id': self._fc_partner_for(inv).id,
|
||||||
|
'invoice_date': inv.get('invoice_date'),
|
||||||
|
'ref': inv.get('invoice_number'),
|
||||||
|
'currency_id': cad.id,
|
||||||
|
'x_fc_nexacloud_invoice_id': nc_id,
|
||||||
|
'x_fc_stripe_invoice_id': inv.get('stripe_invoice_id'),
|
||||||
|
})
|
||||||
|
tax = self._fc_tax_for(inv.get('subtotal'), inv.get('tax'))
|
||||||
|
line_vals = []
|
||||||
|
for it in inv.get('items', []):
|
||||||
|
fam = self._fc_family_for(it.get('description'))
|
||||||
|
summary['by_family'][fam] = round(
|
||||||
|
summary['by_family'].get(fam, 0.0) + float(it.get('amount') or 0.0), 2)
|
||||||
|
line_vals.append((0, 0, {
|
||||||
|
'name': it.get('description') or 'NexaCloud',
|
||||||
|
'quantity': float(it.get('quantity') or 1.0),
|
||||||
|
'price_unit': float(it.get('unit_price') or it.get('amount') or 0.0),
|
||||||
|
'account_id': self._fc_income_account(fam).id,
|
||||||
|
'tax_ids': [(6, 0, tax.ids)] if tax else [(5, 0, 0)],
|
||||||
|
}))
|
||||||
|
move.write({'invoice_line_ids': line_vals})
|
||||||
|
summary['updated' if existing else 'created'] += 1
|
||||||
|
if post:
|
||||||
|
move.action_post()
|
||||||
|
summary['posted'] += 1
|
||||||
|
self._fc_reconcile_payment(move, inv)
|
||||||
|
except Exception as e: # noqa: BLE001 - per-invoice isolation
|
||||||
|
_logger.exception("Ledger ingest: invoice %s failed", nc_id)
|
||||||
|
summary['failed'].append({'id': nc_id, 'error': '%s: %s' % (type(e).__name__, e)})
|
||||||
|
return summary
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _fc_reconcile_payment(self, move, inv):
|
||||||
|
"""Placeholder until Task 5; defined so post=True doesn't AttributeError."""
|
||||||
|
return False
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: run** → PASS. (If tax computes to 13.00 only when the company/fiscal position allows it, read the tax setup on trial; if `amount_tax` ≠ 13.00, the chosen tax is wrong — fix `_fc_tax_for`, never weaken the assertion.)
|
||||||
|
|
||||||
|
- [ ] **Step 5: commit** — `feat(billing): ingest NexaCloud invoices -> draft account.move (idempotent)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Reconcile Stripe payments (paid invoices show paid)
|
||||||
|
|
||||||
|
**Read reference first:** confirm the payment-register flow on trial:
|
||||||
|
```bash
|
||||||
|
ssh pve-worker1 "qm guest exec 316 -- bash -lc 'docker exec odoo-trial-app bash -lc \"grep -nE \\\"_create_payments|def action_create_payments\\\" /mnt/enterprise-addons/account/wizard/account_payment_register.py | head\"'"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Files:** modify `wizards/invoice_ledger.py`, `tests/test_invoice_ledger.py`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: failing test** (append):
|
||||||
|
```python
|
||||||
|
def test_paid_invoice_is_reconciled_and_shows_paid(self):
|
||||||
|
data = _inv_fixture()
|
||||||
|
data[0].update({'status': 'paid', 'amount_paid': 113.0, 'paid_at': '2026-05-02'})
|
||||||
|
self.W._ingest_invoices(data, post=True)
|
||||||
|
mv = self.env['account.move'].search([('x_fc_nexacloud_invoice_id', '=', 'inv-1')])
|
||||||
|
self.assertEqual(mv.state, 'posted')
|
||||||
|
self.assertIn(mv.payment_state, ('paid', 'in_payment'))
|
||||||
|
```
|
||||||
|
(Add this inside `TestLedgerIngest`.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: run** → FAIL (payment not reconciled).
|
||||||
|
|
||||||
|
- [ ] **Step 3: implement** `_fc_reconcile_payment` + a journal helper (replace the placeholder):
|
||||||
|
```python
|
||||||
|
@api.model
|
||||||
|
def _fc_stripe_journal(self):
|
||||||
|
Journal = self.env['account.journal']
|
||||||
|
j = Journal.search([('code', '=', 'NCSTR')], limit=1)
|
||||||
|
if not j:
|
||||||
|
j = Journal.create({'name': 'NexaCloud Stripe', 'code': 'NCSTR', 'type': 'bank'})
|
||||||
|
return j
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _fc_reconcile_payment(self, move, inv):
|
||||||
|
paid = float(inv.get('amount_paid') or 0.0)
|
||||||
|
if (inv.get('status') != 'paid' and paid <= 0) or move.state != 'posted':
|
||||||
|
return False
|
||||||
|
reg = self.env['account.payment.register'].with_context(
|
||||||
|
active_model='account.move', active_ids=move.ids).create({
|
||||||
|
'journal_id': self._fc_stripe_journal().id,
|
||||||
|
'payment_date': inv.get('paid_at') or move.invoice_date or fields.Date.today(),
|
||||||
|
'amount': paid or move.amount_total,
|
||||||
|
})
|
||||||
|
reg._create_payments()
|
||||||
|
return True
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: run** → PASS. (If `payment_state` is `in_payment` rather than `paid`, that's expected when the bank journal isn't reconciled to a statement — accept both, as the assertion does.)
|
||||||
|
|
||||||
|
- [ ] **Step 5: commit** — `feat(billing): reconcile Stripe payments so ingested invoices show paid`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Reader + wizard actions + bulk-post + cron
|
||||||
|
|
||||||
|
**Files:** modify `wizards/invoice_ledger.py`, `views/invoice_ledger_views.xml`, `tests/test_invoice_ledger.py`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: failing test** for bulk-post + DSN guard (append):
|
||||||
|
```python
|
||||||
|
def test_post_ingested_posts_drafts(self):
|
||||||
|
self.W._ingest_invoices(_inv_fixture(), post=False)
|
||||||
|
n = self.W._post_ingested()
|
||||||
|
mv = self.env['account.move'].search([('x_fc_nexacloud_invoice_id', '=', 'inv-1')])
|
||||||
|
self.assertEqual(mv.state, 'posted')
|
||||||
|
self.assertGreaterEqual(n, 1)
|
||||||
|
|
||||||
|
def test_read_invoices_guards_missing_dsn(self):
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
self.env['ir.config_parameter'].sudo().set_param('fusion_billing.nexacloud_dsn', '')
|
||||||
|
with self.assertRaises(UserError):
|
||||||
|
self.W._read_nexacloud_invoices()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: run** → FAIL.
|
||||||
|
|
||||||
|
- [ ] **Step 3: implement** `_post_ingested`, `_read_nexacloud_invoices`, `action_run`, and a cron entry:
|
||||||
|
```python
|
||||||
|
@api.model
|
||||||
|
def _post_ingested(self):
|
||||||
|
moves = self.env['account.move'].search([
|
||||||
|
('x_fc_nexacloud_invoice_id', '!=', False),
|
||||||
|
('state', '=', 'draft'), ('move_type', '=', 'out_invoice')])
|
||||||
|
posted = 0
|
||||||
|
for mv in moves:
|
||||||
|
try:
|
||||||
|
with self.env.cr.savepoint():
|
||||||
|
mv.action_post()
|
||||||
|
posted += 1
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
_logger.exception("Ledger post: move %s failed", mv.id)
|
||||||
|
return posted
|
||||||
|
|
||||||
|
def _read_nexacloud_invoices(self, since=None):
|
||||||
|
import psycopg2
|
||||||
|
import psycopg2.extras
|
||||||
|
dsn = self.env['ir.config_parameter'].sudo().get_param('fusion_billing.nexacloud_dsn')
|
||||||
|
if not dsn:
|
||||||
|
raise UserError("NexaCloud DSN not configured (fusion_billing.nexacloud_dsn).")
|
||||||
|
try:
|
||||||
|
conn = psycopg2.connect(dsn)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
raise UserError("Could not connect to the NexaCloud database: %s" % e)
|
||||||
|
try:
|
||||||
|
conn.set_session(readonly=True)
|
||||||
|
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
|
where = "WHERE i.created_at >= %(since)s" if since else ""
|
||||||
|
cur.execute(
|
||||||
|
"SELECT i.id, i.stripe_invoice_id, i.invoice_number, i.user_id AS user_external_id, "
|
||||||
|
"u.full_name AS partner_name, COALESCE(u.billing_email,u.email) AS partner_email, "
|
||||||
|
"i.created_at AS invoice_date, i.currency, i.status, i.subtotal, i.tax, "
|
||||||
|
"i.amount_paid, i.paid_at "
|
||||||
|
"FROM invoices i JOIN users u ON u.id = i.user_id " + where +
|
||||||
|
" ORDER BY i.created_at", {'since': since})
|
||||||
|
invoices = {str(r['id']): dict(r, items=[]) for r in cur.fetchall()}
|
||||||
|
cur.execute(
|
||||||
|
"SELECT ii.invoice_id, ii.description, ii.quantity, ii.unit_price, ii.amount "
|
||||||
|
"FROM invoice_items ii WHERE ii.invoice_id = ANY(%(ids)s)",
|
||||||
|
{'ids': list(invoices.keys())})
|
||||||
|
for r in cur.fetchall():
|
||||||
|
inv = invoices.get(str(r['invoice_id']))
|
||||||
|
if inv:
|
||||||
|
inv['items'].append({'description': r['description'], 'quantity': r['quantity'],
|
||||||
|
'unit_price': r['unit_price'], 'amount': r['amount']})
|
||||||
|
for inv in invoices.values():
|
||||||
|
inv['id'] = str(inv['id'])
|
||||||
|
inv['user_external_id'] = str(inv['user_external_id'])
|
||||||
|
return list(invoices.values())
|
||||||
|
except psycopg2.Error as e:
|
||||||
|
raise UserError("Failed reading NexaCloud invoices — schema may have changed:\n%s" % e)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def action_run(self):
|
||||||
|
self.ensure_one()
|
||||||
|
data = self._read_nexacloud_invoices()
|
||||||
|
if self.dry_run:
|
||||||
|
class _Rollback(Exception):
|
||||||
|
pass
|
||||||
|
res = {}
|
||||||
|
try:
|
||||||
|
with self.env.cr.savepoint():
|
||||||
|
res.update(self._ingest_invoices(data, post=False))
|
||||||
|
raise _Rollback()
|
||||||
|
except _Rollback:
|
||||||
|
pass
|
||||||
|
res['dry_run'] = True
|
||||||
|
else:
|
||||||
|
res = self._ingest_invoices(data, post=self.auto_post)
|
||||||
|
self.result_summary = json.dumps(res, indent=2, default=str)
|
||||||
|
if res.get('failed'):
|
||||||
|
_logger.error("Ledger ingest: %s failed: %s", len(res['failed']), res['failed'])
|
||||||
|
return {"type": "ir.actions.act_window", "res_model": self._name,
|
||||||
|
"res_id": self.id, "view_mode": "form", "target": "new"}
|
||||||
|
```
|
||||||
|
Add a daily cron to `views/invoice_ledger_views.xml`:
|
||||||
|
```xml
|
||||||
|
<record id="cron_fc_invoice_ledger" model="ir.cron">
|
||||||
|
<field name="name">Fusion Billing: Ingest NexaCloud invoices (daily)</field>
|
||||||
|
<field name="model_id" ref="model_fusion_billing_invoice_ledger_wizard"/>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">model.create({'dry_run': False, 'auto_post': True})._cron_ingest_recent()</field>
|
||||||
|
<field name="interval_number">1</field>
|
||||||
|
<field name="interval_type">days</field>
|
||||||
|
<field name="active">False</field>
|
||||||
|
</record>
|
||||||
|
```
|
||||||
|
And `_cron_ingest_recent` (ingest invoices from the last 2 days, idempotent):
|
||||||
|
```python
|
||||||
|
def _cron_ingest_recent(self):
|
||||||
|
from datetime import timedelta
|
||||||
|
since = fields.Datetime.to_string(fields.Datetime.now() - timedelta(days=2))
|
||||||
|
return self._ingest_invoices(self._read_nexacloud_invoices(since=since), post=True)
|
||||||
|
```
|
||||||
|
(Cron ships `active=False` — enabled only after the backfill is reviewed.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: run** → PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: commit** — `feat(billing): invoice-ledger reader, wizard actions, bulk-post, daily cron`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Prune obsolete metered shadow data
|
||||||
|
|
||||||
|
**Files:** modify `wizards/invoice_ledger.py`, `tests/test_invoice_ledger.py`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: failing test** (append):
|
||||||
|
```python
|
||||||
|
def test_prune_shadow_removes_shadow_subs_only(self):
|
||||||
|
# a shadow sub + a normal order
|
||||||
|
p = self.env['res.partner'].sudo().create({'name': 'X'})
|
||||||
|
shadow = self.env['sale.order'].sudo().create({'partner_id': p.id, 'x_fc_shadow': True})
|
||||||
|
n = self.W._fc_prune_metered_shadow()
|
||||||
|
self.assertFalse(shadow.exists())
|
||||||
|
self.assertGreaterEqual(n.get('subscriptions', 0), 1)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: run** → FAIL.
|
||||||
|
|
||||||
|
- [ ] **Step 3: implement**:
|
||||||
|
```python
|
||||||
|
@api.model
|
||||||
|
def _fc_prune_metered_shadow(self):
|
||||||
|
"""Delete the superseded metered shadow data (shadow sale.orders, NC-* products,
|
||||||
|
NexaCloud charges, reconciliation rows). Reversible only by re-import."""
|
||||||
|
counts = {}
|
||||||
|
subs = self.env['sale.order'].search([('x_fc_shadow', '=', True)])
|
||||||
|
counts['subscriptions'] = len(subs)
|
||||||
|
subs.unlink()
|
||||||
|
prods = self.env['product.product'].search([('default_code', '=like', 'NC-%')])
|
||||||
|
counts['products'] = len(prods)
|
||||||
|
prods.unlink()
|
||||||
|
ch = self.env['fusion.billing.charge'].search([])
|
||||||
|
counts['charges'] = len(ch)
|
||||||
|
ch.unlink()
|
||||||
|
rec = self.env['fusion.billing.reconciliation'].search([])
|
||||||
|
counts['reconciliations'] = len(rec)
|
||||||
|
rec.unlink()
|
||||||
|
return counts
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: run** → PASS. (If a product can't unlink due to references, archive instead — read the error and adjust.)
|
||||||
|
|
||||||
|
- [ ] **Step 5: commit** — `feat(billing): prune obsolete metered shadow data helper`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: Full suite + static checks
|
||||||
|
|
||||||
|
- [ ] `bash scripts/fcb_test_on_trial.sh` → `FCB_EXIT=0`.
|
||||||
|
- [ ] `grep -rn "_sql_constraints" fusion_centralize_billing/ || echo clean` → clean.
|
||||||
|
- [ ] `grep -rnE "sale\.subscription[^.]" fusion_centralize_billing/ | grep -v "sale.subscription.plan"` → only docstring.
|
||||||
|
- [ ] commit any fixes.
|
||||||
|
|
||||||
|
## Done = invoice ledger ready to run
|
||||||
|
|
||||||
|
Then (separate, gated, NOT in this plan): on nexamain — prune shadow data, **dry-run** the full backfill (review the per-family $ summary + unmatched "Other" lines), ingest **as draft**, you review a sample, **bulk-post**, enable the daily cron.
|
||||||
288
docs/superpowers/plans/2026-05-27-nexacloud-reconciliation.md
Normal file
288
docs/superpowers/plans/2026-05-27-nexacloud-reconciliation.md
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
# NexaCloud Dual-Run Reconciliation (Sub-project #2d) — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans. Checkbox steps.
|
||||||
|
|
||||||
|
**Goal:** Compute, per shadow subscription + period, Odoo's would-be charge vs NexaCloud's actual charge and record the delta in `fusion.billing.reconciliation`, so the dual-run can prove parity before any flip.
|
||||||
|
|
||||||
|
**Architecture:** A pure `_compute_reconciliation(...)` (testable) + `_reconcile_rows(rows)` (resolves the shadow sub → flat + charge, upserts recon rows) + a read-only `_read_reconciliation_rows()` (psycopg2, integration glue). Triggered from the import wizard + cron. Odoo-only; reads NexaCloud, writes only reconciliation rows.
|
||||||
|
|
||||||
|
**Tech Stack:** Odoo 19 Enterprise, `psycopg2`. Tests: `TransactionCase` on odoo-trial (`bash scripts/fcb_test_on_trial.sh`, pass = `FCB_EXIT=0`).
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-05-27-nexacloud-reconciliation-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: 2a amendment — store the NexaCloud plan id on the shadow subscription
|
||||||
|
|
||||||
|
**Files:** `models/sale_order.py`, `wizards/import_wizard.py`, `tests/test_importer.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: failing test** (append to `TestImporterSubscriptions` in `tests/test_importer.py`):
|
||||||
|
```python
|
||||||
|
def test_subscription_records_nexacloud_plan_id(self):
|
||||||
|
self.Wizard._import_rows(_fixture())
|
||||||
|
sub1 = self.env['sale.order'].search([('x_fc_nexacloud_subscription_id', '=', 's-1')])
|
||||||
|
self.assertEqual(sub1.x_fc_nexacloud_plan_id, 'p-1')
|
||||||
|
```
|
||||||
|
- [ ] **Step 2: run** `bash scripts/fcb_test_on_trial.sh` → FAIL (field missing).
|
||||||
|
- [ ] **Step 3: add the field** to `models/sale_order.py` (next to the other `x_fc_*`):
|
||||||
|
```python
|
||||||
|
x_fc_nexacloud_plan_id = fields.Char(index=True, copy=False)
|
||||||
|
```
|
||||||
|
- [ ] **Step 4: set it in the importer.** In `wizards/import_wizard.py` `_import_subscription`, add the plan id to both the `shadow_vals` dict (so re-runs keep it current) :
|
||||||
|
```python
|
||||||
|
shadow_vals = {
|
||||||
|
"x_fc_nexacloud_deployment_id": str(srow.get("deployment_id") or ""),
|
||||||
|
"x_fc_nexacloud_plan_id": str(srow.get("plan_id") or ""),
|
||||||
|
"x_fc_billing_service_id": service.id, "x_fc_shadow": True,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- [ ] **Step 5: run** → PASS.
|
||||||
|
- [ ] **Step 6: commit** `feat(billing): record NexaCloud plan id on shadow subscription (for reconciliation)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: pure reconciliation math
|
||||||
|
|
||||||
|
**Files:** `models/reconciliation.py`, `tests/test_reconciliation.py` (new), `tests/__init__.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1:** append `from . import test_reconciliation` to `tests/__init__.py`.
|
||||||
|
- [ ] **Step 2: failing test** `tests/test_reconciliation.py`:
|
||||||
|
```python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from odoo.tests.common import TransactionCase, tagged
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestReconciliationMath(TransactionCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.Recon = self.env['fusion.billing.reconciliation'].sudo()
|
||||||
|
self.metric = self.env['fusion.billing.metric'].sudo().create(
|
||||||
|
{'name': 'CPU seconds', 'code': 'cpu_seconds', 'aggregation': 'sum'})
|
||||||
|
self.charge = self.env['fusion.billing.charge'].sudo().create({
|
||||||
|
'name': 'CPU', 'plan_code': 'p-1', 'metric_id': self.metric.id,
|
||||||
|
'included_quota': 18000.0, 'price_per_unit': 0.0075,
|
||||||
|
'unit_batch': 3600.0, 'charge_model': 'standard'})
|
||||||
|
|
||||||
|
def test_match_within_tolerance(self):
|
||||||
|
# flat 20 + 0 overage (under quota) vs external 20.00 -> match
|
||||||
|
odoo_amt, delta, status = self.Recon._compute_reconciliation(
|
||||||
|
20.0, self.charge, 10000.0, 20.0, 0.01)
|
||||||
|
self.assertAlmostEqual(odoo_amt, 20.0)
|
||||||
|
self.assertEqual(status, 'match')
|
||||||
|
|
||||||
|
def test_overage_match(self):
|
||||||
|
# flat 20 + 2 core-hours overage (7200s -> $0.015) = 20.015 vs external 20.015
|
||||||
|
odoo_amt, delta, status = self.Recon._compute_reconciliation(
|
||||||
|
20.0, self.charge, 18000.0 + 7200.0, 20.015, 0.01)
|
||||||
|
self.assertAlmostEqual(odoo_amt, 20.015, places=4)
|
||||||
|
self.assertEqual(status, 'match')
|
||||||
|
|
||||||
|
def test_delta_flags_mismatch(self):
|
||||||
|
odoo_amt, delta, status = self.Recon._compute_reconciliation(
|
||||||
|
20.0, self.charge, 18000.0, 25.0, 0.01) # external 25 vs odoo 20
|
||||||
|
self.assertAlmostEqual(delta, -5.0, places=2)
|
||||||
|
self.assertEqual(status, 'delta')
|
||||||
|
```
|
||||||
|
- [ ] **Step 3: run** → FAIL (`_compute_reconciliation` missing).
|
||||||
|
- [ ] **Step 4: implement** in `models/reconciliation.py` (add `from odoo import api, fields, models`):
|
||||||
|
```python
|
||||||
|
@api.model
|
||||||
|
def _compute_reconciliation(self, flat_amount, charge, cpu_seconds, external_amount,
|
||||||
|
tolerance=0.01):
|
||||||
|
"""Return (odoo_amount, delta, status). odoo = flat + overage(cpu_seconds);
|
||||||
|
delta = odoo - external; status 'match' if |delta| <= tolerance else 'delta'."""
|
||||||
|
_units, overage = charge._compute_billable(cpu_seconds) if charge else (0.0, 0.0)
|
||||||
|
odoo_amount = round((flat_amount or 0.0) + (overage or 0.0), 2)
|
||||||
|
delta = round(odoo_amount - (external_amount or 0.0), 2)
|
||||||
|
status = 'match' if abs(delta) <= (tolerance or 0.0) else 'delta'
|
||||||
|
return odoo_amount, delta, status
|
||||||
|
```
|
||||||
|
- [ ] **Step 5: run** → PASS.
|
||||||
|
- [ ] **Step 6: commit** `feat(billing): reconciliation math (odoo-computed vs external)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: `_reconcile_rows` — resolve shadow sub and upsert recon rows
|
||||||
|
|
||||||
|
**Files:** `models/reconciliation.py`, `tests/test_reconciliation.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: failing test** (append):
|
||||||
|
```python
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestReconcileRows(TransactionCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.Wizard = self.env['fusion.billing.import.wizard'].sudo()
|
||||||
|
from odoo.addons.fusion_centralize_billing.tests.test_importer import _fixture
|
||||||
|
self.Wizard._import_rows(_fixture()) # creates shadow subs + p-1 charge
|
||||||
|
self.Recon = self.env['fusion.billing.reconciliation'].sudo()
|
||||||
|
|
||||||
|
def test_creates_one_row_per_subscription_with_status(self):
|
||||||
|
# s-1 monthly flat 20, no overage; external 20.00 -> match.
|
||||||
|
# s-2 yearly flat 200; external 250 -> delta -50.
|
||||||
|
summary = self.Recon._reconcile_rows([
|
||||||
|
{'subscription_external_id': 's-1', 'period': '2026-05',
|
||||||
|
'cpu_seconds': 0.0, 'external_amount': 20.0},
|
||||||
|
{'subscription_external_id': 's-2', 'period': '2026-05',
|
||||||
|
'cpu_seconds': 0.0, 'external_amount': 250.0},
|
||||||
|
])
|
||||||
|
rows = self.Recon.search([('period', '=', '2026-05')])
|
||||||
|
self.assertEqual(len(rows), 2)
|
||||||
|
s1 = rows.filtered(lambda r: r.odoo_amount == 20.0)
|
||||||
|
self.assertEqual(s1.status, 'match')
|
||||||
|
s2 = rows.filtered(lambda r: r.odoo_amount == 200.0)
|
||||||
|
self.assertEqual(s2.status, 'delta')
|
||||||
|
self.assertAlmostEqual(s2.delta, -50.0, places=2)
|
||||||
|
self.assertEqual(summary['match'], 1)
|
||||||
|
self.assertEqual(summary['delta'], 1)
|
||||||
|
|
||||||
|
def test_rerun_upserts(self):
|
||||||
|
row = [{'subscription_external_id': 's-1', 'period': '2026-05',
|
||||||
|
'cpu_seconds': 0.0, 'external_amount': 20.0}]
|
||||||
|
self.Recon._reconcile_rows(row)
|
||||||
|
self.Recon._reconcile_rows(row)
|
||||||
|
self.assertEqual(self.Recon.search_count(
|
||||||
|
[('period', '=', '2026-05'),
|
||||||
|
('partner_id', '=', self.env['sale.order'].search(
|
||||||
|
[('x_fc_nexacloud_subscription_id', '=', 's-1')]).partner_id.id)]), 1)
|
||||||
|
|
||||||
|
def test_unknown_subscription_is_skipped(self):
|
||||||
|
summary = self.Recon._reconcile_rows([
|
||||||
|
{'subscription_external_id': 'nope', 'period': '2026-05',
|
||||||
|
'cpu_seconds': 0.0, 'external_amount': 1.0}])
|
||||||
|
self.assertTrue(any(s['id'] == 'nope' for s in summary['skipped']))
|
||||||
|
```
|
||||||
|
- [ ] **Step 2: run** → FAIL.
|
||||||
|
- [ ] **Step 3: implement** in `models/reconciliation.py`:
|
||||||
|
```python
|
||||||
|
@api.model
|
||||||
|
def _reconcile_rows(self, rows, tolerance=0.01):
|
||||||
|
SaleOrder = self.env['sale.order']
|
||||||
|
Charge = self.env['fusion.billing.charge']
|
||||||
|
Service = self.env['fusion.billing.service']
|
||||||
|
service = Service.search([('code', '=', 'nexacloud')], limit=1)
|
||||||
|
summary = {'match': 0, 'delta': 0, 'skipped': [], 'failed': []}
|
||||||
|
for r in rows:
|
||||||
|
sub_ext = str(r.get('subscription_external_id') or '')
|
||||||
|
period = str(r.get('period') or '')
|
||||||
|
try:
|
||||||
|
sub = SaleOrder.search(
|
||||||
|
[('x_fc_nexacloud_subscription_id', '=', sub_ext)], limit=1)
|
||||||
|
if not sub:
|
||||||
|
summary['skipped'].append({'id': sub_ext, 'reason': 'unknown subscription'})
|
||||||
|
continue
|
||||||
|
charge = Charge.search(
|
||||||
|
[('plan_code', '=', sub.x_fc_nexacloud_plan_id)], limit=1)
|
||||||
|
plan_line = sub.order_line.filtered(
|
||||||
|
lambda l: l.product_id.default_code
|
||||||
|
and l.product_id.default_code.startswith('NC-PLAN-'))
|
||||||
|
flat = plan_line[:1].price_unit
|
||||||
|
odoo_amount, delta, status = self._compute_reconciliation(
|
||||||
|
flat, charge, float(r.get('cpu_seconds') or 0.0),
|
||||||
|
float(r.get('external_amount') or 0.0), tolerance)
|
||||||
|
vals = {
|
||||||
|
'service_id': service.id if service else False,
|
||||||
|
'partner_id': sub.partner_id.id, 'period': period,
|
||||||
|
'odoo_amount': odoo_amount,
|
||||||
|
'external_amount': float(r.get('external_amount') or 0.0),
|
||||||
|
'delta': delta, 'status': status,
|
||||||
|
}
|
||||||
|
existing = self.search([
|
||||||
|
('service_id', '=', vals['service_id']),
|
||||||
|
('partner_id', '=', sub.partner_id.id), ('period', '=', period)], limit=1)
|
||||||
|
if existing:
|
||||||
|
existing.write(vals)
|
||||||
|
else:
|
||||||
|
self.create(vals)
|
||||||
|
summary['match' if status == 'match' else 'delta'] += 1
|
||||||
|
except Exception as e: # noqa: BLE001 - per-row isolation
|
||||||
|
summary['failed'].append({'id': sub_ext, 'error': '%s: %s' % (type(e).__name__, e)})
|
||||||
|
return summary
|
||||||
|
```
|
||||||
|
- [ ] **Step 4: run** → PASS.
|
||||||
|
- [ ] **Step 5: commit** `feat(billing): reconcile shadow subscriptions -> fusion.billing.reconciliation`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: read NexaCloud actuals + wizard trigger
|
||||||
|
|
||||||
|
**Files:** `wizards/import_wizard.py`, `views/import_wizard_views.xml`
|
||||||
|
|
||||||
|
- [ ] **Step 1: add the reader** in `wizards/import_wizard.py` (reuses the DSN + the same connect/guard pattern as `_read_nexacloud_rows`). Aggregate usage cpu_hours per (subscription, period) and the invoice subtotal per (subscription, period); return rows shaped for `_reconcile_rows`:
|
||||||
|
```python
|
||||||
|
def _read_reconciliation_rows(self):
|
||||||
|
import psycopg2
|
||||||
|
import psycopg2.extras
|
||||||
|
dsn = self.env["ir.config_parameter"].sudo().get_param("fusion_billing.nexacloud_dsn")
|
||||||
|
if not dsn:
|
||||||
|
raise UserError("NexaCloud DSN not configured (fusion_billing.nexacloud_dsn).")
|
||||||
|
try:
|
||||||
|
conn = psycopg2.connect(dsn)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
raise UserError("Could not connect to the NexaCloud database: %s" % e)
|
||||||
|
try:
|
||||||
|
conn.set_session(readonly=True)
|
||||||
|
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
|
# period label = YYYY-MM of the usage period_start; cpu_seconds = cpu_hours*3600
|
||||||
|
cur.execute("""
|
||||||
|
SELECT u.subscription_id::text AS subscription_external_id,
|
||||||
|
to_char(u.period_start, 'YYYY-MM') AS period,
|
||||||
|
COALESCE(SUM(u.cpu_hours), 0) * 3600.0 AS cpu_seconds
|
||||||
|
FROM usage_records u
|
||||||
|
GROUP BY u.subscription_id, to_char(u.period_start, 'YYYY-MM')""")
|
||||||
|
usage = {(r['subscription_external_id'], r['period']): r for r in cur.fetchall()}
|
||||||
|
cur.execute("""
|
||||||
|
SELECT i.subscription_id::text AS subscription_external_id,
|
||||||
|
to_char(ii.period_start, 'YYYY-MM') AS period,
|
||||||
|
COALESCE(SUM(i.subtotal), 0) AS external_amount
|
||||||
|
FROM invoices i JOIN invoice_items ii ON ii.invoice_id = i.id
|
||||||
|
GROUP BY i.subscription_id, to_char(ii.period_start, 'YYYY-MM')""")
|
||||||
|
rows = []
|
||||||
|
for r in cur.fetchall():
|
||||||
|
key = (r['subscription_external_id'], r['period'])
|
||||||
|
rows.append({
|
||||||
|
'subscription_external_id': r['subscription_external_id'],
|
||||||
|
'period': r['period'],
|
||||||
|
'cpu_seconds': float((usage.get(key) or {}).get('cpu_seconds') or 0.0),
|
||||||
|
'external_amount': float(r['external_amount'] or 0.0)})
|
||||||
|
return rows
|
||||||
|
except psycopg2.Error as e:
|
||||||
|
raise UserError("Failed reading NexaCloud actuals — schema may have changed:\n%s" % e)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def action_run_reconciliation(self):
|
||||||
|
self.ensure_one()
|
||||||
|
rows = self._read_reconciliation_rows()
|
||||||
|
summary = self.env['fusion.billing.reconciliation']._reconcile_rows(rows)
|
||||||
|
self.result_summary = json.dumps(summary, indent=2, default=str)
|
||||||
|
self.failed_count = len(summary.get('failed') or [])
|
||||||
|
if summary.get('delta') or summary.get('failed'):
|
||||||
|
_logger.error("NexaCloud reconciliation: %s delta / %s failed row(s): %s",
|
||||||
|
summary.get('delta'), len(summary.get('failed') or []), summary)
|
||||||
|
return {"type": "ir.actions.act_window", "res_model": self._name,
|
||||||
|
"res_id": self.id, "view_mode": "form", "target": "new"}
|
||||||
|
```
|
||||||
|
- [ ] **Step 2: add the button** to `views/import_wizard_views.xml` footer:
|
||||||
|
```xml
|
||||||
|
<button name="action_run_reconciliation" type="object"
|
||||||
|
string="Run Reconciliation" class="btn-secondary"/>
|
||||||
|
```
|
||||||
|
- [ ] **Step 3:** `bash scripts/fcb_test_on_trial.sh` → `FCB_EXIT=0` (module upgrades; reader is integration-only, not unit-tested).
|
||||||
|
- [ ] **Step 4: commit** `feat(billing): NexaCloud reconciliation reader + wizard trigger`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: full suite + static checks
|
||||||
|
|
||||||
|
- [ ] `bash scripts/fcb_test_on_trial.sh` → `FCB_EXIT=0`.
|
||||||
|
- [ ] `grep -rn "_sql_constraints" fusion_centralize_billing/ || echo clean` → clean.
|
||||||
|
- [ ] `grep -rnE "sale\.subscription[^.]" fusion_centralize_billing/ | grep -v "sale.subscription.plan"` → only docstring.
|
||||||
|
- [ ] commit any fixes.
|
||||||
|
|
||||||
|
## Done = 2d complete
|
||||||
|
|
||||||
|
The dual-run can be run each cycle (button/cron): it reads NexaCloud usage + invoice subtotals, computes Odoo's would-be charge, and records per-subscription `match`/`delta` rows. Flip happens (manually) once a cycle is all-match.
|
||||||
552
docs/superpowers/specs/2026-05-12-nexa-coa-design.md
Normal file
552
docs/superpowers/specs/2026-05-12-nexa-coa-design.md
Normal file
@@ -0,0 +1,552 @@
|
|||||||
|
# Nexa Systems Inc — Chart of Accounts & Accounting Setup Design
|
||||||
|
|
||||||
|
**Date**: 2026-05-12
|
||||||
|
**Target**: odoo-nexa production instance, database `nexamain`
|
||||||
|
**Status**: Design — pending implementation plan
|
||||||
|
|
||||||
|
## 1. Context
|
||||||
|
|
||||||
|
Nexa Systems Inc is a Canadian CCPC providing IT services: custom software development, custom ERP, business apps, hosting, custom websites, and custom web apps. Operations are Canada-wide with planned global expansion. Workforce: solo founder today (Gurpreet, Canadian), hiring plan favours Canadian T4/T4A with occasional India contractors for burst capacity. Nexa will pursue SR&ED tax credits.
|
||||||
|
|
||||||
|
**Current state (as of 2026-05-12)**:
|
||||||
|
- Odoo 19 Enterprise, l10n_ca localization loaded
|
||||||
|
- 426 GL accounts (most unused — generic Canadian template bloat)
|
||||||
|
- 49 active taxes with duplicates
|
||||||
|
- 14 journals incl. 7 bank accounts (overprovisioned)
|
||||||
|
- 776 journal entries, 125 invoices, data 2020-01-01 to 2026-05-04
|
||||||
|
- **Historical Odoo data is NOT authoritative** — accountant has filed externally on Excel-based records. Past will be reconciled later.
|
||||||
|
- All prior years filed with CRA. Fiscal year-end Dec 31.
|
||||||
|
|
||||||
|
**CRA registration & filing cadence**:
|
||||||
|
- **Business Number / HST account**: `741224877` (currently stored as 9-digit BN root only on company record; needs to be updated to full 15-char format `741224877 RT0001` for Odoo's Canadian tax reports to validate cleanly).
|
||||||
|
- **GST/HST filing**: annual. Return due **3 months after fiscal year-end** (March 31).
|
||||||
|
- **T2 corporate income tax filing**: annual. Return due **6 months after fiscal year-end** (June 30). Balance owing due 3 months after year-end (March 31) for CCPCs eligible for SBD; 2 months otherwise.
|
||||||
|
- **HST instalments**: annual filers must remit quarterly instalments if their net tax for the prior year was ≥ $3,000. Track via account 118200 GST/HST Instalments Paid.
|
||||||
|
- **T2 instalments**: monthly or quarterly instalments required if Part I tax owing in prior year ≥ $3,000.
|
||||||
|
|
||||||
|
**Goals**:
|
||||||
|
1. **CRA compliance** — clean tax handling, T2 Schedule 125 alignment, audit-ready
|
||||||
|
2. **Tax savings** — SR&ED claim infrastructure from day 1, zero-rated export handling, CCA structure
|
||||||
|
3. **Automation** — fiscal positions, default accounts, bank feeds, subscription billing
|
||||||
|
4. **Ease of use** — invoicing is one-click after customer/product selection
|
||||||
|
|
||||||
|
**Scope**: Chart of accounts structure + tax/fiscal-position setup + analytic plans + automation hooks. **Out of scope**: bank feed onboarding (separate sub-project), CCA custom module (defer until volume warrants), historical data reconciliation (separate sub-project when accountant records arrive).
|
||||||
|
|
||||||
|
## 2. Approach
|
||||||
|
|
||||||
|
**Approach #2 — Hybrid**: keep l10n_ca's 6-digit code scheme (Canadian accountants recognize it), aggressively curate (~370 unused accounts archived, ~20 renamed, ~70 added), supplement with three analytic plans for finer reporting without GL proliferation.
|
||||||
|
|
||||||
|
**Rejected alternatives**:
|
||||||
|
- *Surgical* — keep all 426 accounts unchanged. Rejected: bookkeeping burden, no IT-services shape.
|
||||||
|
- *Clean slate (custom 4-digit)* — toss l10n_ca. Rejected: accountants would have to learn it; loses pre-mapped CRA tax structure.
|
||||||
|
|
||||||
|
## 3. Code Skeleton
|
||||||
|
|
||||||
|
```
|
||||||
|
1xxxxx ASSETS
|
||||||
|
111xxx Cash & cash equivalents
|
||||||
|
112xxx Accounts receivable
|
||||||
|
113xxx Prepaid expenses
|
||||||
|
114xxx Other current assets
|
||||||
|
115xxx Due from shareholder / related parties
|
||||||
|
118xxx Tax assets (HST ITC, instalments)
|
||||||
|
151xxx Capital assets — cost
|
||||||
|
154xxx Accumulated depreciation (contra)
|
||||||
|
|
||||||
|
2xxxxx LIABILITIES
|
||||||
|
211xxx Accounts payable
|
||||||
|
213xxx HST/GST/QST collected
|
||||||
|
214xxx Net tax payable
|
||||||
|
215xxx Source deductions payable
|
||||||
|
216xxx Corporate income tax payable
|
||||||
|
221xxx Due to shareholder
|
||||||
|
222xxx Due to related parties
|
||||||
|
251xxx Long-term debt
|
||||||
|
|
||||||
|
3xxxxx EQUITY
|
||||||
|
311xxx Share capital + contributed surplus
|
||||||
|
321xxx Retained earnings + dividends
|
||||||
|
|
||||||
|
4xxxxx REVENUE (by service line — jurisdiction handled by tax codes, not by account)
|
||||||
|
411xxx Recurring revenue (SaaS, hosting, support)
|
||||||
|
412xxx Project revenue (custom dev, web app, website, ERP)
|
||||||
|
413xxx Services (consulting, training, support hourly)
|
||||||
|
414xxx Reseller revenue (third-party software/hardware)
|
||||||
|
419xxx Sales adjustments (discounts, returns, bad debt recovery)
|
||||||
|
|
||||||
|
5xxxxx DIRECT COSTS (COGS)
|
||||||
|
511xxx Infrastructure & hosting costs
|
||||||
|
512xxx Project direct costs (subcontractors, project software, project travel)
|
||||||
|
513xxx Cost of resold goods
|
||||||
|
519xxx COGS adjustments
|
||||||
|
|
||||||
|
6xxxxx OPERATING EXPENSES
|
||||||
|
611xxx Personnel — internal staff (T4)
|
||||||
|
612xxx Personnel — contract (T4A non-project)
|
||||||
|
621xxx Office & facilities
|
||||||
|
631xxx Technology — operating (internal SaaS subs)
|
||||||
|
641xxx Marketing & sales
|
||||||
|
651xxx Professional fees
|
||||||
|
661xxx Insurance
|
||||||
|
671xxx Travel & entertainment
|
||||||
|
681xxx Training & development
|
||||||
|
691xxx Banking & finance charges
|
||||||
|
699xxx Other (bad debt, donations, fines, FX losses, depreciation)
|
||||||
|
|
||||||
|
7xxxxx Other income (interest, FX gains)
|
||||||
|
8xxxxx Other expenses (rare; mostly absorbed in 691/699)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Three analytic plans** (orthogonal tagging, applied per journal line):
|
||||||
|
|
||||||
|
| Plan | Required On | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| **Project** | revenue, COGS, project costs | Project P&L, customer profitability, WIP, billable-hour realization |
|
||||||
|
| **Department** | payroll, OpEx | Departmental P&L, overhead allocation |
|
||||||
|
| **SR&ED Tag** | labour, contractors, materials (R&D) | T661 SR&ED claim — eligibility classification |
|
||||||
|
|
||||||
|
## 4. Revenue Accounts (4xxxxx)
|
||||||
|
|
||||||
|
```
|
||||||
|
Recurring Revenue
|
||||||
|
411100 SaaS Subscription Revenue
|
||||||
|
411200 Hosting & Infrastructure Revenue
|
||||||
|
411300 Support & Maintenance Contracts
|
||||||
|
411400 Domain/SSL/Renewal Pass-through Revenue
|
||||||
|
411500 Setup / Onboarding Fees
|
||||||
|
|
||||||
|
Project Revenue (one-time, milestone-billed)
|
||||||
|
412100 Custom Software Development
|
||||||
|
412200 Custom Web Application Development
|
||||||
|
412300 Custom Website Development
|
||||||
|
412400 ERP Implementation & Customization
|
||||||
|
412500 Mobile App Development ← reserved for future
|
||||||
|
412600 Business App / Integration Work
|
||||||
|
|
||||||
|
Services (hourly, retainer)
|
||||||
|
413100 Consulting & Advisory
|
||||||
|
413200 Training & Workshops
|
||||||
|
413300 Technical Support — Per-incident / Hourly
|
||||||
|
|
||||||
|
Reseller / Pass-through
|
||||||
|
414100 Third-party Software Resale (M365, Adobe)
|
||||||
|
414200 Hardware Resale
|
||||||
|
|
||||||
|
Adjustments (contra-revenue)
|
||||||
|
419100 Sales Discounts
|
||||||
|
419200 Sales Returns & Refunds
|
||||||
|
419300 Bad Debt Recovery
|
||||||
|
```
|
||||||
|
|
||||||
|
**Design rule**: one revenue account per service line. Jurisdiction (ON/Atlantic/QC/export/etc.) tracked entirely through tax codes and fiscal positions, NOT duplicate accounts.
|
||||||
|
|
||||||
|
## 5. Direct Costs / COGS (5xxxxx)
|
||||||
|
|
||||||
|
```
|
||||||
|
Infrastructure & Hosting
|
||||||
|
511100 Cloud Infrastructure (AWS, Hetzner, OVH, DigitalOcean, Linode)
|
||||||
|
511110 CDN & Edge Services (Cloudflare, Fastly)
|
||||||
|
511120 Backup & Storage Services
|
||||||
|
511130 Database & Backend Services (Supabase, hosted Postgres, Redis)
|
||||||
|
511140 Monitoring & Observability (customer-facing only)
|
||||||
|
511150 SSL Certificates & Domains (wholesale for resale)
|
||||||
|
511160 DNS & Email Hosting (wholesale)
|
||||||
|
|
||||||
|
Third-party APIs & Per-transaction Costs
|
||||||
|
511200 Third-party API Costs (Twilio, SendGrid, OpenAI)
|
||||||
|
511210 Per-customer Licensing & Royalties
|
||||||
|
|
||||||
|
Note: 511100–511160 are shared between SaaS revenue (411100) and Hosting revenue (411200).
|
||||||
|
Allocation to specific revenue line happens via the Project analytic plan, not separate accounts.
|
||||||
|
|
||||||
|
Project Direct Costs
|
||||||
|
512100 Subcontracted Labour — Canadian (T4A) ← SR&ED-eligible
|
||||||
|
512110 Subcontracted Labour — Foreign ← NOT SR&ED-eligible
|
||||||
|
512200 Project-specific Software & Licenses
|
||||||
|
512300 Project Travel & Onsite (rebilled)
|
||||||
|
512400 Project Hardware (passed through)
|
||||||
|
|
||||||
|
Resold Goods & Services
|
||||||
|
513100 Cost of Software Resold
|
||||||
|
513200 Cost of Hardware Resold
|
||||||
|
|
||||||
|
Adjustments
|
||||||
|
519100 COGS Adjustments / Write-offs
|
||||||
|
```
|
||||||
|
|
||||||
|
**Design choices**:
|
||||||
|
- **Salaries in OpEx, not COGS** — keeps SR&ED tracking clean; allocation to projects via Project analytic plan.
|
||||||
|
- **Stripe/merchant fees in OpEx (691200)** — re-class to COGS later if SaaS revenue dominates.
|
||||||
|
- **Canadian vs Foreign subcontractor split** — critical for SR&ED (80% × 35% = 28% credit on CA arm's length; 0% on foreign).
|
||||||
|
|
||||||
|
## 6. Operating Expenses (6xxxxx)
|
||||||
|
|
||||||
|
```
|
||||||
|
Personnel — Internal Staff (T4)
|
||||||
|
611100 Salaries & Wages — Development ← SR&ED-eligible base
|
||||||
|
611200 Salaries & Wages — Sales & Marketing
|
||||||
|
611300 Salaries & Wages — Admin & Operations
|
||||||
|
611400 Salary — Shareholder/Officer (Gurpreet) ← 75% SR&ED cap (specified employee)
|
||||||
|
611500 Employer CPP / QPP Contributions
|
||||||
|
611600 Employer EI Premiums
|
||||||
|
611700 Employer Health Tax (EHT/QHST)
|
||||||
|
611800 WCB / WSIB Premiums
|
||||||
|
611900 Employee Benefits (health, dental, group)
|
||||||
|
611950 Bonuses & Incentives
|
||||||
|
611960 Vacation Pay Accrual
|
||||||
|
|
||||||
|
Personnel — Contract (non-project)
|
||||||
|
612100 Contract Labour — Canadian (admin/marketing/freelance)
|
||||||
|
612200 Contract Labour — Foreign
|
||||||
|
|
||||||
|
Office & Facilities
|
||||||
|
621100 Rent — Commercial Office
|
||||||
|
621200 Home Office — Business Portion ← own account; allocated %
|
||||||
|
621300 Utilities — Commercial
|
||||||
|
621400 Internet & Phone — Business
|
||||||
|
621500 Office Supplies & Consumables
|
||||||
|
621600 Cleaning & Maintenance
|
||||||
|
621700 Office Snacks & Refreshments
|
||||||
|
|
||||||
|
Technology — Operating
|
||||||
|
631100 Software — Productivity (M365, Slack, Notion, Linear, GitHub)
|
||||||
|
631200 Software — Development Tools (Cursor, Figma, IDEs)
|
||||||
|
631300 Software — Internal Infrastructure
|
||||||
|
631400 Software — Security & IT
|
||||||
|
631500 Software — Sales & Marketing
|
||||||
|
|
||||||
|
Marketing & Sales
|
||||||
|
641100 Advertising — Digital Ads
|
||||||
|
641200 Advertising — Content / SEO
|
||||||
|
641300 Trade Shows & Conferences
|
||||||
|
641400 Promotional Items / Branded Swag
|
||||||
|
641500 Website — Own (nexasystems.ca)
|
||||||
|
|
||||||
|
Professional Fees
|
||||||
|
651100 Legal Fees — General
|
||||||
|
651200 Accounting & Bookkeeping
|
||||||
|
651300 Tax Preparation (T2, T1, GST/HST)
|
||||||
|
651400 Business Consulting
|
||||||
|
|
||||||
|
Insurance
|
||||||
|
661100 Commercial General Liability
|
||||||
|
661200 Professional Liability / E&O
|
||||||
|
661300 Cyber Liability
|
||||||
|
661400 Property Insurance
|
||||||
|
661500 Directors & Officers Insurance
|
||||||
|
|
||||||
|
Travel & Entertainment
|
||||||
|
671100 Travel — Flights, Hotels, Ground Transport
|
||||||
|
671200 Meals & Entertainment — 50% Deductible ← own account; 50% adjustment at year-end
|
||||||
|
671300 Vehicle — Operating
|
||||||
|
671400 Mileage Reimbursement — Personal Vehicle
|
||||||
|
|
||||||
|
Training & Development
|
||||||
|
681100 Conferences & Seminars
|
||||||
|
681200 Courses & Certifications
|
||||||
|
681300 Books & Publications
|
||||||
|
681400 Professional Memberships & Dues
|
||||||
|
|
||||||
|
Banking & Finance
|
||||||
|
691100 Bank Service Charges
|
||||||
|
691200 Merchant Processing Fees (Stripe, PayPal, Square)
|
||||||
|
691300 Wire Transfer & FX Fees
|
||||||
|
691400 Interest Expense — Bank Loans / LOC
|
||||||
|
691500 Interest Expense — Credit Cards
|
||||||
|
691600 Late Payment Penalties — Non-deductible
|
||||||
|
|
||||||
|
Other
|
||||||
|
699100 Bad Debt Expense
|
||||||
|
699200 Donations & Sponsorships
|
||||||
|
699300 Penalties & Fines — Non-deductible
|
||||||
|
699400 Realized FX Losses
|
||||||
|
699500 Depreciation / CCA Expense
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notable design decisions**:
|
||||||
|
- Salaries split by function (dev/sales/admin) — so SR&ED proxy applies cleanly to dev only.
|
||||||
|
- Owner/Shareholder salary isolated (611400) — for T2 Schedule 11 (Compensation of Officers) and CRA reasonableness defence.
|
||||||
|
- Non-deductible items isolated (691600, 699300) — prevents accidental deduction.
|
||||||
|
- Meals & Entertainment own account (671200) — accountant applies the 50% adjustment cleanly.
|
||||||
|
- Home office own account (621200) — business-use % applied to the whole account.
|
||||||
|
|
||||||
|
## 7. Capital Assets & CCA (1xxxxx + asset module)
|
||||||
|
|
||||||
|
```
|
||||||
|
Capital Assets — Cost
|
||||||
|
151100 Computer Hardware & Equipment (CCA Class 50, 55% DB)
|
||||||
|
151200 Office Furniture & Equipment (CCA Class 8, 20% DB)
|
||||||
|
151300 Vehicles (CCA Class 10 / 10.1)
|
||||||
|
151400 Leasehold Improvements (CCA Class 13, SL)
|
||||||
|
151500 Acquired Software/Intangibles (CCA Class 14.1, 5% DB)
|
||||||
|
151600 Tools & Small Equipment <$500 (CCA Class 12, 100% Y1)
|
||||||
|
|
||||||
|
Accumulated Depreciation (contra)
|
||||||
|
154100 Acc. Dep — Computer Hardware
|
||||||
|
154200 Acc. Dep — Office Furniture
|
||||||
|
154300 Acc. Dep — Vehicles
|
||||||
|
154400 Acc. Dep — Leasehold Improvements
|
||||||
|
154500 Acc. Dep — Acquired Software
|
||||||
|
```
|
||||||
|
|
||||||
|
**Asset model approach**: book straight-line depreciation in Odoo for financial reporting (clean monthly journal); maintain CCA schedule separately for T2 filing. CCA rates: Class 50 effective 82.5% Y1 (with AccII through 2027); Class 14.1 software 100% Y1; Class 12 small tools 100% Y1.
|
||||||
|
|
||||||
|
## 8. Tax Accounts (1xxxxx + 2xxxxx)
|
||||||
|
|
||||||
|
```
|
||||||
|
Tax Assets
|
||||||
|
118100 HST/GST Input Tax Credit (ITC) Receivable
|
||||||
|
118200 HST/GST Instalments Paid
|
||||||
|
118300 QST Input Tax Refund Receivable
|
||||||
|
|
||||||
|
Tax Liabilities
|
||||||
|
213100 HST/GST Collected on Sales ← single bucket; tax report breaks down by code
|
||||||
|
213500 QST Collected
|
||||||
|
214100 Net HST/GST Payable
|
||||||
|
215100 Source Deductions Payable — Federal Tax
|
||||||
|
215200 Source Deductions Payable — CPP
|
||||||
|
215300 Source Deductions Payable — EI
|
||||||
|
216100 Corporate Income Tax — Federal Payable
|
||||||
|
216200 Corporate Income Tax — Provincial Payable
|
||||||
|
216300 Corporate Tax Instalments Paid (contra)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. Shareholder, Associated Corporations & Equity
|
||||||
|
|
||||||
|
**Associated corporations** (Gurpreet >25% owner of each → ITA s.256 associated group):
|
||||||
|
- Nexa Systems Inc (this company)
|
||||||
|
- Westin Healthcare Inc
|
||||||
|
- Divine Mobility Inc
|
||||||
|
|
||||||
|
**Treatment**: Westin and Divine are **regular Customers and Vendors of Nexa**, NOT slush accounts. Their transactions flow through normal AR/AP. They get partner records tagged `Related Party — Associated Corporation` for disclosure tracking. The "Due To/From Related Party" GL buckets exist only for true intercompany loans (cash moved between the corps' bank accounts without an invoice).
|
||||||
|
|
||||||
|
```
|
||||||
|
Due From — Assets
|
||||||
|
115100 Due From Shareholder — Gurpreet
|
||||||
|
115900 Due From Associated Corporations (intercompany loans only — NOT customer AR)
|
||||||
|
|
||||||
|
Due To — Liabilities
|
||||||
|
221100 Due To Shareholder — Gurpreet (short-term, <1 year)
|
||||||
|
221200 Shareholder Loan — Gurpreet (long-term, with commercial terms)
|
||||||
|
222900 Due To Associated Corporations (intercompany loans only — NOT vendor AP)
|
||||||
|
|
||||||
|
Equity
|
||||||
|
311100 Share Capital — Common Shares
|
||||||
|
311200 Share Capital — Preferred Shares (placeholder)
|
||||||
|
311300 Contributed Surplus
|
||||||
|
321100 Retained Earnings — Current Year
|
||||||
|
321200 Retained Earnings — Prior Years
|
||||||
|
321900 Dividends Declared (contra)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Partner setup** (under Contacts, not GL accounts):
|
||||||
|
- `Westin Healthcare Inc` → partner with both Customer and Vendor flags; tagged `RP-Associated`
|
||||||
|
- `Divine Mobility Inc` → partner with both Customer and Vendor flags; tagged `RP-Associated`
|
||||||
|
- Nexa invoices Westin/Divine like any client → AR in 112xxx, revenue in 4xxxxx, HST 13% (Ontario)
|
||||||
|
- Westin/Divine bill Nexa → AP in 211xxx, expense in 6xxxxx / COGS in 5xxxxx
|
||||||
|
|
||||||
|
**Intercompany compliance flags (CRITICAL — drives major tax decisions)**:
|
||||||
|
|
||||||
|
1. **Small Business Deduction (SBD) sharing — ITA s.125(5.1)**: The $500k federal SBD limit is **shared across all associated corporations**. If Nexa, Westin, and Divine are each profitable, they collectively get **one** $500k pool, not three. The corps must file Schedule 23 (T2) allocating the limit. Strategy: allocate the limit to whichever corp has the highest taxable income each year.
|
||||||
|
|
||||||
|
2. **SR&ED expenditure limit shared — ITA s.127(10.2)**: The $3M expenditure limit for the 35% refundable ITC is also shared across the associated group. Same Schedule 23 mechanism. Nexa being the dev shop probably consumes most/all of it.
|
||||||
|
|
||||||
|
3. **Transfer pricing — ITA s.247**: Services between related corps must be priced at fair market value. Nexa invoicing Westin at $50/hr while billing arm's-length clients $150/hr will be scrutinized. Document the rate methodology. Penalty for non-compliance is 10% of the adjustment.
|
||||||
|
|
||||||
|
4. **Subsection 15(2) shareholder loans**: outstanding >1 year past FY end → taxable to Gurpreet personally.
|
||||||
|
|
||||||
|
5. **T2 Schedule 9** (Related and Associated Corporations) must be filed by Nexa listing Westin and Divine.
|
||||||
|
|
||||||
|
6. **GAAR risk**: aggressive intercompany pricing or loan arrangements designed primarily for tax benefit can be challenged under general anti-avoidance rules.
|
||||||
|
|
||||||
|
## 10. Analytic Plans
|
||||||
|
|
||||||
|
### 10.1 Project Plan
|
||||||
|
- One analytic account per customer engagement
|
||||||
|
- Naming: `PRJ-{YYYY}-{CUST}-{SHORTNAME}` (e.g., `PRJ-2026-WESTIN-ERP`)
|
||||||
|
- Required on revenue, COGS, project costs
|
||||||
|
- Linked to Odoo Project module for time tracking → automatic GL posting
|
||||||
|
|
||||||
|
### 10.2 Department Plan
|
||||||
|
- `DEPT-DEV` — Development
|
||||||
|
- `DEPT-SALES` — Sales & Marketing
|
||||||
|
- `DEPT-ADMIN` — Admin & Operations
|
||||||
|
- `DEPT-HOSTING` — Hosting Operations (optional future split)
|
||||||
|
- Required on payroll, OpEx
|
||||||
|
|
||||||
|
### 10.3 SR&ED Tag Plan
|
||||||
|
- `SRED-T4-DEV-SALARY` — T4 dev employees on R&D (full proxy 55%)
|
||||||
|
- `SRED-SPECIFIED-EMPLOYEE` — Gurpreet/officers (75% basic salary cap)
|
||||||
|
- `SRED-CONTRACTOR-CA-ARM-LENGTH` — Canadian arm's length (80% eligible)
|
||||||
|
- `SRED-CONTRACTOR-CA-NON-ARM-LENGTH` — affiliated CA contractors
|
||||||
|
- `SRED-MATERIALS-CONSUMED` — R&D materials
|
||||||
|
- `SRED-OVERHEAD-PROXY-BASIS` — direct labour basis
|
||||||
|
- `NOT-ELIGIBLE` — default
|
||||||
|
|
||||||
|
**T661 generation at year-end**: filter analytic report on SR&ED tag → eligible salaries + 55% proxy + 80% contractor + materials = total qualified expenditures × 35% refundable ITC.
|
||||||
|
|
||||||
|
## 11. Tax Setup & Fiscal Positions
|
||||||
|
|
||||||
|
**Consolidated active taxes** (~14, down from 49):
|
||||||
|
|
||||||
|
| Tax | Rate | Sale / Purchase | Applies |
|
||||||
|
|---|---|---|---|
|
||||||
|
| HST 13% Ontario | 13% | Both | ON |
|
||||||
|
| HST 15% Atlantic | 15% | Both | NB, NS, PE, NL |
|
||||||
|
| GST 5% | 5% | Both | AB, MB, SK, BC, YT, NT, NU |
|
||||||
|
| GST 5% + PST 7% BC | 12% group | Both | BC (goods, rare for services) |
|
||||||
|
| GST 5% + PST 7% MB | 12% group | Both | MB |
|
||||||
|
| GST 5% + PST 6% SK | 11% group | Both | SK |
|
||||||
|
| GST 5% + QST 9.975% QC | 14.975% group | Both | QC |
|
||||||
|
| Zero-rated Export | 0% | Sale | US, EU, ROW |
|
||||||
|
| Tax Exempt | 0% | Sale | Cert-holders |
|
||||||
|
|
||||||
|
**Fiscal Positions** (auto-applied based on customer billing address):
|
||||||
|
|
||||||
|
| Position | Customer Location | Auto-Substitute Default Tax |
|
||||||
|
|---|---|---|
|
||||||
|
| CA — Ontario (default) | ON | HST 13% |
|
||||||
|
| CA — Atlantic | NB/NS/PE/NL | HST 15% |
|
||||||
|
| CA — Quebec | QC | GST 5% + QST 9.975% |
|
||||||
|
| CA — BC | BC | GST 5% (PST per-product) |
|
||||||
|
| CA — Prairies / Territories | AB/MB/SK/YT/NT/NU | GST 5% |
|
||||||
|
| Export — US | United States | 0% Zero-rated |
|
||||||
|
| Export — International | Outside CA/US | 0% Zero-rated |
|
||||||
|
| Tax Exempt | Tagged customers | 0% |
|
||||||
|
|
||||||
|
**Invoice flow**: customer → fiscal position auto-applies → product picks default tax → fiscal position substitutes → no manual tax decisions.
|
||||||
|
|
||||||
|
**Export advantage**: zero-rated sales charge no HST but retain ITC claims on all related inputs. For a small shop with 30% US revenue, this is ~$5–15k/year in recovered HST.
|
||||||
|
|
||||||
|
## 12. Cleanup Plan
|
||||||
|
|
||||||
|
### Phase 1 — Archive (~370 accounts)
|
||||||
|
- Every l10n_ca account NOT in the keep-list (built from Sections 4–9).
|
||||||
|
- Constraint: Odoo blocks archiving accounts with postings. Archive zero-history only.
|
||||||
|
- Accounts with history we no longer want: stop posting; they go to $0 going forward.
|
||||||
|
|
||||||
|
### Phase 2 — Rename (~20 accounts)
|
||||||
|
|
||||||
|
| Old | New |
|
||||||
|
|---|---|
|
||||||
|
| 1400 Transferred to Gurpreet | 221100 Due To Shareholder — Gurpreet |
|
||||||
|
| 1505 Sent to India | 612200 Contract Labour — Foreign |
|
||||||
|
| 1580 Transferred to Westin | ARCHIVE — Westin is an associated corp, future transactions go through normal AR/AP via partner record `Westin Healthcare Inc` |
|
||||||
|
| 1590 Transferred to Divine | ARCHIVE — Divine is an associated corp, future transactions go through normal AR/AP via partner record `Divine Mobility Inc` |
|
||||||
|
| 1600 Transferred to Manpreet | ARCHIVE — Manpreet is an employee of another company, not a related party of Nexa; historical transactions to be re-classified by accountant during reconciliation |
|
||||||
|
| 1500 Food & Entertainment | 671200 Meals & Entertainment — 50% Deductible |
|
||||||
|
| 1501 Office Expenses | 621500 Office Supplies & Consumables |
|
||||||
|
| 411000 Inside Sales | ARCHIVE (replaced by 412xxx) |
|
||||||
|
| 412000 Harmonized Provinces Sales | ARCHIVE (jurisdiction = tax codes) |
|
||||||
|
| 413000 Non-Harmonized Provinces Sales | ARCHIVE |
|
||||||
|
| 414000 International Sales | ARCHIVE |
|
||||||
|
| 12000 Abdul & Future Mobility | ARCHIVE (use partner subledger) |
|
||||||
|
| 12001 MSI Account | ARCHIVE |
|
||||||
|
| 110010 Bank Fee | 691100 Bank Service Charges |
|
||||||
|
| 511100 Inside Purchases | ARCHIVE |
|
||||||
|
|
||||||
|
### Phase 3 — Add (~70 new accounts)
|
||||||
|
All per Sections 4–9.
|
||||||
|
|
||||||
|
### Phase 4 — Bank Consolidation
|
||||||
|
Current 8 bank journals (BMO, RBC, RBC VISA, Scotia ×3, Bank, Cash). Audit; archive inactive. Target: ≤5 active (primary operating, USD for future global, LOC, 1–2 credit cards).
|
||||||
|
|
||||||
|
### Phase 5 — Lock Prior Periods
|
||||||
|
Set `fiscalyear_lock_date = 2025-12-31`. Blocks postings to closed periods. Forces all 2026 work into new structure.
|
||||||
|
|
||||||
|
## 13. Automation Hooks
|
||||||
|
|
||||||
|
### Product Categories with Default Accounts
|
||||||
|
|
||||||
|
| Product Category | Default Income | Default COGS | Default Tax |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Services / SaaS Subscription | 411100 | — | per fiscal position |
|
||||||
|
| Services / Hosting | 411200 | — | per fiscal position |
|
||||||
|
| Services / Support Contract | 411300 | — | per fiscal position |
|
||||||
|
| Services / Custom Software Dev | 412100 | — | per fiscal position |
|
||||||
|
| Services / Web App Dev | 412200 | — | per fiscal position |
|
||||||
|
| Services / Website Dev | 412300 | — | per fiscal position |
|
||||||
|
| Services / ERP Implementation | 412400 | — | per fiscal position |
|
||||||
|
| Services / Consulting | 413100 | — | per fiscal position |
|
||||||
|
| Services / Training | 413200 | — | per fiscal position |
|
||||||
|
| Services / Setup Fee | 411500 | — | per fiscal position |
|
||||||
|
| Resale / Software | 414100 | 513100 | per fiscal position |
|
||||||
|
| Resale / Hardware | 414200 | 513200 | per fiscal position |
|
||||||
|
|
||||||
|
### Bank Reconciliation Rules
|
||||||
|
|
||||||
|
| Pattern (description contains) | Auto-categorize To | Tax |
|
||||||
|
|---|---|---|
|
||||||
|
| `AMAZON WEB SERVICES`, `AWS` | 511100 Cloud Infrastructure | HST 13% ITC |
|
||||||
|
| `HETZNER`, `OVH`, `DIGITALOCEAN`, `LINODE` | 511100 | 0% foreign |
|
||||||
|
| `CLOUDFLARE`, `FASTLY` | 511110 CDN | mixed |
|
||||||
|
| `GITHUB`, `JETBRAINS`, `CURSOR` | 631200 Software — Dev Tools | HST 13% ITC |
|
||||||
|
| `MICROSOFT`, `SLACK`, `NOTION`, `LINEAR` | 631100 Software — Productivity | HST 13% ITC |
|
||||||
|
| `STRIPE PAYOUT` | AR receipts journal | — |
|
||||||
|
| `STRIPE FEE` | 691200 Merchant Processing | exempt |
|
||||||
|
| `GOOGLE ADS`, `LINKEDIN ADS` | 641100 Advertising | HST 13% ITC |
|
||||||
|
|
||||||
|
### Bank Feeds (Plaid via Odoo Enterprise)
|
||||||
|
Daily auto-import → bank reconciliation rules → ~70% of transactions auto-categorized.
|
||||||
|
|
||||||
|
### Subscription Module
|
||||||
|
Already installed. Use for SaaS/Hosting/Support contracts: recurring invoices, Stripe auto-charge, MRR/ARR/churn dashboards.
|
||||||
|
|
||||||
|
### Default Journals
|
||||||
|
- Customer Invoices → `INV`
|
||||||
|
- Vendor Bills → `BILL`
|
||||||
|
- Bank feeds → respective bank journals
|
||||||
|
- HR Expenses → `EXP` (add if missing)
|
||||||
|
- Misc → `MISC`
|
||||||
|
- Exchange Difference → `EXCH`
|
||||||
|
|
||||||
|
## 14. Out-of-Scope (Future Sub-Projects)
|
||||||
|
|
||||||
|
- **Historical reconciliation** — load accountant's Excel records into new structure (requires accountant docs).
|
||||||
|
- **Custom CCA module** — only if asset count grows; until then, accountant maintains CCA schedule separately.
|
||||||
|
- **Multi-currency setup** — add USD bank + currency-rate-live config when first US client signs.
|
||||||
|
- **Payroll system** — when first T4 employee is hired; integrate with Wagepoint/Payworks/ADP or Odoo Payroll.
|
||||||
|
- **Approval workflows** — purchase approval, expense approval limits.
|
||||||
|
- **Inventory** — N/A unless reselling hardware regularly.
|
||||||
|
|
||||||
|
## 15. Tax-Saving Opportunities Enabled
|
||||||
|
|
||||||
|
| Opportunity | Mechanism | Estimated Annual Value | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| SR&ED ITC | Analytic SR&ED tag + T661 filing | $30k–$100k (refundable) | **$3M expenditure limit SHARED across Nexa/Westin/Divine — allocate to Nexa via S23** |
|
||||||
|
| Zero-rated exports | Fiscal position for US/international | $5–15k recovered HST on inputs | Per-company |
|
||||||
|
| Small Business Deduction (SBD) | Federal 9% on first $500k taxable income | ~$30k/yr if hitting threshold | **$500k limit SHARED across associated group — allocate to highest-income corp via S23** |
|
||||||
|
| CCA Class 50 + AccII | 82.5% Y1 deduction on computers/servers | Time-value, front-loads deductions | Per-company |
|
||||||
|
| Quick Method GST/HST | If <$400k sales, simpler method | $500–2k/yr cash if eligible | **LIKELY UNAVAILABLE — Quick Method $400k threshold applies to associated-group totals; Nexa + Westin + Divine combined revenue probably exceeds limit. Re-verify with accountant.** |
|
||||||
|
| OIDMTC (Ontario Interactive Digital Media) | If building interactive media products | 35–40% of eligible labour | Strict eligibility test; need to verify product fits |
|
||||||
|
| Apprenticeship Job Creation TC | 10% of eligible apprentice wages, max $2k/yr per apprentice | Per apprentice hired | Activates when first apprentice T4 employee hired |
|
||||||
|
| Intercompany cost recovery | Bill associated corps for shared services (back-office, hosting, IT) | Allocates expenses to highest-tax-rate corp | Requires arm's-length pricing documentation |
|
||||||
|
|
||||||
|
## 16. Risks & Open Questions
|
||||||
|
|
||||||
|
1. **Associated corporation tax planning** — Westin Healthcare Inc, Divine Mobility Inc, and Nexa Systems Inc share the $500k SBD limit and the $3M SR&ED expenditure limit. Yearly Schedule 23 allocation decision needs accountant input. Recommendation: allocate SR&ED limit primarily to Nexa (dev shop); allocate SBD to whichever corp has highest taxable income each year.
|
||||||
|
2. **Transfer pricing on intercompany services** — Nexa billing Westin/Divine must be at fair market value. Document hourly rate methodology and apply consistently across all clients. Penalty: 10% of any adjustment.
|
||||||
|
3. **Past data backposting** — once accountant records arrive, mapping old transactions into new structure requires care to avoid breaking the post-2025-12-31 lock.
|
||||||
|
4. **BC PST on software services** — BC PST exempts custom software developed for a specific customer; off-the-shelf software and certain SaaS subscriptions ARE taxable. For Nexa's mix (most work is custom dev = exempt; SaaS sold off-the-shelf to BC customers = taxable at 7%), each BC customer/product combo needs review. Default to "GST only" for custom dev; flag SaaS-to-BC for review at first sale.
|
||||||
|
5. **Quebec QST registration** — required if Nexa has QC customers and revenue >$30k. Confirm registration status. If not yet registered and you start taking QC clients, registration with Revenu Québec is separate from CRA.
|
||||||
|
8. **HST filing cadence review** — currently annual. Once revenue clears $1.5M (combined Nexa-only, not associated group), CRA may auto-move you to **quarterly** filing. Monitor and update filing cadence in tax report config when it happens.
|
||||||
|
6. **Specified employee SR&ED math** — Gurpreet's salary cap is 75%, no bonus inclusion. Accountant must apply at T661 time.
|
||||||
|
7. **Multi-company Odoo (future sub-project)** — Westin and Divine currently run on separate Odoo databases (odoo-westin, odoo-mobility). Future option: migrate all three into one multi-company nexamain database to enable auto-mirrored intercompany invoices (Nexa invoices Westin → auto-creates Bill in Westin's books). Major data-migration effort; only worth it once intercompany volume justifies the effort.
|
||||||
|
|
||||||
|
## 17. Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] All 11 sections of CoA approved and present in odoo-nexa nexamain DB
|
||||||
|
- [ ] ≥370 unused accounts archived
|
||||||
|
- [ ] 14 active taxes (down from 49)
|
||||||
|
- [ ] 8 fiscal positions configured with auto-detection
|
||||||
|
- [ ] 3 analytic plans created (Project, Department, SR&ED Tag) with seed analytic accounts
|
||||||
|
- [ ] Product categories created with default accounts
|
||||||
|
- [ ] Bank reconciliation rules created
|
||||||
|
- [ ] Fiscal year locked at 2025-12-31
|
||||||
|
- [ ] Company HST/BN number stored in full 15-char form (`741224877 RT0001`)
|
||||||
|
- [ ] HST report config set to **annual filer**, fiscal-year-end Dec 31, deadline March 31
|
||||||
|
- [ ] Westin Healthcare Inc and Divine Mobility Inc partner records created with Customer + Vendor flags, tagged `RP-Associated`
|
||||||
|
- [ ] Test invoice flows through correctly for: ON customer (HST 13%), US customer (Zero-rated), QC customer (GST+QST)
|
||||||
|
- [ ] Test vendor bill creates correct ITC for: Canadian vendor (HST ITC), foreign vendor (no ITC)
|
||||||
|
- [ ] Test intercompany invoice: Nexa → Westin generates proper AR + 13% HST collected (Westin is Ontario-based)
|
||||||
|
- [ ] Bank consolidation complete; ≤5 active bank journals
|
||||||
300
docs/superpowers/specs/2026-05-13-nfc-clock-kiosk-design.md
Normal file
300
docs/superpowers/specs/2026-05-13-nfc-clock-kiosk-design.md
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
# NFC Clock Kiosk — Design
|
||||||
|
|
||||||
|
**Date:** 2026-05-13
|
||||||
|
**Module:** `fusion_clock`
|
||||||
|
**Status:** Approved design — pending implementation plan
|
||||||
|
**Pilot scope:** 1 station per company
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
`fusion_clock` already supports shared-device clock-in/out via a PIN kiosk at `/fusion_clock/kiosk`. Shop-floor employees find name search + PIN entry slow, and shared PINs make buddy-punching trivial. The company is rolling out Ubiquiti UniFi Access NFC readers for door entry, so every employee already carries an NFC card. We want a "tap-and-go" kiosk that:
|
||||||
|
|
||||||
|
- Takes ~2 seconds (vs ~10 seconds for name search + PIN)
|
||||||
|
- Reuses the same physical Ubiquiti-issued card the employee uses for doors
|
||||||
|
- Works with gloves, dirty hands, or wet hands (touchscreens fail here)
|
||||||
|
- Captures a silent photo at every tap so managers can spot-check buddy-punching attempts
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. **Tap-to-clock**: NFC card tap on a wall-mounted Android tablet → attendance state toggles in Odoo within ~1 second of the tap
|
||||||
|
2. **Single-credential**: same card the employee uses for door access also clocks them in
|
||||||
|
3. **Silent photo verification**: front camera snaps a frame on every tap; manager dashboard shows photos for spot-check
|
||||||
|
4. **Self-contained kiosk**: lockable into a single-purpose device, no escape, auto-restart on crash, no Odoo navbar visible
|
||||||
|
5. **Reuses existing fusion_clock backend**: geofencing, penalty rules, activity log, attendance lifecycle — all unchanged
|
||||||
|
6. **One-time setup**: enroll once, then employees never touch a setup flow again
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Multi-station / multi-zone clocking (future — pilot is 1 station per company)
|
||||||
|
- Per-station geolocation (one location per company; tablet is implicitly at the company location)
|
||||||
|
- Offline mode (v1 fails loudly on network loss; offline replay is future work)
|
||||||
|
- Phone-as-credential support (NFC HCE on Android is fragile; iPhone NFC is closed)
|
||||||
|
- QR code alternate credential (deferred to v1.1 if iPhone-only employees push back)
|
||||||
|
- Native Android kiosk app (overkill for a 1-2 station pilot; Web NFC is sufficient)
|
||||||
|
|
||||||
|
## Architecture decision
|
||||||
|
|
||||||
|
**Option B: Separate kiosk page, shared backend.**
|
||||||
|
|
||||||
|
A new route `/fusion_clock/kiosk/nfc` and a new lean template optimized for tap-and-go. The new controller (`controllers/clock_nfc_kiosk.py`) calls into the existing `FusionClockAPI` helpers (`_verify_location`, `_attendance_action_change`, `_log_activity`, `_check_and_create_penalty`, `_apply_break_deduction`) so all geofencing/penalty/activity logic is shared with the PIN kiosk. The existing `/fusion_clock/kiosk` route is untouched.
|
||||||
|
|
||||||
|
**Why not extend the existing kiosk (Option A):** existing PIN kiosk page would get tap-mode JS interleaved with PIN-mode JS, increasing the regression surface for both modes.
|
||||||
|
|
||||||
|
**Why not native Android app (Option C):** maintaining a Kotlin app + Play Console signing/distribution doubles the dev effort for marginal UX gain. Web NFC + Chrome kiosk is production-proven (gyms, warehouses, healthcare check-in).
|
||||||
|
|
||||||
|
## Hardware decision
|
||||||
|
|
||||||
|
**Per company:** 1× Samsung Galaxy Tab Active 5 Pro (10.1") on an official Samsung Pogo charging dock, wall-mounted. Reasoning:
|
||||||
|
|
||||||
|
- Built-in NFC antenna on the back, dead-center
|
||||||
|
- IP68, MIL-STD-810H, drop-resistant (shop-floor durable)
|
||||||
|
- Replaceable battery (avoids battery-swelling failure mode in 24/7-tethered devices)
|
||||||
|
- Knox enables true kiosk lockdown
|
||||||
|
- Pogo dock = magnetic constant power, no cable to yank
|
||||||
|
- 10.1" screen visible from a few feet away (vs 8" on regular Active 5)
|
||||||
|
|
||||||
|
Cards: same Ubiquiti-issued NFC cards employees already carry. Web NFC reads the card's UID via `NDEFReader`'s `serialNumber` field, which works on raw MIFARE access cards even though they have no NDEF data.
|
||||||
|
|
||||||
|
## Data model
|
||||||
|
|
||||||
|
### `hr.employee` — new field
|
||||||
|
- `x_fclk_nfc_card_uid` — `Char`, indexed, unique constraint when not null
|
||||||
|
- Stores card UID as canonical hex (uppercase, colon-separated, MSB first), e.g., `04:A2:B5:62:C1:80`
|
||||||
|
- Editable by HR managers; visible on the employee form in the existing "Clock Settings" section near the existing PIN field
|
||||||
|
|
||||||
|
### `res.company` — new field
|
||||||
|
- `x_fclk_nfc_kiosk_location_id` — `Many2one` to `fusion.clock.location`
|
||||||
|
- Designates which fusion.clock.location is bound to the NFC kiosk for this company
|
||||||
|
- Required when `fusion_clock.enable_nfc_kiosk = True`; the tap endpoint returns `no_location_configured` if it's empty
|
||||||
|
- Editable in the NFC Clock Kiosk settings section (per-company since this is multi-company-aware)
|
||||||
|
|
||||||
|
### `hr.attendance` — new fields
|
||||||
|
- `x_fclk_check_in_photo` — `Binary`, `attachment=True`. Frame captured at clock-in.
|
||||||
|
- `x_fclk_check_out_photo` — `Binary`, `attachment=True`. Frame captured at clock-out.
|
||||||
|
- `x_fclk_clock_source` — extend existing `Selection` field to include `'nfc_kiosk'`.
|
||||||
|
|
||||||
|
### `ir.config_parameter` — new entries
|
||||||
|
- `fusion_clock.enable_nfc_kiosk` — Boolean, default `False`. Master switch.
|
||||||
|
- `fusion_clock.nfc_photo_required` — Boolean, default `True`. If False, photo is best-effort and tap still succeeds without one.
|
||||||
|
- `fusion_clock.nfc_enroll_password` — Char, default empty. Short password the manager types to enter Enroll Mode on the kiosk. If empty, falls back to manager-group membership of the kiosk service user.
|
||||||
|
- `fusion_clock.nfc_kiosk_debug` — Boolean, default `False`. Enables a hidden mock-tap keyboard shortcut for development.
|
||||||
|
|
||||||
|
### `res.config.settings` — new view section
|
||||||
|
"NFC Clock Kiosk" section in the Clock settings page exposing the four `ir.config_parameter` toggles above.
|
||||||
|
|
||||||
|
**No new models.** All data piggybacks on existing `hr.employee`, `hr.attendance`, `fusion.clock.activity.log`.
|
||||||
|
|
||||||
|
## Backend — controller and endpoints
|
||||||
|
|
||||||
|
**New file:** `controllers/clock_nfc_kiosk.py`
|
||||||
|
|
||||||
|
All endpoints under `/fusion_clock/kiosk/nfc/...`. All require `fusion_clock.group_fusion_clock_manager` on the logged-in kiosk service user. All gated on `fusion_clock.enable_nfc_kiosk == 'True'`.
|
||||||
|
|
||||||
|
**Kiosk service user:** an Odoo `res.users` record created per-company specifically for the tablet to log in as. Member of `fusion_clock.group_fusion_clock_manager`. Long random password stored in the tablet's saved-credentials. Distinct from any human user so its session can be revoked independently if the tablet is stolen. Setup is documented in the provisioning script below; no new code creates this user (it's a manual one-time creation in HR Settings).
|
||||||
|
|
||||||
|
### `GET /fusion_clock/kiosk/nfc` — page render
|
||||||
|
- Renders the NFC kiosk QWeb template
|
||||||
|
- Resolves the kiosk's location from `request.env.company.x_fclk_nfc_kiosk_location_id` and passes its name to the template for display ("Clock at: Westin Plant 1")
|
||||||
|
- Returns redirect to `/my` if the kiosk is disabled or the user lacks the manager group
|
||||||
|
|
||||||
|
### `POST /fusion_clock/kiosk/nfc/tap` — clock toggle
|
||||||
|
- `type='jsonrpc'`, `auth='user'`
|
||||||
|
- Input: `{ card_uid: "04:A2:B5:62:C1:80", photo_b64: "data:image/jpeg;base64,..." (optional) }`
|
||||||
|
- Logic:
|
||||||
|
1. Normalize UID (uppercase, colon-separated, reject malformed input)
|
||||||
|
2. Lookup `hr.employee` by `x_fclk_nfc_card_uid` (sudo). Not found → `{error: "card_unknown", message: "Card not enrolled"}`. Log to `fusion.clock.activity.log` with the unknown UID.
|
||||||
|
3. If `x_fclk_enable_clock` is False → `{error: "clock_disabled"}`
|
||||||
|
4. Resolve location from `request.env.company.x_fclk_nfc_kiosk_location_id`. If empty → `{error: "no_location_configured"}`
|
||||||
|
5. Server-side debounce: if same UID was tapped within the last 5 seconds, return `{error: "debounce"}` silently
|
||||||
|
6. Call `FusionClockAPI._attendance_action_change(geo_info)` with `geo_info = { browser: 'nfc_kiosk', ip_address: <remote_addr>, latitude: 0, longitude: 0 }` to toggle attendance state
|
||||||
|
7. Write `x_fclk_clock_source = 'nfc_kiosk'`, `x_fclk_location_id = <resolved>`, distance fields = 0
|
||||||
|
8. If `photo_b64` present, decode and save to `x_fclk_check_in_photo` (clock-in) or `x_fclk_check_out_photo` (clock-out)
|
||||||
|
9. If `nfc_photo_required = True` and photo is missing/decode-failed → reject the tap with `{error: "photo_required"}`
|
||||||
|
10. Reuse `_check_and_create_penalty`, `_apply_break_deduction`, `_log_activity` calls (same as PIN kiosk)
|
||||||
|
11. Return `{ success: true, action: 'clock_in' | 'clock_out', employee_name, employee_avatar_url, message, net_hours_today }`
|
||||||
|
|
||||||
|
### `POST /fusion_clock/kiosk/nfc/enroll` — card enrollment
|
||||||
|
- `type='jsonrpc'`, `auth='user'`
|
||||||
|
- Input: `{ employee_id: 42, card_uid: "04:A2:B5:62:C1:80", enroll_password: "1234" }`
|
||||||
|
- Logic:
|
||||||
|
1. Verify `enroll_password` matches `fusion_clock.nfc_enroll_password` (or accept if config is empty AND caller is in manager group)
|
||||||
|
2. Normalize UID
|
||||||
|
3. Check no other employee has this UID → `{error: "card_already_assigned", existing_employee: "<name>"}`
|
||||||
|
4. Write `x_fclk_nfc_card_uid` on the target employee
|
||||||
|
5. Log to `fusion.clock.activity.log` ("Manager X enrolled card UID Y to employee Z")
|
||||||
|
6. Return `{ success: true, employee_name, card_uid }`
|
||||||
|
|
||||||
|
### `POST /fusion_clock/kiosk/nfc/employee_search` — pick employee for enroll
|
||||||
|
- Reuses the existing `/fusion_clock/kiosk/search` controller method by importing it; does not duplicate logic.
|
||||||
|
|
||||||
|
## Frontend — kiosk page UX
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `views/kiosk_nfc_templates.xml` — QWeb template for the page
|
||||||
|
- `static/src/js/fusion_clock_nfc_kiosk.js` — Web NFC + camera + state machine
|
||||||
|
- `static/src/css/nfc_kiosk.css` — high-contrast shop-floor styling (always dark)
|
||||||
|
|
||||||
|
**Visual:** always-dark, high-contrast, no Odoo navbar. Shop-floor lighting washes out light backgrounds.
|
||||||
|
|
||||||
|
### State machine
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─── (3s timeout) ─────────────────────────┐
|
||||||
|
▼ │
|
||||||
|
┌─────────────────────────┐ tap detected ┌────────────────────┐
|
||||||
|
│ IDLE │ ────────────────► │ PROCESSING │
|
||||||
|
│ "Tap card to clock │ │ spinner, "Reading"│
|
||||||
|
│ in or out" │ └────────────────────┘
|
||||||
|
│ big clock, date, │ │
|
||||||
|
│ company name │ success / error
|
||||||
|
└─────────────────────────┘ ▼
|
||||||
|
▲ ┌─────────────────────────┐
|
||||||
|
│ │ RESULT │
|
||||||
|
│ │ green: "Welcome John, │
|
||||||
|
└─── (3s) ──────────────────│ CLOCKED IN, 8:02 AM" │
|
||||||
|
│ red: "Card not │
|
||||||
|
│ enrolled" │
|
||||||
|
└─────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### IDLE state
|
||||||
|
- Top: company name + current time (HH:MM, updates every second) + date
|
||||||
|
- Center: large NFC icon + "Tap your card to clock in or out", subtle pulse animation
|
||||||
|
- Bottom-right corner: tiny "⚙" icon (gateway to Enroll Mode)
|
||||||
|
|
||||||
|
### PROCESSING state
|
||||||
|
- Brief spinner + "Reading card…"
|
||||||
|
- Mostly imperceptible at typical network latency
|
||||||
|
|
||||||
|
### RESULT state — success
|
||||||
|
- Green panel
|
||||||
|
- Large employee avatar on the left
|
||||||
|
- "John Smith" — name in big text
|
||||||
|
- "CLOCKED IN at 8:02 AM" or "CLOCKED OUT — 8.1h today"
|
||||||
|
- Auto-return to IDLE after 3s
|
||||||
|
|
||||||
|
### RESULT state — error
|
||||||
|
- Red panel
|
||||||
|
- `card_unknown` → "Card not recognized. See your manager."
|
||||||
|
- `network_error` → "No connection. Please try again."
|
||||||
|
- `debounce` → silent (no UI change to avoid double-tap confusion)
|
||||||
|
- `photo_required` → "Camera unavailable. Ask IT to check the kiosk."
|
||||||
|
- Auto-return to IDLE after 4s
|
||||||
|
|
||||||
|
### Web NFC implementation
|
||||||
|
- One-time activation button on first page load: "Tap here to enable NFC reader" (Web NFC requires a user gesture before `scan()` is permitted)
|
||||||
|
- After activation, `NDEFReader.scan()` runs continuously
|
||||||
|
- `reading` event fires for any tap; we extract `event.serialNumber` (works for raw MIFARE access cards even with no NDEF data)
|
||||||
|
- UID format: hex bytes joined by colons, uppercased
|
||||||
|
- If `scan()` throws, restart with a 1-second backoff
|
||||||
|
|
||||||
|
### Camera implementation
|
||||||
|
- `getUserMedia({ video: { facingMode: 'user' } })` activated alongside NFC
|
||||||
|
- Hidden `<video>` element streams continuously
|
||||||
|
- On tap, grab one frame to a `<canvas>`, encode as JPEG quality 0.7 (~30–60 KB), POST as base64 in the same JSON payload as the UID
|
||||||
|
- If `nfc_photo_required = True` and camera is unavailable → tap is rejected ("Camera unavailable") rather than silently degrading
|
||||||
|
|
||||||
|
### Enroll Mode
|
||||||
|
- Tap the bottom-right "⚙" → on-screen numpad password entry → match against `fusion_clock.nfc_enroll_password` → enter Enroll Mode
|
||||||
|
- Enroll Mode UI:
|
||||||
|
1. Search input → employee list (uses `/fusion_clock/kiosk/nfc/employee_search`)
|
||||||
|
2. Manager picks employee → "Now tap John Smith's card on the back of the tablet"
|
||||||
|
3. Tap detected → POST to `/enroll` → "✓ Card 04:A2:B5:62:C1:80 enrolled to John Smith. Enroll another?"
|
||||||
|
4. "Done" button → exit Enroll Mode → back to IDLE
|
||||||
|
- 60-second inactivity timeout in Enroll Mode → auto-exit to IDLE (so an unattended kiosk doesn't stay open in admin mode)
|
||||||
|
|
||||||
|
### One-time setup flow (first load on a new tablet)
|
||||||
|
1. "Welcome to Fusion Clock NFC Kiosk." — large tap-to-continue button (this gesture activates Web NFC)
|
||||||
|
2. Browser permission prompts: NFC, then Camera. Page text guides the manager through each.
|
||||||
|
3. Test prompt: "Tap any card to verify reader is working" → shows the UID detected → "Reader OK ✓"
|
||||||
|
4. "Setup complete." → enters IDLE
|
||||||
|
- After setup, page auto-resumes IDLE on every reload (Web NFC permission is sticky per origin, so no re-prompts)
|
||||||
|
|
||||||
|
### Mock-tap debug mode
|
||||||
|
- Gated by `fusion_clock.nfc_kiosk_debug = True`
|
||||||
|
- When enabled, hidden keyboard shortcut `Ctrl+Shift+T` fires a mock tap with a configurable UID stored in localStorage
|
||||||
|
- Off in production; useful for dev iteration on the UI state machine without hardware, and for support troubleshooting
|
||||||
|
|
||||||
|
## Edge cases & failure modes
|
||||||
|
|
||||||
|
| Scenario | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| Card not enrolled | Red screen "Card not recognized. See your manager." Activity logged with the unknown UID. No attendance change. |
|
||||||
|
| Employee disabled (`x_fclk_enable_clock=False`) | "Clock disabled for this account." Activity logged. |
|
||||||
|
| Card lost/damaged | Manager opens employee form, clears `x_fclk_nfc_card_uid`, issues new card, re-enrolls via kiosk Enroll Mode. |
|
||||||
|
| Card already assigned during enroll | "This card is already assigned to Jane Doe. Unenroll first." No silent overwrite. |
|
||||||
|
| Tablet offline / WiFi drops | Fail loudly: "No connection. Use the portal on your phone." No local cache in v1. |
|
||||||
|
| Same card tapped twice within 5s | Server-side debounce. Second tap silently ignored. |
|
||||||
|
| MIFARE clone attack | UIDs can be cloned with cheap hardware. Mitigation = the photo. Manager dashboard surfaces photos for spot-check. Cards alone are not treated as secure. |
|
||||||
|
| Tablet stolen | Knox remote wipe + revoke kiosk service user credentials in Odoo (instantly invalidates that tablet's session). |
|
||||||
|
| Power outage | Tab Active battery covers brief outages. Full reboot → Chrome+Fully Kiosk auto-launch the kiosk URL. Setup is sticky → goes straight to IDLE. |
|
||||||
|
| Tablet clock drift | Irrelevant. All timestamps come from `fields.Datetime.now()` server-side. Tablet clock is for display only. |
|
||||||
|
| UID format mismatch (Ubiquiti vs Web NFC byte order) | Normalize on the server: uppercase, colon-separated, MSB first. Reject malformed UIDs at the endpoint. |
|
||||||
|
| Camera unavailable while `nfc_photo_required=True` | Tap rejected with "Camera unavailable" — forces a real fix instead of silent degradation. |
|
||||||
|
|
||||||
|
## Hardware checklist (per company)
|
||||||
|
|
||||||
|
- Samsung Galaxy Tab Active 5 Pro (10.1") — ~$700 USD
|
||||||
|
- Samsung official Pogo charging dock — ~$100
|
||||||
|
- Wall mount bracket compatible with Tab Active 5 Pro (The Joy Factory, Maclocks, or Heckler) — ~$80
|
||||||
|
- USB-C 30W PSU + cable — ~$25
|
||||||
|
- Fully Kiosk Browser commercial license (~€10 one-time) OR Samsung Knox Configure (~$30/year/device)
|
||||||
|
- "TAP HERE" decal for the back of the tablet — DIY/printed sticker
|
||||||
|
|
||||||
|
**Total**: ~$915 per company, one-time.
|
||||||
|
|
||||||
|
## Provisioning script (one-time per tablet)
|
||||||
|
|
||||||
|
**Prerequisite — Odoo side (one-time per company):**
|
||||||
|
- Create a `res.users` named e.g. `kiosk-westin@<domain>`, member of `fusion_clock.group_fusion_clock_manager`
|
||||||
|
- Generate a long random password; store it in a password manager
|
||||||
|
- Set `res.company.x_fclk_nfc_kiosk_location_id` for that company to the desired `fusion.clock.location`
|
||||||
|
- Toggle `fusion_clock.enable_nfc_kiosk = True` and `fusion_clock.nfc_photo_required` per policy
|
||||||
|
- Set `fusion_clock.nfc_enroll_password` to a 4-digit Enroll Mode password
|
||||||
|
|
||||||
|
**Tablet side:**
|
||||||
|
1. Factory reset
|
||||||
|
2. Sign in with company Google account
|
||||||
|
3. Install Fully Kiosk Browser from Play Store
|
||||||
|
4. In Fully Kiosk: set kiosk URL → `https://<odoo-domain>/fusion_clock/kiosk/nfc`, enable "hide bars", "auto-restart on crash", "keep screen on while charging", "auto-reload daily at 3am"
|
||||||
|
5. Open kiosk URL once in normal Chrome → log in as the kiosk service user (saved credentials) → walk through the one-time setup flow (activate NFC, allow camera, test-tap a card)
|
||||||
|
6. Lock tablet into kiosk mode via Fully Kiosk's "Start Kiosk" button
|
||||||
|
7. Mount on dock
|
||||||
|
|
||||||
|
## Testing plan
|
||||||
|
|
||||||
|
### Python unit tests (`tests/test_clock_nfc_kiosk.py`)
|
||||||
|
- Tap with valid UID → attendance toggled, photo saved, activity logged
|
||||||
|
- Tap with unknown UID → `card_unknown` error, no attendance row
|
||||||
|
- Tap when `x_fclk_enable_clock=False` → `clock_disabled` error
|
||||||
|
- Double-tap same UID within 5s → second is debounced
|
||||||
|
- Enroll with conflicting UID → `card_already_assigned`, no overwrite
|
||||||
|
- Enroll with wrong password → 403
|
||||||
|
- Tap with no `fusion.clock.location` configured for company → `no_location_configured`
|
||||||
|
- UID normalization: lowercase input → stored uppercase
|
||||||
|
|
||||||
|
### Manual smoke tests (real tablet or Android phone for dev)
|
||||||
|
- Cold boot → IDLE within 5s
|
||||||
|
- Tap → RESULT within 1s
|
||||||
|
- Photo attached to attendance record (verify in backend)
|
||||||
|
- Enroll Mode password gate works; 60s timeout exits cleanly
|
||||||
|
- WiFi disconnect → tap shows "No connection"; reconnect → tap works again
|
||||||
|
- Tap own card 5x in fast succession → only one state change (debounce holds)
|
||||||
|
|
||||||
|
### Dev shortcut
|
||||||
|
- Test the entire flow on any Android phone with NFC + Chrome before touching tablet hardware
|
||||||
|
- For pre-card testing: use any contactless credit/debit card or transit pass (Web NFC reads only the UID, not card data — safe)
|
||||||
|
- Mock-tap debug mode (`Ctrl+Shift+T`) lets the UI state machine be tested without any hardware
|
||||||
|
|
||||||
|
### Soak test (before declaring pilot ready)
|
||||||
|
- 24h continuous on the dock
|
||||||
|
- Periodic taps every few hours
|
||||||
|
- Verify Chrome memory stable (DevTools), NFC reader still active, no zombie permissions prompts
|
||||||
|
|
||||||
|
## Future considerations
|
||||||
|
|
||||||
|
- **Offline mode** — local IndexedDB cache + replay queue when network returns. Adds complexity (conflict resolution, clock-skew handling) for marginal benefit at 1 station. Defer until pilot proves it's a real problem.
|
||||||
|
- **Multi-station** — if a single station becomes a bottleneck at shift change, add a second tablet at the same company. No code changes needed; just provision another tablet pointing at the same URL.
|
||||||
|
- **QR-code-on-portal alternate credential** — for iPhone-only employees who don't want to carry a card. Adds `BarcodeDetector` to the kiosk page alongside `NDEFReader`, plus a "My Clock Code" page in the portal that shows a rotating short-lived QR. Defer to v1.1.
|
||||||
|
- **Ubiquiti webhook integration** — subscribe to UniFi Access tap events on a designated "clock door" reader so an entry tap doubles as clock-in. Saves the tablet purchase but loses the photo verification and the screen feedback. Probably not worth it but easy to add later.
|
||||||
|
- **Native Android kiosk app** — only if the pilot scales to 50+ stations and Web NFC's quirks become operationally painful. Today, not worth it.
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
# ADP Application Received — Bundled Pages 11 & 12 (Design)
|
||||||
|
|
||||||
|
**Date:** 2026-05-19
|
||||||
|
**Module:** `fusion_claims`
|
||||||
|
**Owner:** Gurpreet
|
||||||
|
**Status:** Approved (ready for implementation plan)
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
When marking an ADP application as Received, the `Application Received` wizard requires two separate PDF uploads:
|
||||||
|
|
||||||
|
1. **Original ADP Application** (`x_fc_original_application`)
|
||||||
|
2. **Signed Pages 11 & 12** (`x_fc_signed_pages_11_12`)
|
||||||
|
|
||||||
|
In day-to-day operations the office or the client often scans (or emails) the **entire** ADP application as a single PDF — already including signed pages 11 & 12. Today, staff have to manually split pages 11 & 12 out of the bundled PDF and upload them again as a separate file, even though the same signatures are already present in the original PDF.
|
||||||
|
|
||||||
|
The wizard must continue to support the existing flows (separate signed-pages file, remote signing via Page 11 signing request), but it should also accept the bundled case without manual splitting.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Allow staff to mark Application Received with **one** PDF when pages 11 & 12 are inside it.
|
||||||
|
- Preserve the two existing modes (separate file, remote signing).
|
||||||
|
- Keep downstream audit/case-close checks correct without rewriting every consumer.
|
||||||
|
- Make the wizard easier to use and slightly safer (real PDF detection, friendlier messages).
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- PDF page extraction or splitting (explicitly rejected by user — "no split").
|
||||||
|
- Capturing Page 11 signer identity in the bundled / separate-file modes (existing gap; out of scope).
|
||||||
|
- Re-architecting the document-attachment model to de-duplicate identical binaries (out of scope).
|
||||||
|
- Changes to the remote signing wizard or `fusion.page11.sign.request` model.
|
||||||
|
|
||||||
|
## High-Level Approach
|
||||||
|
|
||||||
|
Add a **single boolean flag** on `sale.order` that records whether pages 11 & 12 are inside the original application PDF. Introduce a **computed helper field** that downstream consumers read instead of `x_fc_signed_pages_11_12` directly. Add a **three-mode radio** at the top of the Application Received wizard.
|
||||||
|
|
||||||
|
Minimal blast radius:
|
||||||
|
- One new boolean, one new computed field on `sale.order`.
|
||||||
|
- Wizard view + Python rewritten to drive logic off the radio mode.
|
||||||
|
- Four downstream call sites change which field they read (no logic change).
|
||||||
|
- Three small complementary fixes folded in (status-gate text, PDF magic-bytes check, page-count indicator).
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
### `sale.order` — new fields
|
||||||
|
|
||||||
|
```python
|
||||||
|
x_fc_pages_11_12_in_original = fields.Boolean(
|
||||||
|
string='Pages 11 & 12 in Original Application',
|
||||||
|
default=False,
|
||||||
|
tracking=True,
|
||||||
|
help='True when the original application PDF already contains the signed pages 11 & 12.',
|
||||||
|
)
|
||||||
|
|
||||||
|
x_fc_has_signed_pages_11_12 = fields.Boolean(
|
||||||
|
string='Has Signed Pages 11 & 12',
|
||||||
|
compute='_compute_has_signed_pages_11_12',
|
||||||
|
store=True,
|
||||||
|
help='True if pages 11 & 12 are satisfied — either bundled, uploaded separately, '
|
||||||
|
'or signed via remote signing request.',
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.depends(
|
||||||
|
'x_fc_signed_pages_11_12',
|
||||||
|
'x_fc_pages_11_12_in_original',
|
||||||
|
'page11_sign_request_ids.state',
|
||||||
|
)
|
||||||
|
def _compute_has_signed_pages_11_12(self):
|
||||||
|
for order in self:
|
||||||
|
order.x_fc_has_signed_pages_11_12 = bool(
|
||||||
|
order.x_fc_pages_11_12_in_original
|
||||||
|
or order.x_fc_signed_pages_11_12
|
||||||
|
or order.page11_sign_request_ids.filtered(lambda r: r.state == 'signed')
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Existing fields — unchanged meaning
|
||||||
|
|
||||||
|
- `x_fc_original_application` — original (or bundled) PDF.
|
||||||
|
- `x_fc_signed_pages_11_12` — separate signed-pages file when one exists. Stays optional.
|
||||||
|
- `page11_sign_request_ids` — remote signing requests. Unchanged.
|
||||||
|
|
||||||
|
### Audit trail field
|
||||||
|
|
||||||
|
`x_fc_trail_has_signed_pages` already exists at [models/sale_order.py:3248](../../fusion_claims/models/sale_order.py:3248). Its compute body changes from `bool(order.x_fc_signed_pages_11_12)` to `order.x_fc_has_signed_pages_11_12`.
|
||||||
|
|
||||||
|
### Migration
|
||||||
|
|
||||||
|
None. Existing records get `x_fc_pages_11_12_in_original = False` by default; their existing `x_fc_signed_pages_11_12` binary continues to satisfy the new computed gate. Stored compute will populate `x_fc_has_signed_pages_11_12` for legacy rows on first read or recompute.
|
||||||
|
|
||||||
|
## Wizard Changes — `fusion_claims.application.received.wizard`
|
||||||
|
|
||||||
|
### New fields
|
||||||
|
|
||||||
|
```python
|
||||||
|
intake_mode = fields.Selection(
|
||||||
|
[
|
||||||
|
('bundled', 'Pages 11 & 12 are INCLUDED in the original application'),
|
||||||
|
('separate', 'Pages 11 & 12 are a SEPARATE file'),
|
||||||
|
('remote', 'Pages 11 & 12 will be SIGNED REMOTELY'),
|
||||||
|
],
|
||||||
|
string='Intake Mode',
|
||||||
|
required=True,
|
||||||
|
default='bundled',
|
||||||
|
)
|
||||||
|
|
||||||
|
original_page_count = fields.Integer(
|
||||||
|
string='Original PDF Page Count',
|
||||||
|
compute='_compute_original_page_count',
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
`signed_pages_11_12` and `signed_pages_filename` keep their current definitions — they're only required in `separate` mode now.
|
||||||
|
|
||||||
|
The existing computed fields `has_pending_page11_request` and `has_signed_page11` ([wizard/application_received_wizard.py:44-49](../../fusion_claims/wizard/application_received_wizard.py:44)) **stay** — they drive the "request pending" / "remote signature complete" banners now only shown when `intake_mode == 'remote'`.
|
||||||
|
|
||||||
|
### `default_get` — pick an initial mode from existing state
|
||||||
|
|
||||||
|
```python
|
||||||
|
# When re-opening the wizard on an order that already has some data:
|
||||||
|
if order.x_fc_pages_11_12_in_original:
|
||||||
|
res['intake_mode'] = 'bundled'
|
||||||
|
elif order.x_fc_signed_pages_11_12:
|
||||||
|
res['intake_mode'] = 'separate'
|
||||||
|
elif order.page11_sign_request_ids.filtered(lambda r: r.state in ('sent', 'signed')):
|
||||||
|
res['intake_mode'] = 'remote'
|
||||||
|
else:
|
||||||
|
res['intake_mode'] = 'bundled' # new default for fresh records
|
||||||
|
```
|
||||||
|
|
||||||
|
### View behaviour (declarative `invisible` on group containers)
|
||||||
|
|
||||||
|
| Mode | Original upload | Signed Pages 11 & 12 upload | Remote-sign banner / button |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `bundled` | shown, required | hidden | hidden |
|
||||||
|
| `separate` | shown, required | shown, required | hidden |
|
||||||
|
| `remote` | shown, required | hidden | shown (existing `action_request_page11_signature` button) |
|
||||||
|
|
||||||
|
Page count is displayed read-only next to the original-application filename once a PDF is loaded. If `pdfrw` fails to parse, show *"(could not read PDF)"* — does not block confirmation.
|
||||||
|
|
||||||
|
### `action_confirm` (new shape)
|
||||||
|
|
||||||
|
```python
|
||||||
|
def action_confirm(self):
|
||||||
|
self.ensure_one()
|
||||||
|
order = self.sale_order_id
|
||||||
|
|
||||||
|
if order.x_fc_adp_application_status not in ('assessment_completed', 'waiting_for_application'):
|
||||||
|
raise UserError(
|
||||||
|
"Can only mark application received from 'Assessment Completed' "
|
||||||
|
"or 'Waiting for Application' status."
|
||||||
|
)
|
||||||
|
|
||||||
|
if not self.original_application:
|
||||||
|
raise UserError("Please upload the Original ADP Application.")
|
||||||
|
|
||||||
|
self._validate_pdf_bytes(self.original_application, 'Original ADP Application')
|
||||||
|
|
||||||
|
vals = {
|
||||||
|
'x_fc_adp_application_status': 'application_received',
|
||||||
|
'x_fc_original_application': self.original_application,
|
||||||
|
'x_fc_original_application_filename': self.original_application_filename,
|
||||||
|
'x_fc_pages_11_12_in_original': (self.intake_mode == 'bundled'),
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.intake_mode == 'separate':
|
||||||
|
if not (self.signed_pages_11_12 or order.x_fc_signed_pages_11_12):
|
||||||
|
raise UserError("Pages 11 & 12 file is required for Separate-file mode.")
|
||||||
|
if self.signed_pages_11_12:
|
||||||
|
self._validate_pdf_bytes(self.signed_pages_11_12, 'Signed Pages 11 & 12')
|
||||||
|
vals['x_fc_signed_pages_11_12'] = self.signed_pages_11_12
|
||||||
|
vals['x_fc_signed_pages_filename'] = self.signed_pages_filename
|
||||||
|
|
||||||
|
elif self.intake_mode == 'remote':
|
||||||
|
has_request = order.page11_sign_request_ids.filtered(
|
||||||
|
lambda r: r.state in ('sent', 'signed')
|
||||||
|
)
|
||||||
|
if not has_request:
|
||||||
|
raise UserError(
|
||||||
|
"Remote-signing request not found. Click 'Request Remote Signature' "
|
||||||
|
"first, or pick a different mode."
|
||||||
|
)
|
||||||
|
# bundled flag stays False — signature lives in the request's signed_pdf
|
||||||
|
|
||||||
|
order.with_context(skip_status_validation=True).write(vals)
|
||||||
|
self._post_chatter(order)
|
||||||
|
return {'type': 'ir.actions.act_window_close'}
|
||||||
|
```
|
||||||
|
|
||||||
|
When `intake_mode == 'bundled'`, any pre-existing `x_fc_signed_pages_11_12` from a prior wizard run is left alone (we don't clear it). The bundled flag plus the existing separate file together are harmless — the computed gate is `OR`.
|
||||||
|
|
||||||
|
### PDF magic-bytes check
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _validate_pdf_bytes(self, b64_data, label):
|
||||||
|
import base64
|
||||||
|
if not b64_data:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
head = base64.b64decode(b64_data)[:5]
|
||||||
|
except Exception:
|
||||||
|
raise UserError(f"{label}: could not decode uploaded file.")
|
||||||
|
if head != b'%PDF-':
|
||||||
|
raise UserError(f"{label} must be a PDF file (content check failed).")
|
||||||
|
```
|
||||||
|
|
||||||
|
The existing filename `.pdf` check stays in place as a defence-in-depth `@api.constrains`.
|
||||||
|
|
||||||
|
### Chatter message — mode-aware
|
||||||
|
|
||||||
|
| Mode | Headline | Detail line |
|
||||||
|
|---|---|---|
|
||||||
|
| `bundled` | *Application Received — bundled* | "Pages 11 & 12 included in original PDF" |
|
||||||
|
| `separate` | *Application Received — separate files* | "Original + separate signed pages uploaded" |
|
||||||
|
| `remote` | *Application Received — remote signature pending* | "Page 11 sent for remote signature (`N` request(s) outstanding)" where `N` is the count of `page11_sign_request_ids` in state `sent` or `signed`. |
|
||||||
|
|
||||||
|
Notes from the wizard, if any, are appended below as today.
|
||||||
|
|
||||||
|
## Downstream Consumer Changes
|
||||||
|
|
||||||
|
These are mechanical: change which field they read. **No logic changes.**
|
||||||
|
|
||||||
|
| File | Line | Old | New |
|
||||||
|
|---|---|---|---|
|
||||||
|
| [wizard/ready_for_submission_wizard.py:95](../../fusion_claims/wizard/ready_for_submission_wizard.py:95) | `_compute_field_status` | `bool(order.x_fc_original_application and order.x_fc_signed_pages_11_12)` | `bool(order.x_fc_original_application and order.x_fc_has_signed_pages_11_12)` |
|
||||||
|
| [wizard/ready_for_submission_wizard.py:148](../../fusion_claims/wizard/ready_for_submission_wizard.py:148) | gate check | `if not order.x_fc_signed_pages_11_12` | `if not order.x_fc_has_signed_pages_11_12` |
|
||||||
|
| [wizard/case_close_verification_wizard.py](../../fusion_claims/wizard/case_close_verification_wizard.py) | wherever pages-11-12 gate is checked | `x_fc_signed_pages_11_12` | `x_fc_has_signed_pages_11_12` |
|
||||||
|
| [models/sale_order.py:3248](../../fusion_claims/models/sale_order.py:3248) | `x_fc_trail_has_signed_pages` compute | `bool(order.x_fc_signed_pages_11_12)` | `order.x_fc_has_signed_pages_11_12` |
|
||||||
|
|
||||||
|
The `x_fc_signed_pages_11_12` field stays in the data model. Any download / preview / "open document" button that points at the literal binary stays as-is — bundled-mode orders simply won't have this field populated, and the UI should hide the "Open signed pages" button when the field is empty (it already does — Odoo hides empty binary widgets by default).
|
||||||
|
|
||||||
|
## Error / Edge Cases
|
||||||
|
|
||||||
|
| Scenario | Behaviour |
|
||||||
|
|---|---|
|
||||||
|
| User toggles from `separate` to `bundled` after uploading a separate file | Wizard does not clear the upload field. On confirm, only the original application is written; bundled flag goes to True. The separate-file binary in the wizard is discarded (it was never written). |
|
||||||
|
| User picks `remote` but has no sent/signed request | Block with the message above; user must click *Request Remote Signature* first. |
|
||||||
|
| User picks `bundled` but the PDF is short (e.g. 4 pages) | Page-count indicator shows *"(4 pages)"* as a visual hint, but **does not block**. The 14-page ADP form is the norm but the system can't reliably enforce it across form versions. |
|
||||||
|
| Legacy record without `x_fc_pages_11_12_in_original` set | Defaults to False. As long as `x_fc_signed_pages_11_12` is present, `x_fc_has_signed_pages_11_12` is True — gate still passes. |
|
||||||
|
| Stored compute not populated for legacy rows | Triggered on first read or via a one-line `_recompute` on module load is **not** required — Odoo computes on first access. If users hit issues, a one-off psql `UPDATE` can be run manually. |
|
||||||
|
| Remote signing completes after `bundled` mode was used | `_compute_has_signed_pages_11_12` already ORs in `page11_sign_request_ids.state == 'signed'` — harmless overlap; trail stays correct. |
|
||||||
|
| Uploaded file is not really a PDF (wrong content) | Magic-byte check raises a UserError; record is not changed. |
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Unit tests — wizard (`tests/test_application_received_wizard.py`, new)
|
||||||
|
|
||||||
|
- `test_bundled_mode_marks_received_with_only_original`
|
||||||
|
- `test_separate_mode_requires_signed_pages`
|
||||||
|
- `test_remote_mode_requires_sent_or_signed_request`
|
||||||
|
- `test_invalid_pdf_bytes_rejected`
|
||||||
|
- `test_chatter_message_mentions_intake_mode`
|
||||||
|
|
||||||
|
### Unit tests — downstream gates
|
||||||
|
|
||||||
|
- `test_ready_for_submission_passes_with_bundled_flag` (no `x_fc_signed_pages_11_12` set)
|
||||||
|
- `test_case_close_audit_accepts_bundled_flag`
|
||||||
|
- `test_trail_has_signed_pages_true_when_bundled`
|
||||||
|
|
||||||
|
### Manual smoke test on local dev DB
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec odoo-dev-app odoo -d fusion-dev -u fusion_claims --stop-after-init
|
||||||
|
```
|
||||||
|
|
||||||
|
Then in the UI:
|
||||||
|
1. Take an order in *Waiting for Application*.
|
||||||
|
2. Click *Mark Application Received* → pick **Bundled** → upload a single PDF → confirm.
|
||||||
|
3. Confirm chatter shows the bundled message and `x_fc_pages_11_12_in_original = True`.
|
||||||
|
4. Click *Mark Ready for Submission* — the document gate should pass.
|
||||||
|
5. Repeat on another order with **Separate** mode to confirm the old flow still works.
|
||||||
|
6. Repeat on a third order with **Remote** mode after triggering a signing request.
|
||||||
|
|
||||||
|
## Rollout
|
||||||
|
|
||||||
|
- Bump `version` in [fusion_claims/__manifest__.py](../../fusion_claims/__manifest__.py).
|
||||||
|
- `docker exec odoo-dev-app odoo -d fusion-dev -u fusion_claims --stop-after-init`.
|
||||||
|
- Reload browser with cache clear (per CLAUDE.md asset-bundle-cache rule).
|
||||||
|
- No production deploy steps unique to this change.
|
||||||
|
|
||||||
|
## Open Questions (none blocking implementation)
|
||||||
|
|
||||||
|
- Should bundled-mode capture Page 11 signer identity (signer name, relationship) the way the remote flow does? Currently neither bundled nor separate-file modes do — existing gap, deferred.
|
||||||
|
- Should the bundled-mode chatter automatically attach a one-line note like *"Operator confirms pages 11 & 12 are within the original application"* with the user's name? The default chatter post already records the user. Leaving as-is.
|
||||||
1351
docs/superpowers/specs/2026-05-20-fusion-repairs-design.md
Normal file
1351
docs/superpowers/specs/2026-05-20-fusion-repairs-design.md
Normal file
File diff suppressed because it is too large
Load Diff
444
docs/superpowers/specs/2026-05-26-fusion-login-audit-design.md
Normal file
444
docs/superpowers/specs/2026-05-26-fusion-login-audit-design.md
Normal file
@@ -0,0 +1,444 @@
|
|||||||
|
# Fusion Login Audit — Design Spec
|
||||||
|
|
||||||
|
**Status:** Approved, ready for implementation planning
|
||||||
|
**Date:** 2026-05-26
|
||||||
|
**Author:** Brainstormed with the user (Gurpreet) for the Westin Healthcare Odoo 19 deployment
|
||||||
|
**Target module path:** `K:\Github\Odoo-Modules\fusion_login_audit\`
|
||||||
|
**Production deploy target:** `/opt/odoo/custom-addons/fusion_login_audit/` on `odoo-westin` (VM 101, worker1, 192.168.1.40)
|
||||||
|
**Production DB:** `westin-v19` (Odoo 19, PostgreSQL)
|
||||||
|
|
||||||
|
## Background and motivation
|
||||||
|
|
||||||
|
A spot audit of user `info@gsafinancialconsulting.com` ("GSA Accounting", uid 63) revealed Odoo's built-in login tracking is effectively unusable for compliance:
|
||||||
|
|
||||||
|
- `res.users.log` rows are pruned by the daily `_gc_user_logs` cron — only the most recent login per user survives. For GSA Accounting the entire history collapsed to a single row at `2026-04-22 20:24 EDT`.
|
||||||
|
- `/var/log/odoo` on the production VM is empty because Odoo is configured at `log_level=warn` with stdout-only logging; INFO-level auth lines aren't captured anywhere.
|
||||||
|
- The container's json log is 444 KB and rotates frequently — nothing about the user remains.
|
||||||
|
- The existing `network_logger` module records outbound HTTP traffic from Odoo (uid=1 always), not user activity.
|
||||||
|
|
||||||
|
Result: today there is **no durable record** of who logged in, when, from where, or how often. A user with `base.group_system` + Technical Features and no 2FA — like GSA Accounting — could be active for months without any reconstructable trail.
|
||||||
|
|
||||||
|
This module closes that gap with a dedicated audit table that survives Odoo's GC, captures successful and failed authentications, surfaces results in the user form, and alerts admins on suspicious failure bursts.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. **Durable audit trail** of every password-authenticated login (success and failure) on `westin-v19`.
|
||||||
|
2. **Per-user visibility** for Settings admins via a tab + smart button on `res.users`.
|
||||||
|
3. **Failure-burst alerting** to admins on a configurable consecutive-failure threshold.
|
||||||
|
4. **Geo-enrichment** of IPs out-of-band so authentication latency is unaffected.
|
||||||
|
5. **Zero risk to the auth path** — an audit-write failure must never block a real login.
|
||||||
|
|
||||||
|
## Non-goals (v1)
|
||||||
|
|
||||||
|
- Logging every HTTP request / page view (explicitly de-scoped during brainstorming).
|
||||||
|
- Logging session resume events from auth cookies.
|
||||||
|
- API-key authentication (`credential['type'] == 'apikey'`) — bypasses `_check_credentials`. Documented as a known gap; addressable in a follow-up.
|
||||||
|
- OAuth / SSO logins — no OAuth provider configured on westin-v19.
|
||||||
|
- Self-service "view my own login activity" for end users — visibility is admin-only.
|
||||||
|
- Auto-disabling users on failed logins — flagged as a self-service DoS vector during brainstorming.
|
||||||
|
|
||||||
|
## Architecture overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Odoo authentication path │
|
||||||
|
│ │
|
||||||
|
│ /web/login → res.users._login() → res.users._check_credentials() │
|
||||||
|
│ ↓ │
|
||||||
|
│ (on success) │
|
||||||
|
│ ↓ │
|
||||||
|
│ res.users._update_last_login() │
|
||||||
|
│ ↓ │
|
||||||
|
│ ┌────────────────────┴────────────────────┐ │
|
||||||
|
│ │ │ │
|
||||||
|
│ ▼ ▼ │
|
||||||
|
│ fusion.login.audit (sudo create) Odoo's existing res_users_log │
|
||||||
|
│ result='success' + IP + UA │
|
||||||
|
│ │
|
||||||
|
│ (on AccessDenied) │
|
||||||
|
│ ↓ │
|
||||||
|
│ fusion.login.audit (sudo create) │
|
||||||
|
│ result='failure' + failure_reason + attempted_login │
|
||||||
|
│ ↓ │
|
||||||
|
│ _fc_recent_failure_count() >= threshold? │
|
||||||
|
│ ↓ yes │
|
||||||
|
│ _fc_send_failure_alert() → mail.mail to base.group_system │
|
||||||
|
└──────────────────────────────────┬──────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌─────────────────────┼─────────────────────┐
|
||||||
|
▼ ▼ ▼
|
||||||
|
cron: cron_geo_enrich cron: cron_retention_gc UI surfaces:
|
||||||
|
every 5 min daily 03:00 UTC - smart button on res.users
|
||||||
|
- reverse DNS - delete rows older than - "Login Activity" tab
|
||||||
|
- ip-api.com lookup x_fc_login_audit_ - Settings → Technical →
|
||||||
|
- 30-day local cache retention_days Login Audit menus
|
||||||
|
- Settings page section
|
||||||
|
```
|
||||||
|
|
||||||
|
The auth-path hooks are synchronous (must run inside the request). Geolocation, alerting, and retention are out-of-band so they cannot affect login latency.
|
||||||
|
|
||||||
|
## Module skeleton
|
||||||
|
|
||||||
|
```
|
||||||
|
fusion_login_audit/
|
||||||
|
├── __manifest__.py
|
||||||
|
├── __init__.py
|
||||||
|
├── models/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── res_users.py # extends res.users with capture hooks + computed fields + smart-button action
|
||||||
|
│ ├── fusion_login_audit.py # the new audit record model
|
||||||
|
│ └── res_config_settings.py # alert threshold + window + retention settings
|
||||||
|
├── data/
|
||||||
|
│ ├── ir_cron_data.xml # cron_geo_enrich + cron_retention_gc
|
||||||
|
│ └── mail_template_data.xml # failed-login alert template
|
||||||
|
├── security/
|
||||||
|
│ ├── security.xml # record rule: read for base.group_system only
|
||||||
|
│ └── ir.model.access.csv
|
||||||
|
├── views/
|
||||||
|
│ ├── fusion_login_audit_views.xml # list / form / kanban / search
|
||||||
|
│ ├── res_users_views.xml # tab + smart button
|
||||||
|
│ ├── res_config_settings_views.xml # Settings section
|
||||||
|
│ └── menus.xml # Settings → Technical → Login Audit
|
||||||
|
├── tests/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── test_login_audit.py
|
||||||
|
│ └── test_security.py
|
||||||
|
└── static/
|
||||||
|
└── description/
|
||||||
|
└── icon.png # copied from C:\Users\gsing\Downloads\fusion logs.png
|
||||||
|
```
|
||||||
|
|
||||||
|
**Manifest highlights**
|
||||||
|
|
||||||
|
- `version='19.0.1.0.0'` (project naming convention)
|
||||||
|
- `license='OPL-1'` (matches `fusion_accounts`)
|
||||||
|
- `depends=['base', 'mail']`
|
||||||
|
- `category='Tools'`
|
||||||
|
- `application=False` (it's a technical addon, not a top-level app)
|
||||||
|
|
||||||
|
**Dependencies (Python):** none new. Uses the `user_agents` library already shipped with Odoo. Geolocation calls `http://ip-api.com/json/<ip>` via the standard `requests` library (no API key required, 45 req/min free tier).
|
||||||
|
|
||||||
|
**Field naming:** new fields on existing models (`res.users`, `res.config.settings`) use the `x_fc_*` prefix per project CLAUDE.md. The new `fusion.login.audit` model uses unprefixed field names.
|
||||||
|
|
||||||
|
## Data model
|
||||||
|
|
||||||
|
### `fusion.login.audit` (new model, table `fusion_login_audit`)
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `user_id` | Many2one(`res.users`, `ondelete='set null'`) | Null if attempted login didn't match any user |
|
||||||
|
| `attempted_login` | Char(255), indexed | Always set — even on unknown-user failures |
|
||||||
|
| `result` | Selection(`success`, `failure`) | Indexed |
|
||||||
|
| `failure_reason` | Selection(`bad_password`, `unknown_user`, `disabled_user`, `2fa_failed`, `other`) | Null on success |
|
||||||
|
| `event_time` | Datetime, indexed, default `fields.Datetime.now()` | UTC; displayed in user TZ via standard widget |
|
||||||
|
| `ip_address` | Char(45) | IPv6-safe length |
|
||||||
|
| `ip_hostname` | Char(255) | Reverse DNS, populated by geo cron |
|
||||||
|
| `country_code` | Char(2), indexed | ISO-3166-1 alpha-2; null until cron runs |
|
||||||
|
| `country_name` | Char(64) | |
|
||||||
|
| `city` | Char(128) | |
|
||||||
|
| `geo_state` | Char(64) | Region/state name |
|
||||||
|
| `geo_lookup_state` | Selection(`pending`, `done`, `private_ip`, `internal`, `failed`) | Drives the geo cron worklist; `internal` = no HTTP request was attached |
|
||||||
|
| `user_agent_raw` | Char(512) | The full UA header |
|
||||||
|
| `browser` | Char(64) | e.g. "Chrome 140" — parsed |
|
||||||
|
| `os` | Char(64) | e.g. "Windows 11" — parsed |
|
||||||
|
| `device_type` | Selection(`desktop`, `mobile`, `tablet`, `bot`, `unknown`) | From `user_agents` |
|
||||||
|
| `database` | Char(64) | Multi-DB safety — which DB was logged into |
|
||||||
|
|
||||||
|
**Indexes (in addition to the column-level `indexed=True`):**
|
||||||
|
- `(user_id, event_time DESC)` — per-user history
|
||||||
|
- `(attempted_login, event_time DESC)` — failure-burst detection by login string
|
||||||
|
- `(geo_lookup_state, event_time)` — cron worklist
|
||||||
|
|
||||||
|
**No `_inherit = ['mail.thread']`** — audit rows are append-only and should not have chatter.
|
||||||
|
|
||||||
|
### `res.users` additions (per CLAUDE.md `x_fc_*` convention)
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `x_fc_login_audit_ids` | One2many(`fusion.login.audit`, `user_id`) | Backs the tab + smart-button count |
|
||||||
|
| `x_fc_login_audit_count` | Integer, compute, store=False | Smart-button label |
|
||||||
|
| `x_fc_last_successful_login` | Datetime, compute, store=True | Indexed; cheap "last seen" lookup |
|
||||||
|
| `x_fc_last_login_ip` | Char(45), compute, store=True | Surfaces last source IP in the form header |
|
||||||
|
|
||||||
|
The `store=True` computes are triggered by the create on `fusion.login.audit` (via `@api.depends('x_fc_login_audit_ids.event_time', 'x_fc_login_audit_ids.result')`).
|
||||||
|
|
||||||
|
### `res.config.settings` additions
|
||||||
|
|
||||||
|
Booleans / integers only (per CLAUDE.md — no Date fields on settings):
|
||||||
|
|
||||||
|
| Field | Default | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `x_fc_login_audit_retention_days` | 365 | Retention GC cron honors this; 0 = keep forever |
|
||||||
|
| `x_fc_login_audit_alert_threshold` | 5 | Consecutive failures before alert |
|
||||||
|
| `x_fc_login_audit_alert_window_min` | 15 | Time window in minutes for "consecutive" |
|
||||||
|
| `x_fc_login_audit_alert_enabled` | True | Master kill-switch for alert emails |
|
||||||
|
|
||||||
|
Each is backed by an `ir.config_parameter` (`fusion_login_audit.retention_days`, etc.) so changes from the Settings page persist.
|
||||||
|
|
||||||
|
### Multi-company
|
||||||
|
|
||||||
|
`fusion.login.audit` is intentionally **company-agnostic**. Logins happen before any company context is established; synthesizing one would either break the unknown-user case or require a "system company" placeholder. Settings admins see all rows globally.
|
||||||
|
|
||||||
|
## Capture flow
|
||||||
|
|
||||||
|
### Successful login (`_update_last_login`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _update_last_login(self):
|
||||||
|
result = super()._update_last_login()
|
||||||
|
try:
|
||||||
|
self._fc_record_login_event(result='success')
|
||||||
|
except Exception:
|
||||||
|
_logger.exception("fusion_login_audit: failed to record success row for %s", self.login)
|
||||||
|
return result
|
||||||
|
```
|
||||||
|
|
||||||
|
Called by Odoo only after the credential check has passed. Super() runs first so Odoo's own bookkeeping is unaffected.
|
||||||
|
|
||||||
|
### Failed login on known user (`_check_credentials`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _check_credentials(self, credential, env):
|
||||||
|
try:
|
||||||
|
return super()._check_credentials(credential, env)
|
||||||
|
except AccessDenied:
|
||||||
|
try:
|
||||||
|
self._fc_record_login_failure(credential, reason='bad_password')
|
||||||
|
if self._fc_recent_failure_count(credential) >= self._fc_alert_threshold():
|
||||||
|
self._fc_send_failure_alert(credential)
|
||||||
|
except Exception:
|
||||||
|
_logger.exception("fusion_login_audit: failed to record/alert failure")
|
||||||
|
raise
|
||||||
|
```
|
||||||
|
|
||||||
|
TOTP failures (from `auth_totp`) also raise `AccessDenied` and are caught here. Distinguish via `credential.get('type') == 'totp'` to set `failure_reason='2fa_failed'`.
|
||||||
|
|
||||||
|
### Failed login on unknown user (`_login` classmethod)
|
||||||
|
|
||||||
|
```python
|
||||||
|
@classmethod
|
||||||
|
def _login(cls, db, credential, user_agent_env):
|
||||||
|
try:
|
||||||
|
return super()._login(db, credential, user_agent_env)
|
||||||
|
except AccessDenied:
|
||||||
|
try:
|
||||||
|
cls._fc_record_unknown_user_failure(db, credential, user_agent_env)
|
||||||
|
except Exception:
|
||||||
|
_logger.exception("fusion_login_audit: failed to record unknown-user failure")
|
||||||
|
raise
|
||||||
|
```
|
||||||
|
|
||||||
|
Without this override, unknown-user attempts never reach `_check_credentials` and would silently disappear from the audit. The classmethod sets `user_id=None` and stores the attempted login string.
|
||||||
|
|
||||||
|
### Context extraction (`_fc_build_event_vals`)
|
||||||
|
|
||||||
|
Single helper shared by all three paths:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _fc_build_event_vals(self, result, attempted_login, failure_reason=None):
|
||||||
|
from odoo.http import request
|
||||||
|
vals = {
|
||||||
|
'attempted_login': attempted_login,
|
||||||
|
'result': result,
|
||||||
|
'failure_reason': failure_reason,
|
||||||
|
'event_time': fields.Datetime.now(),
|
||||||
|
'database': self.env.cr.dbname,
|
||||||
|
'geo_lookup_state': 'pending',
|
||||||
|
}
|
||||||
|
if request and request.httprequest:
|
||||||
|
vals['ip_address'] = request.httprequest.remote_addr # respects proxy_mode
|
||||||
|
ua_str = request.httprequest.user_agent.string or ''
|
||||||
|
vals['user_agent_raw'] = ua_str[:512]
|
||||||
|
from user_agents import parse as ua_parse
|
||||||
|
ua = ua_parse(ua_str)
|
||||||
|
vals['browser'] = f"{ua.browser.family} {ua.browser.version_string}"[:64]
|
||||||
|
vals['os'] = f"{ua.os.family} {ua.os.version_string}"[:64]
|
||||||
|
vals['device_type'] = (
|
||||||
|
'mobile' if ua.is_mobile else
|
||||||
|
'tablet' if ua.is_tablet else
|
||||||
|
'bot' if ua.is_bot else
|
||||||
|
'desktop' if ua.is_pc else 'unknown'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
vals['ip_address'] = 'internal'
|
||||||
|
vals['user_agent_raw'] = '<no-request>'
|
||||||
|
vals['geo_lookup_state'] = 'internal' # distinct from private_ip; cron skips both
|
||||||
|
return vals
|
||||||
|
```
|
||||||
|
|
||||||
|
### Write semantics
|
||||||
|
|
||||||
|
- All writes use `self.env['fusion.login.audit'].sudo().create(vals)` — low-privilege users can still generate their own audit rows despite the read-only record rule.
|
||||||
|
- `mail_create_nolog=True` context to avoid chatter noise.
|
||||||
|
- The password value is **never** present in `vals` and is hard-stripped from any `credential` dict before logging. A regression test asserts this.
|
||||||
|
|
||||||
|
## Async geolocation cron (`cron_geo_enrich`)
|
||||||
|
|
||||||
|
**Schedule:** every 5 minutes, `numbercall=-1`, `priority=10`.
|
||||||
|
|
||||||
|
**Worker logic:**
|
||||||
|
|
||||||
|
1. Select 100 oldest rows where `geo_lookup_state='pending'`.
|
||||||
|
2. For each row:
|
||||||
|
- **Private-IP shortcut:** if `ip_address` is in `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `127.0.0.0/8`, `::1`, or `fe80::/10` → set `geo_lookup_state='private_ip'`, `country_code='--'`, `city='Private network'`.
|
||||||
|
- **Cache check:** look for any prior row with the same `ip_address` and `country_code IS NOT NULL` and `event_time > now() - interval '30 days'`. If found, copy `country_code` / `country_name` / `city` / `geo_state` / `ip_hostname` locally; set state `done`. No external call.
|
||||||
|
- **Reverse DNS:** `socket.gethostbyaddr(ip)` with `socket.setdefaulttimeout(1.5)`.
|
||||||
|
- **HTTP lookup:** `requests.get('http://ip-api.com/json/' + ip, params={'fields': 'status,country,countryCode,regionName,city'}, timeout=3, headers={'User-Agent': 'Odoo-FusionLoginAudit/19.0'})`. The call passes through `network_logger` automatically.
|
||||||
|
- On `status='success'` → fill fields, set state `done`.
|
||||||
|
- On HTTP error, timeout, or `status='fail'` → set state `failed` (no retry).
|
||||||
|
3. `self.env.cr.commit()` after each row so one bad IP cannot roll back the batch.
|
||||||
|
4. **Rate limit defense:** if the response header `X-Rl` is `'0'`, break early and leave remaining rows as `pending` for the next run.
|
||||||
|
|
||||||
|
**Privacy:** the only outbound data is the IP itself. No user identifiers, no Odoo URL, no headers beyond `User-Agent: Odoo-FusionLoginAudit/19.0`. All outbound calls are auditable in `network_logger`.
|
||||||
|
|
||||||
|
## UI surfaces
|
||||||
|
|
||||||
|
### `res.users` form view
|
||||||
|
|
||||||
|
- **Smart button** in the button box, gated `groups="base.group_system"`:
|
||||||
|
```
|
||||||
|
┌──────────────┐
|
||||||
|
│ 🔑 N Logins │
|
||||||
|
└──────────────┘
|
||||||
|
```
|
||||||
|
Click → opens `fusion.login.audit` list view filtered to this user (`domain=[('user_id', '=', active_id)]`).
|
||||||
|
- **New tab "Login Activity"** appended after existing tabs, gated `groups="base.group_system"`:
|
||||||
|
- Header summary: `x_fc_last_successful_login`, `x_fc_last_login_ip` (readonly).
|
||||||
|
- Embedded one2many tree on `x_fc_login_audit_ids`, `limit="30"`, columns: `event_time`, `result` (colored badge), `ip_address`, `country_code` (with flag emoji display), `browser`, `os`, `failure_reason`.
|
||||||
|
- Tree is `create="false" edit="false" delete="false"`.
|
||||||
|
- "View full history →" button below the tree, same action as the smart button.
|
||||||
|
|
||||||
|
### Standalone views for `fusion.login.audit`
|
||||||
|
|
||||||
|
- **List view:** `event_time`, `user_id` (clickable), `attempted_login` (only when `user_id IS NULL`), `result` badge, `ip_address`, `country_code`, `city`, `browser`, `device_type`. Default sort `event_time DESC`.
|
||||||
|
- **Search view:** filters for "Successes", "Failures", "Last 24h", "Last 7d", "Last 30d", "Unknown users (no user_id)"; group-by IP / country / user.
|
||||||
|
- **Form view:** readonly; collapsible "Raw" section for `user_agent_raw`, `ip_hostname`, `database`, `geo_lookup_state`.
|
||||||
|
- **Kanban view:** grouped by `result`, color-coded green/red.
|
||||||
|
|
||||||
|
### Menus
|
||||||
|
|
||||||
|
Under **Settings → Technical → Login Audit**:
|
||||||
|
- "Login Events" → default list view
|
||||||
|
- "Failed Logins (24h)" → list view with default `[('result', '=', 'failure'), ('event_time', '>=', context_today() - 1)]`
|
||||||
|
|
||||||
|
### Settings page
|
||||||
|
|
||||||
|
New "Login Audit" section in **Settings → General Settings** (gated `groups="base.group_system"`):
|
||||||
|
- "Retention period (days)" — integer, help: "0 = keep forever"
|
||||||
|
- "Alert threshold" — integer
|
||||||
|
- "Alert window (minutes)" — integer
|
||||||
|
- "Send failed-login alerts" — boolean
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
### Group
|
||||||
|
|
||||||
|
No new group created. Read is bound to existing `base.group_system`. Rationale: brainstorming decision was "Settings admins only" — reusing the existing group avoids an extra checkbox to manage.
|
||||||
|
|
||||||
|
### Model access (`ir.model.access.csv`)
|
||||||
|
|
||||||
|
| Group | Read | Write | Create | Unlink |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `base.group_system` | ✓ | ✗ | ✗ | ✗ |
|
||||||
|
|
||||||
|
**No write/create/unlink for any group via the UI.** Audit rows are only written via `sudo()` from inside the auth hooks. An audit log admins can mutate is not an audit log.
|
||||||
|
|
||||||
|
### Record rule
|
||||||
|
|
||||||
|
Single global rule on `fusion.login.audit`: read for `base.group_system` only. The user-form one2many is additionally gated at the view level via `groups="base.group_system"` (not via a more permissive record rule) so non-admins have no read path even if they craft a custom view.
|
||||||
|
|
||||||
|
### Field-level
|
||||||
|
|
||||||
|
- `failure_reason` stores a category, never the attempted password.
|
||||||
|
- `_fc_build_event_vals` strips `credential['password']` before any logging or row construction.
|
||||||
|
- The `credential` dict is never persisted.
|
||||||
|
- Regression test: no field on `fusion.login.audit` ever contains a known-test-password string.
|
||||||
|
|
||||||
|
## Retention
|
||||||
|
|
||||||
|
**Cron `cron_retention_gc`** — daily at 03:00 UTC, `numbercall=-1`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
days = int(self.env['ir.config_parameter'].sudo().get_param(
|
||||||
|
'fusion_login_audit.retention_days', 365))
|
||||||
|
if days > 0:
|
||||||
|
cutoff = fields.Datetime.now() - timedelta(days=days)
|
||||||
|
self.env['fusion.login.audit'].sudo().search([
|
||||||
|
('event_time', '<', cutoff)
|
||||||
|
]).unlink()
|
||||||
|
```
|
||||||
|
|
||||||
|
Uses `unlink()` rather than raw `DELETE` so any ORM side effects fire. Expected DB load on `westin-v19`: 27 users × ~2 logins/day × 365 days ≈ 20k rows steady state — trivial for Postgres.
|
||||||
|
|
||||||
|
## Failed-login alert
|
||||||
|
|
||||||
|
**Mail template** in `data/mail_template_data.xml`:
|
||||||
|
|
||||||
|
- **Subject:** `[Login Audit] {threshold} failed login attempts for {attempted_login}`
|
||||||
|
- **Body:** simple HTML table of the last N failure rows for that `attempted_login` — timestamp, IP, country, user-agent summary.
|
||||||
|
- **Recipients:** all users in `base.group_system` with a non-empty `email`.
|
||||||
|
- **Send path:** `mail.mail` queue with `auto_delete=True` so the auth response isn't blocked.
|
||||||
|
|
||||||
|
**Cooldown:** 60 min per `attempted_login`, enforced via an `ir.config_parameter` keyed by `fusion_login_audit.last_alert:{attempted_login}` storing the last-send timestamp. Prevents a sustained attack from flooding admin inboxes.
|
||||||
|
|
||||||
|
**Kill-switch:** if `x_fc_login_audit_alert_enabled = False`, no alerts are sent regardless of threshold.
|
||||||
|
|
||||||
|
## Edge cases
|
||||||
|
|
||||||
|
| Case | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| `request` is None (XML-RPC, internal auth from cron) | Row written with `ip_address='internal'`, `user_agent_raw='<no-request>'`, `geo_lookup_state='internal'` (cron skips) |
|
||||||
|
| Audit insert errors on a hot DB | Login still succeeds — every auth-path hook is wrapped in `try/except Exception: _logger.exception(...)` |
|
||||||
|
| User deleted while audit rows remain | `ondelete='set null'` preserves history; `attempted_login` keeps the readable identifier |
|
||||||
|
| Password reset / `auth_signup` | The reset itself generates no login event; the subsequent login does — matches expectation |
|
||||||
|
| API key authentication | **Out of scope v1** (bypasses `_check_credentials`); documented |
|
||||||
|
| OAuth / SSO | Out of scope v1; no provider configured on westin-v19 |
|
||||||
|
| Portal user (`share=True`) | Logged the same way; smart button remains admin-visible |
|
||||||
|
| Two requests racing on the same private IP | Each writes its own row; geo cache is best-effort, not transactional |
|
||||||
|
| `proxy_mode = False` in `odoo.conf` | `remote_addr` will be the reverse-proxy IP — known limitation, fixable by setting `proxy_mode = True` (out of scope) |
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### `tests/test_login_audit.py` (TransactionCase)
|
||||||
|
|
||||||
|
1. Successful login writes a row with `result='success'` and resolved `user_id`.
|
||||||
|
2. Bad password writes `result='failure'` with `failure_reason='bad_password'` and re-raises `AccessDenied`.
|
||||||
|
3. Unknown user writes `result='failure'` with `failure_reason='unknown_user'`, `user_id=None`, non-null `attempted_login`.
|
||||||
|
4. No field on the written row contains the attempted password (regression).
|
||||||
|
5. Geo cron: pending row gets enriched from local cache when same IP exists within 30 days (no HTTP call made).
|
||||||
|
6. Retention cron: rows older than `retention_days` are deleted; newer survive.
|
||||||
|
7. Alert email: 5 failures in 15 min queues exactly one `mail.mail`; a 6th failure within cooldown queues zero.
|
||||||
|
8. `database` field is populated from `self.env.cr.dbname`.
|
||||||
|
9. Audit-write exception inside `_update_last_login` does not block the login.
|
||||||
|
|
||||||
|
### `tests/test_security.py` (HttpCase)
|
||||||
|
|
||||||
|
1. Non-admin user gets `AccessError` on direct `search(fusion.login.audit)`.
|
||||||
|
2. Non-admin sees the user form view without the smart button or "Login Activity" tab (XML node hidden by `groups`).
|
||||||
|
3. Settings admin sees both.
|
||||||
|
|
||||||
|
## Deployment notes
|
||||||
|
|
||||||
|
- **Local install:** copy module to `K:\Github\Odoo-Modules\fusion_login_audit\` (bind-mounted into `odoo-modsdev-app` container). Update via:
|
||||||
|
```
|
||||||
|
docker exec odoo-modsdev-app odoo -d fusion-dev -i fusion_login_audit --stop-after-init
|
||||||
|
```
|
||||||
|
- **Production install:** sync to `/opt/odoo/custom-addons/fusion_login_audit/` on odoo-westin (via `auto_sync.sh` or git pull on the VM). Update via:
|
||||||
|
```
|
||||||
|
ssh odoo-westin "docker exec odoo-dev-app odoo -d westin-v19 -i fusion_login_audit --stop-after-init"
|
||||||
|
```
|
||||||
|
- **Icon:** copy `C:\Users\gsing\Downloads\fusion logs.png` to `K:\Github\Odoo-Modules\fusion_login_audit\static\description\icon.png`.
|
||||||
|
- **Verify `proxy_mode = True`** in `/opt/odoo/odoo.conf` on odoo-westin before relying on `ip_address` accuracy — otherwise `remote_addr` will be the reverse-proxy IP rather than the real client. Confirmed out of scope for this module, but flag for the operator.
|
||||||
|
- **Verify outbound to `ip-api.com:80`** is reachable from the odoo-westin VM (Tailscale/firewall) — if blocked, `geo_lookup_state` will simply be `failed` and the rest of the module is unaffected.
|
||||||
|
|
||||||
|
## Success criteria
|
||||||
|
|
||||||
|
- Logging in as any user creates exactly one `fusion.login.audit` row with `result='success'` and the correct IP/UA.
|
||||||
|
- Failed login attempts create exactly one row with `result='failure'` and the correct `failure_reason`.
|
||||||
|
- Unknown-user attempts create a row with `user_id=None` and the typed login string in `attempted_login`.
|
||||||
|
- The smart button on `res.users` shows the lifetime count and opens the filtered list.
|
||||||
|
- The "Login Activity" tab shows the last 30 events with correct color coding.
|
||||||
|
- After 5 failures from the same login string within 15 minutes, exactly one alert email arrives in the inbox of every Settings admin with an `email` set.
|
||||||
|
- The geo cron populates `country_code`, `city`, `ip_hostname` for public IPs within 10 minutes of the login.
|
||||||
|
- The retention cron, set to 1 day for a test, deletes rows older than 24 hours and leaves newer ones.
|
||||||
|
- All tests pass: `docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable -i fusion_login_audit --stop-after-init`.
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
# Fusion Helpdesk — Customer Follow-up & Embedded Ticket Inbox
|
||||||
|
|
||||||
|
- **Date:** 2026-05-27
|
||||||
|
- **Status:** Approved design (ready for implementation plan)
|
||||||
|
- **Branch:** `feat/helpdesk-customer-followup`
|
||||||
|
- **Modules touched:** `fusion_helpdesk` (client deployments), `fusion_helpdesk_central` (central Odoo)
|
||||||
|
- **Target system:** `odoo-nexa` / `erp.nexasystems.ca`, DB `nexamain`, Odoo 19 Enterprise
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Summary
|
||||||
|
|
||||||
|
Today, end users at client deployments (ENTECH, MOBILITY, …) file helpdesk tickets through an in-app
|
||||||
|
"Report a Bug / Request a Feature" systray dialog. Those tickets land on the central Odoo Helpdesk but
|
||||||
|
carry **no customer identity**, so:
|
||||||
|
|
||||||
|
- support replies email nobody,
|
||||||
|
- the submitter can't see or follow up on their ticket,
|
||||||
|
- the ticket never appears in any customer portal.
|
||||||
|
|
||||||
|
This design makes ticket follow-up work end to end. It rests on **one keystone fix** (attach the
|
||||||
|
submitter's identity to every ticket) and then exposes **two follow-up surfaces** matched to two
|
||||||
|
audiences:
|
||||||
|
|
||||||
|
1. **In-app embedded inbox** — the systray dialog becomes a small ticket inbox (New + My Tickets). Client
|
||||||
|
staff read replies and follow up **without leaving their own Odoo or logging into the central system**.
|
||||||
|
2. **Native Enterprise portal** — for external web/email customers, the existing Odoo portal + magic-link
|
||||||
|
+ free sign-up does the job; they have no workspace to embed into.
|
||||||
|
|
||||||
|
Scope tier: **Polished** (light branding + acknowledgement email + in-app unread badge). Not a custom
|
||||||
|
portal theme.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Problem & Diagnosis (grounded in the live system)
|
||||||
|
|
||||||
|
### 2.1 Current architecture
|
||||||
|
|
||||||
|
- **`fusion_helpdesk`** (installed on *client* deployments): OWL systray dialog → `POST
|
||||||
|
/fusion_helpdesk/submit` → forwards to central over **XML-RPC as a shared bot account** (API key issued
|
||||||
|
by `fusion_helpdesk_central`). Ticket payload today is only `{name, description, team_id}`. The
|
||||||
|
reporter's name/login is embedded as **HTML text inside the description's "Diagnostic context" table** —
|
||||||
|
not as structured fields.
|
||||||
|
- **`fusion_helpdesk_central`** (installed on *central* Odoo): manages the per-client API keys on the
|
||||||
|
shared bot user. Does **not** touch tickets, portal, notifications.
|
||||||
|
|
||||||
|
### 2.2 The actual bug (verified on `nexamain`, 2026-05-27)
|
||||||
|
|
||||||
|
All **51/51** tickets have `partner_id`, `partner_email`, `partner_name` = NULL (0 coverage). With no
|
||||||
|
customer attached, Odoo has nobody to email, nobody to add as follower, no `/my/tickets` to populate, and
|
||||||
|
no recipient for a magic link.
|
||||||
|
|
||||||
|
### 2.3 The platform already does the hard part
|
||||||
|
|
||||||
|
Installed & enabled on `odoo-nexa`:
|
||||||
|
|
||||||
|
- Modules: `helpdesk` 19.0.1.6, `website_helpdesk`, `website_helpdesk_knowledge`, `helpdesk_account`,
|
||||||
|
`helpdesk_sale`, `portal`, `website`, `auth_signup`.
|
||||||
|
- `auth_signup.invitation_scope = b2c` (free customer sign-up ON), `auth_signup.reset_password = True`.
|
||||||
|
- `web.base.url = https://erp.nexasystems.ca`, `mail.catchall.domain = nexasystems.ca`, 4 working SMTP
|
||||||
|
servers → outbound email works.
|
||||||
|
- Team 1 **"Customer Care"** is already portal-ready: `privacy_visibility = portal`,
|
||||||
|
`use_website_helpdesk_form = true`, `allow_portal_ticket_closing = true`, `use_alias = true`, alias
|
||||||
|
`support` (→ `support@nexasystems.ca`).
|
||||||
|
|
||||||
|
`helpdesk.ticket` model (Enterprise source, verified):
|
||||||
|
|
||||||
|
- `_inherit = ['portal.mixin', 'mail.thread.cc', 'rating.mixin']`; `_mail_thread_customer = True`;
|
||||||
|
`_primary_email = 'partner_email'`; `access_url = '/my/ticket/<id>'` (← that is the magic link).
|
||||||
|
- **`create()` auto-resolves the partner**: when `partner_email` is given and `partner_id` is not, it calls
|
||||||
|
`mail.thread._partner_find_from_emails_single([partner_email], {name, company_id})` to find-or-create the
|
||||||
|
partner and set `partner_id` (`helpdesk_ticket.py` ≈ L564–572).
|
||||||
|
- **`create()` subscribes the customer as a follower** (the "make customer follower" loop, ≈ L600–620),
|
||||||
|
so they receive reply notifications by email.
|
||||||
|
- Portal routes: `/my/tickets` (auth=`user`); `/my/ticket/<int:ticket_id>/<access_token>` (auth=`public`)
|
||||||
|
→ validates token via `_document_check_access` → renders `helpdesk.tickets_followup` (reply composer
|
||||||
|
included); `/my/ticket/close/<id>/<token>` posts a message with `author_id = partner_id`; public web
|
||||||
|
form at `/helpdesk/<team>`.
|
||||||
|
|
||||||
|
**Consequence:** the keystone fix is small — pass `partner_email` + `partner_name` in the create payload and
|
||||||
|
native helpdesk creates the partner, links it, and subscribes it. Replies then email the customer with a
|
||||||
|
magic-link "View Ticket" button automatically.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Goals / Non-Goals
|
||||||
|
|
||||||
|
### Goals
|
||||||
|
- Every new ticket carries the submitter's real identity (`partner_email`, `partner_name`,
|
||||||
|
`x_fc_client_label`).
|
||||||
|
- Agent replies reach the customer **by email** with a working **magic link**.
|
||||||
|
- **In-app staff** can list, read, and reply to their tickets **inside their own Odoo** — no login, no
|
||||||
|
context switch.
|
||||||
|
- **External web/email customers** get the native portal + magic link + free sign-up.
|
||||||
|
- Light branding (logo/colours) + an acknowledgement email on ticket creation.
|
||||||
|
- Hybrid in-app visibility: regular users see their own tickets; a designated admin sees all of their
|
||||||
|
deployment's tickets.
|
||||||
|
|
||||||
|
### Non-Goals
|
||||||
|
- No custom portal theme, custom website submission form, KB-deflection, or SLA timeline UI (that was
|
||||||
|
Tier C — deliberately out of scope).
|
||||||
|
- No replication of tickets into the client database — the in-app inbox is a **live RPC view**.
|
||||||
|
- No backfill of the 51 existing identity-less tickets (low value; their only identity is free text).
|
||||||
|
- No changes to the billing module (`fusion_centralize_billing`) — separate work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Audiences & channels (locked decisions)
|
||||||
|
|
||||||
|
| Decision | Choice |
|
||||||
|
|---|---|
|
||||||
|
| Channels | **Both** — in-app reporter *and* external web/email |
|
||||||
|
| In-app visibility | **Hybrid** — own by default; designated admin sees all of their deployment's tickets |
|
||||||
|
| Scope tier | **Polished** — light branding + ack email + in-app unread badge |
|
||||||
|
| Acknowledgement email on create | **Yes** (immediate magic link) |
|
||||||
|
| Reporter email at submit | **Confirmed / editable** in the New form |
|
||||||
|
| "See all" gating | **New group** on the client deployment |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Architecture
|
||||||
|
|
||||||
|
### 5.1 Keystone — identity layer
|
||||||
|
|
||||||
|
- **Client side (`fusion_helpdesk`)**: in `submit()`, add to the create payload:
|
||||||
|
- `partner_name` = `request.env.user.name`
|
||||||
|
- `partner_email` = confirmed value from the form (default `request.env.user.email or .login`, editable)
|
||||||
|
- `x_fc_client_label` = `cfg['client_label']`
|
||||||
|
- **Central side (`fusion_helpdesk_central`)**: add `x_fc_client_label` (Char, indexed) to `helpdesk.ticket`
|
||||||
|
and surface it in the agent backend (list column + search filter) so support can filter by client. Native
|
||||||
|
helpdesk does the partner resolution + follower subscription.
|
||||||
|
|
||||||
|
`x_fc_client_label` is the structured tag that makes deployment-scoped queries (and the admin "see all"
|
||||||
|
view) reliable — far better than parsing the `[ENTECH]` subject prefix.
|
||||||
|
|
||||||
|
### 5.2 Two surfaces
|
||||||
|
|
||||||
|
- **Surface A — in-app embedded inbox** (`fusion_helpdesk`, client deployments). New work.
|
||||||
|
- **Surface B — native Enterprise portal** (`fusion_helpdesk_central` config + light branding). Mostly
|
||||||
|
configuration; near-zero new code.
|
||||||
|
|
||||||
|
### 5.3 Module responsibilities
|
||||||
|
|
||||||
|
**`fusion_helpdesk` (client) — majority of new work**
|
||||||
|
- Controller (`controllers/main.py`): keystone payload change + new endpoints (§6.1).
|
||||||
|
- OWL dialog (`static/src/js/…`, `static/src/xml/…`): New + My Tickets tabs; thread view; reply box.
|
||||||
|
- Systray (`fusion_helpdesk_systray.js`): unread badge.
|
||||||
|
- `res.groups`: `group_reporter_admin` ("Helpdesk Reporter Admin").
|
||||||
|
- Model `fusion.helpdesk.ticket.seen`: per-user read tracking for the badge.
|
||||||
|
- `res.config.settings`: (existing) — no new config required beyond what exists.
|
||||||
|
|
||||||
|
**`fusion_helpdesk_central` (central) — small additions**
|
||||||
|
- `helpdesk.ticket` inherit: `x_fc_client_label` field + backend list/search exposure.
|
||||||
|
- `mail.template`: branded acknowledgement on ticket create (with the magic-link CTA).
|
||||||
|
- Data/doc: confirm the "Customer Care" team portal config (already correct on live — assert via comment or
|
||||||
|
light data, don't fight existing config).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Surface A — In-app embedded inbox (detail)
|
||||||
|
|
||||||
|
### 6.1 Controller endpoints
|
||||||
|
|
||||||
|
All `type='jsonrpc'`, `auth='user'`. **Identity is always derived server-side from `request.env.user`** —
|
||||||
|
never from request parameters. All remote calls go through the existing bot XML-RPC layer.
|
||||||
|
|
||||||
|
| Route | Returns | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `POST /fusion_helpdesk/submit` *(modified)* | `{ok, ticket_id, ticket_url}` | Adds `x_fc_client_label` + `partner_name`; the confirmed form email is sent as `partner_email` (param may be named `reply_email`, but it maps straight to `partner_email`). |
|
||||||
|
| `/fusion_helpdesk/my_tickets` | `[{id, ref, subject, stage, last_update, has_unread}]` | Scoped (§8). Reuses one remote `search_read`. |
|
||||||
|
| `/fusion_helpdesk/ticket/<int:ticket_id>` | `{id, subject, stage, messages:[…], can_reply}` | **Public comments only** — internal notes excluded (§8). Re-checks scope. |
|
||||||
|
| `/fusion_helpdesk/ticket/<int:ticket_id>/reply` | `{ok}` | Re-checks scope; posts `message_post` with `author_id` = replier's partner. |
|
||||||
|
| `/fusion_helpdesk/unread_count` | `{count}` | For the systray badge (§7). |
|
||||||
|
|
||||||
|
### 6.2 Dialog UX
|
||||||
|
|
||||||
|
- The existing dialog gains two tabs:
|
||||||
|
- **New** — today's form, plus a confirmed/editable **"Your email"** field (prefilled from the logged-in
|
||||||
|
user; used as `reply_email`).
|
||||||
|
- **My Tickets** — list of the user's tickets (ref, subject, stage chip, last-update, unread dot). Admins
|
||||||
|
(in `group_reporter_admin`) see a **"Mine / All [LABEL]"** toggle.
|
||||||
|
- Clicking a ticket opens a **thread view**: customer-visible messages (author, timestamp, body,
|
||||||
|
attachments) + a **reply box** (text + attach) + a "Done"/back control. Opening a ticket marks it seen.
|
||||||
|
|
||||||
|
### 6.3 Reply attribution
|
||||||
|
|
||||||
|
- Replies post to central as `message_type='comment'`, `subtype_xmlid='mail.mt_comment'`, with `author_id`
|
||||||
|
= the **replying user's** partner on central (resolved find-or-create by their email). For a user replying
|
||||||
|
to their own ticket that's the ticket's customer; for an admin replying to a colleague's ticket it's the
|
||||||
|
admin's own identity (correct attribution).
|
||||||
|
- A customer reply notifies the assigned agent + followers (native), closing the two-way loop.
|
||||||
|
|
||||||
|
### 6.4 Read tracking & admin group
|
||||||
|
|
||||||
|
- Model `fusion.helpdesk.ticket.seen` (client DB): `user_id` (m2o `res.users`), `central_ticket_id`
|
||||||
|
(Integer), `last_seen_message_id` (Integer) — unique `(user_id, central_ticket_id)`. This is
|
||||||
|
read-tracking **metadata only** (no ticket content is stored) — it preserves the live-RPC-view principle
|
||||||
|
while letting the badge work without re-fetching on every page load.
|
||||||
|
- `group_reporter_admin` — an Odoo group on the client deployment. Membership unlocks the "All [LABEL]"
|
||||||
|
query path **server-side** (the controller checks `has_group` before broadening scope).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Notifications & emails
|
||||||
|
|
||||||
|
- **Agent → customer:** customer is a follower → **native email** with a "View Ticket" magic link
|
||||||
|
(portal.mixin `access_url` + token). Satisfies "they get replies in their email." In-app users also see
|
||||||
|
the reply in My Tickets and the badge increments.
|
||||||
|
- **Acknowledgement on create:** branded `mail.template` sent to the customer with the magic-link CTA so they
|
||||||
|
can track immediately. Fires for any ticket on the portal-enabled team that has a `partner_email`,
|
||||||
|
regardless of channel (in-app, web, email). Per Odoo 19, the template renders the link from the record
|
||||||
|
(`object.access_url` / portal URL); no need to pass it via `ctx` (CLAUDE rule 12). **Implementation note:**
|
||||||
|
verify `website_helpdesk` does not already send its own "ticket received" confirmation for web-form
|
||||||
|
submissions — if it does, gate ours so external customers don't get two acknowledgements.
|
||||||
|
- **Unread badge:** `unread_count` = number of the user's in-scope tickets whose latest customer-visible
|
||||||
|
**support** message id is greater than the local `last_seen_message_id`. Cleared per-ticket on open.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Security & scoping (the sharp edge)
|
||||||
|
|
||||||
|
The shared bot can read **every** client's tickets on central, so the client-side controller is the
|
||||||
|
security boundary.
|
||||||
|
|
||||||
|
- Endpoints are `auth='user'`; identity is taken from `request.env.user`, never from the browser.
|
||||||
|
- Scoped domain, built server-side:
|
||||||
|
- regular user → `[('x_fc_client_label','=',label), ('partner_email','=ilike', me.email or me.login)]`
|
||||||
|
- admin (`group_reporter_admin`) → `[('x_fc_client_label','=',label)]`
|
||||||
|
- **`x_fc_client_label = <my deployment>` is ALWAYS ANDed in** (defense in depth) so no user — regular or
|
||||||
|
admin — can ever read another deployment's tickets, even if two deployments share a reporter email.
|
||||||
|
- `ticket/<id>` and `…/reply` **re-resolve the ticket through the same scoped domain** before reading or
|
||||||
|
posting; a ticket outside scope returns not-found.
|
||||||
|
- Thread fetch returns **only customer-visible messages** (exclude internal notes — `subtype_id.internal =
|
||||||
|
True`), mirroring what the portal shows. Internal agent discussion never reaches a client.
|
||||||
|
- Reuse the module's existing granular remote-error handling for auth/network failures.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Data flow
|
||||||
|
|
||||||
|
```
|
||||||
|
SUBMIT (in-app)
|
||||||
|
staff clicks icon → New tab → confirm email → submit
|
||||||
|
client controller adds partner_email + partner_name + x_fc_client_label
|
||||||
|
→ XML-RPC create on central (as bot)
|
||||||
|
→ helpdesk find-or-creates partner_id + subscribes follower
|
||||||
|
→ branded acknowledgement email w/ magic link
|
||||||
|
|
||||||
|
AGENT REPLY (Nexa support)
|
||||||
|
reply as a comment in the ticket chatter on central
|
||||||
|
→ native email to customer w/ "View Ticket" magic link
|
||||||
|
→ in-app users also see it in My Tickets; badge increments
|
||||||
|
|
||||||
|
CUSTOMER FOLLOW-UP (any of three, same thread)
|
||||||
|
in-app dialog reply → RPC message_post (author = replier's partner)
|
||||||
|
portal magic link → native reply on /my/ticket/<id>/<token>
|
||||||
|
email reply → native email-in via support@nexasystems.ca
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Edge cases
|
||||||
|
|
||||||
|
- **Missing/invalid reporter email** — New form prefills + lets the user confirm/edit. If still empty, the
|
||||||
|
ticket is created without a customer (degrades to today's behaviour) and the dialog flags "no follow-up
|
||||||
|
email captured."
|
||||||
|
- **Same email across deployments** — partner is shared (their portal shows all their tickets), but the
|
||||||
|
in-app inbox still scopes by `x_fc_client_label`, so each deployment shows only its own.
|
||||||
|
- **Admin replies to a colleague's ticket** — author = the admin's own partner, not the ticket customer.
|
||||||
|
- **Existing 51 orphan tickets** — left as-is (no reliable identity to backfill).
|
||||||
|
- **Bot key revoked/rotated** (managed by `fusion_helpdesk_central`) — endpoints fail gracefully via the
|
||||||
|
existing typed remote-error responses.
|
||||||
|
- **Internal notes** — never returned to the client (subtype filter).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Testing strategy
|
||||||
|
|
||||||
|
- **`fusion_helpdesk_central`** (Enterprise; runs on an Enterprise env such as odoo-trial, like the billing
|
||||||
|
module — local dev is Community and can't install `helpdesk`):
|
||||||
|
- `x_fc_client_label` field exists + is searchable.
|
||||||
|
- Integration: `helpdesk.ticket.create({partner_email, partner_name, x_fc_client_label})` resolves
|
||||||
|
`partner_id` and adds the partner as a follower.
|
||||||
|
- Acknowledgement template renders the magic link from the record.
|
||||||
|
- **`fusion_helpdesk`** (client; XML-RPC layer **mocked** — no live central in unit tests):
|
||||||
|
- Scoping: regular vs admin domain construction; `x_fc_client_label` always ANDed.
|
||||||
|
- `…/reply` rejects a ticket outside the caller's scope.
|
||||||
|
- Thread fetch excludes internal notes.
|
||||||
|
- `unread_count` math against `fusion.helpdesk.ticket.seen`.
|
||||||
|
- Refactor the remote proxy so it is injectable/mockable.
|
||||||
|
- **Manual QA on `odoo-nexa`**: full round-trip — submit → agent reply → email + badge → in-app reply →
|
||||||
|
portal magic link → external sign-up shows `/my/tickets`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Out of scope / future
|
||||||
|
|
||||||
|
- Custom portal theme, branded custom web form, KB deflection, SLA/status timeline (Tier C).
|
||||||
|
- Backfilling identity on historical tickets.
|
||||||
|
- Push/websocket live updates in the dialog (polling/refresh is sufficient for v1).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. References
|
||||||
|
|
||||||
|
**Current code (this repo)**
|
||||||
|
- `fusion_helpdesk/controllers/main.py` — `submit()`, `_read_config()`, `_authenticate()`,
|
||||||
|
`_build_diag_block()` (XML-RPC forwarder; today sends only `{name, description, team_id}`).
|
||||||
|
- `fusion_helpdesk/static/src/js/fusion_helpdesk_dialog.js` — OWL submission dialog.
|
||||||
|
- `fusion_helpdesk/static/src/js/fusion_helpdesk_systray.js` — systray entry (badge target).
|
||||||
|
- `fusion_helpdesk/models/res_config_settings.py` — remote endpoint config params.
|
||||||
|
- `fusion_helpdesk_central/models/fusion_helpdesk_client_key.py` — bot user + API-key management.
|
||||||
|
|
||||||
|
**Live system facts (verified 2026-05-27 on `nexamain`)**
|
||||||
|
- Modules installed: `helpdesk` 19.0.1.6, `website_helpdesk`, `website_helpdesk_knowledge`,
|
||||||
|
`helpdesk_account`, `helpdesk_sale`, `portal`, `website`, `auth_signup`.
|
||||||
|
- `auth_signup.invitation_scope=b2c`; `web.base.url=https://erp.nexasystems.ca`;
|
||||||
|
`mail.catchall.domain=nexasystems.ca`; 4 SMTP servers.
|
||||||
|
- Team 1 "Customer Care": `privacy_visibility=portal`, `use_website_helpdesk_form=t`,
|
||||||
|
`allow_portal_ticket_closing=t`, `use_alias=t`, alias `support`.
|
||||||
|
- 51/51 tickets have NULL `partner_id`/`partner_email`/`partner_name`.
|
||||||
|
|
||||||
|
**Enterprise source (read-only, on container)**
|
||||||
|
- `helpdesk/models/helpdesk_ticket.py` — `_inherit` (portal.mixin, mail.thread.cc, rating.mixin);
|
||||||
|
`access_url='/my/ticket/<id>'`; `create()` partner find-or-create (≈L564–572) + follower subscription
|
||||||
|
(≈L600–620).
|
||||||
|
- `helpdesk/controllers/portal.py` — `/my/tickets`, `/my/ticket/<id>/<access_token>`,
|
||||||
|
`/my/ticket/close/<id>/<token>`.
|
||||||
|
- `website_helpdesk/controllers/main.py` — `/helpdesk/<team>` public web form.
|
||||||
|
|
||||||
|
**Odoo 19 gotchas to respect (from repo CLAUDE.md)**
|
||||||
|
- `res.users` group field is `group_ids` (not `groups_id`).
|
||||||
|
- `message_post(body=…)` HTML must be wrapped in `Markup()`.
|
||||||
|
- `mail.template` `ctx` is `env.context`; pass dynamic data via `with_context(**data)`.
|
||||||
|
- `res.config.settings` Boolean via `config_parameter` doesn't persist `False`.
|
||||||
|
- SQL constraints/indexes use declarative `models.Constraint` / `models.Index`.
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
# fusion_centralize_billing — Centralized Billing Engine on Odoo 19
|
||||||
|
|
||||||
|
- **Date:** 2026-05-27
|
||||||
|
- **Status:** Design approved — pending written-spec review
|
||||||
|
- **Author:** Design session (Claude + Gurpreet)
|
||||||
|
- **Module:** `fusion_centralize_billing` (target: `K:\Github\Odoo-Modules\fusion_centralize_billing`)
|
||||||
|
- **Host:** odoo-nexa (Proxmox VM 315, worker1), Odoo 19 **Enterprise**, live DB `nexamain`
|
||||||
|
|
||||||
|
## 1. Goal
|
||||||
|
|
||||||
|
Make the Odoo Enterprise instance (`odoo-nexa`) the single billing brain for every
|
||||||
|
NexaSystems service — hosting (NexaCloud), live chat (NexaDesk/Fusion-Chat), the
|
||||||
|
metered maps API (NexaMaps), plus custom-app retainers, memberships, and one-off
|
||||||
|
services. It replaces Lago in the role Lago currently plays, and absorbs NexaCloud's
|
||||||
|
home-grown Stripe billing, so there is one customer ledger, one accounting system,
|
||||||
|
one place revenue is recognized.
|
||||||
|
|
||||||
|
## 2. Current state (recon, 2026-05-27)
|
||||||
|
|
||||||
|
Billing is fragmented across **three+ independent engines**:
|
||||||
|
|
||||||
|
| System | Bills for | Engine today | Data home |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **NexaCloud** (LXC 102, `10.200.0.250`) | VPS/LXC hosting, Coolify apps, CPU-seconds + throttle-removal fees, snapshots, domains | Own Postgres models + **direct Stripe** (`stripe_service.py`, `billing_service.py`, `usage_metering.py`, `invoice_generator.py`) | `nexacloud` DB (LXC 201) |
|
||||||
|
| **NexaDesk / Fusion-Chat** (VM 314) | Chat plans (monthly/annual), feature + channel add-ons, message/token overage, token wallets | **Lago** v1.44.0 (VM 318) + Stripe (provider code `nexadesk`) | Lago (VM 318, `192.168.1.117`) |
|
||||||
|
| **NexaMaps** (`fusionapps.maps_*`) | Metered geocoding/routing API: monthly quota + overage per 1k | Own tables; **~189k usage events / month** for 2 clients | Supabase `fusionapps` |
|
||||||
|
| Services / memberships | Custom apps, consulting, retainers | ad-hoc / manual | — |
|
||||||
|
|
||||||
|
**Decisive fact:** `odoo-nexa` is **Odoo 19 Enterprise** and already runs the full
|
||||||
|
Lago-equivalent stack: `sale_subscription` (+ `_stock`, `_timesheet`,
|
||||||
|
`_external_tax`), `account_accountant`, `payment_stripe`, `website_sale` +
|
||||||
|
`website_sale_subscription`, `crm/project/industry_fsm_sale_subscription`, plus
|
||||||
|
custom `nexa_coa_setup`, `fusion_whitelabels`, `fusion_helpdesk_central`,
|
||||||
|
`fusion_pdf_preview`. So Odoo already does subscriptions, recurring invoicing, full
|
||||||
|
accounting/GL, Stripe, HST taxes, customer portal, credit notes, and self-serve
|
||||||
|
checkout.
|
||||||
|
|
||||||
|
**The only capability Lago has that Odoo lacks natively is usage-based metered
|
||||||
|
billing** (billable metrics → aggregation → quota/overage charges). That, plus the
|
||||||
|
integration surface, is all we build.
|
||||||
|
|
||||||
|
Prior decision on record (Supabase `fusionapps.decisions`): Lago was deployed as the
|
||||||
|
centralizer for NexaDesk + NexaCloud. This design **supersedes** that — the billing
|
||||||
|
brain moves into the Odoo Enterprise already owned and operated.
|
||||||
|
|
||||||
|
## 3. Decisions locked in this session
|
||||||
|
|
||||||
|
1. **Odoo fully replaces Lago.** Build a metered-billing engine inside `fusion_centralize_billing`; decommission Lago VM 318 at the end.
|
||||||
|
2. **One unified customer, separate invoice per service.** One `res.partner` per real client; each service bills on its own subscription/cycle. No cross-product invoice merging.
|
||||||
|
3. **Apps drive; Odoo is the billing system of record.** Each app keeps its own signup, provisioning, and entitlement enforcement, and calls Odoo's billing API (the same way it calls Lago today). Odoo invoices, charges Stripe, and emits webhooks back.
|
||||||
|
4. **Odoo owns the billing catalog; apps own entitlements.** Odoo is SoR for products, prices, recurrence, metric rate/quota/overage, taxes — keyed by a stable `plan_code`. Apps enforce feature limits (max_chatbots, CPU quota, API rate-limit) against the same code.
|
||||||
|
5. **Pilot = NexaCloud, phased dual-run cutover** (one product at a time, parallel run + reconciliation before flip).
|
||||||
|
6. **Aggregate-push usage ingestion.** Apps push periodic pre-aggregated counters; Odoo stores rollups and feeds native `sale.subscription` metered lines. No raw-event firehose into Odoo.
|
||||||
|
|
||||||
|
## 4. Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
NexaCloud NexaDesk NexaMaps (apps keep signup + provisioning + entitlements)
|
||||||
|
│ │ │
|
||||||
|
│ customers / subscriptions / usage counters (inbound REST, API-key bearer auth)
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌──────────────────────────────────────────────┐
|
||||||
|
│ fusion_centralize_billing (custom Odoo 19 module) │
|
||||||
|
│ • Service registry (one row per app) │
|
||||||
|
│ • Identity links (ext acct → res.partner) │
|
||||||
|
│ • Metric + Charge catalog (quota/overage) │
|
||||||
|
│ • Usage engine (ingest → aggregate → bill) │
|
||||||
|
│ • Outbound webhook queue (HMAC + retry) │
|
||||||
|
└───────────────┬────────────────────────────────┘
|
||||||
|
│ writes billable qty onto
|
||||||
|
▼
|
||||||
|
sale.order(is_subscription) → account.move → payment_stripe (NATIVE Odoo Enterprise)
|
||||||
|
│ invoicing, HST tax, proration,
|
||||||
|
│ invoice paid / failed / sub ended dunning, portal, credit notes
|
||||||
|
▼
|
||||||
|
outbound webhooks ──► apps suspend / restore / deprovision
|
||||||
|
```
|
||||||
|
|
||||||
|
Principle: **build only the metering + integration layer; inherit all financial
|
||||||
|
behaviour from native Odoo Enterprise.**
|
||||||
|
|
||||||
|
## 5. Data model
|
||||||
|
|
||||||
|
### 5.1 New models (`fusion.billing.*`)
|
||||||
|
|
||||||
|
| Model | Key fields | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `fusion.billing.service` | `name`, `code` (nexacloud/nexadesk/nexamaps), `api_key_hash`, `webhook_url`, `webhook_secret`, `active` | One row per source app — the auth + routing boundary. |
|
||||||
|
| `fusion.billing.account.link` | `service_id`, `external_id`, `partner_id`, `external_email`; unique `(service_id, external_id)` | Identity resolution: folds each app's account into one `res.partner`. |
|
||||||
|
| `fusion.billing.metric` | `code`, `name`, `aggregation` (sum/max/last/unique_count), `unit_label`, `rounding` | Billable metric definition. |
|
||||||
|
| `fusion.billing.charge` | `plan_ref`/`product_id`, `metric_id`, `included_quota`, `price_per_unit`, `unit_batch` (e.g. per 1000), `charge_model` (standard/graduated/package/volume) | Maps a plan + metric → quota & overage pricing. Where "5M quota / $0.10 per 1k" lives. |
|
||||||
|
| `fusion.billing.usage` | `subscription_id`, `metric_id`, `period_start`, `period_end`, `quantity`, `source`, `idempotency_key`; index `(subscription, metric, period)` | **Aggregated** usage rows (rollups, not raw events). |
|
||||||
|
| `fusion.billing.webhook` | `service_id`, `event_type`, `payload` (JSON), `state` (pending/sent/failed/dead), `attempts`, `next_retry_at`, `signature` | Outbound event queue, processed by cron with backoff + HMAC. |
|
||||||
|
| `fusion.billing.reconciliation` | `service_id`, `partner_id`, `period`, `odoo_amount`, `external_amount`, `delta`, `status` | Dual-run shadow-mode comparison (Odoo-computed vs app-actual). |
|
||||||
|
|
||||||
|
### 5.2 Native models reused as-is
|
||||||
|
|
||||||
|
`res.partner` (customer), **`sale.order` with `is_subscription=True`** (the subscription),
|
||||||
|
`sale.subscription.plan` (recurrence/plan), `sale.order.line` (metered lines),
|
||||||
|
`account.move` (invoice + credit note), `payment_stripe`/`payment.transaction` (Stripe),
|
||||||
|
`account.tax` (HST per province), customer portal. Catalog = `product.template` +
|
||||||
|
`sale.subscription.plan`, tagged with the shared `plan_code`.
|
||||||
|
|
||||||
|
New fields on native models use the `x_fc_*` prefix (e.g. `res.partner.x_fc_billing_external_ids`).
|
||||||
|
|
||||||
|
> **Odoo 19 modeling note (verified on live `nexamain`, 2026-05-27):** there is **no
|
||||||
|
> `sale.subscription` model**. A subscription IS a `sale.order` with `is_subscription=True`,
|
||||||
|
> `plan_id` → `sale.subscription.plan`, plus `subscription_state` / `next_invoice_date` /
|
||||||
|
> `recurring_monthly`. Every "subscription" reference in this spec means that. The usage
|
||||||
|
> engine links `fusion.billing.usage.subscription_id` → `sale.order`.
|
||||||
|
|
||||||
|
### 5.3 Relationship to `fusion_api` (reuse, don't duplicate)
|
||||||
|
|
||||||
|
The existing **`fusion_api`** module (`fusion.api.key` / `.consumer` / `.service` /
|
||||||
|
`.usage` / `.usage.daily`) centralizes **outbound** provider keys (OpenAI, Anthropic,
|
||||||
|
Google Maps, Twilio) with cost/usage tracking + rate limiting — i.e. what **Nexa pays
|
||||||
|
providers** (COGS). It is **complementary**, not a substitute:
|
||||||
|
`fusion_centralize_billing` tracks what **customers owe Nexa**. Two concrete ties:
|
||||||
|
(a) feed `fusion.api.usage.daily` cost into margin reporting against billed revenue;
|
||||||
|
(b) mirror its daily-rollup aggregation pattern for `fusion.billing.usage`. The
|
||||||
|
customer-facing metered billing and the inbound API remain ours to build.
|
||||||
|
|
||||||
|
## 6. Usage engine (aggregate-push)
|
||||||
|
|
||||||
|
1. Apps `POST /usage` with periodic counters and an `idempotency_key`
|
||||||
|
(e.g. `service:metric:subscription:window`). NexaCloud pushes CPU-seconds per
|
||||||
|
deployment hourly; NexaMaps pushes api_calls per client daily; NexaDesk pushes
|
||||||
|
messages/tokens. Upsert into `fusion.billing.usage` keyed by `idempotency_key` so
|
||||||
|
retries never double-bill.
|
||||||
|
2. A **pre-invoice cron** (runs ahead of each subscription's invoice date) sums the
|
||||||
|
period's `fusion.billing.usage` per metric, applies the matching
|
||||||
|
`fusion.billing.charge` (quota → free, overage → priced by `charge_model`), and
|
||||||
|
writes the billable quantity/amount onto the subscription's draft invoice line
|
||||||
|
(usage product).
|
||||||
|
3. Native subscription invoicing issues the invoice, applies HST, and charges Stripe.
|
||||||
|
Quota resets per period.
|
||||||
|
|
||||||
|
At ~189k Maps events/month pushed as daily counters, Odoo stores ≈30 rows per client
|
||||||
|
per metric per month — trivial volume.
|
||||||
|
|
||||||
|
## 7. Inbound API (Lago-shaped, drop-in)
|
||||||
|
|
||||||
|
Base path `/api/billing/v1/*`. Odoo 19 routing: `type="http"`, `auth="none"`,
|
||||||
|
`csrf=False`, manual **Bearer** API-key check against `fusion.billing.service`
|
||||||
|
(hashed), JSON request/response via `request.make_json_response`, per-service rate
|
||||||
|
limiting. (`type="jsonrpc"` is for Odoo session RPC — not used here, because external
|
||||||
|
apps authenticate with bearer tokens, not Odoo sessions.)
|
||||||
|
|
||||||
|
Endpoints intentionally mirror `Fusion-Chat/src/lib/billing/lago-client.ts` so the
|
||||||
|
NexaDesk swap is ≈ one file, and NexaCloud's integration is a thin client:
|
||||||
|
|
||||||
|
| Method · Path | Maps to |
|
||||||
|
|---|---|
|
||||||
|
| `POST /customers` | upsert `res.partner` + `account.link` (identity resolution) |
|
||||||
|
| `POST /subscriptions` · `PUT /subscriptions/:id` · `DELETE /subscriptions/:id` | create / change-upgrade / cancel subscription `sale.order` |
|
||||||
|
| `POST /usage` | batch aggregated counters (hot path → 202 Accepted) |
|
||||||
|
| `POST /invoices` | one-off invoice (token packs, throttle-removal fee) |
|
||||||
|
| `GET /invoices` · `GET /invoices/:id` · `POST /invoices/:id/download` | list / fetch / PDF |
|
||||||
|
| `POST /invoices/:id/retry_payment` · `POST /invoices/:id/void` | payment retry / void |
|
||||||
|
| `POST /credit_notes` | refund via `account.move` reversal |
|
||||||
|
| `GET /plans` · `GET /catalog` | apps fetch pricing (as NexaDesk fetches from Lago) |
|
||||||
|
| `GET /customers/:id/checkout_url` | Stripe payment-method setup |
|
||||||
|
|
||||||
|
## 8. Outbound webhooks (control loop)
|
||||||
|
|
||||||
|
Odoo → app, HMAC-SHA256 signed, retried with exponential backoff, dead-lettered after
|
||||||
|
N attempts (reuse the proven pattern in `Fusion-Chat/src/lib/billing/lago-payment-retry-job.ts`):
|
||||||
|
|
||||||
|
| Event | App reaction |
|
||||||
|
|---|---|
|
||||||
|
| `invoice.payment_failed` (after dunning) | **suspend** — NexaCloud throttle/network-isolate; NexaDesk suspend tenant; NexaMaps disable API key |
|
||||||
|
| `invoice.payment_succeeded` / `subscription.reactivated` | **restore** service |
|
||||||
|
| `subscription.terminated` | **deprovision** |
|
||||||
|
| `usage.threshold_reached` (80% / 100%, optional) | warn / cap |
|
||||||
|
|
||||||
|
## 9. NexaCloud pilot
|
||||||
|
|
||||||
|
- **Identity & catalog mapping:** `nexacloud.users` → `res.partner` via `account.link`;
|
||||||
|
`nexacloud.products`/`plans` → `product.template` + subscription plans
|
||||||
|
(`plan_code` = NexaCloud plan id/slug, prices from `price_monthly`/`price_yearly`);
|
||||||
|
`nexacloud.deployments` + `subscriptions` → one subscription `sale.order` per deployment
|
||||||
|
(NexaCloud bills per deployment).
|
||||||
|
- **Metering:** CPU-seconds → `fusion.billing.metric` `cpu_seconds` (sum) + `charge`
|
||||||
|
(included = plan quota, overage priced). Throttle-removal fee → one-off invoice
|
||||||
|
(or add-on product). `nexacloud/.../usage_metering.py` pushes counters to `/usage`.
|
||||||
|
- **Control loop:** `invoice.payment_failed` → NexaCloud suspends using its existing
|
||||||
|
`network_isolation` / `throttle_checker` / `resource_manager`; `subscription.terminated`
|
||||||
|
→ NexaCloud deprovisions.
|
||||||
|
|
||||||
|
## 10. Dual-run + migration (phased)
|
||||||
|
|
||||||
|
1. **Import** NexaCloud customers + active subscriptions into Odoo (script reads the
|
||||||
|
`nexacloud` DB → creates partners / links / subscriptions / charges).
|
||||||
|
2. **Shadow mode ≥ 1 billing cycle:** Odoo computes invoices while NexaCloud keeps
|
||||||
|
charging via its own Stripe. `fusion.billing.reconciliation` diffs Odoo-computed vs
|
||||||
|
NexaCloud-actual per customer/period; investigate every delta.
|
||||||
|
3. **Flip** when deltas are within tolerance: NexaCloud calls Odoo's API as SoR and
|
||||||
|
stops its internal Stripe billing. Past invoices stay archived (PDF / opening
|
||||||
|
balances) — not re-issued.
|
||||||
|
4. **Repeat** for NexaDesk (retire Lago for chat) → NexaMaps → then decommission
|
||||||
|
Lago VM 318.
|
||||||
|
|
||||||
|
## 11. Risks & open items
|
||||||
|
|
||||||
|
- **🟢 Stripe account unification — RESOLVED (2026-05-27).** All systems share ONE Stripe
|
||||||
|
account: **`acct_1ShlA9IkwUB1dVox`** (Nexa Systems Inc, CA, live). Verified live:
|
||||||
|
NexaCloud's direct `sk_live` key resolves to that account, and Lago has three Stripe
|
||||||
|
providers (`nexasystems`, `nexadesk`, `nexamaps`) that **all** resolve to the same
|
||||||
|
account. Therefore **no Stripe account migration is needed** — Odoo's `payment_stripe`
|
||||||
|
connects to that single account and **reuses existing Stripe customers + saved payment
|
||||||
|
methods** (map each Stripe `provider_customer_id` → `res.partner`). This removes what
|
||||||
|
was the biggest migration risk.
|
||||||
|
- **Idempotency** on usage counters is mandatory (dedupe key) to prevent double billing on retries.
|
||||||
|
- **Entitlement sync SLA:** on plan change, Odoo webhook informs the app; define how
|
||||||
|
fast app-side limits must update (and the reconciliation if a webhook is missed).
|
||||||
|
- **Odoo 19 correctness:** implementation MUST read live reference files from the
|
||||||
|
container (`docker exec odoo-nexa-app cat …`) before coding subscription/API/account
|
||||||
|
internals — never from memory (per `K:\Github\CLAUDE.md`).
|
||||||
|
- **Tax:** HST/GST per Canadian province via `account.tax`; confirm tax codes align
|
||||||
|
with current Lago `hst_on` usage.
|
||||||
|
- **Auth hardening:** API keys hashed at rest, per-service scoping, rate limiting,
|
||||||
|
request audit log; webhook secrets rotated.
|
||||||
|
|
||||||
|
## 12. Phasing — spec sequence
|
||||||
|
|
||||||
|
Each is its own spec → plan → build cycle:
|
||||||
|
|
||||||
|
1. **`fusion_centralize_billing` core** — service registry, identity links, metric/charge catalog,
|
||||||
|
usage engine, inbound API, outbound webhook engine. *(detailed below — first deliverable)*
|
||||||
|
2. **NexaCloud adapter + dual-run reconciliation** *(the pilot — coupled to #1)*
|
||||||
|
3. NexaDesk adapter (swap the Lago client for the Odoo billing client)
|
||||||
|
4. NexaMaps adapter
|
||||||
|
5. Lago decommission + memberships/services onboarding + portal polish
|
||||||
|
|
||||||
|
## 13. First-deliverable scope (sub-projects #1 + #2)
|
||||||
|
|
||||||
|
**In scope**
|
||||||
|
- `fusion_centralize_billing` module skeleton (manifest, security/ACLs + record rules, README) following the `nexa_coa_setup` layout.
|
||||||
|
- Models in §5.1; new native fields use `x_fc_*`.
|
||||||
|
- Aggregate-push usage engine (§6) incl. pre-invoice cron + idempotent upsert.
|
||||||
|
- Inbound API (§7) with bearer auth, and outbound webhook engine (§8).
|
||||||
|
- NexaCloud mapping + importer + shadow-mode reconciliation (§9, §10).
|
||||||
|
- Manifest `depends`: `sale_subscription`, `account_accountant`, `payment_stripe`,
|
||||||
|
`sale_management` (+ `nexa_coa_setup` if COA dependencies apply).
|
||||||
|
|
||||||
|
**Out of scope (YAGNI for now)**
|
||||||
|
- NexaDesk / NexaMaps adapters (specs #3/#4).
|
||||||
|
- Raw-event ingestion / per-event audit in Odoo (apps retain raw events).
|
||||||
|
- Lago decommission (spec #5) — Lago stays running until NexaDesk is migrated.
|
||||||
|
- Customer-portal redesign — use native portal as-is initially.
|
||||||
|
|
||||||
|
## 14. Success criteria (first deliverable)
|
||||||
|
|
||||||
|
- A NexaCloud deployment can be created as an Odoo subscription `sale.order` via the API,
|
||||||
|
with one `res.partner` resolving the NexaCloud user.
|
||||||
|
- CPU-seconds counters pushed to `/usage` aggregate correctly and produce a draft
|
||||||
|
invoice with quota + overage applied, taxed (HST), and charged through `payment_stripe`.
|
||||||
|
- A simulated `invoice.payment_failed` delivers a signed webhook NexaCloud can act on.
|
||||||
|
- Shadow-mode reconciliation report shows Odoo-computed vs NexaCloud-actual within
|
||||||
|
tolerance for ≥ 1 cycle before any flip.
|
||||||
|
- No double billing under usage-counter retries (idempotency verified).
|
||||||
|
|
||||||
|
## 15. Open questions for review
|
||||||
|
|
||||||
|
1. ~~Stripe: one account across all products, or separate?~~ **ANSWERED (2026-05-27):** one
|
||||||
|
account `acct_1ShlA9IkwUB1dVox` for everything (NexaCloud direct + Lago's
|
||||||
|
`nexasystems`/`nexadesk`/`nexamaps` providers). No account migration; reuse existing
|
||||||
|
Stripe customers + payment methods.
|
||||||
|
2. NexaCloud billing granularity — confirm **one subscription per deployment** (vs one per customer with deployment line items).
|
||||||
|
3. Membership model — Odoo native `membership` module, or model memberships as plain recurring subscriptions?
|
||||||
|
4. Spec/module commit target — confirm branch strategy in `Odoo-Modules` (currently on `feat/fusion-login-audit`).
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
# Sub-project #2a — NexaCloud → Odoo Billing Importer (Design)
|
||||||
|
|
||||||
|
- **Date:** 2026-05-27
|
||||||
|
- **Status:** Design approved (brainstorming session) — implementation in progress
|
||||||
|
- **Module:** `fusion_centralize_billing` (Odoo 19 Enterprise, host odoo-nexa / tested on odoo-trial)
|
||||||
|
- **Parent:** Sub-project #2 (NexaCloud adapter + dual-run reconciliation). This spec covers **chunk 2a only** — the read-only importer/backfill. 2b (usage wiring), 2c (control loop), 2d (reconciliation) are separate specs.
|
||||||
|
- **Depends on:** the core engine (sub-project #1, on `main` at `d770c0c3`): service registry, `_resolve_or_create_partner`, `fusion.billing.charge._compute_billable`, `fusion.billing.usage`, the inbound API, the webhook engine.
|
||||||
|
|
||||||
|
## 1. Goal
|
||||||
|
|
||||||
|
Backfill the **existing** NexaCloud customers, plans, and deployments into Odoo so the
|
||||||
|
central billing engine has a complete shadow copy to run dual-run reconciliation (2d)
|
||||||
|
against. The importer is a **one-time, re-runnable** migration — *not* a continuous sync.
|
||||||
|
New NexaCloud signups after the cutover already flow through the live inbound API built in
|
||||||
|
sub-project #1.
|
||||||
|
|
||||||
|
The importer must be **safe by construction**: while NexaCloud is still the live biller,
|
||||||
|
nothing the importer creates in Odoo may charge, post, or email a customer.
|
||||||
|
|
||||||
|
## 2. Decisions locked in brainstorming (2026-05-27)
|
||||||
|
|
||||||
|
1. **Per-deployment granularity.** NexaCloud's own `subscriptions` table carries
|
||||||
|
`deployment_id` + `plan_id`, so the natural mapping is **one Odoo subscription
|
||||||
|
`sale.order` per deployment**. (Confirms spec §15 Q2.)
|
||||||
|
2. **Billing model = flat plan price + metered overage.** Customers pay a fixed
|
||||||
|
monthly/yearly plan price PLUS per-unit charges for usage above the plan's quota.
|
||||||
|
(Confirms the original §6 quota+overage assumption.)
|
||||||
|
3. **CPU metric standardized to `cpu_seconds`.** The NexaCloud plan quota
|
||||||
|
(`plans.cpu_seconds_quota`) is already in seconds, so it maps to `charge.included_quota`
|
||||||
|
with no conversion. NexaCloud's CPU rate ($0.0075/core-hour) maps to
|
||||||
|
`price_per_unit = 0.0075`, `unit_batch = 3600` (one core-hour = 3600 cpu-seconds).
|
||||||
|
4. **CPU is the only metered-overage metric in v1.** It is the only resource with a plan
|
||||||
|
quota. RAM / disk / bandwidth are treated as bundled in the flat plan price for now,
|
||||||
|
addable later as more metrics if NexaCloud actually bills them as overage. (YAGNI.)
|
||||||
|
5. **Importer = Odoo-side read-only reader** (Approach A). An Odoo wizard connects
|
||||||
|
read-only to the `nexacloud` Postgres, reads its tables, and writes only into Odoo via
|
||||||
|
the existing model methods. No NexaCloud code is touched.
|
||||||
|
6. **Idempotent / re-runnable.** Every created entity is upserted on a stable key, so the
|
||||||
|
importer can run each cycle during the dual-run and update rather than duplicate.
|
||||||
|
|
||||||
|
## 3. Source data (NexaCloud, read-only)
|
||||||
|
|
||||||
|
Confirmed by reading `/Users/gurpreet/Github/Nexa-Cloud/backend/app/models`. FastAPI +
|
||||||
|
async SQLAlchemy on Postgres. Relevant tables/columns:
|
||||||
|
|
||||||
|
- **`users`** — `id` (UUID), `email`, `full_name`, `company`, `billing_email`,
|
||||||
|
`billing_address`/`_city`/`_state`/`_postal_code`/`_country`, `tax_id`,
|
||||||
|
`stripe_customer_id`.
|
||||||
|
- **`plans`** — `id` (UUID), `product_id`, `name`, `price_monthly`, `price_yearly`,
|
||||||
|
`stripe_price_id`, `cpu_seconds_quota` (BigInteger), `is_active`.
|
||||||
|
- **`deployments`** — `id` (UUID), `user_id`, `product_id`, `plan_id`, `name`, `status`,
|
||||||
|
`billing_cycle`, `next_due_date`.
|
||||||
|
- **`subscriptions`** — `id` (UUID), `user_id`, `deployment_id`, `plan_id`, `status`
|
||||||
|
(active/cancelled/past_due/trialing/paused), `billing_cycle` (monthly/yearly),
|
||||||
|
`current_period_start`, `current_period_end`, `stripe_subscription_id`.
|
||||||
|
|
||||||
|
(The `usage_records`, `invoices`, `addons` tables are out of scope for 2a — usage wiring
|
||||||
|
is 2b; reconciliation against NexaCloud invoice/usage totals is 2d.)
|
||||||
|
|
||||||
|
## 4. Data mapping
|
||||||
|
|
||||||
|
| NexaCloud (read) | Odoo (upsert) | Idempotency key |
|
||||||
|
|---|---|---|
|
||||||
|
| `users` | `res.partner` + `fusion.billing.account.link` (service=`nexacloud`, external_id=`user.id`) | `account.link (service_id, external_id)` (existing unique constraint) |
|
||||||
|
| `plans` | one subscription `product.template` (flat price) + one CPU-overage `product.product` + one `fusion.billing.charge` | `charge.plan_code = plan.id` (UUID string) |
|
||||||
|
| `subscriptions`/`deployments` | one **draft** `sale.order(is_subscription)` per deployment | `sale.order.x_fc_nexacloud_subscription_id` |
|
||||||
|
| (constant) | `fusion.billing.metric` `cpu_seconds` | `metric.code` (existing unique) |
|
||||||
|
| (constant) | `sale.subscription.plan` Monthly + Yearly recurrences | `(billing_period_value, billing_period_unit)` |
|
||||||
|
|
||||||
|
### 4.1 Identity (`users` → partner + link)
|
||||||
|
|
||||||
|
Reuse `account_link._resolve_or_create_partner(service, external_id, name, email, extra)`.
|
||||||
|
- `external_id` = `str(user.id)`, `email` = `user.billing_email or user.email`,
|
||||||
|
`name` = `user.full_name or user.company or email`.
|
||||||
|
- `extra` carries billing address fields → `res.partner` (`street`, `city`, `country_id`
|
||||||
|
resolved from the ISO/name, `vat` from `tax_id`).
|
||||||
|
- Stash `user.stripe_customer_id` on `res.partner.x_fc_stripe_customer_id` so the eventual
|
||||||
|
flip (not 2a) can reuse the existing Stripe customer instead of creating a new one.
|
||||||
|
|
||||||
|
### 4.2 Catalog (`plans` → product + charge)
|
||||||
|
|
||||||
|
For each active NexaCloud plan:
|
||||||
|
- **Subscription product** (`product.template`, `type='service'`, `recurring_invoice=True`)
|
||||||
|
named after the plan. `recurring_invoice=True` is what makes Odoo treat an order using
|
||||||
|
it as a subscription (verified pattern from the core engine's `_api_create_subscription`).
|
||||||
|
- **CPU-overage product** (`product.product`, `type='service'`) — the product the rating
|
||||||
|
math attaches the overage amount to (`charge.product_id`).
|
||||||
|
- **`fusion.billing.charge`**: `plan_code=str(plan.id)`, `metric_id=cpu_seconds`,
|
||||||
|
`product_id=`overage product, `included_quota=plan.cpu_seconds_quota`,
|
||||||
|
`price_per_unit=0.0075`, `unit_batch=3600`, `charge_model='standard'`, CAD.
|
||||||
|
**`plan_id` is left NULL on purpose** (see §6) — the hourly auto-rating cron skips
|
||||||
|
charges with no `plan_id`, so importing charges never auto-mutates shadow subscriptions.
|
||||||
|
|
||||||
|
### 4.3 Subscription (`deployment` → draft shadow sale.order)
|
||||||
|
|
||||||
|
For each deployment that has a NexaCloud subscription:
|
||||||
|
- `partner_id` = the mapped partner.
|
||||||
|
- `plan_id` = the Monthly or Yearly `sale.subscription.plan` per `subscription.billing_cycle`.
|
||||||
|
- `order_line` = one line: the plan's subscription product, qty 1, **`price_unit` set
|
||||||
|
explicitly** to `plan.price_monthly` or `plan.price_yearly` (matching the cycle). Setting
|
||||||
|
the price explicitly makes Odoo's computed amount match NexaCloud's by construction —
|
||||||
|
it does not depend on Odoo subscription-pricing internals or a pricelist.
|
||||||
|
- `x_fc_nexacloud_subscription_id` = `str(subscription.id)` (upsert key),
|
||||||
|
`x_fc_nexacloud_deployment_id` = `str(deployment.id)`,
|
||||||
|
`x_fc_billing_service_id` = the nexacloud service, `x_fc_shadow = True`.
|
||||||
|
- **Left in draft** (`action_confirm()` is NOT called). No payment token is attached.
|
||||||
|
|
||||||
|
## 5. Architecture / mechanism
|
||||||
|
|
||||||
|
A new transient model **`fusion.billing.import.wizard`** with one button, but the logic
|
||||||
|
lives in two model methods so it is unit-testable headless (the core-engine pattern —
|
||||||
|
logic in model methods, thin UI):
|
||||||
|
|
||||||
|
- **`_read_nexacloud_rows()`** — opens a **read-only `psycopg2`** connection using a DSN
|
||||||
|
from `ir.config_parameter` (`fusion_billing.nexacloud_dsn`), runs `SELECT`s, and returns
|
||||||
|
a plain dict: `{'users': [...], 'plans': [...], 'subscriptions': [...]}` (rows as dicts).
|
||||||
|
This is the *only* code that touches NexaCloud, and it only reads.
|
||||||
|
- **`_import_rows(data, dry_run=False)`** — pure Odoo writes. Consumes the dict, upserts in
|
||||||
|
FK order (metric+recurrences → partners → catalog → subscriptions), returns a summary
|
||||||
|
`{'created': {...}, 'updated': {...}, 'skipped': [...], 'failed': [...]}`. With
|
||||||
|
`dry_run=True` it computes the summary inside a rolled-back savepoint and writes nothing.
|
||||||
|
|
||||||
|
`action_run_import()` on the wizard wires them: `self._import_rows(self._read_nexacloud_rows(), dry_run=self.dry_run)`.
|
||||||
|
|
||||||
|
## 6. Shadow-mode safety (the critical property)
|
||||||
|
|
||||||
|
While NexaCloud is the live biller, the importer must not produce any customer-visible
|
||||||
|
billing in Odoo. Three independent guarantees, any one of which is sufficient:
|
||||||
|
|
||||||
|
1. **Subscriptions are imported in `draft`.** Odoo's native recurring-invoice cron only
|
||||||
|
invoices confirmed (`3_progress`) subscriptions, so draft imports are never auto-invoiced,
|
||||||
|
posted, or emailed.
|
||||||
|
2. **No payment token is imported.** Even a posted invoice could not be auto-charged,
|
||||||
|
because Odoo has no saved Stripe payment method for the partner. Charging is physically
|
||||||
|
impossible.
|
||||||
|
3. **Charges are imported with `plan_id = NULL`.** The hourly `_cron_rate_open_periods`
|
||||||
|
skips charges without a `plan_id`, so importing the catalog never mutates any order line.
|
||||||
|
|
||||||
|
`x_fc_shadow=True` marks every imported subscription for later identification. The flip
|
||||||
|
(out of scope here) is: set `charge.plan_id`, attach payment tokens, `action_confirm()`.
|
||||||
|
|
||||||
|
## 7. Error handling
|
||||||
|
|
||||||
|
- **Per-row `savepoint`** (`with self.env.cr.savepoint():`) around each entity write
|
||||||
|
(CLAUDE rule #14 — no `cr.commit()` in tests). One malformed row (missing email, unknown
|
||||||
|
plan, bad country) is recorded in `failed` with its reason and skipped; the batch
|
||||||
|
continues.
|
||||||
|
- Rows that reference an unresolved parent (subscription whose user/plan failed) are
|
||||||
|
`skipped` with a reason, not failed.
|
||||||
|
- `_read_nexacloud_rows()` raises a clear `UserError` if the DSN config param is missing or
|
||||||
|
the connection fails — the wizard surfaces it; nothing is half-written (read happens
|
||||||
|
before any write).
|
||||||
|
|
||||||
|
## 8. Testing
|
||||||
|
|
||||||
|
Split mirrors §5 so the Odoo logic is fully testable without a foreign DB:
|
||||||
|
- **`_import_rows(data)` unit tests** (`TransactionCase`, run on odoo-trial Enterprise via
|
||||||
|
`bash scripts/fcb_test_on_trial.sh`) with hand-built fixture dicts:
|
||||||
|
- partners + links created; re-run updates, does not duplicate (idempotency).
|
||||||
|
- catalog: `cpu_seconds` metric, product, and a `charge` with `included_quota` = quota,
|
||||||
|
`unit_batch=3600`, `price_per_unit=0.0075`, **`plan_id` NULL**.
|
||||||
|
- subscription: one **draft** `sale.order` per deployment, `is_subscription=True`,
|
||||||
|
`price_unit` = the cycle's NexaCloud price, `x_fc_shadow=True`, no confirm.
|
||||||
|
- shadow safety: imported subscription is `draft`/not `3_progress`; no `account.move`
|
||||||
|
is created; partner has no payment token.
|
||||||
|
- malformed rows land in `failed`/`skipped` without aborting the batch.
|
||||||
|
- `dry_run=True` writes nothing (counts only).
|
||||||
|
- The `psycopg2` read path is verified manually against the real `nexacloud` DB once
|
||||||
|
access is granted (cannot be unit-tested against a foreign DB).
|
||||||
|
|
||||||
|
## 9. Prerequisite (flagged, not blocking the build)
|
||||||
|
|
||||||
|
Odoo on nexa (VM 315) needs network reachability + a **read-only credential** to the
|
||||||
|
`nexacloud` Postgres (LXC 201), stored as `ir.config_parameter` `fusion_billing.nexacloud_dsn`.
|
||||||
|
The build and all unit tests proceed with fixtures; only the live import run is blocked
|
||||||
|
until this is granted.
|
||||||
|
|
||||||
|
## 10. Out of scope (YAGNI / later chunks)
|
||||||
|
|
||||||
|
- RAM / disk / bandwidth overage metrics (only if NexaCloud bills them — add as metrics).
|
||||||
|
- The **flip** to live billing (confirm subs, attach tokens, set `charge.plan_id`).
|
||||||
|
- Usage metering wiring (2b), control-loop webhooks (2c), reconciliation compute (2d).
|
||||||
|
- Importing historical NexaCloud invoices / `usage_records` (2d reads NexaCloud actuals).
|
||||||
|
- Add-ons (`deployment_addons`) as recurring lines — revisit if material.
|
||||||
|
|
||||||
|
> **Flip-day note (carry into 2b):** the inbound `/usage` API resolves a subscription by
|
||||||
|
> its **Odoo integer id** (`int(subscription_external_id)`), but imported shadow subs are
|
||||||
|
> keyed by NexaCloud's UUID in `x_fc_nexacloud_subscription_id`. Before NexaCloud can push
|
||||||
|
> usage (2b), decide how it learns the Odoo id (return the mapping from the importer, or
|
||||||
|
> extend the usage API to also resolve by `x_fc_nexacloud_subscription_id`). Not a 2a bug
|
||||||
|
> (2a is read-only), but it must be resolved before the flip.
|
||||||
|
|
||||||
|
## 11. Verify at implementation (do NOT code from memory — CLAUDE rule #1)
|
||||||
|
|
||||||
|
Confirm on odoo-trial Enterprise before relying on them:
|
||||||
|
- A **draft** `sale.order` with `plan_id` + a `recurring_invoice=True` product line reports
|
||||||
|
`is_subscription=True` (so `fusion.billing.usage.subscription_id`'s domain accepts it).
|
||||||
|
- `product.template.recurring_invoice` is the correct field name in this build.
|
||||||
|
- `sale.subscription.plan` fields `billing_period_value` / `billing_period_unit` (used by
|
||||||
|
the core tests) are the right find-or-create keys.
|
||||||
|
- `res.partner` country resolution field (`country_id`) and `vat` for `tax_id`.
|
||||||
|
|
||||||
|
## 12. Success criteria
|
||||||
|
|
||||||
|
- Running `_import_rows(fixture)` produces, per the mapping in §4, partners+links, a
|
||||||
|
`cpu_seconds`-based charge catalog (`plan_id` NULL), and one **draft** shadow subscription
|
||||||
|
per deployment with the correct flat `price_unit` — and re-running it changes nothing
|
||||||
|
(pure idempotency).
|
||||||
|
- No `account.move` and no payment token exist for any imported partner after an import
|
||||||
|
(shadow safety, asserted in tests).
|
||||||
|
- Full suite green on odoo-trial (`FCB_EXIT=0`); no `_sql_constraints`, no bare
|
||||||
|
`sale.subscription` model references.
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
# NexaCloud → Odoo Invoice Ledger (Design)
|
||||||
|
|
||||||
|
- **Date:** 2026-05-27
|
||||||
|
- **Status:** Design approved (brainstorming) — pending written-spec review
|
||||||
|
- **Module:** `fusion_centralize_billing` (Odoo 19 Enterprise; build/test on odoo-trial, run on `nexamain`)
|
||||||
|
- **Supersedes (for NexaCloud):** the metered-billing direction (recompute charges from a CPU-seconds model). The dual-run proved that model captures ~6% of reality.
|
||||||
|
|
||||||
|
## 1. Why this exists (the pivot)
|
||||||
|
|
||||||
|
The dual-run reconciliation (2026-05-27) showed **94% of NexaCloud's revenue is billed
|
||||||
|
outside** the per-deployment/CPU-metered model the engine was built for:
|
||||||
|
|
||||||
|
| NexaCloud invoices | count | total |
|
||||||
|
|---|---|---|
|
||||||
|
| NOT linked to a `subscriptions` row (Hosting services, add-ons) | 22 | **$2,881.08** |
|
||||||
|
| Linked to a `subscriptions` row (what the metered importer reads) | 7 | **$180.79** |
|
||||||
|
|
||||||
|
NexaCloud bills via **Stripe** — service invoices (Odoo ERP Hosting / WordPress Hosting
|
||||||
|
~$214.50/mo), **add-ons** (Daily Backup, WhatsApp, Forms Builder, White Label), and
|
||||||
|
**Stripe proration** ("Remaining time on …"). That billing already works. **Re-implementing
|
||||||
|
Stripe's proration + add-on logic in Odoo is the wrong move.** Instead, Odoo **ingests
|
||||||
|
NexaCloud's actual invoices** and becomes the single **accounting system of record**
|
||||||
|
(posted invoices + reconciled payments + HST), while NexaCloud/Stripe keep doing the billing.
|
||||||
|
|
||||||
|
## 2. Goal & scope (locked in brainstorming)
|
||||||
|
|
||||||
|
- **Full accounting SoR:** posted `account.move` customer invoices, **Stripe payments
|
||||||
|
reconciled** (invoices show paid, AR accurate), **HST** modelled.
|
||||||
|
- **All history + ongoing.** Backfill every NexaCloud invoice, then a daily cron for new ones.
|
||||||
|
- **Revenue split by service family** into distinct income accounts (P&L breakdown).
|
||||||
|
- **Draft-first rollout:** first nexamain run creates drafts for review, then bulk-post.
|
||||||
|
|
||||||
|
## 3. Architecture
|
||||||
|
|
||||||
|
A new ingestion component in `fusion_centralize_billing`, mirroring the importer's
|
||||||
|
read/write split (reuses the read-only DSN + the `account.link` partner mapping already
|
||||||
|
set up on nexamain):
|
||||||
|
|
||||||
|
- **`_read_nexacloud_invoices(since=None)`** — read-only `psycopg2`: `invoices` +
|
||||||
|
`invoice_items` (+ `users` for partner resolution), optionally since a date. Returns
|
||||||
|
plain row dicts. The only code touching NexaCloud.
|
||||||
|
- **`_ingest_invoices(data, post=False)`** — pure Odoo: for each NexaCloud invoice,
|
||||||
|
upsert one `account.move` (`move_type='out_invoice'`) with lines, tax, and (if paid) a
|
||||||
|
reconciled payment. Idempotent on `x_fc_nexacloud_invoice_id`. Returns a summary. With
|
||||||
|
`post=False` invoices are left **draft**; a separate `_post_ingested(...)` bulk-posts
|
||||||
|
after review.
|
||||||
|
- Trigger: an **`account.move`-creation wizard/action** + a daily `ir.cron` for ongoing.
|
||||||
|
|
||||||
|
## 4. Data mapping
|
||||||
|
|
||||||
|
### 4.1 Invoice → `account.move`
|
||||||
|
- `move_type='out_invoice'`, `partner_id` = unified `res.partner` (resolve `invoice.user_id`
|
||||||
|
→ `account.link` (service=nexacloud) → partner; create via the importer's resolver if missing),
|
||||||
|
`invoice_date` = NexaCloud invoice date, `ref` = `invoice_number`, `currency_id` = CAD.
|
||||||
|
- New fields (x_fc_*) on `account.move`: `x_fc_nexacloud_invoice_id` (idempotency key, unique),
|
||||||
|
`x_fc_stripe_invoice_id`.
|
||||||
|
|
||||||
|
### 4.2 `invoice_item` → `account.move.line` (one per item)
|
||||||
|
- `name` = item description, `quantity`, `price_unit`, `account_id` = the **service-family
|
||||||
|
income account** (see 4.3).
|
||||||
|
- **Tax:** derive the invoice's effective rate from `invoice.tax / invoice.subtotal`; map to
|
||||||
|
the matching Odoo `account.tax` — **HST 13%** when ≈13%, **no tax** when 0, else the closest
|
||||||
|
configured tax. Odoo's computed tax must equal NexaCloud's `invoice.tax` (assert in tests).
|
||||||
|
|
||||||
|
### 4.3 Service-family → income account (keyword mapping, with fallback)
|
||||||
|
| Family | Matches (description keywords) |
|
||||||
|
|---|---|
|
||||||
|
| **Hosting** | "Odoo ERP Hosting", "WordPress Website Hosting" |
|
||||||
|
| **Managed plans** | "Managed", "Managed Odoo - Standard", "… - Managed" |
|
||||||
|
| **Add-ons** | "Daily Backup Protection", "WhatsApp Business Messaging", "Forms Builder", "White Label Branding" |
|
||||||
|
| **Proration** | "Remaining time on …" → resolve to the family of the named item |
|
||||||
|
| **Other** (fallback) | anything unmatched → a generic NexaCloud income account (flagged in the summary for review) |
|
||||||
|
|
||||||
|
Income-account codes come from the COA (`nexa_coa_setup`); confirm/create at implementation.
|
||||||
|
|
||||||
|
### 4.4 Payment reconciliation
|
||||||
|
- For invoices with `status='paid'` (or `amount_paid >= amount_due`): register an
|
||||||
|
`account.payment` via a **"NexaCloud Stripe" bank journal**, dated `paid_at`, amount
|
||||||
|
`amount_paid`, ref = `stripe_invoice_id`; reconcile it against the posted invoice so the
|
||||||
|
invoice shows **paid** and AR clears.
|
||||||
|
- Open/unpaid invoices: post (or draft) without a payment → they sit in AR. Void invoices:
|
||||||
|
ingest as cancelled (or skip) — decide from the data at implementation.
|
||||||
|
|
||||||
|
## 5. Idempotency & ongoing sync
|
||||||
|
- Upsert on `x_fc_nexacloud_invoice_id` (a DB-unique field on `account.move`). Re-running
|
||||||
|
updates a still-draft move or skips a posted one (never duplicates, never silently mutates
|
||||||
|
a posted ledger entry — posted invoices that changed upstream are reported for manual review).
|
||||||
|
- Daily `ir.cron` calls `_read_nexacloud_invoices(since=last_run)` → `_ingest_invoices(post=True)`
|
||||||
|
for go-forward invoices (configurable auto-post once trusted).
|
||||||
|
|
||||||
|
## 6. Safety & rollout (touches the live ledger)
|
||||||
|
1. Build + **TDD on odoo-trial** (fixture invoices → assert move totals, tax = source tax,
|
||||||
|
payment reconciled, idempotency, family→account mapping).
|
||||||
|
2. **Dry-run** mode (read + report, write nothing) — like the importer.
|
||||||
|
3. First **nexamain** run: ingest **all history as DRAFT**, report a summary (counts per
|
||||||
|
family, total $, unmatched-"Other" lines, tax mismatches). **You review a sample.**
|
||||||
|
4. **Bulk-post** after approval. Then enable the daily cron.
|
||||||
|
5. **Prune the obsolete metered shadow data** first: delete the 87 draft shadow
|
||||||
|
`sale.order`s (`x_fc_shadow=True`), the ~464 `NC-*` products, the NexaCloud charges, and
|
||||||
|
the reconciliation rows — they belong to the superseded recompute approach and would
|
||||||
|
confuse the ledger.
|
||||||
|
|
||||||
|
## 7. Out of scope
|
||||||
|
- The metered recompute engine's go-live (flip, control loop, usage push) — superseded for
|
||||||
|
NexaCloud. The engine code stays in the module (potential future metered service, e.g.
|
||||||
|
NexaMaps) but is inert.
|
||||||
|
- NexaDesk / NexaMaps ledgers — separate (same ingestion pattern when needed).
|
||||||
|
- Reproducing Stripe's billing logic — explicitly NOT done; we ingest its output.
|
||||||
|
|
||||||
|
## 8. Verify at implementation (Odoo 19; never from memory)
|
||||||
|
- `account.move` / `account.move.line` / `account.payment` field names + the post flow
|
||||||
|
(`action_post`) and payment register/reconcile API (read `account` + `account_accountant`
|
||||||
|
reference on odoo-trial).
|
||||||
|
- The HST `account.tax` record + income accounts + a usable bank journal on `nexamain`
|
||||||
|
(from `nexa_coa_setup`); create the "NexaCloud Stripe" journal + family income accounts if absent.
|
||||||
|
- Whether `invoice_items.amount` is pre-tax (expected: `invoice.subtotal = Σ items`; tax separate).
|
||||||
|
|
||||||
|
## 9. Success criteria
|
||||||
|
- A fixture NexaCloud invoice ingests to a posted `account.move` whose untaxed total, tax
|
||||||
|
(= source `invoice.tax`), and total match the source; a paid one is reconciled and shows paid.
|
||||||
|
- Re-running ingests nothing new (idempotent).
|
||||||
|
- Dry-run on nexamain reports the full backfill (counts per family, $ totals, unmatched lines)
|
||||||
|
with zero writes; the real run creates drafts; bulk-post on approval.
|
||||||
|
- Full suite green on odoo-trial (`FCB_EXIT=0`).
|
||||||
|
|
||||||
|
## 10. Backfill status + go-forward caveat (2026-05-27)
|
||||||
|
|
||||||
|
- **Backfill done + verified on nexamain.** 23 customer invoices posted + payment-reconciled
|
||||||
|
($3,403.46), 1 void deleted. NexaCloud's `created_at`/`status`/`paid_at` proved
|
||||||
|
**unreliable** (sync-stamped today; one void marked otherwise), so invoice + payment dates
|
||||||
|
and paid status were verified against the **source systems**:
|
||||||
|
- **Stripe** (14 invoices, `in_*` ids) — real `created` / `paid_at` via the Stripe API.
|
||||||
|
- **Lago** (9 `NEX-*` invoices, `lago:*` ids, billed pre-Stripe) — `issuing_date` +
|
||||||
|
`payment_status=succeeded` via the Lago API (`billing.nexasystems.ca/api/api/v1`, key in
|
||||||
|
Fusion-Chat; Lago host 192.168.1.117, double-hop ssh via supabase-prod).
|
||||||
|
Partner names came from the NexaCloud `company` field (not the user's full_name).
|
||||||
|
- **GO-FORWARD: verified sync is LIVE (2026-05-27).** The verification used in the backfill
|
||||||
|
is now folded into the ingest path, and the daily cron is enabled:
|
||||||
|
- `_fc_verify(inv)` routes each invoice to its source by `stripe_invoice_id` prefix
|
||||||
|
(`in_` → Stripe REST `GET /v1/invoices/{id}`; `lago:` → Lago REST) and returns
|
||||||
|
`{invoice_date, void, draft, paid, paid_at, amount_paid}` taken from the SOURCE — or
|
||||||
|
`None` if it can't be determined/reached. Credentials live in `ir.config_parameter`:
|
||||||
|
`fusion_billing.stripe_api_key` (set, live), `fusion_billing.lago_api_url` /
|
||||||
|
`fusion_billing.lago_api_key` (optional; unset — no new Lago invoices expected).
|
||||||
|
- `_cron_sync_verified()` reads all NexaCloud invoices, skips ones already posted, then
|
||||||
|
for the rest: skips **void** and **draft** (not finalized at source), logs **unverified**
|
||||||
|
for retry next run, and ingests the rest with `_ingest_invoices(post=True, verified=…)`
|
||||||
|
so the move uses the source invoice_date (accounting date too) and a payment is
|
||||||
|
reconciled ONLY when the source confirms paid. Never acts on NexaCloud's raw fields.
|
||||||
|
- Cron `cron_fc_invoice_ledger` on nexamain: **active**, daily at 06:00 UTC. (A stale
|
||||||
|
pre-existing copy of this record still called the removed `_cron_ingest_recent`; because
|
||||||
|
the data file is `noupdate="1"` the upgrade didn't rewrite it, so its server-action code
|
||||||
|
+ name were corrected once via SQL. Fresh installs get the right definition from the XML.)
|
||||||
|
- First live run (2026-05-27): 23 already-posted, 1 void + 2 Stripe drafts + 5 genuine
|
||||||
|
$0 invoices all correctly skipped, **0 new posted**, ledger intact at $3,403.46.
|
||||||
|
- Verification helpers are unit-tested without network (routing short-circuits when no
|
||||||
|
credentials are set; the cron is exercised with `_read_nexacloud_invoices` / `_fc_verify`
|
||||||
|
patched). Full suite green on odoo-trial (`FCB_EXIT=0`).
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# Sub-project #2d — NexaCloud Dual-Run Reconciliation (Design)
|
||||||
|
|
||||||
|
- **Date:** 2026-05-27
|
||||||
|
- **Status:** Design (proceeding straight to build — approach determined by parent spec §10)
|
||||||
|
- **Module:** `fusion_centralize_billing` (Odoo 19 Enterprise; tested on odoo-trial)
|
||||||
|
- **Parent:** Sub-project #2. Depends on **2a** (the importer creates the shadow subscriptions + the `cpu_seconds` charge catalog this reconciles against).
|
||||||
|
- **Model already exists:** `fusion.billing.reconciliation` (`service_id`, `partner_id`, `period`, `odoo_amount`, `external_amount`, `delta`, `status` ∈ match/delta/resolved, `note`).
|
||||||
|
|
||||||
|
## 1. Goal
|
||||||
|
|
||||||
|
Prove, for ≥ 1 billing cycle, that Odoo's billing engine computes the **same charge** as
|
||||||
|
NexaCloud already does — per subscription, per period — before any real billing is flipped.
|
||||||
|
Read-only against NexaCloud; writes only `fusion.billing.reconciliation` rows in Odoo.
|
||||||
|
|
||||||
|
## 2. What gets compared
|
||||||
|
|
||||||
|
For each imported shadow subscription and period:
|
||||||
|
|
||||||
|
- **`external_amount`** = NexaCloud's **actual** pre-tax charge for that subscription+period
|
||||||
|
(the NexaCloud invoice **subtotal**, i.e. flat plan + its own metered overage, before HST).
|
||||||
|
- **`odoo_amount`** = what **Odoo would charge** for the same period:
|
||||||
|
`flat + overage`, where
|
||||||
|
- `flat` = the shadow subscription's plan-product line `price_unit` (the imported flat price), and
|
||||||
|
- `overage` = `charge._compute_billable(cpu_seconds)[1]` for the period's CPU usage, with
|
||||||
|
`cpu_seconds = Σ usage_records.cpu_hours × 3600` (the 2a unit convention).
|
||||||
|
- **`delta`** = `odoo_amount − external_amount`.
|
||||||
|
- **`status`** = `match` if `abs(delta) ≤ tolerance` (default $0.01, configurable), else `delta`.
|
||||||
|
|
||||||
|
Comparing **pre-tax subtotals** keeps it apples-to-apples — HST is native Odoo and not what
|
||||||
|
we're validating; the metered math + catalog mapping is.
|
||||||
|
|
||||||
|
## 3. Architecture (mirrors 2a: pure compute split from the read)
|
||||||
|
|
||||||
|
- **`_compute_reconciliation(flat_amount, charge, cpu_seconds, external_amount, tolerance)`**
|
||||||
|
→ `(odoo_amount, delta, status)`. Pure, deterministic, unit-tested with fixtures. This is
|
||||||
|
the reconciliation core.
|
||||||
|
- **`_reconcile_rows(rows, tolerance=0.01)`** — pure Odoo: for each input row
|
||||||
|
`{subscription_external_id, period, cpu_seconds, external_amount}`, resolve the shadow
|
||||||
|
`sale.order` (by `x_fc_nexacloud_subscription_id`), its `flat` (plan-line `price_unit`) and
|
||||||
|
its `charge` (by `x_fc_nexacloud_plan_id` → `charge.plan_code`), call
|
||||||
|
`_compute_reconciliation`, and **upsert** one `fusion.billing.reconciliation` row keyed by
|
||||||
|
`(service_id, partner_id, period)`. Returns a summary `{match, delta, skipped, failed}`.
|
||||||
|
- **`_read_reconciliation_rows(period=None)`** — read-only `psycopg2` (reuses the 2a DSN):
|
||||||
|
per subscription+period, `Σ usage_records.cpu_hours` and the NexaCloud invoice **subtotal**.
|
||||||
|
Integration glue (validated manually, like 2a's reader); not unit-tested against a foreign DB.
|
||||||
|
- **Trigger:** a button on the existing import wizard (**“Run Reconciliation”**) and a model
|
||||||
|
method suitable for an `ir.cron`. A non-zero `delta`/`failed` count is surfaced loudly
|
||||||
|
(banner + ERROR log), same as the importer.
|
||||||
|
|
||||||
|
## 4. 2a amendment (small, required)
|
||||||
|
|
||||||
|
Add **`x_fc_nexacloud_plan_id`** (`Char`) to `sale.order` and set it in the importer's
|
||||||
|
`_import_subscription` (from `subscription.plan_id`). Reconciliation needs sub → plan → charge,
|
||||||
|
and parsing it out of the product `default_code` would be fragile.
|
||||||
|
|
||||||
|
## 5. Idempotency / re-runnability
|
||||||
|
|
||||||
|
Reconciliation rows upsert on `(service_id, partner_id, period)`, so re-running a period
|
||||||
|
updates its row rather than duplicating — the dual-run is run every cycle.
|
||||||
|
|
||||||
|
## 6. Shadow-safety
|
||||||
|
|
||||||
|
Reconciliation is pure measurement: it reads NexaCloud and writes only
|
||||||
|
`fusion.billing.reconciliation`. It never touches subscriptions, invoices, payments, or the
|
||||||
|
charge catalog, so the 2a shadow guarantees are untouched.
|
||||||
|
|
||||||
|
## 7. Testing
|
||||||
|
|
||||||
|
`TransactionCase` on odoo-trial with fixtures:
|
||||||
|
- `_compute_reconciliation`: under-quota match; overage match; a real delta flips status to
|
||||||
|
`delta`; tolerance boundary.
|
||||||
|
- `_reconcile_rows`: creates one recon row per subscription; `match` vs `delta` set correctly;
|
||||||
|
re-run upserts (no duplicate); a row for an unknown subscription/charge lands in
|
||||||
|
`skipped`/`failed`, not a crash.
|
||||||
|
- amendment: importer sets `x_fc_nexacloud_plan_id`.
|
||||||
|
|
||||||
|
## 8. Out of scope
|
||||||
|
|
||||||
|
- The **flip** (set `charge.plan_id`, attach tokens, confirm subs) — happens once deltas are
|
||||||
|
within tolerance for ≥ 1 cycle; not automated here.
|
||||||
|
- Reading NexaCloud RAM/disk/bandwidth (CPU is the only metered-overage metric in v1, per 2a).
|
||||||
|
- A reconciliation dashboard/report view beyond the list of `fusion.billing.reconciliation`.
|
||||||
|
|
||||||
|
## 9. Success criteria
|
||||||
|
|
||||||
|
- For fixture data where Odoo's math equals NexaCloud's, every row is `match`; where it
|
||||||
|
diverges beyond tolerance, the row is `delta` with the correct signed `delta`.
|
||||||
|
- Re-running a period upserts (no duplicate rows).
|
||||||
|
- Full suite green on odoo-trial (`FCB_EXIT=0`).
|
||||||
350
docs/superpowers/specs/2026-05-27-owner-approval-flow-design.md
Normal file
350
docs/superpowers/specs/2026-05-27-owner-approval-flow-design.md
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
# Owner Approval Flow — Design Spec
|
||||||
|
|
||||||
|
**Date**: 2026-05-27
|
||||||
|
**Author**: Gurpreet (with Claude)
|
||||||
|
**Status**: Approved — ready for implementation plan
|
||||||
|
**Touches**: `fusion_helpdesk` (client / entech), `fusion_helpdesk_central` (nexa)
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Some in-app feature requests and bug reports require sign-off from a real decision-maker at the client (the "owner" — the person paying the bill, not just an Odoo Manager-by-permission). Today this happens out-of-band via WhatsApp or phone, leaving no record on the ticket and forcing Gurpreet to remember who said what to whom.
|
||||||
|
|
||||||
|
We need a structured way to loop the client's owner in on tickets that need approval, on-demand from the central support side, with a low-friction approve/reject flow for the owner and a transcript of the decision living on the ticket itself.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Central support (Gurpreet on nexa) decides *which* tickets need approval — never automatic.
|
||||||
|
- Owner approves or rejects with **one click** from their email, no login required.
|
||||||
|
- The approval decision is **publicly visible** on the ticket (per existing chatter / inbox plumbing) — both the originating employee and central support see who approved or rejected and any optional comment.
|
||||||
|
- Owner contact lives in **entech settings** (source of truth) and stays automatically fresh on nexa via piggyback on every ticket submission.
|
||||||
|
- An **AI summary** of the ticket goes in the approval email so the owner can decide in 30 seconds without reading the whole thread.
|
||||||
|
- **Single-shot reminder** if no response in N days.
|
||||||
|
- **Bulk engagement** when multiple requests need the same owner's sign-off in one batch.
|
||||||
|
- **Reporting dashboard** so Gurpreet can spot stuck approvals at a glance.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Manager-tier approvals (rejected during brainstorming — "manager" by Odoo permission ≠ business-authority owner; only owner needed).
|
||||||
|
- SLAs / hard deadlines on owner response.
|
||||||
|
- Multi-step approval chains (one owner, one decision).
|
||||||
|
- Owner-facing mobile app or portal beyond the approve / reject confirmation page — email + magic link is the entire UX.
|
||||||
|
- Auto-progressing the ticket stage on approval — Gurpreet still manually completes the work.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Module split
|
||||||
|
|
||||||
|
| Module | Role | Touches |
|
||||||
|
|---|---|---|
|
||||||
|
| `fusion_helpdesk` (entech, client) | Lets the client configure their owner contact; sends contacts upstream on every ticket | 2 ICP settings, settings view, `/fusion_helpdesk/submit` payload |
|
||||||
|
| `fusion_helpdesk_central` (nexa) | Owns the engagement flow end-to-end: storage, wizard, email, public portal, reminder cron, dashboard | New wizard model, ticket fields, mail template, public controllers, OpenAI client, reporting views |
|
||||||
|
|
||||||
|
### Data model
|
||||||
|
|
||||||
|
#### Entech (`fusion_helpdesk`)
|
||||||
|
|
||||||
|
Two new `ir.config_parameter` keys exposed in **Settings → Fusion Helpdesk → Owner Approval**:
|
||||||
|
|
||||||
|
- `fusion_helpdesk.owner_email` — Char
|
||||||
|
- `fusion_helpdesk.owner_name` — Char
|
||||||
|
|
||||||
|
`controllers/main.py::submit` piggybacks both keys on every ticket payload (alongside the existing identity keys). Both are optional — leaving them blank disables the Engage button on central for that client.
|
||||||
|
|
||||||
|
#### Central (`fusion_helpdesk_central`)
|
||||||
|
|
||||||
|
Extend existing `fusion.helpdesk.client.key` (one row per client deployment):
|
||||||
|
|
||||||
|
| Field | Type | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `owner_email` | Char | Current owner contact for this client. Upserted on every incoming ticket from the submit payload. |
|
||||||
|
| `owner_name` | Char | Display name for greeting / chatter attribution. |
|
||||||
|
|
||||||
|
Extend `helpdesk.ticket`:
|
||||||
|
|
||||||
|
| Field | Type | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `x_fc_engagement_state` | Selection (`none`/`pending`/`approved`/`rejected`) | Drives kanban badge + state pill on form. Default `none`. |
|
||||||
|
| `x_fc_engagement_email` | Char | Snapshot of owner email reached for *this* engagement. Survives later edits to `client_key.owner_email`. |
|
||||||
|
| `x_fc_engagement_name` | Char | Snapshot of owner name. |
|
||||||
|
| `x_fc_engagement_token` | Char (UUID4) | Single-use token in the magic link. Cleared on confirm. |
|
||||||
|
| `x_fc_engagement_sent_at` | Datetime | When the engagement email was first queued. |
|
||||||
|
| `x_fc_engagement_reminded_at` | Datetime, nullable | When the single reminder went out. Set by cron. |
|
||||||
|
| `x_fc_engagement_decided_at` | Datetime, nullable | When state transitioned to `approved`/`rejected`. Drives turnaround metric. |
|
||||||
|
| `x_fc_ai_summary` | Text | The brief used in the email; editable in the wizard before send; read-only after. |
|
||||||
|
| `x_fc_engagement_turnaround_hours` | Float, `store=True`, computed | `(decided_at - sent_at) / 3600`. Lets the pivot view aggregate. |
|
||||||
|
|
||||||
|
New transient model `fusion.helpdesk.engagement.wizard` — see Engagement Wizard below.
|
||||||
|
|
||||||
|
New `ir.config_parameter` keys (Helpdesk → Configuration):
|
||||||
|
|
||||||
|
- `fusion_helpdesk_central.openai_api_key` — Char, system-only readable
|
||||||
|
- `fusion_helpdesk_central.openai_model` — Char, default `gpt-4o-mini`
|
||||||
|
- `fusion_helpdesk_central.engagement_reminder_days` — Integer, default `3`; `0` disables reminders
|
||||||
|
|
||||||
|
## Engagement flow (single ticket)
|
||||||
|
|
||||||
|
1. Support opens the ticket → clicks **`Request Owner Approval`** (header button; only rendered when `x_fc_client_label` is set and `client_key.owner_email` is configured).
|
||||||
|
2. Wizard `fusion.helpdesk.engagement.wizard` opens:
|
||||||
|
- **AI Summary** textarea — auto-populated on `default_get` via one OpenAI call against `{ticket.name + html2plaintext(ticket.description) + each public chatter message}`. Editable.
|
||||||
|
- **Personal note** textarea — Gurpreet's own one-liner that prepends the email body.
|
||||||
|
- Read-only display of `owner_email` / `owner_name` resolved from `client_key`.
|
||||||
|
- **[Send]** button.
|
||||||
|
3. On send:
|
||||||
|
- `token = uuid4().hex`
|
||||||
|
- Ticket fields written: `engagement_state='pending'`, `engagement_email`, `engagement_name`, `engagement_token`, `engagement_sent_at=now`, `ai_summary`
|
||||||
|
- Mail template `mail_template_engagement` rendered → queued (`mail.mail`, `auto_delete=True`)
|
||||||
|
- Wizard closes
|
||||||
|
4. Owner receives email → reads → clicks **`Approve`** or **`Reject`** (two big buttons, each a `https://erp.nexasystems.ca/fusion_helpdesk/engagement/<token>/<decision>` URL).
|
||||||
|
5. Public controller resolves the token → renders a small standalone QWeb page (not the heavy portal layout):
|
||||||
|
- Header strip with Nexa Systems branding
|
||||||
|
- Ticket title + one-line AI summary
|
||||||
|
- Optional comment textarea
|
||||||
|
- **[Confirm Approval]** / **[Confirm Rejection]** button
|
||||||
|
- If token invalid / used / wrong state → friendly "This link has already been used or is no longer valid" page
|
||||||
|
6. On confirm:
|
||||||
|
- Resolve owner partner: find-or-create `res.partner` by email (reusing the existing `_resolve_author`-style pattern from customer replies)
|
||||||
|
- Post chatter message on ticket, attributed to that partner, subtype `mail.mt_comment` (public):
|
||||||
|
```
|
||||||
|
✓ Approved by {{ owner_name }}
|
||||||
|
<i>{{ comment }}</i> ← only if comment provided
|
||||||
|
```
|
||||||
|
- Write `engagement_state='approved'|'rejected'`, `engagement_token=False`, `engagement_decided_at=now`
|
||||||
|
- The chatter message propagates to the employee's My Tickets thread via the existing `_public_messages` filter, satisfying the "Fully visible" UX choice.
|
||||||
|
- Gurpreet receives the standard Odoo follower notification.
|
||||||
|
7. Support sees the state pill flip from amber `⏳ Awaiting approval from Kris` to green `✓ Approved by Kris`, then progresses the ticket as normal.
|
||||||
|
|
||||||
|
### Re-engagement
|
||||||
|
|
||||||
|
If Gurpreet clicks **`Request Owner Approval`** on a ticket that's already `pending` / `approved` / `rejected`, the wizard opens normally; on send it overwrites the token, snapshot fields, summary, `sent_at`, and clears `reminded_at` and `decided_at`. State resets to `pending`. Old chatter messages from prior engagements stay as audit history. Old tokens are immediately dead (the token field has changed).
|
||||||
|
|
||||||
|
### Token security
|
||||||
|
|
||||||
|
UUID4 is 122 bits of entropy — sufficient against guessing. Tokens are single-use (cleared on confirm). No date-based expiry in v1 — keep it simple; if abuse appears, add a 14-day `engagement_sent_at` cutoff in the controller.
|
||||||
|
|
||||||
|
## AI summary (OpenAI integration)
|
||||||
|
|
||||||
|
- Model: `gpt-4o-mini` (configurable via ICP). ~$0.15/1M input tokens; one call per Engage click. ~$0.01/month at 10 engagements/week.
|
||||||
|
- Transport: `urllib.request` against `https://api.openai.com/v1/chat/completions` — no new pip dependency.
|
||||||
|
- Timeout: 15 seconds. On failure → summary field renders empty + soft banner "AI summary unavailable — write a quick brief manually." Wizard remains usable.
|
||||||
|
- HTML stripping: `odoo.tools.mail.html2plaintext()` (built-in).
|
||||||
|
- Token cap: assembled prompt truncated to 8000 characters (well below context window, bounds cost on tickets with 50+ messages).
|
||||||
|
- Prompt is a Python constant (`fusion_helpdesk_central/utils.py::SUMMARY_PROMPT`) so it's editable in one place without UI churn. See Engagement Wizard for prompt text.
|
||||||
|
- **Privacy**: ticket description + chatter goes to OpenAI. Document in client onboarding. Empty API key disables the auto-fill but keeps the wizard working with a manual summary.
|
||||||
|
|
||||||
|
## Engagement Wizard (`fusion.helpdesk.engagement.wizard`)
|
||||||
|
|
||||||
|
`models.TransientModel` with:
|
||||||
|
|
||||||
|
- `ticket_id` Many2one (or `ticket_ids` for bulk — see below)
|
||||||
|
- `personal_note` Char
|
||||||
|
- `ai_summary` Text
|
||||||
|
- `owner_email_display` Char (computed, readonly)
|
||||||
|
- `owner_name_display` Char (computed, readonly)
|
||||||
|
- `is_reminder` Boolean (set by cron, not by user)
|
||||||
|
|
||||||
|
`default_get` triggers `_compute_ai_summary()` which:
|
||||||
|
|
||||||
|
1. Reads ticket name, description (`html2plaintext`), and public messages
|
||||||
|
2. Builds the prompt from `SUMMARY_PROMPT` template
|
||||||
|
3. Truncates to 8000 chars
|
||||||
|
4. POSTs to OpenAI, parses response, sets `ai_summary`
|
||||||
|
5. Catches all exceptions → logs warning, sets `ai_summary=''`
|
||||||
|
|
||||||
|
`action_send` performs all writes + queues mail and returns `{'type': 'ir.actions.act_window_close'}`.
|
||||||
|
|
||||||
|
### Summary prompt (frozen Python constant)
|
||||||
|
|
||||||
|
```
|
||||||
|
You are summarising a customer support ticket for a busy executive
|
||||||
|
who needs to decide whether to approve the work.
|
||||||
|
|
||||||
|
Output rules:
|
||||||
|
- 4–6 short bullet points, plain text (no markdown).
|
||||||
|
- First bullet: the ask, in one sentence.
|
||||||
|
- Second bullet: the business impact if approved.
|
||||||
|
- Third bullet: the business impact if NOT approved (or "none material").
|
||||||
|
- Optional bullets: cost / effort signals if any are mentioned.
|
||||||
|
- Final bullet: open questions the approver should think about.
|
||||||
|
- Do not invent facts. If the thread doesn't say, write "not stated".
|
||||||
|
- No greetings, no sign-offs, no preamble.
|
||||||
|
|
||||||
|
Ticket title: {name}
|
||||||
|
Original report:
|
||||||
|
{description_plain}
|
||||||
|
|
||||||
|
Replies so far:
|
||||||
|
{messages_plain}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Email + magic links
|
||||||
|
|
||||||
|
`mail.template` shipped in `fusion_helpdesk_central/data/mail_template_engagement.xml`.
|
||||||
|
|
||||||
|
- **From**: outgoing mail server default
|
||||||
|
- **Reply-To**: Gurpreet's email (`gs@nexasystems.ca`) — replies don't fall into the bot inbox
|
||||||
|
- **To**: `x_fc_engagement_email`
|
||||||
|
- **Subject**: `Action needed: please review request "{{ ticket.name }}"`
|
||||||
|
- **Reminder subject** (when wizard's `is_reminder=True`, set by cron): `Reminder: still waiting on your approval — "{{ ticket.name }}"`
|
||||||
|
- **Body**: branded HTML matching the existing ack template style; greeting uses `engagement_name`; includes personal note, summary, full description + chatter in a `<details>` collapsible, two big approve/reject buttons.
|
||||||
|
|
||||||
|
### Public approval portal
|
||||||
|
|
||||||
|
Routes (both `auth='public'`, `csrf=False`):
|
||||||
|
|
||||||
|
- `GET /fusion_helpdesk/engagement/<token>/<string:decision>` — renders the confirmation page (or "no longer valid" page if token / state invalid). `decision` is validated against `('approve', 'reject')`.
|
||||||
|
- `POST /fusion_helpdesk/engagement/<token>/<string:decision>` — accepts optional `comment` form field, performs the state transition + chatter post, renders a "Thanks — your decision is recorded" page.
|
||||||
|
|
||||||
|
Token resolution helper `_resolve_engagement(token, decision)` returns the ticket or raises a friendly error if anything's off. Used by both GET and POST.
|
||||||
|
|
||||||
|
## Bulk engagement
|
||||||
|
|
||||||
|
Server action on `helpdesk.ticket` list view: **`Request Owner Approval (bulk)`**.
|
||||||
|
|
||||||
|
### Validation (hard errors)
|
||||||
|
|
||||||
|
- All selected tickets share the same `x_fc_client_label` — otherwise: "Cannot bulk-engage tickets across different deployments."
|
||||||
|
- All selected tickets have `engagement_state in ('none', 'rejected')` — otherwise: "{n} of the selected tickets already have a pending or approved engagement. Engage them individually."
|
||||||
|
- `client_key.owner_email` is configured for the deployment — otherwise the standard tooltip error.
|
||||||
|
|
||||||
|
### Wizard
|
||||||
|
|
||||||
|
Same `fusion.helpdesk.engagement.wizard` model gains a `ticket_ids` Many2many to `helpdesk.ticket` (single-ticket mode keeps using `ticket_id`; the wizard checks which is set and branches). Per-ticket AI summaries generated **in parallel** via `concurrent.futures.ThreadPoolExecutor(max_workers=5)` with a 30-second overall timeout. Each per-ticket summary is editable in its own row in the wizard view via a child transient model `fusion.helpdesk.engagement.wizard.line` (fields: `wizard_id`, `ticket_id`, `ai_summary`).
|
||||||
|
|
||||||
|
### Email
|
||||||
|
|
||||||
|
A single combined email with one card per ticket. Each card has its own `[Approve][Reject]` buttons, each pointing at that ticket's unique token. Owner can decide per-ticket, ignore some, come back to the same email later (links stay live until clicked or re-engaged).
|
||||||
|
|
||||||
|
### Layout (rendered HTML)
|
||||||
|
|
||||||
|
```
|
||||||
|
Hi Kris,
|
||||||
|
|
||||||
|
5 requests from ENTECH need your sign-off. Each can be approved or
|
||||||
|
rejected independently — clicking a button on one card only acts on
|
||||||
|
that card.
|
||||||
|
|
||||||
|
──── Request 1 of 5 ──────────────────────────────
|
||||||
|
"Drag and drop steps"
|
||||||
|
• <summary bullets>
|
||||||
|
[✓ Approve] [✗ Reject]
|
||||||
|
|
||||||
|
──── Request 2 of 5 ──────────────────────────────
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reminder cron
|
||||||
|
|
||||||
|
`ir.cron`, daily at 09:00, sudo:
|
||||||
|
|
||||||
|
```python
|
||||||
|
N = int(ICP.get_param('fusion_helpdesk_central.engagement_reminder_days') or 3)
|
||||||
|
if N <= 0:
|
||||||
|
return # disabled
|
||||||
|
cutoff = fields.Datetime.now() - timedelta(days=N)
|
||||||
|
to_remind = self.env['helpdesk.ticket'].search([
|
||||||
|
('x_fc_engagement_state', '=', 'pending'),
|
||||||
|
('x_fc_engagement_sent_at', '<=', cutoff),
|
||||||
|
('x_fc_engagement_reminded_at', '=', False),
|
||||||
|
])
|
||||||
|
for ticket in to_remind:
|
||||||
|
template.with_context(is_reminder=True).send_mail(
|
||||||
|
ticket.id, force_send=False)
|
||||||
|
ticket.x_fc_engagement_reminded_at = fields.Datetime.now()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Single-shot by design** — no second reminder. If still no response after one nudge, the right action is human (call the owner), not another email.
|
||||||
|
|
||||||
|
Same token, same magic links — the owner can click either the original or the reminder email.
|
||||||
|
|
||||||
|
## Reporting dashboard
|
||||||
|
|
||||||
|
Menu: **Helpdesk → Reporting → Owner Engagements** (new entry, after Tickets Analysis).
|
||||||
|
|
||||||
|
Action opens four views over `helpdesk.ticket` filtered by `('x_fc_engagement_state', '!=', 'none')`:
|
||||||
|
|
||||||
|
1. **Pivot** (default): rows = `x_fc_client_label`, columns = `x_fc_engagement_state`, measures = count + avg `x_fc_engagement_turnaround_hours`
|
||||||
|
2. **Graph (bar)**: engagement count over time grouped by `x_fc_client_label`
|
||||||
|
3. **List**: ticket_ref, client, owner name/email, state, sent_at, reminded_at, decided_at, turnaround_hours
|
||||||
|
4. **Kanban (default group by state)**: at-a-glance count per state
|
||||||
|
|
||||||
|
Filters: by client, by state, by date range. Canned filter "Pending > 7 days" highlights stuck approvals.
|
||||||
|
|
||||||
|
No new model; everything is derived from `helpdesk.ticket`. The stored computed field `x_fc_engagement_turnaround_hours` makes the pivot fast on large datasets.
|
||||||
|
|
||||||
|
## UI changes
|
||||||
|
|
||||||
|
### Helpdesk ticket form (nexa)
|
||||||
|
|
||||||
|
- New header button **`Request Owner Approval`** (visible iff `x_fc_client_label` set AND `client_key.owner_email` set; tooltip on disabled state explains why)
|
||||||
|
- State pill right of the title:
|
||||||
|
- `none` → no pill
|
||||||
|
- `pending` → amber `⏳ Awaiting approval from {{ engagement_name }}`
|
||||||
|
- `approved` → green `✓ Approved by {{ engagement_name }}`
|
||||||
|
- `rejected` → red `✗ Rejected by {{ engagement_name }}`
|
||||||
|
- New collapsible group **`Owner Engagement`** showing `ai_summary` (read-only after send), `engagement_email`, `engagement_name`, `engagement_sent_at`, `engagement_reminded_at`, `engagement_decided_at`, `engagement_turnaround_hours`
|
||||||
|
|
||||||
|
### Helpdesk ticket kanban (nexa)
|
||||||
|
|
||||||
|
Amber corner dot when `engagement_state == 'pending'` — surfaces blockers in the kanban view without opening each card.
|
||||||
|
|
||||||
|
### Entech settings UI
|
||||||
|
|
||||||
|
New section **Owner Approval** under existing Fusion Helpdesk group:
|
||||||
|
|
||||||
|
- `Owner email` text input
|
||||||
|
- `Owner name` text input
|
||||||
|
- Help text: "Used when Nexa Systems support requests approval for a feature or bug fix that needs sign-off. Leave blank if your deployment doesn't require approvals."
|
||||||
|
|
||||||
|
## Edge cases
|
||||||
|
|
||||||
|
| Case | Behaviour |
|
||||||
|
|---|---|
|
||||||
|
| Owner contact not configured on entech | `Request Owner Approval` button disabled, tooltip: "Owner contact not configured for this client. Ask them to fill it in under Settings → Fusion Helpdesk." |
|
||||||
|
| Token reused after first click | Friendly "This approval link has already been used or is no longer valid" page with a `mailto:support@nexasystems.ca` link. |
|
||||||
|
| Owner gets re-engaged | New token replaces old; old immediately invalid. State resets to `pending`. Old chatter is preserved. `reminded_at` / `decided_at` cleared. |
|
||||||
|
| OpenAI down / no API key | Wizard opens with empty summary + soft banner; you type your own brief, send normally. |
|
||||||
|
| Owner replies to the email instead of clicking | Mail gateway treats it as a regular comment (existing flow). State stays `pending` until they click a magic link. |
|
||||||
|
| Employee files a follow-up while owner is deciding | Reply lands in chatter normally; owner sees it next time they reload, but their engagement is tied to the snapshot AI summary (intentional — owner judges a stable artifact). |
|
||||||
|
| Bulk action selects tickets across clients | Hard error before wizard opens. |
|
||||||
|
| Bulk action selects tickets that already have pending engagements | Hard error specifying the count of disallowed tickets. |
|
||||||
|
| Approved ticket needs to be "reversed" | No undo button. Re-engage with a fresh wizard → new summary → re-send. Audit chain stays in chatter. |
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Pure helpers in `fusion_helpdesk_central/utils.py` (new file):
|
||||||
|
|
||||||
|
- `build_summary_prompt(ticket_dict, messages)` → str
|
||||||
|
- `truncate_for_openai(prompt, max_chars=8000)` → str
|
||||||
|
- `format_engagement_chatter(decision, owner_name, comment)` → Markup
|
||||||
|
|
||||||
|
`fusion_helpdesk_central/tests/test_utils.py`:
|
||||||
|
|
||||||
|
- Prompt structure (correct ordering, all fields present, empty-thread fallback)
|
||||||
|
- Truncation (preserves the prefix and ticket title)
|
||||||
|
- Chatter formatting (approve / reject / with-comment / without-comment)
|
||||||
|
|
||||||
|
`fusion_helpdesk_central/tests/test_engagement.py`:
|
||||||
|
|
||||||
|
- Token generation is unique per call
|
||||||
|
- Wizard `action_send` writes all expected fields, queues mail, returns close action
|
||||||
|
- Re-engagement clears the old token + decided_at + reminded_at, resets state to `pending`
|
||||||
|
- Public controller rejects invalid / used / wrong-decision tokens with friendly error
|
||||||
|
- Public controller `POST` confirms decision, posts chatter, writes state
|
||||||
|
- State transitions are correctly one-way (approved → approved is no-op, approved → re-engaged → pending works)
|
||||||
|
- Bulk wizard rejects mixed-client selection
|
||||||
|
- Bulk wizard rejects already-pending tickets in selection
|
||||||
|
- Reminder cron only acts on rows past cutoff and not already reminded
|
||||||
|
- Computed `turnaround_hours` matches expected delta after decision
|
||||||
|
|
||||||
|
OpenAI is mocked in tests — no live API calls in CI.
|
||||||
|
|
||||||
|
## Versions
|
||||||
|
|
||||||
|
- `fusion_helpdesk` → bump to `19.0.2.0.0` (minor feature, new settings)
|
||||||
|
- `fusion_helpdesk_central` → bump to `19.0.2.0.0` (major feature, multiple new fields + wizard + controllers + cron + reporting)
|
||||||
|
|
||||||
|
## Deployment order
|
||||||
|
|
||||||
|
1. Deploy `fusion_helpdesk_central` first (it owns the storage, the wizard, the email template, the public routes, the cron, the reporting). It can sit dormant — no Engage button is reachable until `client_key.owner_email` is populated.
|
||||||
|
2. Deploy `fusion_helpdesk` second (adds the entech settings + payload piggyback). First ticket filed after this deploy populates `client_key.owner_email` on central.
|
||||||
|
3. Backfill: for any client that already has owner contact info known to Gurpreet (e.g., entech → kris@enplating.ca), edit the `client_key` row directly on nexa via the existing config UI. Or simply wait — the next ticket from that client will populate it.
|
||||||
File diff suppressed because it is too large
Load Diff
7
fusion-plating/.claude/settings.local.json
Normal file
7
fusion-plating/.claude/settings.local.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(ls /k/Github/Odoo-Modules/ | grep -i -E \"shopfloor|tablet|fusion_plating\")"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,969 @@
|
|||||||
|
# Step Qty Gate, Partial-Qty Handling, and Job Display Rename — Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Add a quantity gate on `fp.job.step.button_finish` (with last-step exemption), introduce a per-row `Complete 1 → Next` action for streaming flow, add an auto-move shim on Finish & Next for the 1-of-1 case, and override `fp.job.display_name` so jobs render as `Work Order # 00011` instead of `WH/JOB/00011`.
|
||||||
|
|
||||||
|
**Architecture:** Five small Python changes (one compute + one gate + one action + one helper + manager-bypass keys) on `fp.job` and `fp.job.step`, plus two view edits (form `<h1>` and embedded step list row button). Move wizard's existing zero-qty + over-qty guards stay; one regression test added for them. All changes deploy on entech, sync back to the local repo as the final task.
|
||||||
|
|
||||||
|
**Tech Stack:** Odoo 19, PostgreSQL. No new dependencies.
|
||||||
|
|
||||||
|
**Spec:** [`docs/superpowers/specs/2026-05-12-step-qty-gate-and-display-rename-design.md`](../specs/2026-05-12-step-qty-gate-and-display-rename-design.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deployment conventions
|
||||||
|
|
||||||
|
Same pattern as the milestone-cascade plan that just shipped:
|
||||||
|
|
||||||
|
- File paths are **entech container paths** (`/mnt/extra-addons/custom/...`).
|
||||||
|
- Edits go via base64-encoded Python patch scripts:
|
||||||
|
```bash
|
||||||
|
B64=$(base64 -w0 path/to/_patch.py)
|
||||||
|
ssh pve-worker5 "pct exec 111 -- bash -c \"echo $B64 | base64 -d > /tmp/_patch.py && python3 /tmp/_patch.py\""
|
||||||
|
```
|
||||||
|
- After each Python change: manifest version bump, then upgrade module:
|
||||||
|
```bash
|
||||||
|
ssh pve-worker5 "pct exec 111 -- bash -c \"systemctl stop odoo && \
|
||||||
|
su - odoo -s /bin/bash -c '/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u <module> --stop-after-init' 2>&1 | tail -5 && \
|
||||||
|
systemctl start odoo && systemctl is-active odoo\""
|
||||||
|
```
|
||||||
|
- Tests via:
|
||||||
|
```bash
|
||||||
|
ssh pve-worker5 "pct exec 111 -- bash -c \"systemctl stop odoo && \
|
||||||
|
su - odoo -s /bin/bash -c '/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating_jobs --test-enable --test-tags /fusion_plating_jobs --stop-after-init' 2>&1 | grep -E 'TestQtyGate.*(FAIL|ERROR|Starting)' | head -30 && \
|
||||||
|
systemctl start odoo\""
|
||||||
|
```
|
||||||
|
- Backups: `cp <file> /tmp/<basename>.bak` before the first patch of any file.
|
||||||
|
- No git commits during tasks. Final task (Task 7) syncs touched files back to `K:/Github/Odoo-Modules/` and commits there.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File structure
|
||||||
|
|
||||||
|
| File | Type | Responsibility |
|
||||||
|
|---|---|---|
|
||||||
|
| `fusion_plating_jobs/models/fp_job.py` | modify | Add `_compute_display_name` override (renames `WH/JOB/00011` → `Work Order # 00011`). |
|
||||||
|
| `fusion_plating/models/fp_job_step.py` | modify | Quantity gate in `button_finish`; new `action_complete_one_to_next`; new helper `_fp_record_one_piece_auto_move`; wire the helper into `action_finish_and_advance`. |
|
||||||
|
| `fusion_plating_jobs/views/fp_job_form_inherit.xml` | modify | `<h1>` binds `display_name`; per-row "Complete 1 → Next" button. |
|
||||||
|
| `fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py` | modify | Append `TestQtyGate` class with 14 tests. |
|
||||||
|
| `fusion_plating/__manifest__.py` | modify | Version bump. |
|
||||||
|
| `fusion_plating_jobs/__manifest__.py` | modify | Version bump. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Display rename — `Work Order # 00011`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/models/fp_job.py`
|
||||||
|
- Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/views/fp_job_form_inherit.xml`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Backup files**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh pve-worker5 "pct exec 111 -- bash -c 'cp /mnt/extra-addons/custom/fusion_plating_jobs/models/fp_job.py /tmp/fp_job_t1.py.bak && cp /mnt/extra-addons/custom/fusion_plating_jobs/views/fp_job_form_inherit.xml /tmp/fp_job_form_inherit_t1.xml.bak'"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add `_compute_display_name` to `fp.job`**
|
||||||
|
|
||||||
|
Locate the existing class declaration in `fp_job.py` (around the first `class FpJob(models.Model)` line, then the `_inherit = 'fp.job'` block). Find the existing `name` field declaration (around line 62 — `name = fields.Char(...)`). Add the new compute method immediately after the existing field declarations on the class (any spot inside the class body before existing `@api.depends` methods is fine; convention is to put it near the field it depends on).
|
||||||
|
|
||||||
|
Insert:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@api.depends('name')
|
||||||
|
def _compute_display_name(self):
|
||||||
|
"""Reformat 'WH/JOB/00011' → 'Work Order # 00011' for every
|
||||||
|
human-facing surface (form header, breadcrumbs, M2O dropdowns,
|
||||||
|
smart-button titles, error messages). The DB `name` is unchanged
|
||||||
|
so existing certs / deliveries / chatter references don't break.
|
||||||
|
"""
|
||||||
|
for job in self:
|
||||||
|
if job.name and '/' in job.name:
|
||||||
|
suffix = job.name.rsplit('/', 1)[-1]
|
||||||
|
job.display_name = _('Work Order # %s') % suffix
|
||||||
|
else:
|
||||||
|
job.display_name = job.name or ''
|
||||||
|
```
|
||||||
|
|
||||||
|
Use a patch script with anchor-based string replacement. The anchor should be unique enough to find exactly one insertion site — pick a stable nearby field declaration (e.g. the `state` field's closing `)` if it's unique).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Bind `display_name` in the form header**
|
||||||
|
|
||||||
|
In `fp_job_form_inherit.xml`, find the `<h1>` block in the sheet header that currently binds `name`:
|
||||||
|
|
||||||
|
Search anchor:
|
||||||
|
```xml
|
||||||
|
<h1><field name="name"/></h1>
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
```xml
|
||||||
|
<h1><field name="display_name"/></h1>
|
||||||
|
```
|
||||||
|
|
||||||
|
If the file uses a slightly different markup (e.g. with extra attributes like `class=...` or `readonly=...`), keep those attributes and just change `name="name"` to `name="display_name"`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Bump fusion_plating_jobs manifest version**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh pve-worker5 "pct exec 111 -- bash -c \"CUR=\\\$(grep \\\"'version':\\\" /mnt/extra-addons/custom/fusion_plating_jobs/__manifest__.py | head -1 | grep -oP '\\\\d+\\\\.\\\\d+\\\\.\\\\d+\\\\.\\\\d+\\\\.\\\\d+') && echo \\\"current: \\\$CUR\\\"\""
|
||||||
|
```
|
||||||
|
|
||||||
|
Bump the last component (`19.0.8.19.6` → `19.0.8.19.7`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh pve-worker5 "pct exec 111 -- bash -c \"sed -i \\\"s/'version': '19.0.8.19.6'/'version': '19.0.8.19.7'/\\\" /mnt/extra-addons/custom/fusion_plating_jobs/__manifest__.py\""
|
||||||
|
```
|
||||||
|
|
||||||
|
(If the current version is different from `19.0.8.19.6` because Phase 1 work iterated more, substitute the actual current version.)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Validate Python + XML syntax**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh pve-worker5 "pct exec 111 -- bash -c \"python3 -c 'import ast; ast.parse(open(\\\"/mnt/extra-addons/custom/fusion_plating_jobs/models/fp_job.py\\\").read()); print(\\\"py OK\\\")' && python3 -c 'import xml.etree.ElementTree as ET; ET.parse(\\\"/mnt/extra-addons/custom/fusion_plating_jobs/views/fp_job_form_inherit.xml\\\"); print(\\\"xml OK\\\")'\""
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `py OK` and `xml OK`.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Upgrade fusion_plating_jobs**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh pve-worker5 "pct exec 111 -- bash -c \"systemctl stop odoo && su - odoo -s /bin/bash -c '/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating_jobs --stop-after-init' 2>&1 | tail -5 && systemctl start odoo && systemctl is-active odoo\""
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `Modules loaded`, `Registry loaded`, then `active`.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Verify display_name renders correctly via odoo shell**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SCRIPT='job = env["fp.job"].search([("name", "like", "WH/JOB/")], limit=1)
|
||||||
|
print(">>> name=", job.name)
|
||||||
|
print(">>> display_name=", job.display_name)'
|
||||||
|
B64=$(echo -n "$SCRIPT" | base64 -w0)
|
||||||
|
ssh pve-worker5 "pct exec 111 -- bash -c \"echo $B64 | base64 -d > /tmp/check.py && su - odoo -s /bin/bash -c '/usr/bin/odoo shell -c /etc/odoo/odoo.conf -d admin --no-http < /tmp/check.py' 2>&1 | grep '>>>'\""
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
```
|
||||||
|
>>> name= WH/JOB/00011
|
||||||
|
>>> display_name= Work Order # 00011
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Quantity gate on `button_finish`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/mnt/extra-addons/custom/fusion_plating/models/fp_job_step.py`
|
||||||
|
- Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Backup**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh pve-worker5 "pct exec 111 -- bash -c 'cp /mnt/extra-addons/custom/fusion_plating/models/fp_job_step.py /tmp/fp_job_step_t2.py.bak && cp /mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py /tmp/test_fp_job_milestone_cascade_t2.py.bak'"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add quantity gate to `button_finish`**
|
||||||
|
|
||||||
|
Find the existing method in `fp_job_step.py` (around line 385). The current opening looks like:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def button_finish(self):
|
||||||
|
for step in self:
|
||||||
|
if step.state != 'in_progress':
|
||||||
|
raise UserError(_(
|
||||||
|
"Step '%s' is in state '%s' — only in-progress steps can finish."
|
||||||
|
) % (step.name, step.state))
|
||||||
|
now = fields.Datetime.now()
|
||||||
|
# Close the open timelog (the one with no date_finished)
|
||||||
|
open_log = step.time_log_ids.filtered(lambda l: not l.date_finished)
|
||||||
|
```
|
||||||
|
|
||||||
|
Use a patch script to inject the quantity gate immediately after the existing `state != 'in_progress'` check. New text:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def button_finish(self):
|
||||||
|
skip_qty_gate = self.env.context.get('fp_skip_qty_gate')
|
||||||
|
for step in self:
|
||||||
|
if step.state != 'in_progress':
|
||||||
|
raise UserError(_(
|
||||||
|
"Step '%s' is in state '%s' — only in-progress steps can finish."
|
||||||
|
) % (step.name, step.state))
|
||||||
|
# Quantity gate: refuses if parts still parked AND there's a
|
||||||
|
# downstream step to move them to. Last runnable step is
|
||||||
|
# exempt — parts finishing there complete in place.
|
||||||
|
if not skip_qty_gate and step.qty_at_step > 0:
|
||||||
|
has_downstream = step.job_id.step_ids.filtered(
|
||||||
|
lambda s: s.sequence > step.sequence
|
||||||
|
and s.state in ('pending', 'ready')
|
||||||
|
)
|
||||||
|
if has_downstream:
|
||||||
|
raise UserError(_(
|
||||||
|
"Step '%(name)s' still has %(n)d part(s) parked "
|
||||||
|
"— move them to the next step before finishing. "
|
||||||
|
"Use the row's 'Complete 1 → Next' or 'Move…' "
|
||||||
|
"button."
|
||||||
|
) % {'name': step.name, 'n': step.qty_at_step})
|
||||||
|
now = fields.Datetime.now()
|
||||||
|
# Close the open timelog (the one with no date_finished)
|
||||||
|
open_log = step.time_log_ids.filtered(lambda l: not l.date_finished)
|
||||||
|
```
|
||||||
|
|
||||||
|
Patch script uses the existing method-opening anchor (`def button_finish(self):\n for step in self:\n if step.state != 'in_progress':`) and replaces with the new opening.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add `TestQtyGate` test class skeleton + 3 gate tests**
|
||||||
|
|
||||||
|
Append to `test_fp_job_milestone_cascade.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
|
||||||
|
|
||||||
|
class TestQtyGate(TransactionCase):
|
||||||
|
"""Step-level quantity gate + partial-qty handling.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- button_finish blocks when qty_at_step > 0 AND downstream
|
||||||
|
steps exist (mid-recipe)
|
||||||
|
- manager bypass via fp_skip_qty_gate=True
|
||||||
|
- last-runnable-step exemption (qty_at_step > 0 allowed)
|
||||||
|
- action_complete_one_to_next (Task 3)
|
||||||
|
- auto-move shim on action_finish_and_advance (Task 4)
|
||||||
|
- display_name rename (Task 1)
|
||||||
|
"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
super().setUpClass()
|
||||||
|
cls.partner = cls.env['res.partner'].create({'name': 'QtyCust'})
|
||||||
|
cls.product = cls.env['product.product'].create({
|
||||||
|
'name': 'QtyWidget',
|
||||||
|
})
|
||||||
|
|
||||||
|
def _make_job(self, qty=3, **kw):
|
||||||
|
vals = {
|
||||||
|
'partner_id': self.partner.id,
|
||||||
|
'product_id': self.product.id,
|
||||||
|
'qty': qty,
|
||||||
|
}
|
||||||
|
vals.update(kw)
|
||||||
|
return self.env['fp.job'].create(vals)
|
||||||
|
|
||||||
|
def _make_step(self, job, name='Step', sequence=10, state='pending'):
|
||||||
|
return self.env['fp.job.step'].create({
|
||||||
|
'job_id': job.id,
|
||||||
|
'name': name,
|
||||||
|
'sequence': sequence,
|
||||||
|
'state': state,
|
||||||
|
})
|
||||||
|
|
||||||
|
def _make_two_step_chain(self, qty=3):
|
||||||
|
"""Create a job with two steps; the first is in_progress
|
||||||
|
with `qty` parts parked, the second is ready. Returns
|
||||||
|
(job, step1, step2)."""
|
||||||
|
job = self._make_job(qty=qty)
|
||||||
|
step1 = self._make_step(
|
||||||
|
job, name='Plate', sequence=10, state='in_progress',
|
||||||
|
)
|
||||||
|
step2 = self._make_step(
|
||||||
|
job, name='Bake', sequence=20, state='ready',
|
||||||
|
)
|
||||||
|
# date_started required by button_finish's timelog close
|
||||||
|
step1.date_started = fields.Datetime.now()
|
||||||
|
return job, step1, step2
|
||||||
|
|
||||||
|
# ---------------- button_finish gate ----------------------------
|
||||||
|
|
||||||
|
def test_button_finish_blocks_when_qty_at_step(self):
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
job, step1, step2 = self._make_two_step_chain(qty=3)
|
||||||
|
# First-step seed gives step1 qty_at_step = job.qty = 3
|
||||||
|
step1.invalidate_recordset(['qty_at_step'])
|
||||||
|
self.assertEqual(step1.qty_at_step, 3)
|
||||||
|
with self.assertRaises(UserError) as exc:
|
||||||
|
step1.button_finish()
|
||||||
|
self.assertIn('parts parked', str(exc.exception))
|
||||||
|
|
||||||
|
def test_button_finish_bypass(self):
|
||||||
|
job, step1, step2 = self._make_two_step_chain(qty=3)
|
||||||
|
step1.invalidate_recordset(['qty_at_step'])
|
||||||
|
step1.with_context(fp_skip_qty_gate=True).button_finish()
|
||||||
|
self.assertEqual(step1.state, 'done')
|
||||||
|
|
||||||
|
def test_button_finish_allows_last_step_with_qty(self):
|
||||||
|
"""Last runnable step is exempt — parts complete in place."""
|
||||||
|
job = self._make_job(qty=5)
|
||||||
|
last = self._make_step(
|
||||||
|
job, name='FinalInspect', sequence=10, state='in_progress',
|
||||||
|
)
|
||||||
|
last.date_started = fields.Datetime.now()
|
||||||
|
last.invalidate_recordset(['qty_at_step'])
|
||||||
|
self.assertEqual(last.qty_at_step, 5) # first-step seed
|
||||||
|
# No downstream step → gate exempt
|
||||||
|
last.button_finish()
|
||||||
|
self.assertEqual(last.state, 'done')
|
||||||
|
|
||||||
|
def test_button_finish_passes_when_qty_zero(self):
|
||||||
|
"""qty_at_step==0 (already moved out manually) → no gate fires."""
|
||||||
|
job, step1, step2 = self._make_two_step_chain(qty=2)
|
||||||
|
# Move all parts out so step1.qty_at_step = 0
|
||||||
|
self.env['fp.job.step.move'].create({
|
||||||
|
'job_id': job.id,
|
||||||
|
'from_step_id': step1.id,
|
||||||
|
'to_step_id': step2.id,
|
||||||
|
'transfer_type': 'step',
|
||||||
|
'qty_moved': 2,
|
||||||
|
'moved_by_user_id': self.env.user.id,
|
||||||
|
})
|
||||||
|
step1.invalidate_recordset(['qty_at_step'])
|
||||||
|
self.assertEqual(step1.qty_at_step, 0)
|
||||||
|
step1.button_finish()
|
||||||
|
self.assertEqual(step1.state, 'done')
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Bump fusion_plating manifest version**
|
||||||
|
|
||||||
|
Find current version, bump the last component:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh pve-worker5 "pct exec 111 -- bash -c \"grep \\\"'version':\\\" /mnt/extra-addons/custom/fusion_plating/__manifest__.py | head -1\""
|
||||||
|
```
|
||||||
|
|
||||||
|
Then bump (assuming current is `19.0.18.14.12`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh pve-worker5 "pct exec 111 -- bash -c \"sed -i \\\"s/'version': '19.0.18.14.12'/'version': '19.0.18.14.13'/\\\" /mnt/extra-addons/custom/fusion_plating/__manifest__.py\""
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Validate Python**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh pve-worker5 "pct exec 111 -- bash -c \"python3 -c 'import ast; ast.parse(open(\\\"/mnt/extra-addons/custom/fusion_plating/models/fp_job_step.py\\\").read()); ast.parse(open(\\\"/mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py\\\").read()); print(\\\"OK\\\")'\""
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `OK`.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Upgrade fusion_plating + fusion_plating_jobs with tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh pve-worker5 "pct exec 111 -- bash -c \"systemctl stop odoo && su - odoo -s /bin/bash -c '/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating,fusion_plating_jobs --test-enable --test-tags /fusion_plating_jobs --stop-after-init' 2>&1 | grep -E 'TestQtyGate.*(FAIL|ERROR|Starting)' | head -30 && systemctl start odoo\""
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 4 `Starting TestQtyGate.test_button_finish_*` lines, no FAIL or ERROR lines for TestQtyGate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: `action_complete_one_to_next`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/mnt/extra-addons/custom/fusion_plating/models/fp_job_step.py`
|
||||||
|
- Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `action_complete_one_to_next` method**
|
||||||
|
|
||||||
|
Append the new method to `fp_job_step.py` at the end of the `FpJobStep` class (after `button_manager_reset_to_ready` from the milestone-cascade Phase 1 work, since both are recent additions and group together). Patch via append-or-anchor-replace.
|
||||||
|
|
||||||
|
Code:
|
||||||
|
|
||||||
|
```python
|
||||||
|
|
||||||
|
def action_complete_one_to_next(self):
|
||||||
|
"""One-piece flow shortcut: records move(qty=1) from this step
|
||||||
|
to the next pending/ready step, drains qty_at_step by 1. If
|
||||||
|
the drain takes qty_at_step to 0, auto-finishes the source
|
||||||
|
and starts the destination step (delegates to
|
||||||
|
action_finish_and_advance)."""
|
||||||
|
self.ensure_one()
|
||||||
|
if self.state != 'in_progress':
|
||||||
|
raise UserError(_(
|
||||||
|
"Step '%s' must be in progress to complete a part."
|
||||||
|
) % self.name)
|
||||||
|
if self.qty_at_step < 1:
|
||||||
|
raise UserError(_(
|
||||||
|
"No parts parked at step '%s' — nothing to complete."
|
||||||
|
) % self.name)
|
||||||
|
next_step = self.job_id.step_ids.filtered(
|
||||||
|
lambda s: s.sequence > self.sequence
|
||||||
|
and s.state in ('pending', 'ready')
|
||||||
|
).sorted('sequence')[:1]
|
||||||
|
if not next_step:
|
||||||
|
raise UserError(_(
|
||||||
|
"Step '%s' is the last runnable step on the job — "
|
||||||
|
"no downstream step to move into. Finish the step "
|
||||||
|
"instead (it will close out the job)."
|
||||||
|
) % self.name)
|
||||||
|
self.env['fp.job.step.move'].create({
|
||||||
|
'job_id': self.job_id.id,
|
||||||
|
'from_step_id': self.id,
|
||||||
|
'to_step_id': next_step.id,
|
||||||
|
'transfer_type': 'step',
|
||||||
|
'qty_moved': 1,
|
||||||
|
'moved_by_user_id': self.env.user.id,
|
||||||
|
})
|
||||||
|
# qty_at_step is computed from moves; force re-read before
|
||||||
|
# checking whether this was the last part. Without invalidate
|
||||||
|
# the cache still says "still 1 parked" and auto-finish never
|
||||||
|
# fires.
|
||||||
|
self.invalidate_recordset(['qty_at_step'])
|
||||||
|
if self.qty_at_step == 0:
|
||||||
|
return self.action_finish_and_advance()
|
||||||
|
return True
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add 4 tests for `action_complete_one_to_next`**
|
||||||
|
|
||||||
|
Append to `TestQtyGate` class:
|
||||||
|
|
||||||
|
```python
|
||||||
|
|
||||||
|
# ---------------- action_complete_one_to_next -------------------
|
||||||
|
|
||||||
|
def test_complete_one_to_next_records_move(self):
|
||||||
|
job, step1, step2 = self._make_two_step_chain(qty=3)
|
||||||
|
step1.invalidate_recordset(['qty_at_step'])
|
||||||
|
self.assertEqual(step1.qty_at_step, 3)
|
||||||
|
step1.action_complete_one_to_next()
|
||||||
|
# One move(qty=1) created
|
||||||
|
moves = self.env['fp.job.step.move'].search([
|
||||||
|
('from_step_id', '=', step1.id),
|
||||||
|
])
|
||||||
|
self.assertEqual(len(moves), 1)
|
||||||
|
self.assertEqual(moves.qty_moved, 1)
|
||||||
|
# step1 still in progress, 2 parts left
|
||||||
|
step1.invalidate_recordset(['qty_at_step'])
|
||||||
|
self.assertEqual(step1.state, 'in_progress')
|
||||||
|
self.assertEqual(step1.qty_at_step, 2)
|
||||||
|
|
||||||
|
def test_complete_one_to_next_auto_finishes_on_last(self):
|
||||||
|
job, step1, step2 = self._make_two_step_chain(qty=1)
|
||||||
|
step1.invalidate_recordset(['qty_at_step'])
|
||||||
|
self.assertEqual(step1.qty_at_step, 1)
|
||||||
|
step1.action_complete_one_to_next()
|
||||||
|
# Source step done; next step started
|
||||||
|
self.assertEqual(step1.state, 'done')
|
||||||
|
self.assertEqual(step2.state, 'in_progress')
|
||||||
|
|
||||||
|
def test_complete_one_to_next_blocks_when_empty(self):
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
job, step1, step2 = self._make_two_step_chain(qty=2)
|
||||||
|
# Move all out first → qty_at_step = 0
|
||||||
|
self.env['fp.job.step.move'].create({
|
||||||
|
'job_id': job.id,
|
||||||
|
'from_step_id': step1.id,
|
||||||
|
'to_step_id': step2.id,
|
||||||
|
'transfer_type': 'step',
|
||||||
|
'qty_moved': 2,
|
||||||
|
'moved_by_user_id': self.env.user.id,
|
||||||
|
})
|
||||||
|
step1.invalidate_recordset(['qty_at_step'])
|
||||||
|
with self.assertRaises(UserError) as exc:
|
||||||
|
step1.action_complete_one_to_next()
|
||||||
|
self.assertIn('nothing to complete', str(exc.exception))
|
||||||
|
|
||||||
|
def test_complete_one_to_next_blocks_when_no_next_step(self):
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
job = self._make_job(qty=3)
|
||||||
|
last = self._make_step(
|
||||||
|
job, name='Inspect', sequence=10, state='in_progress',
|
||||||
|
)
|
||||||
|
last.date_started = fields.Datetime.now()
|
||||||
|
last.invalidate_recordset(['qty_at_step'])
|
||||||
|
with self.assertRaises(UserError) as exc:
|
||||||
|
last.action_complete_one_to_next()
|
||||||
|
self.assertIn('last runnable step', str(exc.exception))
|
||||||
|
|
||||||
|
def test_complete_one_to_next_blocks_when_not_in_progress(self):
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
job, step1, step2 = self._make_two_step_chain(qty=3)
|
||||||
|
step1.state = 'pending' # not in_progress
|
||||||
|
with self.assertRaises(UserError) as exc:
|
||||||
|
step1.action_complete_one_to_next()
|
||||||
|
self.assertIn('must be in progress', str(exc.exception))
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Bump fusion_plating manifest version**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh pve-worker5 "pct exec 111 -- bash -c \"sed -i \\\"s/'version': '19.0.18.14.13'/'version': '19.0.18.14.14'/\\\" /mnt/extra-addons/custom/fusion_plating/__manifest__.py\""
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Validate + run tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh pve-worker5 "pct exec 111 -- bash -c \"python3 -c 'import ast; ast.parse(open(\\\"/mnt/extra-addons/custom/fusion_plating/models/fp_job_step.py\\\").read()); ast.parse(open(\\\"/mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py\\\").read()); print(\\\"OK\\\")' && systemctl stop odoo && su - odoo -s /bin/bash -c '/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating,fusion_plating_jobs --test-enable --test-tags /fusion_plating_jobs --stop-after-init' 2>&1 | grep -E 'TestQtyGate.*test_complete_one_to_next.*(FAIL|ERROR|Starting)' | head -15 && systemctl start odoo\""
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 5 `Starting` lines (the test from Step 2 plus 4 here), zero FAIL/ERROR.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Auto-move shim on Finish & Next
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/mnt/extra-addons/custom/fusion_plating/models/fp_job_step.py`
|
||||||
|
- Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `_fp_record_one_piece_auto_move` helper**
|
||||||
|
|
||||||
|
Find the existing `action_finish_and_advance` method on `fp.job.step` (search for `def action_finish_and_advance`). It probably looks like:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def action_finish_and_advance(self):
|
||||||
|
"""Finish this step and auto-start the next pending/ready
|
||||||
|
step (Steelhead-style per-row button)."""
|
||||||
|
self.ensure_one()
|
||||||
|
if self.state == 'in_progress':
|
||||||
|
self.button_finish()
|
||||||
|
# ...rest: pick next step + button_start
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the helper as a sibling method, then wire it in. New code:
|
||||||
|
|
||||||
|
```python
|
||||||
|
|
||||||
|
def _fp_record_one_piece_auto_move(self):
|
||||||
|
"""Decide whether to silently record a move(qty=1) before
|
||||||
|
the step finishes. Five cases:
|
||||||
|
- qty_at_step == 0: nothing to do (parts already moved).
|
||||||
|
- last runnable step: parts complete in place; no move.
|
||||||
|
- qty_at_step == 1 + downstream: record move(1).
|
||||||
|
- qty_at_step > 1 + downstream: raise.
|
||||||
|
- qty_at_step > 1 + last step: allow (parts complete in
|
||||||
|
place; qty_done auto-tick is Phase 2).
|
||||||
|
Called from action_finish_and_advance just before
|
||||||
|
button_finish.
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
qty = self.qty_at_step
|
||||||
|
if qty <= 0:
|
||||||
|
return False
|
||||||
|
next_step = self.job_id.step_ids.filtered(
|
||||||
|
lambda s: s.sequence > self.sequence
|
||||||
|
and s.state in ('pending', 'ready')
|
||||||
|
).sorted('sequence')[:1]
|
||||||
|
if not next_step:
|
||||||
|
# Last runnable step: parts complete in place.
|
||||||
|
return False
|
||||||
|
if qty > 1:
|
||||||
|
raise UserError(_(
|
||||||
|
"Step '%s' still has %d parts here — use the row's "
|
||||||
|
"'Complete 1 → Next' button (for one-by-one flow) "
|
||||||
|
"or the 'Move…' wizard (for batched flow) to drain "
|
||||||
|
"the step before finishing."
|
||||||
|
) % (self.name, qty))
|
||||||
|
# qty == 1 + next_step exists → record move silently.
|
||||||
|
self.env['fp.job.step.move'].create({
|
||||||
|
'job_id': self.job_id.id,
|
||||||
|
'from_step_id': self.id,
|
||||||
|
'to_step_id': next_step.id,
|
||||||
|
'transfer_type': 'step',
|
||||||
|
'qty_moved': 1,
|
||||||
|
'moved_by_user_id': self.env.user.id,
|
||||||
|
})
|
||||||
|
return True
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Wire the helper into `action_finish_and_advance`**
|
||||||
|
|
||||||
|
Find `action_finish_and_advance`. The current code likely starts:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def action_finish_and_advance(self):
|
||||||
|
self.ensure_one()
|
||||||
|
if self.state == 'in_progress':
|
||||||
|
self.button_finish()
|
||||||
|
```
|
||||||
|
|
||||||
|
Insert the helper call before `button_finish`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def action_finish_and_advance(self):
|
||||||
|
self.ensure_one()
|
||||||
|
if self.state == 'in_progress':
|
||||||
|
# Auto-move shim: for qty_at_step==1 + downstream, record a
|
||||||
|
# move(qty=1) so the qty gate in button_finish passes. Raises
|
||||||
|
# for qty>1 with a friendly pointer to Complete 1 → Next.
|
||||||
|
self._fp_record_one_piece_auto_move()
|
||||||
|
self.button_finish()
|
||||||
|
```
|
||||||
|
|
||||||
|
The patch script uses the existing method's `self.ensure_one()\n if self.state == 'in_progress':\n self.button_finish()` as the anchor.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add 4 auto-move shim tests**
|
||||||
|
|
||||||
|
Append to `TestQtyGate`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
|
||||||
|
# ---------------- auto-move shim on Finish & Next ---------------
|
||||||
|
|
||||||
|
def test_finish_and_advance_auto_move_for_qty_1(self):
|
||||||
|
job, step1, step2 = self._make_two_step_chain(qty=1)
|
||||||
|
step1.invalidate_recordset(['qty_at_step'])
|
||||||
|
self.assertEqual(step1.qty_at_step, 1)
|
||||||
|
step1.action_finish_and_advance()
|
||||||
|
# Move(qty=1) recorded silently
|
||||||
|
moves = self.env['fp.job.step.move'].search([
|
||||||
|
('from_step_id', '=', step1.id),
|
||||||
|
])
|
||||||
|
self.assertEqual(len(moves), 1)
|
||||||
|
self.assertEqual(moves.qty_moved, 1)
|
||||||
|
self.assertEqual(step1.state, 'done')
|
||||||
|
self.assertEqual(step2.state, 'in_progress')
|
||||||
|
|
||||||
|
def test_finish_and_advance_blocks_for_qty_gt_1(self):
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
job, step1, step2 = self._make_two_step_chain(qty=3)
|
||||||
|
step1.invalidate_recordset(['qty_at_step'])
|
||||||
|
self.assertEqual(step1.qty_at_step, 3)
|
||||||
|
with self.assertRaises(UserError) as exc:
|
||||||
|
step1.action_finish_and_advance()
|
||||||
|
self.assertIn("Complete 1", str(exc.exception))
|
||||||
|
# State unchanged
|
||||||
|
self.assertEqual(step1.state, 'in_progress')
|
||||||
|
|
||||||
|
def test_finish_and_advance_passes_for_qty_0(self):
|
||||||
|
job, step1, step2 = self._make_two_step_chain(qty=2)
|
||||||
|
# Move all out first
|
||||||
|
self.env['fp.job.step.move'].create({
|
||||||
|
'job_id': job.id,
|
||||||
|
'from_step_id': step1.id,
|
||||||
|
'to_step_id': step2.id,
|
||||||
|
'transfer_type': 'step',
|
||||||
|
'qty_moved': 2,
|
||||||
|
'moved_by_user_id': self.env.user.id,
|
||||||
|
})
|
||||||
|
step1.invalidate_recordset(['qty_at_step'])
|
||||||
|
before = self.env['fp.job.step.move'].search_count([
|
||||||
|
('from_step_id', '=', step1.id),
|
||||||
|
])
|
||||||
|
step1.action_finish_and_advance()
|
||||||
|
after = self.env['fp.job.step.move'].search_count([
|
||||||
|
('from_step_id', '=', step1.id),
|
||||||
|
])
|
||||||
|
self.assertEqual(after, before) # no extra move
|
||||||
|
self.assertEqual(step1.state, 'done')
|
||||||
|
|
||||||
|
def test_finish_and_advance_allows_last_step_with_qty_gt_1(self):
|
||||||
|
"""Last runnable step: parts complete in place; no auto-move,
|
||||||
|
no UserError, no qty gate."""
|
||||||
|
job = self._make_job(qty=5)
|
||||||
|
last = self._make_step(
|
||||||
|
job, name='FinalInspect', sequence=10, state='in_progress',
|
||||||
|
)
|
||||||
|
last.date_started = fields.Datetime.now()
|
||||||
|
last.invalidate_recordset(['qty_at_step'])
|
||||||
|
self.assertEqual(last.qty_at_step, 5)
|
||||||
|
before = self.env['fp.job.step.move'].search_count([])
|
||||||
|
last.action_finish_and_advance()
|
||||||
|
after = self.env['fp.job.step.move'].search_count([])
|
||||||
|
self.assertEqual(after, before) # no move recorded
|
||||||
|
self.assertEqual(last.state, 'done')
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Bump fusion_plating manifest version**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh pve-worker5 "pct exec 111 -- bash -c \"sed -i \\\"s/'version': '19.0.18.14.14'/'version': '19.0.18.14.15'/\\\" /mnt/extra-addons/custom/fusion_plating/__manifest__.py\""
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Validate + run tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh pve-worker5 "pct exec 111 -- bash -c \"python3 -c 'import ast; ast.parse(open(\\\"/mnt/extra-addons/custom/fusion_plating/models/fp_job_step.py\\\").read()); ast.parse(open(\\\"/mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py\\\").read()); print(\\\"OK\\\")' && systemctl stop odoo && su - odoo -s /bin/bash -c '/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating,fusion_plating_jobs --test-enable --test-tags /fusion_plating_jobs --stop-after-init' 2>&1 | grep -E 'TestQtyGate.*test_finish_and_advance.*(FAIL|ERROR|Starting)' | head -10 && systemctl start odoo\""
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 4 `Starting` lines for `test_finish_and_advance_*`, zero FAIL/ERROR.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Per-row "Complete 1 → Next" button + display_name tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/views/fp_job_form_inherit.xml`
|
||||||
|
- Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the per-row button**
|
||||||
|
|
||||||
|
In `fp_job_form_inherit.xml`, find the embedded step list's button block. The existing per-row buttons include `button_pause`, `action_open_input_wizard`, `button_skip`, `action_open_move_wizard`. We're adding "Complete 1 → Next" after `button_pause` and before `action_open_input_wizard` (so it sits with the primary-action buttons).
|
||||||
|
|
||||||
|
Anchor — the existing Pause button:
|
||||||
|
```xml
|
||||||
|
<button name="button_pause" type="object"
|
||||||
|
string="Pause" icon="fa-pause"
|
||||||
|
class="btn-link text-warning"
|
||||||
|
invisible="state != 'in_progress'"/>
|
||||||
|
```
|
||||||
|
|
||||||
|
Insert immediately after Pause's closing `/>`:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<!-- Streaming flow: complete 1 part at a time, move to next
|
||||||
|
step. Hidden when there's nothing parked or the step isn't
|
||||||
|
actively running. Auto-finishes the step when qty_at_step
|
||||||
|
drains to 0. -->
|
||||||
|
<button name="action_complete_one_to_next" type="object"
|
||||||
|
string="Complete 1 → Next" icon="fa-forward"
|
||||||
|
class="btn-link text-success"
|
||||||
|
invisible="state != 'in_progress' or qty_at_step < 1"/>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add display_name + Move wizard regression tests**
|
||||||
|
|
||||||
|
Append to `TestQtyGate`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
|
||||||
|
# ---------------- display_name rename ----------------------------
|
||||||
|
|
||||||
|
def test_display_name_format(self):
|
||||||
|
job = self._make_job(qty=1)
|
||||||
|
# The default ir.sequence creates name='WH/JOB/NNNNN'.
|
||||||
|
self.assertTrue(job.name.startswith('WH/JOB/'))
|
||||||
|
self.assertTrue(job.display_name.startswith('Work Order # '))
|
||||||
|
# Suffix matches.
|
||||||
|
suffix = job.name.rsplit('/', 1)[-1]
|
||||||
|
self.assertEqual(job.display_name, 'Work Order # %s' % suffix)
|
||||||
|
|
||||||
|
def test_display_name_no_slash_passthrough(self):
|
||||||
|
"""Manually-named jobs without the sequence prefix display
|
||||||
|
as-is (no rewrite)."""
|
||||||
|
job = self._make_job(qty=1)
|
||||||
|
# Override name to something without a slash
|
||||||
|
job.name = 'SmokeJob42'
|
||||||
|
job.invalidate_recordset(['display_name'])
|
||||||
|
self.assertEqual(job.display_name, 'SmokeJob42')
|
||||||
|
|
||||||
|
# ---------------- Move wizard zero-qty regression ----------------
|
||||||
|
|
||||||
|
def test_move_wizard_blocks_zero_qty(self):
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
job, step1, step2 = self._make_two_step_chain(qty=2)
|
||||||
|
wiz = self.env['fp.job.step.move.wizard'].create({
|
||||||
|
'job_id': job.id,
|
||||||
|
'from_step_id': step1.id,
|
||||||
|
'to_step_id': step2.id,
|
||||||
|
'qty_moved': 0,
|
||||||
|
'transfer_type':'step',
|
||||||
|
})
|
||||||
|
with self.assertRaises(UserError) as exc:
|
||||||
|
wiz.action_commit()
|
||||||
|
self.assertIn('at least 1', str(exc.exception))
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Bump fusion_plating_jobs manifest version**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh pve-worker5 "pct exec 111 -- bash -c \"sed -i \\\"s/'version': '19.0.8.19.7'/'version': '19.0.8.19.8'/\\\" /mnt/extra-addons/custom/fusion_plating_jobs/__manifest__.py\""
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Validate XML + Python**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh pve-worker5 "pct exec 111 -- bash -c \"python3 -c 'import xml.etree.ElementTree as ET; ET.parse(\\\"/mnt/extra-addons/custom/fusion_plating_jobs/views/fp_job_form_inherit.xml\\\"); print(\\\"xml OK\\\")' && python3 -c 'import ast; ast.parse(open(\\\"/mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py\\\").read()); print(\\\"py OK\\\")'\""
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `xml OK`, `py OK`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Upgrade fusion_plating_jobs + run tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh pve-worker5 "pct exec 111 -- bash -c \"systemctl stop odoo && su - odoo -s /bin/bash -c '/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating_jobs --test-enable --test-tags /fusion_plating_jobs --stop-after-init' 2>&1 | grep -E 'TestQtyGate.*(FAIL|ERROR)' | head -10 && systemctl start odoo\""
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 0 lines (no failures in `TestQtyGate`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: End-to-end smoke test on entech
|
||||||
|
|
||||||
|
**Files:** none (verification via odoo shell + browser).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create a 3-step recipe job with qty=2**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SCRIPT='partner = env["res.partner"].create({"name": "QtyGate Smoke"})
|
||||||
|
prod = env["product.product"].create({"name": "QtyGateProd"})
|
||||||
|
job = env["fp.job"].create({"partner_id": partner.id, "product_id": prod.id, "qty": 2})
|
||||||
|
step1 = env["fp.job.step"].create({"job_id": job.id, "name": "S1-Plate", "sequence": 10, "state": "in_progress"})
|
||||||
|
step1.date_started = fields.Datetime.now()
|
||||||
|
step2 = env["fp.job.step"].create({"job_id": job.id, "name": "S2-Bake", "sequence": 20, "state": "ready"})
|
||||||
|
step3 = env["fp.job.step"].create({"job_id": job.id, "name": "S3-Inspect", "sequence": 30, "state": "ready"})
|
||||||
|
job.invalidate_recordset()
|
||||||
|
print(">>> JOB_ID=", job.id)
|
||||||
|
print(">>> JOB_NAME=", job.name)
|
||||||
|
print(">>> DISPLAY_NAME=", job.display_name)
|
||||||
|
print(">>> step1.qty_at_step=", step1.qty_at_step)
|
||||||
|
env.cr.commit()'
|
||||||
|
B64=$(echo -n "$SCRIPT" | base64 -w0)
|
||||||
|
ssh pve-worker5 "pct exec 111 -- bash -c \"echo $B64 | base64 -d > /tmp/smoke_qty.py && su - odoo -s /bin/bash -c '/usr/bin/odoo shell -c /etc/odoo/odoo.conf -d admin --no-http < /tmp/smoke_qty.py' 2>&1 | grep '>>>'\""
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
```
|
||||||
|
>>> JOB_ID= <some id>
|
||||||
|
>>> JOB_NAME= WH/JOB/00xxx
|
||||||
|
>>> DISPLAY_NAME= Work Order # 00xxx
|
||||||
|
>>> step1.qty_at_step= 2
|
||||||
|
```
|
||||||
|
|
||||||
|
Note JOB_ID for later steps.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Try to finish step1 — must be blocked**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SCRIPT='from odoo.exceptions import UserError
|
||||||
|
step1 = env["fp.job"].browse(<JOB_ID>).step_ids.filtered(lambda s: s.name == "S1-Plate")
|
||||||
|
try:
|
||||||
|
step1.button_finish()
|
||||||
|
print(">>> RESULT: no error (unexpected)")
|
||||||
|
except UserError as e:
|
||||||
|
print(">>> RESULT: blocked,", str(e)[:120])'
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the script (substituting JOB_ID). Expected:
|
||||||
|
```
|
||||||
|
>>> RESULT: blocked, Step 'S1-Plate' still has 2 part(s) parked — move them to the next step before finishing...
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Use action_complete_one_to_next to drain step1**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SCRIPT='step1 = env["fp.job"].browse(<JOB_ID>).step_ids.filtered(lambda s: s.name == "S1-Plate")
|
||||||
|
step1.action_complete_one_to_next()
|
||||||
|
step1.invalidate_recordset(["qty_at_step"])
|
||||||
|
print(">>> step1.state=", step1.state, "qty_at_step=", step1.qty_at_step)
|
||||||
|
step2 = env["fp.job"].browse(<JOB_ID>).step_ids.filtered(lambda s: s.name == "S2-Bake")
|
||||||
|
step2.invalidate_recordset(["qty_at_step"])
|
||||||
|
print(">>> step2.state=", step2.state, "qty_at_step=", step2.qty_at_step)
|
||||||
|
env.cr.commit()'
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected after first call:
|
||||||
|
```
|
||||||
|
>>> step1.state= in_progress qty_at_step= 1
|
||||||
|
>>> step2.state= ready qty_at_step= 0
|
||||||
|
```
|
||||||
|
|
||||||
|
(Step2 stays `ready` because step1 still has 1 part — step1 isn't done yet.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Complete the second part — auto-finish**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SCRIPT='step1 = env["fp.job"].browse(<JOB_ID>).step_ids.filtered(lambda s: s.name == "S1-Plate")
|
||||||
|
step1.action_complete_one_to_next()
|
||||||
|
step1.invalidate_recordset()
|
||||||
|
step2 = env["fp.job"].browse(<JOB_ID>).step_ids.filtered(lambda s: s.name == "S2-Bake")
|
||||||
|
step2.invalidate_recordset()
|
||||||
|
print(">>> step1.state=", step1.state)
|
||||||
|
print(">>> step2.state=", step2.state, "qty_at_step=", step2.qty_at_step)
|
||||||
|
env.cr.commit()'
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
```
|
||||||
|
>>> step1.state= done
|
||||||
|
>>> step2.state= in_progress qty_at_step= 2
|
||||||
|
```
|
||||||
|
|
||||||
|
(step2 now has both parts; auto-finish + auto-start fired on the last `Complete 1 → Next` call.)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Open the job in browser, verify the header label**
|
||||||
|
|
||||||
|
Navigate to `https://enplating.com/odoo` → open the smoke job. Verify:
|
||||||
|
- Form header reads **"Work Order # 00xxx"** (not WH/JOB/00xxx).
|
||||||
|
- Step1 row no longer shows the "Complete 1 → Next" button (state=done).
|
||||||
|
- Step2 row DOES show "Complete 1 → Next" (state=in_progress, qty_at_step > 0).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Clean up smoke data**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SCRIPT='job = env["fp.job"].browse(<JOB_ID>)
|
||||||
|
if job.exists():
|
||||||
|
env["fp.job.step.move"].search([("job_id", "=", job.id)]).sudo().unlink()
|
||||||
|
job.step_ids.sudo().unlink()
|
||||||
|
job.sudo().unlink()
|
||||||
|
env["res.partner"].search([("name", "=", "QtyGate Smoke")]).sudo().unlink()
|
||||||
|
env["product.product"].search([("name", "=", "QtyGateProd")]).sudo().unlink()
|
||||||
|
env.cr.commit()
|
||||||
|
print(">>> cleanup done")'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Sync touched files back to local repo + commit
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `K:/Github/Odoo-Modules/fusion_plating/fusion_plating_jobs/models/fp_job.py`
|
||||||
|
- `K:/Github/Odoo-Modules/fusion_plating/fusion_plating_jobs/views/fp_job_form_inherit.xml`
|
||||||
|
- `K:/Github/Odoo-Modules/fusion_plating/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py`
|
||||||
|
- `K:/Github/Odoo-Modules/fusion_plating/fusion_plating_jobs/__manifest__.py`
|
||||||
|
- `K:/Github/Odoo-Modules/fusion_plating/fusion_plating/models/fp_job_step.py`
|
||||||
|
- `K:/Github/Odoo-Modules/fusion_plating/fusion_plating/__manifest__.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Pull each touched file from entech to local repo**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh pve-worker5 "pct exec 111 -- bash -c 'cat /mnt/extra-addons/custom/fusion_plating_jobs/models/fp_job.py'" > K:/Github/Odoo-Modules/fusion_plating/fusion_plating_jobs/models/fp_job.py
|
||||||
|
ssh pve-worker5 "pct exec 111 -- bash -c 'cat /mnt/extra-addons/custom/fusion_plating_jobs/views/fp_job_form_inherit.xml'" > K:/Github/Odoo-Modules/fusion_plating/fusion_plating_jobs/views/fp_job_form_inherit.xml
|
||||||
|
ssh pve-worker5 "pct exec 111 -- bash -c 'cat /mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py'" > K:/Github/Odoo-Modules/fusion_plating/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py
|
||||||
|
ssh pve-worker5 "pct exec 111 -- bash -c 'cat /mnt/extra-addons/custom/fusion_plating_jobs/__manifest__.py'" > K:/Github/Odoo-Modules/fusion_plating/fusion_plating_jobs/__manifest__.py
|
||||||
|
ssh pve-worker5 "pct exec 111 -- bash -c 'cat /mnt/extra-addons/custom/fusion_plating/models/fp_job_step.py'" > K:/Github/Odoo-Modules/fusion_plating/fusion_plating/models/fp_job_step.py
|
||||||
|
ssh pve-worker5 "pct exec 111 -- bash -c 'cat /mnt/extra-addons/custom/fusion_plating/__manifest__.py'" > K:/Github/Odoo-Modules/fusion_plating/fusion_plating/__manifest__.py
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Review diff**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd K:/Github/Odoo-Modules && git diff --stat fusion_plating/fusion_plating_jobs/ fusion_plating/fusion_plating/
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: ~6 files changed, additions concentrated in `fp_job_step.py` (button_finish gate + action_complete_one_to_next + _fp_record_one_piece_auto_move + wiring), `fp_job.py` (_compute_display_name), and `test_fp_job_milestone_cascade.py` (14 new tests).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Stage + commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd K:/Github/Odoo-Modules && git add fusion_plating/fusion_plating_jobs/ fusion_plating/fusion_plating/ && git commit -m "$(cat <<'EOF'
|
||||||
|
feat(jobs): step qty gate + partial-qty + display rename
|
||||||
|
|
||||||
|
Three coupled shop-floor corrections:
|
||||||
|
- fp.job.step.button_finish: refuses if qty_at_step > 0 AND a
|
||||||
|
downstream pending/ready step exists. Last runnable step is
|
||||||
|
exempt (parts complete in place). Manager bypass via
|
||||||
|
fp_skip_qty_gate=True context key.
|
||||||
|
- fp.job.step.action_complete_one_to_next: per-row "Complete
|
||||||
|
1 -> Next" button. Records move(qty=1) to next step; if that
|
||||||
|
drains qty_at_step to 0, auto-finishes source + auto-starts
|
||||||
|
destination via existing action_finish_and_advance.
|
||||||
|
- fp.job.step._fp_record_one_piece_auto_move: auto-move shim
|
||||||
|
wired into action_finish_and_advance. qty=1 + downstream =>
|
||||||
|
silently record move(1). qty>1 + downstream => raise pointing
|
||||||
|
at Complete 1 -> Next. Last step always allowed.
|
||||||
|
- fp.job._compute_display_name: renders "Work Order # 00011"
|
||||||
|
in form header, breadcrumbs, M2O dropdowns, error messages.
|
||||||
|
DB name stays as WH/JOB/00011 - existing refs unchanged.
|
||||||
|
- 14 new TestQtyGate tests covering gate / shim / auto-finish /
|
||||||
|
last-step exemption / display rename / Move wizard zero-qty.
|
||||||
|
|
||||||
|
Spec: docs/superpowers/specs/2026-05-12-step-qty-gate-and-display-rename-design.md
|
||||||
|
Plan: docs/superpowers/plans/2026-05-12-step-qty-gate-and-display-rename.md
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Push (optional)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd K:/Github/Odoo-Modules && git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-review notes
|
||||||
|
|
||||||
|
- **Spec coverage:** Architecture sections 1–5 map to Tasks 1, 2, 3, 4, 5. State diagram entries are each covered by a dedicated test. Out-of-scope items (qty_done auto-tick, per-step scrap, cert PDF audit) are explicitly NOT in any task.
|
||||||
|
- **Placeholder scan:** Two `<JOB_ID>` placeholders in Task 6 are cross-step substitutions (the engineer reads the value from Step 1's output). All code blocks are complete; no "TBD" or "...similar to..." references.
|
||||||
|
- **Type consistency:** `action_complete_one_to_next` / `_fp_record_one_piece_auto_move` / `button_finish` all reference the same field names (`qty_at_step`, `state`, `sequence`, `job_id`, `step_ids`). The auto-move-shim's call site in `action_finish_and_advance` matches the helper's signature (no arguments, returns bool that the caller ignores). Test `TestQtyGate.setUpClass` matches the test method's `self.partner`, `self.product` references.
|
||||||
|
- **Field invalidation:** Every test that creates a Move and then checks `qty_at_step` calls `invalidate_recordset(['qty_at_step'])` first. Inside `action_complete_one_to_next` itself, the same invalidate is performed before the auto-finish check. The spec's "implementation notes" callout matches the tests.
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
# Job Milestone Cascade — Design Spec
|
||||||
|
|
||||||
|
**Date:** 2026-05-12
|
||||||
|
**Status:** Approved for implementation (Phase 1)
|
||||||
|
**Scope:** `fusion_plating`, `fusion_plating_jobs`, `fusion_plating_certificates`, `fusion_plating_logistics` (on entech)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Replace the per-step "Finish & Next" button on the `fp.job` form header with a single context-aware milestone-advance button. When all steps are done, the button cycles the manager through the remaining post-step lifecycle:
|
||||||
|
|
||||||
|
```
|
||||||
|
Mark Job Done → Issue Certs → Schedule Delivery → Mark Shipped → (closed)
|
||||||
|
```
|
||||||
|
|
||||||
|
Each click runs the existing downstream method (no new business logic invented). The button is **one place** the manager looks; the system always tells them what's next.
|
||||||
|
|
||||||
|
## Motivation (workflow gap audit)
|
||||||
|
|
||||||
|
End-to-end audit found:
|
||||||
|
|
||||||
|
- **G1.** `fp.job.state` and `fp.job.workflow_state_id` are two parallel state machines that drift.
|
||||||
|
- **G2.** No auto-fire of `button_mark_done` when all steps complete. The cascade (delivery / cert / notification) hangs off a manual click that has no UI surface after Finish & Next becomes a no-op.
|
||||||
|
- **G3.** Delivery + cert creation only happen via `button_mark_done`.
|
||||||
|
- **G4.** Invoice timing is strategy-dependent; no `on_job_done` strategy.
|
||||||
|
- **G5.** Certificate auto-creation is best-effort and only spawns CoC. Thickness Report cert is never auto-created even when the part / partner requires it.
|
||||||
|
- **G6.** No "next action" surface on the job header.
|
||||||
|
|
||||||
|
Phase 1 closes **G2 and G6 directly**, makes meaningful progress on **G5**, and lays groundwork for G3/G4. G1 is explicitly deferred.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
| Decision | Choice | Rationale |
|
||||||
|
|---|---|---|
|
||||||
|
| Ship in recipe vs separate | **Separate (Option C — Hybrid)** | Recipes = manufacturing; deliveries = logistics. Surface "next" on the job header so manager doesn't have to navigate. Supports split shipments naturally. |
|
||||||
|
| Cert gate strictness on Mark Shipped | **Hard block** (with manager bypass via context key) | AS9100 / Nadcap compliance — no shipping without paperwork. |
|
||||||
|
| Per-cert vs bulk issuance | **Per-cert** | Each cert (CoC vs Thickness Report) needs its own compliance review. |
|
||||||
|
| No-cert-required jobs | Skip Issue Certs, go straight to Schedule Delivery | Commercial customers don't need to click a button that has nothing to do. |
|
||||||
|
| Migration of existing data | **None — dev stage** | No production jobs to preserve. Just rewrite the `Shipped` state seed XML; `-u` reloads it. |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### New compute fields on `fp.job`
|
||||||
|
|
||||||
|
```python
|
||||||
|
all_steps_terminal = fields.Boolean(
|
||||||
|
compute='_compute_all_steps_terminal', store=True,
|
||||||
|
help='True ⇔ at least one step exists AND every step is in '
|
||||||
|
'done/skipped/cancelled.',
|
||||||
|
)
|
||||||
|
|
||||||
|
next_milestone_action = fields.Selection([
|
||||||
|
('mark_done', 'Mark Job Done'),
|
||||||
|
('issue_certs', 'Issue Certs'),
|
||||||
|
('schedule_delivery', 'Schedule Delivery'),
|
||||||
|
('mark_shipped', 'Mark Shipped'),
|
||||||
|
('closed', 'Closed'),
|
||||||
|
], compute='_compute_next_milestone_action')
|
||||||
|
|
||||||
|
next_milestone_label = fields.Char(
|
||||||
|
compute='_compute_next_milestone_action',
|
||||||
|
help='Human label for the next-action button — read by the view.',
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
`_compute_next_milestone_action` resolution order (top wins):
|
||||||
|
|
||||||
|
```
|
||||||
|
1. NOT all_steps_terminal → None (the existing Finish & Next stays)
|
||||||
|
2. state != 'done' → mark_done
|
||||||
|
3. ANY required cert in state='draft' → issue_certs
|
||||||
|
4. NO delivery, OR delivery in state='draft' → schedule_delivery
|
||||||
|
5. delivery.state in scheduled/in_transit → mark_shipped
|
||||||
|
6. otherwise → closed
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dispatcher action
|
||||||
|
|
||||||
|
```python
|
||||||
|
def action_advance_next_milestone(self):
|
||||||
|
"""Single entry point — branches on next_milestone_action and
|
||||||
|
delegates to the existing method. Never invents new business logic."""
|
||||||
|
self.ensure_one()
|
||||||
|
handlers = {
|
||||||
|
'mark_done': self.button_mark_done,
|
||||||
|
'issue_certs': self._action_open_draft_certs,
|
||||||
|
'schedule_delivery': self._action_open_draft_delivery,
|
||||||
|
'mark_shipped': self._action_mark_active_delivery_delivered,
|
||||||
|
}
|
||||||
|
fn = handlers.get(self.next_milestone_action)
|
||||||
|
if fn:
|
||||||
|
return fn()
|
||||||
|
return True
|
||||||
|
```
|
||||||
|
|
||||||
|
**Helper methods** (each returns an Odoo action dict or calls the existing
|
||||||
|
business-logic method):
|
||||||
|
|
||||||
|
- `_action_open_draft_certs` → returns an `ir.actions.act_window` opening
|
||||||
|
the `fp.certificate` list view with domain
|
||||||
|
`[('x_fc_job_id', '=', self.id), ('state', '=', 'draft')]` and
|
||||||
|
`target='current'` so the manager works on the cert list, then uses the
|
||||||
|
breadcrumb to return.
|
||||||
|
- `_action_open_draft_delivery` → finds the first delivery in
|
||||||
|
`state='draft'` for this job and returns an `ir.actions.act_window`
|
||||||
|
opening that record's form in `target='current'`. Falls back to the
|
||||||
|
delivery list view filtered to this job if no draft delivery exists.
|
||||||
|
- `_action_mark_active_delivery_delivered` → finds the first delivery in
|
||||||
|
`state in ('scheduled', 'in_transit')`, calls `action_mark_delivered`
|
||||||
|
on it directly (no UI navigation — the cascade just *does* the thing).
|
||||||
|
Posts to job chatter on success.
|
||||||
|
|
||||||
|
`target='current'` is chosen everywhere because the manager is working
|
||||||
|
on the cascade as a multi-step process; a popup would lose breadcrumb
|
||||||
|
context. The existing job-form breadcrumb survives, so they can navigate
|
||||||
|
back when done.
|
||||||
|
|
||||||
|
### New trigger on `fp.job.workflow.state`
|
||||||
|
|
||||||
|
```python
|
||||||
|
trigger_on_delivery_state = fields.Boolean(
|
||||||
|
string='Trigger on Delivery Delivered',
|
||||||
|
help='When True, this state passes once at least one '
|
||||||
|
'fusion.plating.delivery linked to the job reaches '
|
||||||
|
'state="delivered". Use for the Shipped milestone in '
|
||||||
|
'lieu of recipe-side default_kind="ship" tagging.',
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
`fp.job.workflow.state._fp_is_passed_for_job(job)` gains:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if self.trigger_on_delivery_state:
|
||||||
|
return any(d.state == 'delivered' for d in job.delivery_ids)
|
||||||
|
```
|
||||||
|
|
||||||
|
`fp.job._compute_workflow_state_id`'s `@api.depends` extends to include `delivery_ids.state`.
|
||||||
|
|
||||||
|
### Cert auto-create hardening
|
||||||
|
|
||||||
|
Add to `fp.job`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _resolve_required_cert_types(self):
|
||||||
|
"""Return the set of cert types this job must produce.
|
||||||
|
Reads the part's certificate_requirement; falls back to the
|
||||||
|
customer's send_coc / send_thickness_report flags when the part
|
||||||
|
is set to 'inherit'."""
|
||||||
|
req = (self.part_catalog_id and
|
||||||
|
self.part_catalog_id.certificate_requirement) or 'inherit'
|
||||||
|
if req == 'inherit':
|
||||||
|
types = set()
|
||||||
|
if self.partner_id.x_fc_send_coc:
|
||||||
|
types.add('coc')
|
||||||
|
if self.partner_id.x_fc_send_thickness_report:
|
||||||
|
types.add('thickness_report')
|
||||||
|
return types
|
||||||
|
return {
|
||||||
|
'none': set(),
|
||||||
|
'coc': {'coc'},
|
||||||
|
'coc_thickness': {'coc', 'thickness_report'},
|
||||||
|
}.get(req, {'coc'})
|
||||||
|
```
|
||||||
|
|
||||||
|
`_fp_create_certificates` is rewritten to loop over the resolved set and create one draft `fp.certificate` per type, idempotent per type (checks `x_fc_job_id` + `certificate_type` before creating).
|
||||||
|
|
||||||
|
### Cert gate on Mark Shipped
|
||||||
|
|
||||||
|
`fusion.plating.delivery.action_mark_delivered` gains a gate:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def action_mark_delivered(self):
|
||||||
|
skip_cert = self.env.context.get('fp_skip_cert_gate')
|
||||||
|
for delivery in self:
|
||||||
|
if not skip_cert and delivery.job_ref:
|
||||||
|
job = self.env['fp.job'].search(
|
||||||
|
[('name', '=', delivery.job_ref)], limit=1)
|
||||||
|
if job:
|
||||||
|
draft_certs = self.env['fp.certificate'].search([
|
||||||
|
('x_fc_job_id', '=', job.id),
|
||||||
|
('state', '=', 'draft'),
|
||||||
|
])
|
||||||
|
if draft_certs:
|
||||||
|
raise UserError(_(
|
||||||
|
'Cannot mark delivery %(d)s shipped — '
|
||||||
|
'job %(j)s still has %(n)d draft certificate(s). '
|
||||||
|
'Issue them first, or override via '
|
||||||
|
'fp_skip_cert_gate=True context key.'
|
||||||
|
) % {
|
||||||
|
'd': delivery.name,
|
||||||
|
'j': job.name,
|
||||||
|
'n': len(draft_certs),
|
||||||
|
})
|
||||||
|
return super().action_mark_delivered()
|
||||||
|
```
|
||||||
|
|
||||||
|
Lives in `fusion_plating_certificates/models/fp_delivery.py` (so the gate ships with the certs module — no coupling to logistics).
|
||||||
|
|
||||||
|
### View changes
|
||||||
|
|
||||||
|
In `fusion_plating_jobs/views/fp_job_form_inherit.xml`:
|
||||||
|
|
||||||
|
1. **Hide existing Finish & Next** when `all_steps_terminal`:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<button name="action_finish_current_step" type="object"
|
||||||
|
string="Finish & Next" class="btn-primary" icon="fa-arrow-right"
|
||||||
|
invisible="state not in ('confirmed', 'in_progress') or all_steps_terminal"/>
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Add four mutually-exclusive milestone buttons.** Each binds to `action_advance_next_milestone` but with a hardcoded label so users don't see a generic button. Visibility is gated on `next_milestone_action`:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<button name="action_advance_next_milestone" type="object"
|
||||||
|
string="Mark Job Done" class="btn-success" icon="fa-check-circle"
|
||||||
|
invisible="next_milestone_action != 'mark_done'"/>
|
||||||
|
<button name="action_advance_next_milestone" type="object"
|
||||||
|
string="Issue Certs" class="btn-primary" icon="fa-certificate"
|
||||||
|
invisible="next_milestone_action != 'issue_certs'"/>
|
||||||
|
<button name="action_advance_next_milestone" type="object"
|
||||||
|
string="Schedule Delivery" class="btn-primary" icon="fa-truck"
|
||||||
|
invisible="next_milestone_action != 'schedule_delivery'"/>
|
||||||
|
<button name="action_advance_next_milestone" type="object"
|
||||||
|
string="Mark Shipped" class="btn-success" icon="fa-paper-plane"
|
||||||
|
invisible="next_milestone_action != 'mark_shipped'"/>
|
||||||
|
```
|
||||||
|
|
||||||
|
`next_milestone_action == 'closed'` shows nothing (terminal).
|
||||||
|
|
||||||
|
3. **Hide invisible field** — register `<field name="next_milestone_action" invisible="1"/>` and `<field name="all_steps_terminal" invisible="1"/>` so the view can reference them in `invisible=` expressions.
|
||||||
|
|
||||||
|
### Data change — Shipped workflow state seed
|
||||||
|
|
||||||
|
In `fusion_plating_jobs/data/fp_workflow_state_data.xml`, replace the `Shipped` state record:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<record id="workflow_state_shipped" model="fp.job.workflow.state">
|
||||||
|
<field name="name">Shipped</field>
|
||||||
|
<field name="code">shipped</field>
|
||||||
|
<field name="sequence">60</field>
|
||||||
|
<field name="color">success</field>
|
||||||
|
<field name="trigger_on_delivery_state" eval="True"/>
|
||||||
|
<field name="description">Shipment confirmed (delivery marked delivered). Customer can be notified.</field>
|
||||||
|
</record>
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep `noupdate="1"` on the wrapping `<data>` block since shops may further customise. In dev, `-u fusion_plating_jobs` re-applies it on fresh DBs.
|
||||||
|
|
||||||
|
## State transition cascade (visual)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────┐
|
||||||
|
│ Steps still running │ ← Finish & Next visible
|
||||||
|
└──────────┬───────────┘
|
||||||
|
▼ last step done
|
||||||
|
┌──────────────────────┐
|
||||||
|
│ Mark Job Done │ ← button cascade starts
|
||||||
|
└──────────┬───────────┘
|
||||||
|
▼ button_mark_done (gates + create delivery + cert)
|
||||||
|
┌────────────────────────────┴─────────────────────────────┐
|
||||||
|
│ │
|
||||||
|
any draft cert? no required certs
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌────────────┐ (skip to next)
|
||||||
|
│ Issue Certs│
|
||||||
|
└─────┬──────┘
|
||||||
|
▼ all certs issued
|
||||||
|
┌─────────────────┐
|
||||||
|
│ Schedule Deliv. │
|
||||||
|
└─────┬───────────┘
|
||||||
|
▼ delivery scheduled
|
||||||
|
┌─────────────┐
|
||||||
|
│ Mark Shipped │ ← gates on issued certs (cert module)
|
||||||
|
└─────┬────────┘
|
||||||
|
▼ delivery.action_mark_delivered
|
||||||
|
(workflow_state → Shipped via the new trigger;
|
||||||
|
invoice fires if strategy='on_delivery')
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Closed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files touched
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|---|---|
|
||||||
|
| `fusion_plating_jobs/models/fp_job.py` | Add `all_steps_terminal`, `next_milestone_action`, `next_milestone_label` compute fields. Add `action_advance_next_milestone` dispatcher + 3 helper methods. Add `_resolve_required_cert_types`. Rewrite `_fp_create_certificates` to loop over resolved types. Extend `@api.depends` on `_compute_workflow_state_id` to include `delivery_ids.state`. |
|
||||||
|
| `fusion_plating_jobs/models/fp_job_workflow_state.py` | Add `trigger_on_delivery_state` Boolean. Extend `_fp_is_passed_for_job` with delivery-state branch. |
|
||||||
|
| `fusion_plating_jobs/data/fp_workflow_state_data.xml` | Rewrite `Shipped` state seed: drop `trigger_default_kinds='ship'`, add `trigger_on_delivery_state=True`. |
|
||||||
|
| `fusion_plating_jobs/views/fp_job_form_inherit.xml` | Hide `Finish & Next` when `all_steps_terminal`. Add 4 milestone buttons. Add invisible field declarations. |
|
||||||
|
| `fusion_plating_certificates/models/fp_delivery.py` | Inherit `fusion.plating.delivery`; override `action_mark_delivered` to gate on draft certs. Manager bypass via `fp_skip_cert_gate=True`. |
|
||||||
|
| `fusion_plating_certificates/__init__.py` / `models/__init__.py` | Register the new `fp_delivery.py` if needed. |
|
||||||
|
|
||||||
|
Manifest versions to bump:
|
||||||
|
- `fusion_plating_jobs`
|
||||||
|
- `fusion_plating_certificates`
|
||||||
|
|
||||||
|
## Out of scope (Phase 2+)
|
||||||
|
|
||||||
|
- **Send Certs to Customer button** — wrap `action_send_to_customer` per cert into the cascade after Mark Shipped. Existing `fp_notification_trigger` hooks already handle ship-time customer email; needs integration design.
|
||||||
|
- **`on_job_done` invoice strategy** — currently invoices fire at SO confirm or delivery delivered. A "fire at job done" option is desirable for cash-up-front shops; needs strategy-pattern extension in `fusion_plating_invoicing/models/sale_order.py`.
|
||||||
|
- **`fp.job.state` ↔ `workflow_state_id` reconciliation (G1)** — pick one source of truth, drop or compute the other. Larger refactor; defer until Phase 1 lands and we see how the cascade affects state-machine readability.
|
||||||
|
|
||||||
|
## Implementation notes / gotchas
|
||||||
|
|
||||||
|
- `next_milestone_action` is **not stored** — recompute on every access. Cheap (4 boolean checks). Avoids dependency-tracking complexity when delivery state changes.
|
||||||
|
- The cascade reads `delivery_ids` on `fp.job`. Confirm this field exists (related/computed) before relying on it. Fallback: search `fusion.plating.delivery` by `job_ref == self.name`.
|
||||||
|
- The cert gate in `action_mark_delivered` lives in the certs module so logistics doesn't depend on certs (currently logistics is upstream of certs in the dependency graph — verify).
|
||||||
|
- View buttons share the same `name="action_advance_next_milestone"` but Odoo distinguishes them by their `string=` attribute in the rendered DOM — this is the standard Odoo pattern for context-aware buttons (see `sale.order` action buttons).
|
||||||
|
- All four buttons are inside the header; users won't see more than one at a time thanks to the `invisible=` filters.
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
# Step Quantity Gate, Partial-Qty Handling, and Job Display Rename
|
||||||
|
|
||||||
|
**Date:** 2026-05-12
|
||||||
|
**Status:** Approved for implementation
|
||||||
|
**Scope:** `fusion_plating`, `fusion_plating_jobs` (on entech)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Three coupled shop-floor corrections on `fp.job` / `fp.job.step`:
|
||||||
|
|
||||||
|
1. **Display rename:** show `Work Order # 00011` everywhere a job appears to humans, while keeping `name = "WH/JOB/00011"` as the stable DB identifier.
|
||||||
|
2. **Quantity gate on `button_finish`:** prevent a step from being marked Done while parts are still parked at it. The current implementation has no quantity check, which is how an operator can produce the "all steps Done, qty_done=0" state visible in production.
|
||||||
|
3. **Partial-quantity flow:** add a per-row "Complete 1 → Next" action so streaming (large parts moving one-by-one through the same step) is a single click per part. Keep the Move wizard for batched (sub-batch) flow. Keep "Finish & Next" working for the 1-of-1 case via a transparent auto-move shim.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
The current state observed in production (job `WH/JOB/00011`, `qty=1`, `qty_done=0`, 11 steps all `Done`) shows the data integrity problem: `fp.job.step.button_finish()` checks only `state == 'in_progress'`. No quantity validation. The user can click Finish on every step regardless of whether parts physically moved through. The job-level `button_mark_done` catches the qty discrepancy at the very end, but by then the per-step audit trail is already a fiction.
|
||||||
|
|
||||||
|
Real shop floors run three flows on the same job model:
|
||||||
|
|
||||||
|
| Flow | Example | Operator UX needed |
|
||||||
|
|---|---|---|
|
||||||
|
| **1-of-1** | One large valve body, qty=1 | One click: Finish & Next (auto-moves the 1 part) |
|
||||||
|
| **Streaming** | 10 large parts going one-by-one through the same plating tank | One click per part: Complete 1 → Next |
|
||||||
|
| **Batched** | 50 small parts going through in groups of 10 | Move wizard for each chunk, then Finish |
|
||||||
|
|
||||||
|
The data model (`fp.job.step.move` records, `qty_at_step` compute) already supports all three. What's missing is the gate plus a first-class shortcut for streaming.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
| Decision | Choice | Rationale |
|
||||||
|
|---|---|---|
|
||||||
|
| Job rename mechanism | Override `display_name` via compute; leave `name` untouched | DB identifier stable; old references in chatter/certs/deliveries don't break; rollback is one line |
|
||||||
|
| Quantity gate scope | `qty_at_step > 0` blocks `button_finish` | Catches the bug at the right layer; manager bypass via context |
|
||||||
|
| Partial qty UX | Move-driven (Option A from brainstorming) | Maps cleanly to all three flows with one click per natural unit of work |
|
||||||
|
| Streaming shortcut | New `action_complete_one_to_next` row button | First-class action for the one-by-one case; no wizard ceremony |
|
||||||
|
| 1-of-1 shortcut | Auto-move shim on existing `action_finish_current_step` + `action_finish_and_advance` | Keeps the single-click UX; transparently records the move |
|
||||||
|
| Move wizard zero-qty | Already guarded (`qty_moved <= 0` raises) | Verify with a test; no code change needed |
|
||||||
|
| Manager force-complete | Stays bypass-by-design (already skips `button_finish`) | Manager use-case is "this step was done outside ERP" — no qty in ERP to validate |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### 1. `fp.job.display_name` compute
|
||||||
|
|
||||||
|
Single override on `fp.job`. No model change beyond adding a computed method.
|
||||||
|
|
||||||
|
```python
|
||||||
|
@api.depends('name')
|
||||||
|
def _compute_display_name(self):
|
||||||
|
"""Reformat 'WH/JOB/00011' → 'Work Order # 00011' for every
|
||||||
|
human-facing surface (form header, breadcrumbs, M2O dropdowns,
|
||||||
|
smart-button titles, error messages). The DB `name` is unchanged
|
||||||
|
so existing certs / deliveries / chatter references don't break.
|
||||||
|
"""
|
||||||
|
for job in self:
|
||||||
|
if job.name and '/' in job.name:
|
||||||
|
suffix = job.name.rsplit('/', 1)[-1]
|
||||||
|
job.display_name = _('Work Order # %s') % suffix
|
||||||
|
else:
|
||||||
|
job.display_name = job.name or ''
|
||||||
|
```
|
||||||
|
|
||||||
|
View change: the form `<h1>` binds `display_name` instead of `name`. Everywhere else Odoo uses `display_name` automatically — M2O widgets, kanban titles, list views, breadcrumbs.
|
||||||
|
|
||||||
|
### 2. Quantity gate on `fp.job.step.button_finish`
|
||||||
|
|
||||||
|
The gate only fires when there's a *downstream* step parts could move into. The **last runnable step** of a recipe is allowed to finish with parts here — they complete the recipe in place. (`qty_done` reconciliation at job close is unchanged for Phase 1; see Out of Scope.)
|
||||||
|
|
||||||
|
```python
|
||||||
|
def button_finish(self):
|
||||||
|
"""[existing docstring extended]
|
||||||
|
|
||||||
|
Quantity gate (new): refuses if qty_at_step > 0 AND there is at
|
||||||
|
least one downstream pending/ready step. The last runnable step
|
||||||
|
is exempt — parts finishing in place are valid. Manager bypass
|
||||||
|
via context key fp_skip_qty_gate=True.
|
||||||
|
"""
|
||||||
|
skip_qty_gate = self.env.context.get('fp_skip_qty_gate')
|
||||||
|
for step in self:
|
||||||
|
if step.state != 'in_progress':
|
||||||
|
raise UserError(...) # existing
|
||||||
|
if not skip_qty_gate and step.qty_at_step > 0:
|
||||||
|
has_downstream = step.job_id.step_ids.filtered(
|
||||||
|
lambda s: s.sequence > step.sequence
|
||||||
|
and s.state in ('pending', 'ready')
|
||||||
|
)
|
||||||
|
if has_downstream:
|
||||||
|
raise UserError(_(
|
||||||
|
"Step '%(name)s' still has %(n)d part(s) parked "
|
||||||
|
"— move them to the next step before finishing. "
|
||||||
|
"Use the row's 'Complete 1 → Next' or 'Move…' "
|
||||||
|
"button."
|
||||||
|
) % {'name': step.name, 'n': step.qty_at_step})
|
||||||
|
# No downstream step: this is the last runnable step.
|
||||||
|
# Parts finishing here become "done" with the recipe.
|
||||||
|
# ...remainder unchanged
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. New `fp.job.step.action_complete_one_to_next`
|
||||||
|
|
||||||
|
```python
|
||||||
|
def action_complete_one_to_next(self):
|
||||||
|
"""One-piece flow shortcut: records move(qty=1) from this step
|
||||||
|
to the next pending/ready step. Drains qty_at_step by 1. If the
|
||||||
|
drain takes qty_at_step to 0, auto-finishes the source step and
|
||||||
|
starts the destination step (delegates to action_finish_and_advance,
|
||||||
|
which already handles auto-start)."""
|
||||||
|
self.ensure_one()
|
||||||
|
if self.state != 'in_progress':
|
||||||
|
raise UserError(_(
|
||||||
|
"Step '%s' must be in progress to complete a part."
|
||||||
|
) % self.name)
|
||||||
|
if self.qty_at_step < 1:
|
||||||
|
raise UserError(_(
|
||||||
|
"No parts parked at step '%s' — nothing to complete."
|
||||||
|
) % self.name)
|
||||||
|
next_step = self.job_id.step_ids.filtered(
|
||||||
|
lambda s: s.sequence > self.sequence
|
||||||
|
and s.state in ('pending', 'ready')
|
||||||
|
).sorted('sequence')[:1]
|
||||||
|
if not next_step:
|
||||||
|
raise UserError(_(
|
||||||
|
"Step '%s' is the last runnable step on the job — "
|
||||||
|
"no downstream step to move into. Finish the step "
|
||||||
|
"instead (it will close out the job)."
|
||||||
|
) % self.name)
|
||||||
|
self.env['fp.job.step.move'].create({
|
||||||
|
'job_id': self.job_id.id,
|
||||||
|
'from_step_id': self.id,
|
||||||
|
'to_step_id': next_step.id,
|
||||||
|
'transfer_type': 'step',
|
||||||
|
'qty_moved': 1,
|
||||||
|
'moved_by_user_id': self.env.user.id,
|
||||||
|
})
|
||||||
|
# qty_at_step is computed from moves; force re-read before deciding
|
||||||
|
# whether this was the last part. Without invalidate the cache says
|
||||||
|
# "still 1 parked" and the auto-finish never fires.
|
||||||
|
self.invalidate_recordset(['qty_at_step'])
|
||||||
|
if self.qty_at_step == 0:
|
||||||
|
return self.action_finish_and_advance()
|
||||||
|
return True
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Auto-move shim on `action_finish_current_step` + `action_finish_and_advance`
|
||||||
|
|
||||||
|
Both methods finish "the current step" and (for the former) "auto-start the next". The shim adds:
|
||||||
|
|
||||||
|
- **Before finishing:** if `qty_at_step == 1` AND there's a next pending/ready step → record a `move(qty=1)` to the next step, then proceed.
|
||||||
|
- **If `qty_at_step > 1`:** raise with a friendly message pointing at "Complete 1 → Next" or "Move…".
|
||||||
|
- **If `qty_at_step == 0`:** proceed as today (the parts already moved via Move wizard or Complete 1 → Next).
|
||||||
|
|
||||||
|
The shim lives in `action_finish_and_advance` (on `fp.job.step`); `action_finish_current_step` (on `fp.job`) calls it, so it inherits the shim. Single point of behaviour.
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _fp_record_one_piece_auto_move(self):
|
||||||
|
"""Helper called from action_finish_and_advance. Decides whether
|
||||||
|
to silently record a move(qty=1) before the step finishes. Three
|
||||||
|
cases:
|
||||||
|
- qty_at_step == 0: nothing to do (parts already moved manually).
|
||||||
|
- qty_at_step == 1 + downstream step exists: record move(1).
|
||||||
|
- qty_at_step == 1 + no downstream (last step): no move; parts
|
||||||
|
complete in place.
|
||||||
|
- qty_at_step > 1 + downstream exists: raise (operator must use
|
||||||
|
Complete 1 → Next or Move… to drain the step).
|
||||||
|
- qty_at_step > 1 + no downstream (last step): allow; parts
|
||||||
|
all complete in place. (qty_done auto-tick is Phase 2.)
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
qty = self.qty_at_step
|
||||||
|
if qty <= 0:
|
||||||
|
return False
|
||||||
|
next_step = self.job_id.step_ids.filtered(
|
||||||
|
lambda s: s.sequence > self.sequence
|
||||||
|
and s.state in ('pending', 'ready')
|
||||||
|
).sorted('sequence')[:1]
|
||||||
|
if not next_step:
|
||||||
|
# Last runnable step — parts here complete in place. The
|
||||||
|
# button_finish gate already permits this case; just allow.
|
||||||
|
return False
|
||||||
|
if qty > 1:
|
||||||
|
raise UserError(_(
|
||||||
|
"Step '%s' still has %d parts here — use the row's "
|
||||||
|
"'Complete 1 → Next' button (for one-by-one flow) or "
|
||||||
|
"the 'Move…' wizard (for batched flow) to drain the "
|
||||||
|
"step before finishing."
|
||||||
|
) % (self.name, qty))
|
||||||
|
# qty == 1 and next_step exists → record the move silently.
|
||||||
|
self.env['fp.job.step.move'].create({
|
||||||
|
'job_id': self.job_id.id,
|
||||||
|
'from_step_id': self.id,
|
||||||
|
'to_step_id': next_step.id,
|
||||||
|
'transfer_type': 'step',
|
||||||
|
'qty_moved': 1,
|
||||||
|
'moved_by_user_id': self.env.user.id,
|
||||||
|
})
|
||||||
|
return True
|
||||||
|
```
|
||||||
|
|
||||||
|
Wired into `action_finish_and_advance` immediately before the existing finish logic:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def action_finish_and_advance(self):
|
||||||
|
self.ensure_one()
|
||||||
|
if self.state == 'in_progress':
|
||||||
|
self._fp_record_one_piece_auto_move() # may raise on qty>1
|
||||||
|
# ...rest unchanged (button_finish + auto-start next)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. View additions
|
||||||
|
|
||||||
|
In `fp_job_form_inherit.xml` (embedded step list):
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<!-- Complete 1 part and advance — streaming flow (large parts
|
||||||
|
going one-by-one through the same step). Hidden when there's
|
||||||
|
nothing parked or the step isn't actively running. -->
|
||||||
|
<button name="action_complete_one_to_next" type="object"
|
||||||
|
string="Complete 1 → Next" icon="fa-forward"
|
||||||
|
class="btn-link text-success"
|
||||||
|
invisible="state != 'in_progress' or qty_at_step < 1"/>
|
||||||
|
```
|
||||||
|
|
||||||
|
Placed in the row's button column, after "Pause" and before "Move…". The header `Finish & Next` button is unchanged in markup — the auto-move/qty-gate logic is entirely behind the existing button.
|
||||||
|
|
||||||
|
In the form header `<sheet>` block, change the `<h1>` to bind `display_name`:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<h1><field name="display_name"/></h1>
|
||||||
|
```
|
||||||
|
|
||||||
|
`qty_at_step` is already a list column on the embedded step list (visible as "Qty Here"). No change needed for visibility — the existing field declaration is sufficient for the `invisible=` expression.
|
||||||
|
|
||||||
|
## State transition diagram
|
||||||
|
|
||||||
|
```
|
||||||
|
Before this work:
|
||||||
|
in_progress ──button_finish──> done (no qty check)
|
||||||
|
|
||||||
|
After:
|
||||||
|
any step, qty_at_step==0 ──button_finish──> done
|
||||||
|
mid-recipe step, qty_at_step==1 ──Finish & Next──> [auto-move(1)] ──> done
|
||||||
|
mid-recipe step, qty_at_step==1 ──Complete 1→Next──> [move(1)] ──> done + start_next
|
||||||
|
mid-recipe step, qty_at_step>1 ──Complete 1→Next──> [move(1)] (stays in_progress)
|
||||||
|
mid-recipe step, qty_at_step>1 ──Finish & Next──> ❌ UserError (use shortcuts)
|
||||||
|
LAST recipe step, qty_at_step>0 ──Finish & Next──> done (no move; parts complete in place)
|
||||||
|
```
|
||||||
|
|
||||||
|
"Mid-recipe step" = at least one downstream step is pending/ready. "LAST recipe step" = no downstream step in pending/ready state (either truly last, or all later steps are skipped/cancelled).
|
||||||
|
|
||||||
|
## Test plan
|
||||||
|
|
||||||
|
New class `TestQtyGate` in `tests/test_fp_job_milestone_cascade.py`:
|
||||||
|
|
||||||
|
| Test | Scenario | Expected |
|
||||||
|
|---|---|---|
|
||||||
|
| `test_button_finish_blocks_when_qty_at_step` | qty_at_step=3, click Finish | `UserError("still 3 parts parked")` |
|
||||||
|
| `test_button_finish_bypass` | `fp_skip_qty_gate=True` context | state→done |
|
||||||
|
| `test_complete_one_to_next_records_move` | qty=3 → click | move(qty=1) created, qty_at_step=2, state still in_progress |
|
||||||
|
| `test_complete_one_to_next_auto_finishes_on_last` | qty=1 → click | move(qty=1), source state→done, next step started |
|
||||||
|
| `test_complete_one_to_next_blocks_when_empty` | qty=0 | `UserError("nothing to complete")` |
|
||||||
|
| `test_complete_one_to_next_blocks_when_no_next_step` | last step | `UserError("last runnable step")` |
|
||||||
|
| `test_complete_one_to_next_blocks_when_not_in_progress` | state=pending | `UserError("must be in progress")` |
|
||||||
|
| `test_finish_and_advance_auto_move_for_qty_1` | running step, qty_at_step=1 | move(qty=1) recorded, then finish + auto-start next |
|
||||||
|
| `test_finish_and_advance_blocks_for_qty_gt_1` | running step, qty_at_step=3 | `UserError("use Complete 1 → Next or Move")` |
|
||||||
|
| `test_finish_and_advance_passes_for_qty_0` | qty=0 (already moved) | finish proceeds, no extra move |
|
||||||
|
| `test_button_finish_allows_last_step_with_qty` | last runnable step, qty_at_step=3, click Finish | state→done; no UserError; no move recorded |
|
||||||
|
| `test_finish_and_advance_allows_last_step_with_qty_gt_1` | last runnable step, qty_at_step=5 | state→done; no auto-move; no UserError |
|
||||||
|
| `test_display_name_format` | name=`WH/JOB/00099` | display_name=`Work Order # 00099` |
|
||||||
|
| `test_display_name_no_slash_passthrough` | name=`SmokeJob` | display_name=`SmokeJob` |
|
||||||
|
| `test_move_wizard_blocks_zero_qty` | wizard.qty_moved=0 → commit | `UserError("at least 1")` |
|
||||||
|
|
||||||
|
## Files touched
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|---|---|
|
||||||
|
| `fusion_plating_jobs/models/fp_job.py` | Add `_compute_display_name` override. |
|
||||||
|
| `fusion_plating/models/fp_job_step.py` | Quantity gate in `button_finish`; new `action_complete_one_to_next`; new helper `_fp_record_one_piece_auto_move` invoked from `action_finish_and_advance`. |
|
||||||
|
| `fusion_plating_jobs/views/fp_job_form_inherit.xml` | Header `<h1>` → `display_name`; per-row "Complete 1 → Next" button. |
|
||||||
|
| `fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py` | New `TestQtyGate` class with the 13 tests above. |
|
||||||
|
| `fusion_plating_jobs/__manifest__.py` | Version bump. |
|
||||||
|
| `fusion_plating/__manifest__.py` | Version bump (touches `fp_job_step.py`). |
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- **Auto-tick `job.qty_done` when last step finishes.** Currently `qty_done` is operator-entered before the job-level "Mark Job Done" button. A future improvement: when the last runnable step finishes with `qty_at_step > 0`, automatically bump `job.qty_done` by that count. Skipped from Phase 1 because (a) the existing job-level qty-reconciliation gate already catches mismatches and (b) it requires capturing pre-finish `qty_at_step` into the existing-but-unused `qty_at_step_finish` field, which expands scope.
|
||||||
|
- **Per-step scrap tracking** — currently scrap is captured at the *job* level (`qty_scrapped`). Per-step scrap (which step did each scrap event happen at?) is a real shop-floor desire but a bigger data-model change; future spec.
|
||||||
|
- **Auto-finish on Move wizard's last move** — when the Move wizard records a move that drops `qty_at_step` to 0, it could optionally auto-finish the source step. Skipped because the Move wizard is already explicit (operator chose a qty); an extra confirmation step adds value. Can reconsider if the manual Finish click after a manual Move becomes a friction complaint.
|
||||||
|
- **Display name in CoC / cert PDFs** — `display_name` automatically threads through Odoo's M2O rendering, but the CoC PDF template may hardcode `name` in places. Audit pass in a follow-up if/when shop reports the new label needs to land on customer-facing paperwork.
|
||||||
|
|
||||||
|
## Implementation notes / gotchas
|
||||||
|
|
||||||
|
- `qty_at_step` is `compute=False, store=False`. After creating a Move in `action_complete_one_to_next`, the in-memory cache still holds the pre-move value. Always call `invalidate_recordset(['qty_at_step'])` before reading it to decide auto-finish.
|
||||||
|
- The Move wizard's existing zero-qty guard lives in `action_commit` (raises `UserError`). The new `action_complete_one_to_next` doesn't go through the wizard, so it has its own `qty_at_step < 1` check (gates differently — refuses when nothing to move, vs. refusing when qty entered is 0). Both surfaces are now protected.
|
||||||
|
- `display_name` is a magic field in Odoo — overriding its compute is the supported pattern. Odoo's M2O widget, breadcrumb, and `name_get` API all route through it. No additional wiring needed.
|
||||||
BIN
fusion_accounting/.DS_Store
vendored
Normal file
BIN
fusion_accounting/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
fusion_accounting/fusion_accounting/.DS_Store
vendored
Normal file
BIN
fusion_accounting/fusion_accounting/.DS_Store
vendored
Normal file
Binary file not shown.
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
BIN
fusion_accounting/fusion_accounting_ai/.DS_Store
vendored
Normal file
BIN
fusion_accounting/fusion_accounting_ai/.DS_Store
vendored
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user