Compare commits
698 Commits
v1.0.0
...
7f2e61c059
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f2e61c059 | ||
|
|
eaae2e5b40 | ||
|
|
8fb6d681a0 | ||
|
|
17934fe210 | ||
|
|
ef54b5d94b | ||
|
|
3a0ea871c2 | ||
|
|
1c8abc7c1e | ||
|
|
37bf6ba23b | ||
|
|
2e649aa61b | ||
|
|
735a72d23e | ||
|
|
ebf9b6c495 | ||
|
|
13d26ac41b | ||
|
|
b829b11791 | ||
|
|
4a056ef402 | ||
|
|
da65da79c0 | ||
|
|
aa6b3fb9ad | ||
|
|
b1dd118628 | ||
|
|
f1f9d157cc | ||
|
|
283b71fda9 | ||
|
|
f27dd5620e | ||
|
|
143f59c62e | ||
|
|
be6b6517a5 | ||
|
|
d96973310b | ||
|
|
39278c4483 | ||
|
|
4534ad86f1 | ||
|
|
10e8e5be9b | ||
|
|
2fa541e962 | ||
|
|
ad2f7e6f78 | ||
| 43c62607a8 | |||
| 5cff728d79 | |||
|
|
d6382f624b | ||
| 08febb904f | |||
| 20d733e787 | |||
| 984b2ba56f | |||
| f67e5d8ccc | |||
| 2bf02e687c | |||
| 3c45ebd50f | |||
| 22d186f0ac | |||
| 0979021f41 | |||
| af4ae99dc0 | |||
| 11c489f79d | |||
| 620d6bb604 | |||
| 97cfd13da2 | |||
| 126455bf0f | |||
| 97cc447e0a | |||
| e2bbd0e522 | |||
| 51778bda9e | |||
| c06ea3bd99 | |||
| f0b0666773 | |||
| edca5fed55 | |||
| 8b6b93171f | |||
| 841f7a1c20 | |||
| 91acaba3dc | |||
| 3e5320cf9e | |||
| f9010ddefd | |||
| d722b58f4e | |||
| 3e646ea035 | |||
| 90f97daa7f | |||
| e1488e9677 | |||
| 0bcec5d3c4 | |||
| 10848ed533 | |||
| b90d65d245 | |||
| 357478e74c | |||
| 1f49c00cff | |||
| d38d0324d5 | |||
| b8ec7a3d2d | |||
| 0ac261b670 | |||
| 51411faff2 | |||
| a94906bb53 | |||
| 2262e79361 | |||
| 5327ca4f21 | |||
| 03997db3e4 | |||
| 74e4ac0c1b | |||
| 353cf17af7 | |||
| 412f8f00e0 | |||
| 6b841db4ce | |||
| 4e3818d46a | |||
| 8bb34aa4fb | |||
| 0392b191c5 | |||
| f98b0fd010 | |||
| 97dd672e6a | |||
| bde600e86a | |||
| 0e7cabdc00 | |||
| 15f9f4b724 | |||
| c24de18fcd | |||
| cb9c868e88 | |||
| 0e76aa6312 | |||
| 062ca10340 | |||
| 175c7a5e30 | |||
| e6519d30ee | |||
| f9ee416d99 | |||
| 80deac62f8 | |||
| b5d771341d | |||
| afdbd05375 | |||
| 36b94b98af | |||
| c655eb4170 | |||
| ed11b23f79 | |||
| 140eae06a0 | |||
| 234084f073 | |||
| 2fc6fbbd76 | |||
| 0b9c85f5ad | |||
| 717b51ad19 | |||
| dfdaf7a1cd | |||
| 7dba25c6d7 | |||
| 765d90512d | |||
| 090b4ea5c6 | |||
| 1326c8df4c | |||
| d10d805753 | |||
| 85bb272edf | |||
| 5f475d5714 | |||
| 8cc091ef57 | |||
| 7ba170e072 | |||
| 931b5b9ea2 | |||
| eb98ef87bb | |||
| 834ae46a14 | |||
| 7c746ad931 | |||
| 95f5c4af57 | |||
| f83c62ffa8 | |||
| 1f4b36633b | |||
| 36dc48bee0 | |||
| fc52aa8bbb | |||
| 5c516465e2 | |||
| d827fa6140 | |||
| 93d27059af | |||
| d2a13b3c01 | |||
| 22c4766e66 | |||
| 73eeee0f72 | |||
| 260300681f | |||
| a2e0c36a3c | |||
| 4f6641ceeb | |||
| cabd032bc6 | |||
| b2e095aad5 | |||
| 8c3ba0a23d | |||
| 13072de28f | |||
| a63a46cc40 | |||
| bf6e9f6a78 | |||
| 6c7947c7c1 | |||
| 6954044f7e | |||
| d6207855a0 | |||
| a41ec4412d | |||
| 8c3c224c07 | |||
| 3581b7ea80 | |||
| f7d87d3a78 | |||
| 5a52f09f97 | |||
| 420577cd72 | |||
| 01777f1536 | |||
| 24c8cd8075 | |||
| 7ace327d3f | |||
| a8d37bd766 | |||
| 6b96cdbc88 | |||
| 5bb1618b30 | |||
| 3ef819bf86 | |||
| 7ae54f9977 | |||
| 410604d890 | |||
| c8bbf9a12e | |||
| f32cce6177 | |||
| 32b48cf6d1 | |||
| eebcf936fe | |||
| 127a38f857 | |||
| 1cedd11304 | |||
| 8881caccd0 | |||
| 59a6554fa3 | |||
| 3d80a6ba5e | |||
| 07816b9cfe | |||
| 5bbd68448a | |||
| 185f26f31b | |||
| bbb88dbd22 | |||
| 3ad2ae2624 | |||
| e78eb80d08 | |||
| 498da9a728 | |||
| eac35b849f | |||
| 13770cc299 | |||
| 72b71675ae | |||
| 5cf5ca520a | |||
| 5fe0be5c59 | |||
| c504b4b2d7 | |||
| bbd3d30b37 | |||
| b4d09d3a69 | |||
| 8e7f9648fc | |||
| 2183d8d5a4 | |||
| 8398a8c212 | |||
| 8859ccad1a | |||
| c2adc96fbf | |||
| 9330662b30 | |||
| eadbbbbbc8 | |||
| 5b12e5f8db | |||
| 1d10591cf6 | |||
| fd3ce2188a | |||
| 4695a93e44 | |||
| 93f0e6b0af | |||
| 319d8a4afb | |||
| b6e85ee710 | |||
| c349441874 | |||
| 73fdbe9eba | |||
| b20b0c8df6 | |||
| cc5f4da38c | |||
| 016112a355 | |||
| 0cf03a75b0 | |||
| 7f6b93094f | |||
| ad58d40da7 | |||
| f0c1b8909e | |||
| 8e8afe39d0 | |||
| 8da50c72c7 | |||
| 683d3600ac | |||
| e2cb840844 | |||
| 19a4363d10 | |||
| 23c4edfec5 | |||
| b07ad57a36 | |||
| 6d06125360 | |||
| 4a14a78f78 | |||
| 2fba795b11 | |||
| b23dba865f | |||
| 0b1bb2ffa5 | |||
| 6890d0de07 | |||
| c180e8926f | |||
| 0fe16df326 | |||
| 3e15052520 | |||
| dee1896a6c | |||
| faf5fa605e | |||
| a7846b359c | |||
| c0cffde079 | |||
| 814071e4c1 | |||
| 30ae86a987 | |||
| 1de11a846b | |||
| 93acacb955 | |||
| 6d053dfe03 | |||
| 5f49e01495 | |||
| d609864822 | |||
| aff4459942 | |||
| 840f03e5d8 | |||
| 039b278757 | |||
| 265b41b206 | |||
| 5235b381c6 | |||
| 52c58df547 | |||
| cc8540ee91 | |||
| d70a784db8 | |||
| e6984e3393 | |||
| f4ae7ebd7c | |||
| 5f744ec25c | |||
| 1dc69008d8 | |||
| 96aab15a7a | |||
| dd06fc523c | |||
| 7cafd8381f | |||
| c009b15ed0 | |||
| 6717feb22d | |||
| 7998467adf | |||
| 83ef3c6ae9 | |||
| 372d04b30f | |||
| 4e6ddc39c1 | |||
| a3f15c30e6 | |||
| cdb0bf7254 | |||
| 843ade6f3d | |||
| 62f731530b | |||
| 14c2548c16 | |||
| 13000f110d | |||
| 915fd17d83 | |||
| 12de27d105 | |||
| 1a1f675cf6 | |||
| 3b67f3c251 | |||
| 551a040df0 | |||
| b6c3279917 | |||
| 2b956e6142 | |||
| ae0817e0ac | |||
| 7451e19125 | |||
| 52418b6244 | |||
| a577595ee6 | |||
| 8db31ddaf0 | |||
| e371b116f7 | |||
| 3212d551d2 | |||
| 8d557a63a0 | |||
| ee9866c28e | |||
| 8d9dd6c275 | |||
| 0841fb4609 | |||
| 5b059b70e6 | |||
| 3319563b22 | |||
| ddc2884f61 | |||
| 5dfd24b5de | |||
| 7ae145b6c1 | |||
| 7909b10636 | |||
| bab6c14499 | |||
| c816ecdbb7 | |||
| 024f8f78bd | |||
| 94595346b7 | |||
| 8a4d730c5a | |||
| 3226f55a7d | |||
| 8eba77b26c | |||
| a0c1408f8e | |||
| 7e726010b8 | |||
| 4abd9b7e85 | |||
| e984c026b9 | |||
| 4affffaf0a | |||
| c55338e879 | |||
| 1c288d9646 | |||
| d37bad643d | |||
| f493901e8d | |||
| 934f3511f7 | |||
| 62a1bc69f7 | |||
| 741389b71c | |||
| 9dd4543fc1 | |||
| af89743b40 | |||
| 7f0400400c | |||
| d9cf6c9a49 | |||
| 1a9114da7e | |||
| b5bc559b11 | |||
| 8e67c7aecb | |||
| 6b884af9cc | |||
| e768fe71a3 | |||
| 5f8d081b0e | |||
| 20c3dacded | |||
| 6fdf5e1994 | |||
| 92210a381a | |||
| cd2a82f61c | |||
| 4eae444c93 | |||
| 02993336de | |||
| 2c38d2742b | |||
| a724e0a086 | |||
| 399d9da9e5 | |||
| 6f0e0d65ee | |||
| 20f2b87d99 | |||
| 56f2c3a4a6 | |||
| a1a13a6846 | |||
| c244cf658c | |||
| 6fc0e5982a | |||
| 84fa03bf77 | |||
| 11cabcee32 | |||
| 1cdc54e59e | |||
| d2288e009f | |||
| d81b19bfc6 | |||
| b2fb1e9c4c | |||
| cac84abbef | |||
| 191abf022f | |||
| 0011a7f943 | |||
| 697b53e68a | |||
| 0e5c40e9a2 | |||
| bb313d29bf | |||
| 3f54cbef6a | |||
| 00cae2f82b | |||
| d1d37ee4b3 | |||
| 08a66bc219 | |||
| 35c9731a85 | |||
| d458e9849d | |||
| b94d6e911d | |||
| 4fb39d564e | |||
| 10b370f11b | |||
| 479e0d4d5a | |||
| 4059cd591e | |||
| 9565e82850 | |||
| 29ada1899e | |||
| 67d3ce545e | |||
| 9b8d372fa6 | |||
| dda6a63e74 | |||
| 2fc89282b9 | |||
| cca82dd9f7 | |||
| 1e51061ddc | |||
| 267e5a3509 | |||
| e040a4fc11 | |||
| 6f4649c778 | |||
| 177f862263 | |||
| 93456e9530 | |||
| 8ee9354327 | |||
| 6f4ad3723f | |||
| f3370e89ea | |||
| 330fc8d320 | |||
| f0b979ac8c | |||
| f3ebeb7cd3 | |||
| 981a97ed05 | |||
| 9ed4d5f6bb | |||
| e43189f052 | |||
| 0fcaca2b5a | |||
| 3db2f8c96e | |||
| b0ebc02434 | |||
| 0be0194ab5 | |||
| 7d99f56fa5 | |||
| 6eec004781 | |||
| 70603d95d4 | |||
| c78f8b1079 | |||
| 9ec76410f7 | |||
| e47023c4ba | |||
| 133805eab5 | |||
| 878588d567 | |||
| d88a9f204c | |||
| 1ab2b56f32 | |||
| b41fe0fa54 | |||
| 1326669f01 | |||
| e37cd4a1cc | |||
| 1eb86892de | |||
| 91095f28e9 | |||
| e41ad51634 | |||
| 2eabfe2587 | |||
| a71d666619 | |||
| 38254fee48 | |||
| 5cc66b3a3a | |||
| d007db7a1b | |||
| 935862a66c | |||
| 0c83fcf35e | |||
| bc5aa57319 | |||
| 231b733222 | |||
| c02d7212bb | |||
| df5ef074ad | |||
| 04179fde77 | |||
| 0cd9d63b15 | |||
| b5b2f1c362 | |||
| 14dd557e58 | |||
| 1e7cd1c7be | |||
| 0a04774fac | |||
| 720ad804a4 | |||
| 175b10e59d | |||
| 03d923441e | |||
| 3da61070eb | |||
| 6a0acb1c20 | |||
| 3195994444 | |||
| 7ab361c2b8 | |||
| 894375c143 | |||
| 225df5c6da | |||
| 1a2d595303 | |||
| cccff5726c | |||
| 9a354181b0 | |||
| c7dce8bedc | |||
| adecdbdb4f | |||
| 06d40ddbae | |||
| e009a9e1e3 | |||
| d22610abb3 | |||
| 8c033f4eab | |||
| cb6c1819f4 | |||
| 762cfc66c1 | |||
| 1d653b2756 | |||
| b7927b787d | |||
| e57019b39c | |||
| a09a88ff5f | |||
| c1a4b442a3 | |||
| 34166dedc6 | |||
| 9095f3b470 | |||
| 5255959614 | |||
| 1b92b1d207 | |||
| 67eca35a9a | |||
| a35e304561 | |||
| f93c81624d | |||
| d47c2cb5fb | |||
| 45ab8e8904 | |||
| 0f3497e867 | |||
| b0e19226eb | |||
| 95589bcf73 | |||
| 03c7f41457 | |||
| cd0c068b3f | |||
| 05d5d85bee | |||
| d213328fd9 | |||
| 9235c22e04 | |||
| c1ca8c707c | |||
| 6c4ded50d0 | |||
| 86b8d3e250 | |||
| 7b94c56e7c | |||
| c6f963b6be | |||
| 181217e755 | |||
| 00a6a5debe | |||
| 91f85ea7c1 | |||
| aaf4a264a1 | |||
| b03a97d02d | |||
| a69a9dcb57 | |||
| 0a88b26160 | |||
| 3a79652c59 | |||
| a0ed097b6c | |||
| a9d6f8fa58 | |||
| 2ebad01847 | |||
| 71716c1ad5 | |||
| 240f35af26 | |||
| 1ebbb874ef | |||
| eef972c679 | |||
| 011af03e8a | |||
| 24843ab72d | |||
| 65c0053df4 | |||
| f391e17fb6 | |||
| 44fc7dc855 | |||
| 99d895c951 | |||
| 39e59b5b8b | |||
| 7bc3e350ba | |||
| fe7c10d527 | |||
| 272875eb69 | |||
| 16df1c99a6 | |||
| 403db5133e | |||
| 43312ba412 | |||
| f4a53f2705 | |||
| 0a41765551 | |||
| 28789200c3 | |||
| 350e41714f | |||
| 84b70b73c5 | |||
| 68e0000afc | |||
| 33eeabf5e1 | |||
| a4f0a08469 | |||
| 7f7b103945 | |||
| 8fddb599ef | |||
| 313d574a08 | |||
| f7d6ed00fd | |||
| 6c570acfe6 | |||
| 2ba836c284 | |||
| 5b32a402a6 | |||
| 10aaff4cc8 | |||
| 40025169d4 | |||
| 38a903469c | |||
| 70a0a41787 | |||
| 318f9e5564 | |||
| d73816285e | |||
| 86ac172cfd | |||
| 703c7e46fa | |||
| 747c2e4a0e | |||
| 7d89418874 | |||
| 64806b143b | |||
| 7b669a27a8 | |||
| 62c2080fee | |||
| f5d6d4ef55 | |||
| 4513ce8ac7 | |||
| 88eb890c46 | |||
| 9a66952f50 | |||
| d0db0abdd4 | |||
| d9cb2b7961 | |||
| 3eedc0b30e | |||
| b51ee12331 | |||
| 744e147a1a | |||
| 8cc0bffcae | |||
| 2b3cf5c095 | |||
| 94f15d66da | |||
| 8edd5ea901 | |||
| 1e23528258 | |||
| 9447a685f2 | |||
| 7155708d4b | |||
| a76b8798ae | |||
| 016b92e5aa | |||
| 6085879d6f | |||
| 42652d0fcb | |||
| 5e8917430b | |||
| cdcefe6fbc | |||
| 0678263f3e | |||
| aa69b8157e | |||
| 47ce23f4e8 | |||
| f6fe3fb2bd | |||
| fabc380784 | |||
| 7c845d0f9a | |||
| 50da3d7a68 | |||
| da1b268862 | |||
| 73e46b399b | |||
| 431062f123 | |||
| 3846932517 | |||
| aa10a58b60 | |||
| 9db6d2cb45 | |||
| 509dcac22e | |||
| f3102ee835 | |||
| 4c3056326b | |||
| 1339f51256 | |||
| 408c5d82e4 | |||
| 71a58814b4 | |||
| 870f2e93f4 | |||
| 842a2b1da6 | |||
| bf7de1e5d1 | |||
| ba0dd0e0fb | |||
| e0c7480dd2 | |||
| 9b324ca7cd | |||
| 0cb824076b | |||
| 02320ce9c8 | |||
| 6d82621a34 | |||
| 0dae857364 | |||
| 9a974365a7 | |||
| e9e57803ae | |||
| a3b7d11ae3 | |||
| 2248f49f9d | |||
| f96e73d7b9 | |||
| ece0e55f98 | |||
| db207f4995 | |||
| f94f8d3abe | |||
| 017eb92c6a | |||
| 765d526c16 | |||
| e71c6b14d0 | |||
| 210adad3f6 | |||
| 72b1d4ffcd | |||
| e2fc7178c4 | |||
| 58b360ed6c | |||
| 985f4dc19d | |||
| 757b9f724d | |||
| fbfc943301 | |||
| 4c70f0bc5f | |||
| ef27c020e0 | |||
| 23d9d88492 | |||
| fffbb9708a | |||
| e7ff1fd56c | |||
| 5b962eeb1d | |||
| 08581f24ea | |||
| 5e9141f03a | |||
| 2269fc7709 | |||
| b966366bc4 | |||
| e2b0695cb8 | |||
| 000547ae21 | |||
| 8cc19b67f9 | |||
| fcb6377b21 | |||
| 8a54b6fcbc | |||
| ecdc4b283d | |||
| d9ab3cfe47 | |||
| 72fc0c5431 | |||
| 044096c8d7 | |||
| 96b6be3398 | |||
| 42fb2e0d22 | |||
| 227eb3cf46 | |||
| 0e007afed3 | |||
| ddf023f078 | |||
| a9d1fe7baf | |||
| aeadca6f60 | |||
| 17233d8ded | |||
| 797783b59e | |||
| e5e76e2dcb | |||
| 0fc80958af | |||
| b43ab082b4 | |||
| fcf61605d6 | |||
| c5053638df | |||
| c5fbd3e528 | |||
| 117c33a2ea | |||
| 327de18f1a | |||
| 437904bcd8 | |||
| 6d75b4660e | |||
| a110019345 | |||
| da530a2211 | |||
| 51a512e568 | |||
| 5df6378e80 | |||
| a479dc718b | |||
| c39bb1d6d1 | |||
| 5c1ca6a895 | |||
| 24c1186061 | |||
| 8ae4aeb3e8 | |||
| 9daa701a28 | |||
| 336c2415b3 | |||
| 49731501d6 | |||
| 661d38404a | |||
| e869338703 | |||
| b55c1e3c8f | |||
| 3820f00182 | |||
| 178fb25a2f | |||
| 6b2d5aebf8 | |||
| 0d3bfda08c | |||
| 11f33236cf | |||
| db7288c709 | |||
| a31e51152b | |||
| 97b8632b74 | |||
| 2ebbe334bf | |||
| 7571a1cb80 | |||
| b4f794c275 | |||
| 2f1adffba3 | |||
| 0047e6e879 | |||
| e8453c76a7 | |||
| 5d7d0ffe5b | |||
| 39c9d83f1b | |||
| b38f7553a6 | |||
| 9b887a6b4c | |||
| b43db20bc4 | |||
| 8e38ceb13e | |||
| 18c3cf21c6 | |||
| a73564a24d | |||
| 9c6029d152 | |||
| 7a35413da5 | |||
| 660e714983 | |||
| 9edfcd5058 | |||
| cd8f785881 | |||
| 24895173d6 | |||
| 6a1c26ce70 | |||
| ba06fb60c2 | |||
| 3204eee7ac | |||
| 815b8696d2 | |||
| 2e80c19e0e | |||
| 170cc09627 | |||
| 8fd8de607f | |||
| ebdfd7f499 | |||
| 19669281d5 | |||
| ccbf545fe2 | |||
| 550ad57354 | |||
| dbb488ba52 | |||
| 49d2438ea0 | |||
| 92ba297bb7 | |||
| cad3d04d08 | |||
| f1e002998e | |||
| c42edc4efd | |||
| e5056c6ea1 | |||
| 56a4bd9e82 | |||
| 9449f4f0ec | |||
| 5111265019 | |||
| 750db2fe4d | |||
| 36f9fb48a8 | |||
| b857ab3eed | |||
| dfc2ea3a4e | |||
| dfc2ae4896 | |||
| 272b91609c | |||
| f47036b6df | |||
| bed9c9deac | |||
| 837c75f8f0 | |||
| dee3d2ff90 | |||
| 02b99b4622 | |||
| 37d73b6962 | |||
| 35c56538dd | |||
| 13b7553641 | |||
| bb117d4ee6 | |||
| b0b2741422 | |||
| ca83f0ec7b | |||
| 1ab5e57017 | |||
| a34f02db00 |
17
.claude/settings.local.json
Normal file
17
.claude/settings.local.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(cd /c/Users/SylvainDUVERNAY/Documents/Visual Studio Code/Tradon DEV/tradon/modules)",
|
||||
"Bash(ls -d */)",
|
||||
"Bash(cd /c/Users/SylvainDUVERNAY/Documents/Visual Studio Code/Tradon DEV/tradon/modules/purchase_trade)",
|
||||
"Bash(ls -1d */)",
|
||||
"Bash(for f:*)",
|
||||
"Bash(do echo:*)",
|
||||
"Read(//c/Users/SylvainDUVERNAY/Documents/Visual Studio Code/Tradon DEV/tradon/**)",
|
||||
"Bash(done)",
|
||||
"Bash(cd /c/Users/SylvainDUVERNAY/Documents/Visual Studio Code/Tradon DEV/tradon/modules/purchase_trade/view)",
|
||||
"Bash(ls -1 *.xml)",
|
||||
"Bash(py --version)"
|
||||
]
|
||||
}
|
||||
}
|
||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
deployment/vps-TradonDev_Instructions.md
|
||||
deployment/vps/46.202.173.47-credentials.md
|
||||
107
AGENTS.md
Normal file
107
AGENTS.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# AGENTS.md
|
||||
|
||||
Guide rapide pour les agents qui codent dans ce repository.
|
||||
|
||||
## 1) Contexte du projet
|
||||
|
||||
- Codebase Tryton monolithique (coeur + modules metier).
|
||||
- Noyau serveur a la racine: `application.py`, `wsgi.py`, `admin.py`, `worker.py`, `cron.py`.
|
||||
- Couches framework importantes:
|
||||
- ORM: `model/`
|
||||
- Meta/systeme (`ir`): `ir/`
|
||||
- Protocoles RPC: `protocols/`
|
||||
- Backend DB: `backend/`
|
||||
- Modules metier: `modules/<module_name>/` (~220 modules).
|
||||
|
||||
## 2) Regles de travail pour agent
|
||||
|
||||
- Ne jamais toucher des fichiers sans rapport avec la demande.
|
||||
- Limiter le scope de modif au minimum necessaire.
|
||||
- Respecter le style existant du module cible.
|
||||
- Ne pas supprimer du code legacy sans verifier les usages.
|
||||
- Si comportement incertain: preferer un patch conservateur + test.
|
||||
|
||||
## 3) Zones de bruit a ignorer pendant l'exploration
|
||||
|
||||
- `.venv/`
|
||||
- `__pycache__/`
|
||||
- `build/` (quand present dans des sous-modules)
|
||||
- Fichiers temporaires editeur (ex: `*.swp`)
|
||||
|
||||
## 4) Comment choisir ou coder selon le besoin
|
||||
|
||||
- Si bug ORM/champs:
|
||||
- Lire `model/fields/*.py` et les tests `tests/test_field_*.py`.
|
||||
- Si bug transaction/DB:
|
||||
- Lire `transaction.py`, `backend/*/database.py`, `tests/test_backend.py`.
|
||||
- Si bug API/RPC/HTTP:
|
||||
- Lire `wsgi.py`, `rpc.py`, `protocols/*`, `tests/test_rpc.py`, `tests/test_wsgi.py`.
|
||||
- Si bug metier:
|
||||
- Modifier uniquement `modules/<module>/` + ses tests.
|
||||
- Si bug template Relatorio (`.fodt`):
|
||||
- Lire d'abord le template standard voisin du meme domaine (`invoice.fodt`, `sale.fodt`, etc.).
|
||||
- Preferer des proprietes Python simples exposees par le modele plutot que des expressions Genshi complexes dans le template.
|
||||
- Dans les placeholders XML, utiliser `"` et `'` plutot que des antislashs type `\'`.
|
||||
- Si un document facture depend fortement d'une vente/achat, ajouter au besoin un petit pont Python pour exposer des `report_*` stables au template.
|
||||
- Si plusieurs actions de report pointent vers `report_name = 'account.invoice'`, verifier aussi le cache `invoice_report_cache` dans `modules/account_invoice/invoice.py`: un mauvais cache peut faire croire que plusieurs actions utilisent le meme `.fodt`.
|
||||
- Avant de conclure qu'un template ou une action est faux, verifier si le report alternatif doit bypasser le cache standard.
|
||||
- Dans `purchase_trade`, pour remonter d'une facture vers shipment, pro forma, freight ou autres donnees logistiques, privilegier le lot physique comme pont entre `purchase.line`, `sale.line` et shipment.
|
||||
- Pour `FREIGHT VALUE`, ne pas lire un champ direct sur la facture: retrouver le fee de shipment (`shipment_in`) dont le produit est `Maritime freight`, puis utiliser `fee.get_amount()`.
|
||||
|
||||
## 5) Workflow de modification (obligatoire)
|
||||
|
||||
1. Identifier le module et le flux impacte.
|
||||
2. Localiser un test existant proche du comportement a changer.
|
||||
3. Implementer le plus petit patch possible.
|
||||
4. Ajouter/adapter les tests au plus pres du changement.
|
||||
5. Lancer la validation ciblee (pas toute la suite si inutile).
|
||||
6. Donner un resume du risque residuel.
|
||||
|
||||
## 6) Checklist avant de rendre une modif
|
||||
|
||||
- Le changement est-il limite au domaine demande ?
|
||||
- Le comportement existant non cible est-il preserve ?
|
||||
- Les droits/regles (`ir.rule`, acces) sont-ils impactes ?
|
||||
- Les vues XML et labels sont-ils coherents si un champ change ?
|
||||
- Les tests modifies couvrent-ils le bug/la feature ?
|
||||
- Le message de commit (si demande) explique clairement le pourquoi ?
|
||||
|
||||
## 7) Tests: point de depart pratique
|
||||
|
||||
- Suite coeur: `tests/test_tryton.py`
|
||||
- Tests coeur par domaine: `tests/test_*.py`
|
||||
- Tests module:
|
||||
- `modules/<module>/tests/test_module.py`
|
||||
- `modules/<module>/tests/test_scenario.py`
|
||||
- `modules/<module>/tests/scenario_*.rst`
|
||||
|
||||
Quand possible, lancer d'abord la cible minimale:
|
||||
|
||||
- fichier de test touche
|
||||
- puis fichier voisin de regression
|
||||
- puis suite plus large uniquement si necessaire
|
||||
|
||||
## 8) Contrat de sortie attendu de l'agent
|
||||
|
||||
Toujours fournir:
|
||||
|
||||
- Liste des fichiers modifies
|
||||
- Resume fonctionnel (ce qui change)
|
||||
- Resume technique (pourquoi ce design)
|
||||
- Tests executes + resultat
|
||||
- Risques residuels et impacts potentiels
|
||||
|
||||
## 9) Cas sensibles (demander confirmation humaine)
|
||||
|
||||
- Changement schema/structure de donnees
|
||||
- Changement de logique de securite/acces
|
||||
- Changement de comportement transverse (transaction, pool, RPC, worker)
|
||||
- Refactor multi-modules sans ticket explicite
|
||||
|
||||
## 10) Raccourci de demarrage pour agent
|
||||
|
||||
1. Lire ce fichier.
|
||||
2. Lire le(s) fichier(s) touche(s) et leurs tests.
|
||||
3. Proposer le patch minimal.
|
||||
4. Implementer + tester cible.
|
||||
5. Rendre avec le contrat de sortie (section 8).
|
||||
5
deployment/README.md
Normal file
5
deployment/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Deployment Notes
|
||||
|
||||
- Runbook onboarding SSH: `deployment/runbooks/vps-onboarding.md`
|
||||
- VPS 46.202.173.47 credentials: `deployment/vps/46.202.173.47-credentials.md`
|
||||
- VPS 46.202.173.47 quickstart: `deployment/vps/46.202.173.47-quickstart.md`
|
||||
57
deployment/runbooks/vps-onboarding.md
Normal file
57
deployment/runbooks/vps-onboarding.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Procedure - Ajouter un nouveau VPS (SSH)
|
||||
|
||||
Date de reference: 2026-04-07
|
||||
|
||||
## 1) Preparation locale (Windows)
|
||||
|
||||
1. Creer le dossier SSH local si absent:
|
||||
`New-Item -ItemType Directory -Force $env:USERPROFILE\.ssh`
|
||||
|
||||
2. Generer une cle dediee VPS:
|
||||
`ssh-keygen -t ed25519 -C "vps-deploy" -f $env:USERPROFILE\.ssh\vps_deploy_key`
|
||||
|
||||
3. Lire la cle publique:
|
||||
`Get-Content $env:USERPROFILE\.ssh\vps_deploy_key.pub`
|
||||
|
||||
## 2) Installer la cle sur le VPS
|
||||
|
||||
1. Se connecter au VPS avec mot de passe (premiere fois):
|
||||
`ssh <user>@<ip_vps>`
|
||||
|
||||
2. Sur le VPS, preparer le dossier SSH:
|
||||
`mkdir -p ~/.ssh`
|
||||
`chmod 700 ~/.ssh`
|
||||
|
||||
3. Ajouter la cle publique (une ligne complete):
|
||||
`echo "ssh-ed25519 ... vps-deploy" >> ~/.ssh/authorized_keys`
|
||||
`chmod 600 ~/.ssh/authorized_keys`
|
||||
|
||||
## 3) Tester la connexion par cle
|
||||
|
||||
Depuis Windows:
|
||||
`ssh -i $env:USERPROFILE\.ssh\vps_deploy_key <user>@<ip_vps>`
|
||||
|
||||
## 4) Test operationnel minimal
|
||||
|
||||
Creer un dossier distant:
|
||||
`ssh -i $env:USERPROFILE\.ssh\vps_deploy_key <user>@<ip_vps> "mkdir -p ~/test_codex_deploy && ls -ld ~/test_codex_deploy"`
|
||||
|
||||
## 5) Durcissement recommande
|
||||
|
||||
- Desactiver l'authentification par mot de passe apres validation de la cle.
|
||||
- Utiliser une cle dediee par environnement (dev/staging/prod).
|
||||
- Documenter user + IP + chemin de cle dans une fiche VPS separee.
|
||||
|
||||
## 6) Note importante - Contrainte sandbox Codex
|
||||
|
||||
- Si une commande SSH/SCP echoue avec un message proche de:
|
||||
- `Identity file ... not accessible: Permission denied`
|
||||
- Cause probable:
|
||||
- la session est en sandbox et ne peut pas lire la cle locale dans
|
||||
`C:\Users\<user>\.ssh\...`.
|
||||
- Action:
|
||||
- relancer la commande en mode `require_escalated` (hors sandbox) pour
|
||||
autoriser l'acces a la cle locale.
|
||||
- Exemple observe le 2026-04-07:
|
||||
- creation du dossier `/root/test` sur `46.202.173.47` reussie uniquement
|
||||
apres escalation.
|
||||
39
deployment/vps/46.202.173.47-quickstart.md
Normal file
39
deployment/vps/46.202.173.47-quickstart.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Commandes Rapides - VPS 46.202.173.47
|
||||
|
||||
Date de reference: 2026-04-07
|
||||
|
||||
## Cles SSH par utilisateur
|
||||
|
||||
| Utilisateur | Fichier cle locale |
|
||||
|---|---|
|
||||
| Laurent Barontini | `$env:USERPROFILE\.ssh\vps_deploy_key` |
|
||||
| Sylvain Duvernay | `$env:USERPROFILE\.ssh\id_ed25519` |
|
||||
|
||||
> Les commandes ci-dessous utilisent `id_ed25519` (Sylvain Duvernay).
|
||||
|
||||
## 1) Test SSH
|
||||
|
||||
`ssh -i $env:USERPROFILE\.ssh\id_ed25519 <user>@46.202.173.47 "echo ok"`
|
||||
|
||||
## 2) Creer un dossier test distant
|
||||
|
||||
`ssh -i $env:USERPROFILE\.ssh\id_ed25519 <user>@46.202.173.47 "mkdir -p ~/test_codex_deploy && ls -ld ~/test_codex_deploy"`
|
||||
|
||||
## 3) Lister home distant
|
||||
|
||||
`ssh -i $env:USERPROFILE\.ssh\id_ed25519 <user>@46.202.173.47 "ls -la ~"`
|
||||
|
||||
## 4) Copier un fichier local vers le VPS
|
||||
|
||||
`scp -i $env:USERPROFILE\.ssh\id_ed25519 .\local.txt <user>@46.202.173.47:~/local.txt`
|
||||
|
||||
## 5) Recuperer un fichier du VPS
|
||||
|
||||
`scp -i $env:USERPROFILE\.ssh\id_ed25519 <user>@46.202.173.47:~/remote.txt .\remote.txt`
|
||||
|
||||
## 6) Depannage sandbox (Codex)
|
||||
|
||||
- Symptome:
|
||||
- `Identity file ... not accessible: Permission denied`
|
||||
- Correctif:
|
||||
- relancer la commande SSH/SCP en mode escalade (`require_escalated`).
|
||||
3
deployment/vps/vps-TradonDev.md
Normal file
3
deployment/vps/vps-TradonDev.md
Normal file
@@ -0,0 +1,3 @@
|
||||
Serveur: 'VPS-62.72.36.116'
|
||||
Alias Name: 'VPS DEV'
|
||||
IP Address:'62.72.36.116'
|
||||
@@ -93,6 +93,7 @@ class Model(
|
||||
cursor.execute(*ir_model.select(ir_model.id,
|
||||
where=ir_model.model == model.__name__))
|
||||
model_id = None
|
||||
logger.info("MODEL_NAME:%s",model.__name__)
|
||||
if cursor.rowcount == -1 or cursor.rowcount is None:
|
||||
data = cursor.fetchone()
|
||||
if data:
|
||||
|
||||
@@ -30,8 +30,8 @@ class Binary(Field):
|
||||
on_change_with=None, depends=None, context=None, loading='lazy',
|
||||
filename=None, file_id=None, store_prefix=None):
|
||||
self.filename = filename
|
||||
self.file_id = file_id
|
||||
self.store_prefix = store_prefix
|
||||
self.file_id = None #file_id
|
||||
self.store_prefix = None #store_prefix
|
||||
super(Binary, self).__init__(string=string, help=help,
|
||||
required=required, readonly=readonly, domain=domain, states=states,
|
||||
on_change=on_change, on_change_with=on_change_with,
|
||||
|
||||
@@ -103,15 +103,26 @@ class Model(URLMixin, PoolBase, metaclass=ModelMeta):
|
||||
|
||||
@classmethod
|
||||
def _get_name(cls):
|
||||
'''
|
||||
Returns the first non-empty line of the model docstring.
|
||||
'''
|
||||
assert cls.__doc__, '%s has no docstring' % cls
|
||||
if cls.__doc__ is None:
|
||||
print("\n💥 MODELE SANS DOCSTRING :", cls.__name__, " (module:", cls.__module__, ")")
|
||||
raise Exception("MODELE SANS DOCSTRING")
|
||||
|
||||
lines = cls.__doc__.splitlines()
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if line:
|
||||
return line
|
||||
if lines:
|
||||
return lines[0]
|
||||
return cls.__name__
|
||||
|
||||
# @classmethod
|
||||
# def _get_name(cls):
|
||||
# '''
|
||||
# Returns the first non-empty line of the model docstring.
|
||||
# '''
|
||||
# assert cls.__doc__, '%s has no docstring' % cls
|
||||
# lines = cls.__doc__.splitlines()
|
||||
# for line in lines:
|
||||
# line = line.strip()
|
||||
# if line:
|
||||
# return line
|
||||
|
||||
@classmethod
|
||||
def __register__(cls, module_name):
|
||||
|
||||
@@ -117,6 +117,7 @@ class Move(DescriptionOriginMixin, ModelSQL, ModelView):
|
||||
date = fields.Date('Effective Date', required=True, states=_MOVE_STATES)
|
||||
post_date = fields.Date('Post Date', readonly=True)
|
||||
description = fields.Char('Description', states=_MOVE_STATES)
|
||||
ext_ref = fields.Char('Ext. Ref')
|
||||
origin = fields.Reference('Origin', selection='get_origin',
|
||||
states=_MOVE_STATES)
|
||||
state = fields.Selection([
|
||||
@@ -921,6 +922,7 @@ class Line(DescriptionOriginMixin, MoveLineMixin, ModelSQL, ModelView):
|
||||
fields.Reference("Move Origin", selection='get_move_origin'),
|
||||
'get_move_field', searcher='search_move_field')
|
||||
description = fields.Char('Description', states=_states)
|
||||
ext_ref = fields.Char('Ext. Ref')
|
||||
move_description_used = fields.Function(
|
||||
fields.Char("Move Description", states=_states),
|
||||
'get_move_field',
|
||||
|
||||
@@ -21,6 +21,8 @@ this repository contains the full copyright notices and license terms. -->
|
||||
<field name="origin" colspan="3"/>
|
||||
<label name="description_used"/>
|
||||
<field name="description_used" colspan="3"/>
|
||||
<label name="ext_ref"/>
|
||||
<field name="ext_ref" colspan="3"/>
|
||||
<notebook>
|
||||
<page name="lines">
|
||||
<field name="lines" colspan="4"
|
||||
|
||||
@@ -26,6 +26,8 @@ this repository contains the full copyright notices and license terms. -->
|
||||
<field name="origin"/>
|
||||
<label name="description_used"/>
|
||||
<field name="description_used" colspan="3"/>
|
||||
<label name="ext_ref"/>
|
||||
<field name="ext_ref" colspan="3"/>
|
||||
<notebook colspan="4">
|
||||
<page string="Other Info" id="info">
|
||||
<label name="date"/>
|
||||
|
||||
@@ -4,6 +4,7 @@ this repository contains the full copyright notices and license terms. -->
|
||||
<tree editable="1">
|
||||
<field name="move"/>
|
||||
<field name="account" expand="1"/>
|
||||
<field name="ext_ref" expand="1" optional="1"/>
|
||||
<field name="party" expand="1"/>
|
||||
<field name="debit" sum="1"/>
|
||||
<field name="credit" sum="1"/>
|
||||
|
||||
@@ -1286,9 +1286,14 @@ class Invoice(Workflow, ModelSQL, ModelView, TaxableMixin, InvoiceReportMixin):
|
||||
remainder = sum(l.debit - l.credit for l in move_lines)
|
||||
if self.payment_term:
|
||||
payment_date = self.payment_term_date or self.invoice_date or today
|
||||
purchase_line = int(str(self.lines[0].origin).split(",")[1]) if self.lines[0].origin else None
|
||||
term_lines = self.payment_term.compute(
|
||||
self.total_amount, self.currency, payment_date, purchase_line)
|
||||
model = str(self.lines[0].origin).split(",")[0] if self.lines[0].origin else None
|
||||
logger.info("MODEL:%s",model)
|
||||
if model:
|
||||
Line = Pool().get(model)
|
||||
line = Line(int(str(self.lines[0].origin).split(",")[1]))
|
||||
logger.info("LINE:%s",line)
|
||||
term_lines = self.payment_term.compute(
|
||||
self.total_amount, self.currency, payment_date, line)
|
||||
else:
|
||||
term_lines = [(self.payment_term_date or today, self.total_amount)]
|
||||
past_payment_term_dates = []
|
||||
@@ -1960,14 +1965,16 @@ class Invoice(Workflow, ModelSQL, ModelView, TaxableMixin, InvoiceReportMixin):
|
||||
if amount < 0:
|
||||
move_line.debit = Decimal(0)
|
||||
move_line.credit = -amount
|
||||
move_line.account = gl.product.account_stock_used
|
||||
move_line.account = gl.product.account_stock_used if not (move_line.lot.sale_invoice_line or move_line.lot.sale_invoice_line_prov) else gl.product.account_stock_out_used
|
||||
move_line.account = gl.product.account_cogs_used if gl.fee else move_line.account
|
||||
move_line_.credit = Decimal(0)
|
||||
move_line_.debit = -amount
|
||||
move_line_.account = gl.product.account_stock_in_used
|
||||
else:
|
||||
move_line.debit = amount
|
||||
move_line.credit = Decimal(0)
|
||||
move_line.account = gl.product.account_stock_used
|
||||
move_line.account = gl.product.account_stock_used if not (move_line.lot.sale_invoice_line or move_line.lot.sale_invoice_line_prov) else gl.product.account_stock_out_used
|
||||
move_line.account = gl.product.account_cogs_used if gl.fee else move_line.account
|
||||
move_line_.debit = Decimal(0)
|
||||
move_line_.credit = amount
|
||||
move_line_.account = gl.product.account_stock_in_used
|
||||
@@ -2031,7 +2038,11 @@ class Invoice(Workflow, ModelSQL, ModelView, TaxableMixin, InvoiceReportMixin):
|
||||
var_qt = sum([i.quantity for i in gl])
|
||||
logger.info("LOT_TO_PROCESS:%s",lot)
|
||||
logger.info("FEE_TO_PROCESS:%s",gl[0].fee)
|
||||
if lot:
|
||||
if (gl[0].fee and not gl[0].product.landed_cost):
|
||||
diff = gl[0].fee.amount - gl[0].fee.get_non_cog(lot)
|
||||
account_move = gl[0].fee._get_account_move_fee(lot,'in',diff)
|
||||
Move.save([account_move])
|
||||
if (lot and not gl[0].fee) or (gl[0].fee and gl[0].product.landed_cost):
|
||||
adjust_move_lines = []
|
||||
mov = None
|
||||
if self.type == 'in':
|
||||
@@ -3684,13 +3695,19 @@ class InvoiceReport(Report):
|
||||
Invoice = pool.get('account.invoice')
|
||||
# Re-instantiate because records are TranslateModel
|
||||
invoice, = Invoice.browse(records)
|
||||
if invoice.invoice_report_cache:
|
||||
report_path = cls._get_action_report_path(action)
|
||||
use_cache = (
|
||||
report_path in (None, 'account_invoice/invoice.fodt')
|
||||
and invoice.invoice_report_cache
|
||||
)
|
||||
if use_cache:
|
||||
return (
|
||||
invoice.invoice_report_format,
|
||||
invoice.invoice_report_cache)
|
||||
else:
|
||||
result = super()._execute(records, header, data, action)
|
||||
if invoice.invoice_report_versioned:
|
||||
if (invoice.invoice_report_versioned
|
||||
and report_path in (None, 'account_invoice/invoice.fodt')):
|
||||
format_, data = result
|
||||
if isinstance(data, str):
|
||||
data = bytes(data, 'utf-8')
|
||||
@@ -3707,6 +3724,12 @@ class InvoiceReport(Report):
|
||||
with Transaction().set_context(language=False):
|
||||
return super().render(*args, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
def _get_action_report_path(action):
|
||||
if isinstance(action, dict):
|
||||
return action.get('report')
|
||||
return getattr(action, 'report', None)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, ids, data):
|
||||
pool = Pool()
|
||||
|
||||
@@ -264,11 +264,6 @@ this repository contains the full copyright notices and license terms. -->
|
||||
<field name="wiz_name">account.invoice.refresh_invoice_report</field>
|
||||
<field name="model">account.invoice</field>
|
||||
</record>
|
||||
<record model="ir.action.keyword" id="refresh_invoice_report_keyword">
|
||||
<field name="keyword">form_print</field>
|
||||
<field name="model">account.invoice,-1</field>
|
||||
<field name="action" ref="refresh_invoice_report_wizard"/>
|
||||
</record>
|
||||
<record model="ir.action-res.group" id="refresh_invoice_report-group_account_admin">
|
||||
<field name="action" ref="refresh_invoice_report_wizard"/>
|
||||
<field name="group" ref="account.group_account_admin"/>
|
||||
@@ -293,7 +288,7 @@ this repository contains the full copyright notices and license terms. -->
|
||||
</record>
|
||||
|
||||
<record model="ir.action.report" id="report_invoice">
|
||||
<field name="name">Invoice</field>
|
||||
<field name="name">Provisional Invoice</field>
|
||||
<field name="model">account.invoice</field>
|
||||
<field name="report_name">account.invoice</field>
|
||||
<field name="report">account_invoice/invoice.fodt</field>
|
||||
@@ -318,6 +313,19 @@ this repository contains the full copyright notices and license terms. -->
|
||||
<field name="action" ref="report_prepayment"/>
|
||||
</record>
|
||||
|
||||
<record model="ir.action.report" id="report_invoice_ict_final">
|
||||
<field name="name">Final Invoice</field>
|
||||
<field name="model">account.invoice</field>
|
||||
<field name="report_name">account.invoice</field>
|
||||
<field name="report">account_invoice/invoice_ict_final.fodt</field>
|
||||
<field name="single" eval="True"/>
|
||||
</record>
|
||||
<record model="ir.action.keyword" id="report_invoice_ict_final_keyword">
|
||||
<field name="keyword">form_print</field>
|
||||
<field name="model">account.invoice,-1</field>
|
||||
<field name="action" ref="report_invoice_ict_final"/>
|
||||
</record>
|
||||
|
||||
<record model="ir.sequence.type" id="sequence_type_account_invoice">
|
||||
<field name="name">Invoice</field>
|
||||
</record>
|
||||
|
||||
4103
modules/account_invoice/invoice_ict.fodt
Normal file
4103
modules/account_invoice/invoice_ict.fodt
Normal file
File diff suppressed because it is too large
Load Diff
4097
modules/account_invoice/invoice_ict_final.fodt
Normal file
4097
modules/account_invoice/invoice_ict_final.fodt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -13,8 +13,10 @@ from trytond.pool import Pool
|
||||
from trytond.pyson import Eval
|
||||
from trytond.transaction import Transaction
|
||||
from trytond.wizard import Button, StateView, Wizard
|
||||
|
||||
from .exceptions import PaymentTermComputeError, PaymentTermValidationError
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PaymentTerm(DeactivableMixin, ModelSQL, ModelView):
|
||||
@@ -46,7 +48,7 @@ class PaymentTerm(DeactivableMixin, ModelSQL, ModelView):
|
||||
'.msg_payment_term_missing_last_remainder',
|
||||
payment_term=term.rec_name))
|
||||
|
||||
def compute(self, amount, currency, date, purchase_line = None):
|
||||
def compute(self, amount, currency, date, line_ = None):
|
||||
"""Calculate payment terms and return a list of tuples
|
||||
with (date, amount) for each payment term line.
|
||||
|
||||
@@ -59,7 +61,7 @@ class PaymentTerm(DeactivableMixin, ModelSQL, ModelView):
|
||||
remainder = amount
|
||||
for line in self.lines:
|
||||
value = line.get_value(remainder, amount, currency)
|
||||
value_date = line.get_date(date, purchase_line)
|
||||
value_date = line.get_date(date, line_)
|
||||
if value is None or not value_date:
|
||||
continue
|
||||
if ((remainder - value) * sign) < Decimal(0):
|
||||
@@ -155,12 +157,11 @@ class PaymentTermLine(sequence_ordered(), ModelSQL, ModelView):
|
||||
self.ratio = self.round(1 / self.divisor,
|
||||
self.__class__.ratio.digits[1])
|
||||
|
||||
def get_date(self, date, purchase_line = None):
|
||||
def get_date(self, date, line = None):
|
||||
#find date based on trigger:
|
||||
if purchase_line and self.trigger_event:
|
||||
PurchaseLine = Pool().get('purchase.line')
|
||||
purchase_line = PurchaseLine(purchase_line)
|
||||
trigger_date = purchase_line.get_date(self.trigger_event)
|
||||
if line and self.trigger_event:
|
||||
trigger_date = line.get_date(self.trigger_event)
|
||||
logger.info("DATE_FROM_LINE:%s",trigger_date)
|
||||
if trigger_date:
|
||||
date = trigger_date
|
||||
|
||||
|
||||
@@ -92,8 +92,8 @@ this repository contains the full copyright notices and license terms. -->
|
||||
<field name="invoice_report_revisions" colspan="4"/>
|
||||
</page>
|
||||
<page string="Rate management" id="rate">
|
||||
<label name="warning"/>
|
||||
<field name="warning"/>
|
||||
<!-- <label name="warning"/>
|
||||
<field name="warning"/> -->
|
||||
<newline/>
|
||||
<label name="rate"/>
|
||||
<field name="rate"/>
|
||||
|
||||
14
modules/account_itsa/__init__.py
Normal file
14
modules/account_itsa/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
||||
# This file is part of Tryton. The COPYRIGHT file at the top level of
|
||||
# this repository contains the full copyright notices and license terms.
|
||||
|
||||
from trytond.pool import Pool
|
||||
|
||||
from . import account
|
||||
|
||||
def register():
|
||||
Pool.register(
|
||||
account.AccountTemplate,
|
||||
module='account_itsa', type_='model')
|
||||
Pool.register(
|
||||
account.CreateChart,
|
||||
module='account_itsa', type_='wizard')
|
||||
40
modules/account_itsa/account.py
Normal file
40
modules/account_itsa/account.py
Normal file
@@ -0,0 +1,40 @@
|
||||
# This file is part of Tryton. The COPYRIGHT file at the top level of
|
||||
# this repository contains the full copyright notices and license terms.
|
||||
import csv
|
||||
from io import BytesIO, TextIOWrapper
|
||||
|
||||
from sql import Table
|
||||
from sql.aggregate import Sum
|
||||
from sql.conditionals import Coalesce
|
||||
|
||||
from trytond.config import config
|
||||
from trytond.model import ModelStorage, ModelView, fields
|
||||
from trytond.pool import Pool, PoolMeta
|
||||
from trytond.pyson import Eval
|
||||
from trytond.transaction import Transaction
|
||||
from trytond.wizard import Button, StateTransition, StateView, Wizard
|
||||
|
||||
class AccountTemplate(metaclass=PoolMeta):
|
||||
__name__ = 'account.account.template'
|
||||
|
||||
@classmethod
|
||||
def __register__(cls, module_name):
|
||||
cursor = Transaction().connection.cursor()
|
||||
model_data = Table('ir_model_data')
|
||||
super().__register__(module_name)
|
||||
|
||||
class CreateChart(metaclass=PoolMeta):
|
||||
__name__ = 'account.create_chart'
|
||||
|
||||
def default_properties(self, fields):
|
||||
pool = Pool()
|
||||
ModelData = pool.get('ir.model.data')
|
||||
defaults = super().default_properties(fields)
|
||||
# template_id = ModelData.get_id('account_ch.root')
|
||||
# if self.account.account_template.id == template_id:
|
||||
# defaults['account_receivable'] = self.get_account(
|
||||
# 'account_ch.3400')
|
||||
# defaults['account_payable'] = self.get_account(
|
||||
# 'account_ch.6040')
|
||||
return defaults
|
||||
|
||||
3336
modules/account_itsa/account_itsa.xml
Normal file
3336
modules/account_itsa/account_itsa.xml
Normal file
File diff suppressed because it is too large
Load Diff
10
modules/account_itsa/tryton.cfg
Normal file
10
modules/account_itsa/tryton.cfg
Normal file
@@ -0,0 +1,10 @@
|
||||
[tryton]
|
||||
version=7.2.3
|
||||
depends:
|
||||
account
|
||||
extras_depend:
|
||||
account_invoice
|
||||
xml:
|
||||
account_itsa.xml
|
||||
#tax_ict.xml
|
||||
|
||||
@@ -6,7 +6,7 @@ from decimal import Decimal
|
||||
from trytond.i18n import gettext
|
||||
from trytond.pool import Pool, PoolMeta
|
||||
from trytond.transaction import Transaction
|
||||
|
||||
from trytond.exceptions import UserWarning, UserError
|
||||
from .exceptions import COGSWarning
|
||||
import logging
|
||||
|
||||
@@ -74,8 +74,8 @@ class InvoiceLine(metaclass=PoolMeta):
|
||||
if move_line.second_currency:
|
||||
move_line.amount_second_currency = amount
|
||||
else:
|
||||
move_line.debit = Decimal(0)
|
||||
move_line.credit = -amount_converted
|
||||
move_line.debit = -amount_converted
|
||||
move_line.credit = Decimal(0)
|
||||
move_line.account = self.product.account_stock_out_used
|
||||
if move_line.second_currency:
|
||||
move_line.amount_second_currency = amount
|
||||
@@ -171,10 +171,28 @@ class InvoiceLine(metaclass=PoolMeta):
|
||||
cost = self.amount
|
||||
else:
|
||||
cost = self.lot.get_cog()
|
||||
if not cost or cost == 0:
|
||||
raise UserError('No COG for this invoice, please generate the reception of the goods')
|
||||
if self.amount < 0 :
|
||||
cost *= -1
|
||||
logger.info("GETMOVELINES_COST:%s",cost)
|
||||
anglo_saxon_move_lines_ = []
|
||||
with Transaction().set_context(
|
||||
company=self.invoice.company.id, date=accounting_date):
|
||||
anglo_saxon_move_lines = self._get_anglo_saxon_move_lines(
|
||||
cost, type_)
|
||||
if type_ == 'in_supplier' and (self.lot.sale_invoice_line_prov or self.lot.sale_invoice_line) and not self.fee:
|
||||
anglo_saxon_move_lines_ = self._get_anglo_saxon_move_lines(cost, 'out_customer')
|
||||
result.extend(anglo_saxon_move_lines)
|
||||
result.extend(anglo_saxon_move_lines_)
|
||||
#Fee inventoried delivery management
|
||||
if self.lot and type_ != 'in_supplier':
|
||||
FeeLots = Pool().get('fee.lots')
|
||||
fees = FeeLots.search(['lot','=',self.lot.id])
|
||||
for fl in fees:
|
||||
if fl.fee.type == 'ordered' and fl.fee.product.template.landed_cost:
|
||||
AccountMove = Pool().get('account.move')
|
||||
account_move = fl.fee._get_account_move_fee(fl.lot,'out')
|
||||
AccountMove.save([account_move])
|
||||
|
||||
return result
|
||||
|
||||
@@ -16,11 +16,11 @@ account_names = [
|
||||
class Category(metaclass=PoolMeta):
|
||||
__name__ = 'product.category'
|
||||
account_stock = fields.MultiValue(fields.Many2One(
|
||||
'account.account', "Account Stock",
|
||||
'account.account', "Account Stock/Cost Income",
|
||||
domain=[
|
||||
('closed', '!=', True),
|
||||
('type.stock', '=', True),
|
||||
('type.statement', '=', 'balance'),
|
||||
# ('type.stock', '=', True),
|
||||
# ('type.statement', '=', 'balance'),
|
||||
('company', '=', Eval('context', {}).get('company', -1)),
|
||||
],
|
||||
states={
|
||||
@@ -29,7 +29,7 @@ class Category(metaclass=PoolMeta):
|
||||
| ~Eval('accounting', False)),
|
||||
}))
|
||||
account_stock_in = fields.MultiValue(fields.Many2One(
|
||||
'account.account', "Account Stock IN",
|
||||
'account.account', "Account Stock IN/Cost liability",
|
||||
domain=[
|
||||
('closed', '!=', True),
|
||||
('type.stock', '=', True),
|
||||
@@ -41,7 +41,7 @@ class Category(metaclass=PoolMeta):
|
||||
| ~Eval('accounting', False)),
|
||||
}))
|
||||
account_stock_out = fields.MultiValue(fields.Many2One(
|
||||
'account.account', "Account Stock OUT",
|
||||
'account.account', "Account Stock OUT/Cost liability",
|
||||
domain=[
|
||||
('closed', '!=', True),
|
||||
('type.stock', '=', True),
|
||||
@@ -103,8 +103,8 @@ class CategoryAccount(metaclass=PoolMeta):
|
||||
'account.account', "Account Stock",
|
||||
domain=[
|
||||
('closed', '!=', True),
|
||||
('type.stock', '=', True),
|
||||
('type.statement', '=', 'balance'),
|
||||
# ('type.stock', '=', True),
|
||||
# ('type.statement', '=', 'balance'),
|
||||
('company', '=', Eval('company', -1)),
|
||||
])
|
||||
account_stock_in = fields.Many2One(
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
from trytond.pool import Pool
|
||||
from . import automation,rules #, document
|
||||
from . import automation,rules,freight_booking,cron #, document
|
||||
|
||||
def register():
|
||||
Pool.register(
|
||||
automation.AutomationDocument,
|
||||
rules.AutomationRuleSet,
|
||||
freight_booking.FreightBookingInfo,
|
||||
cron.Cron,
|
||||
cron.AutomationCron,
|
||||
module='automation', type_='model')
|
||||
@@ -1,10 +1,15 @@
|
||||
from trytond.model import ModelSQL, ModelView, fields, Workflow
|
||||
from trytond.pool import Pool, PoolMeta
|
||||
from trytond.pyson import Eval
|
||||
from trytond.wizard import Button
|
||||
from trytond.transaction import Transaction
|
||||
from sql import Table
|
||||
from decimal import getcontext, Decimal, ROUND_HALF_UP
|
||||
import requests
|
||||
import io
|
||||
import logging
|
||||
import json
|
||||
import re
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -17,6 +22,7 @@ class AutomationDocument(ModelSQL, ModelView, Workflow):
|
||||
('invoice', 'Invoice'),
|
||||
('statement_of_facts', 'Statement of Facts'),
|
||||
('weight_report', 'Weight Report'),
|
||||
('controller', 'Controller'),
|
||||
('bol', 'Bill of Lading'),
|
||||
('controller_invoice', 'Controller Invoice'),
|
||||
], 'Type')
|
||||
@@ -57,25 +63,53 @@ class AutomationDocument(ModelSQL, ModelView, Workflow):
|
||||
def run_ocr(cls, docs):
|
||||
for doc in docs:
|
||||
try:
|
||||
# Décoder le fichier depuis le champ Binary
|
||||
file_data = doc.document.data or b""
|
||||
logger.info(f"File size: {len(file_data)} bytes")
|
||||
logger.info(f"First 20 bytes: {file_data[:20]}")
|
||||
logger.info(f"Last 20 bytes: {file_data[-20:]}")
|
||||
if doc.type == 'weight_report':
|
||||
# Décoder le fichier depuis le champ Binary
|
||||
file_data = doc.document.data or b""
|
||||
logger.info(f"File size: {len(file_data)} bytes")
|
||||
logger.info(f"First 20 bytes: {file_data[:20]}")
|
||||
logger.info(f"Last 20 bytes: {file_data[-20:]}")
|
||||
|
||||
file_name = doc.document.name or "document"
|
||||
file_name = doc.document.name or "document"
|
||||
|
||||
# Envoyer le fichier au service OCR
|
||||
response = requests.post(
|
||||
"http://automation-service:8006/ocr",
|
||||
files={"file": (file_name, io.BytesIO(file_data))}
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
logger.info("RUN_OCR_RESPONSE:%s",data)
|
||||
doc.ocr_text = data.get("ocr_text", "")
|
||||
doc.state = "ocr_done"
|
||||
doc.notes = (doc.notes or "") + "OCR done\n"
|
||||
# Envoyer le fichier au service OCR
|
||||
response = requests.post(
|
||||
"http://automation-service:8006/ocr",
|
||||
files={"file": (file_name, io.BytesIO(file_data))}
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
logger.info("RUN_OCR_RESPONSE:%s",data)
|
||||
doc.ocr_text = data.get("ocr_text", "")
|
||||
doc.state = "ocr_done"
|
||||
doc.notes = (doc.notes or "") + "OCR done\n"
|
||||
else:
|
||||
doc.ocr_text = (doc.document.data or b"").decode('utf-8', errors='replace')
|
||||
match = re.search(r"\bID\s*:\s*(\d+)", doc.ocr_text)
|
||||
if match:
|
||||
request_id = match.group(1)
|
||||
match = re.search(r"\bBL\s*number\s*:\s*([A-Za-z0-9_-]+)", doc.ocr_text, re.IGNORECASE)
|
||||
if match:
|
||||
bl_number = match.group(1)
|
||||
ShipmentIn = Pool().get('stock.shipment.in')
|
||||
sh = ShipmentIn.search(['bl_number','=',bl_number])
|
||||
if sh:
|
||||
sh[0].returned_id = request_id
|
||||
ShipmentIn.save(sh)
|
||||
doc.notes = (doc.notes or "") + "Id returned: " + request_id
|
||||
|
||||
so_payload = {
|
||||
"ServiceOrderKey": sh[0].service_order_key,
|
||||
"ID_Number": request_id
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
"http://automation-service:8006/service-order-update",
|
||||
json=so_payload,
|
||||
timeout=10
|
||||
)
|
||||
response.raise_for_status()
|
||||
doc.notes = (doc.notes or "") + " SO updated"
|
||||
|
||||
except Exception as e:
|
||||
doc.state = "error"
|
||||
@@ -154,7 +188,8 @@ class AutomationDocument(ModelSQL, ModelView, Workflow):
|
||||
logger.info("Sending OCR text to metadata API: %s", doc.ocr_text)
|
||||
|
||||
response = requests.post(
|
||||
"http://automation-service:8006/metadata",
|
||||
#"http://automation-service:8006/metadata",
|
||||
"http://automation-service:8006/parse",
|
||||
json={"text": doc.ocr_text or ""}
|
||||
)
|
||||
response.raise_for_status()
|
||||
@@ -176,6 +211,18 @@ class AutomationDocument(ModelSQL, ModelView, Workflow):
|
||||
logger.error("Metadata processing error: %s", e)
|
||||
|
||||
doc.save()
|
||||
|
||||
def create_weight_report(self,wr_payload):
|
||||
|
||||
response = requests.post(
|
||||
"http://automation-service:8006/weight-report",
|
||||
json=wr_payload, # 👈 ICI la correction
|
||||
timeout=10
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
# -------------------------------------------------------
|
||||
# FULL PIPELINE
|
||||
# -------------------------------------------------------
|
||||
@@ -184,18 +231,66 @@ class AutomationDocument(ModelSQL, ModelView, Workflow):
|
||||
def run_pipeline(cls, docs):
|
||||
for doc in docs:
|
||||
try:
|
||||
if cls.rule_set.ocr_required:
|
||||
cls.run_ocr([doc])
|
||||
if cls.rule_set.structure_required and doc.state != "error":
|
||||
cls.run_structure([doc])
|
||||
if cls.rule_set.table_required and doc.state != "error":
|
||||
cls.run_tables([doc])
|
||||
if cls.rule_set.metadata_required and doc.state != "error":
|
||||
cls.run_metadata([doc])
|
||||
if doc.state != "error":
|
||||
doc.state = "validated"
|
||||
doc.notes = (doc.notes or "") + "Pipeline completed\n"
|
||||
logger.info("DATA_TYPE:%s",type(doc.metadata_json))
|
||||
metadata = json.loads(str(doc.metadata_json))
|
||||
logger.info("JSON STRUCTURE:%s",metadata)
|
||||
|
||||
WeightReport = Pool().get('weight.report')
|
||||
wr = WeightReport.create_from_json(metadata)
|
||||
|
||||
ShipmentIn = Pool().get('stock.shipment.in')
|
||||
ShipmentWR = Pool().get('shipment.wr')
|
||||
sh = ShipmentIn.search([('bl_number','ilike',wr.bl_no)])
|
||||
if sh:
|
||||
swr = ShipmentWR()
|
||||
swr.shipment_in = sh[0]
|
||||
swr.wr = wr
|
||||
ShipmentWR.save([swr])
|
||||
doc.notes = (doc.notes or "") + f"Shipment found: {sh[0].number}\n"
|
||||
logger.info("BL_NUMBER:%s",sh[0].bl_number)
|
||||
if sh[0].incoming_moves:
|
||||
factor_net = wr.net_landed_kg / wr.bales if wr.bales else 1
|
||||
factor_gross = wr.gross_landed_kg / wr.bales if wr.bales else 1
|
||||
for move in sh[0].incoming_moves:
|
||||
lot = move.lot
|
||||
if lot.lot_type == 'physic':
|
||||
wr_payload = {
|
||||
"chunk_key": lot.lot_chunk_key,
|
||||
"gross_weight": float(round(Decimal(lot.lot_qt) * factor_gross,5)),
|
||||
"net_weight": float(round(Decimal(lot.lot_qt) * factor_net,5)),
|
||||
"tare_total": float(round(wr.tare_kg * (Decimal(lot.lot_qt) / wr.bales),5)) ,
|
||||
"bags": int(lot.lot_qt),
|
||||
"surveyor_code": sh[0].controller.get_alf(),
|
||||
"place_key": sh[0].to_location.get_places(),
|
||||
"report_date": int(wr.report_date.strftime("%Y%m%d")),#wr.report_date.isoformat() if wr.report_date else None,
|
||||
"weight_date": int(wr.weight_date.strftime("%Y%m%d")),#wr.weight_date.isoformat() if wr.weight_date else None,
|
||||
"agent": sh[0].agent.get_alf(),
|
||||
"forwarder_ref": sh[0].returned_id
|
||||
}
|
||||
logger.info("PAYLOAD:%s",wr_payload)
|
||||
data = doc.create_weight_report(wr_payload)
|
||||
doc.notes = (doc.notes or "") + f"WR created in Fintrade: {data.get('success')}\n"
|
||||
doc.notes = (doc.notes or "") + f"WR key: {data.get('weight_report_key')}\n"
|
||||
|
||||
# if cls.rule_set.ocr_required:[]
|
||||
# cls.run_ocr([doc])
|
||||
# if cls.rule_set.structure_required and doc.state != "error":
|
||||
# cls.run_structure([doc])
|
||||
# if cls.rule_set.table_required and doc.state != "error":
|
||||
# cls.run_tables([doc])
|
||||
# if cls.rule_set.metadata_required and doc.state != "error":
|
||||
# cls.run_metadata([doc])
|
||||
# if doc.state != "error":
|
||||
# doc.state = "validated"
|
||||
# doc.notes = (doc.notes or "") + "Pipeline completed\n"
|
||||
except Exception as e:
|
||||
logger.exception("PIPELINE FAILED") # 👈 TRACE COMPLETE
|
||||
doc.state = "error"
|
||||
doc.notes = (doc.notes or "") + f"Pipeline error: {e}\n"
|
||||
doc.save()
|
||||
raise
|
||||
|
||||
# except Exception as e:
|
||||
# doc.state = "error"
|
||||
# doc.notes = (doc.notes or "") + f"Pipeline error: {e}\n"
|
||||
doc.save()
|
||||
@@ -75,7 +75,7 @@
|
||||
<record model="ir.model.button" id="auto_button1">
|
||||
<field name="model">automation.document</field>
|
||||
<field name="name">run_pipeline</field>
|
||||
<field name="string">Run Full Pipeline</field>
|
||||
<field name="string">Create Weight Report</field>
|
||||
</record>
|
||||
<record model="ir.model.button" id="auto_button2">
|
||||
<field name="model">automation.document</field>
|
||||
|
||||
377
modules/automation/cron.py
Normal file
377
modules/automation/cron.py
Normal file
@@ -0,0 +1,377 @@
|
||||
import requests
|
||||
from decimal import getcontext, Decimal, ROUND_HALF_UP
|
||||
from datetime import datetime, timedelta
|
||||
from trytond.model import fields
|
||||
from trytond.model import ModelSQL, ModelView
|
||||
from trytond.pool import Pool, PoolMeta
|
||||
from trytond.transaction import Transaction
|
||||
import logging
|
||||
from sql import Table
|
||||
import traceback
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class Cron(metaclass=PoolMeta):
|
||||
__name__ = 'ir.cron'
|
||||
|
||||
@classmethod
|
||||
def __setup__(cls):
|
||||
super().__setup__()
|
||||
cls.method.selection.append(
|
||||
('automation.cron|update_shipment', "Update Shipment from freight booking info")
|
||||
)
|
||||
|
||||
class AutomationCron(ModelSQL, ModelView):
|
||||
"Automation Cron"
|
||||
__name__ = 'automation.cron'
|
||||
|
||||
frequency = fields.Selection([
|
||||
('daily', "Daily"),
|
||||
('weekly', "Weekly"),
|
||||
('monthly', "Monthly"),
|
||||
], "Frequency", required=True,
|
||||
help="How frequently rates must be updated.")
|
||||
|
||||
last_update = fields.Date("Last Update", required=True)
|
||||
|
||||
@classmethod
|
||||
def run(cls, crons):
|
||||
cls.update_shipment()
|
||||
|
||||
@classmethod
|
||||
def update_shipment(cls):
|
||||
PoolObj = Pool()
|
||||
ShipmentIn = PoolObj.get('stock.shipment.in')
|
||||
Party = PoolObj.get('party.party')
|
||||
Vessel = PoolObj.get('trade.vessel')
|
||||
Location = PoolObj.get('stock.location')
|
||||
|
||||
# Table externe
|
||||
t = Table('freight_booking_info')
|
||||
cursor = Transaction().connection.cursor()
|
||||
cursor.execute(*t.select(
|
||||
t.ShippingInstructionNumber,
|
||||
t.ShippingInstructionDate,
|
||||
t.ShippingInstructionQuantity,
|
||||
t.ShippingInstructionQuantityUnit,
|
||||
t.NumberOfContainers,
|
||||
t.ContainerType,
|
||||
t.Loading,
|
||||
t.Destination,
|
||||
t.BookingAgent,
|
||||
t.Carrier,
|
||||
t.Vessel,
|
||||
t.BL_Number,
|
||||
t.ETD_Date,
|
||||
t.BL_Date,
|
||||
t.ExpectedController,
|
||||
t.Comments,
|
||||
t.FintradeBookingKey,
|
||||
))
|
||||
|
||||
rows = cursor.fetchall()
|
||||
logger.info(f"Nombre total de lignes à traiter : {len(rows)}")
|
||||
|
||||
# ---- PREMIÈRE TRANSACTION : Création des objets de référence ----
|
||||
with Transaction().new_transaction() as trans1:
|
||||
try:
|
||||
logger.info("Début de la création des objets de référence...")
|
||||
|
||||
parties_to_save = []
|
||||
vessels_to_save = []
|
||||
locations_to_save = []
|
||||
|
||||
parties_cache = {}
|
||||
vessels_cache = {}
|
||||
locations_cache = {}
|
||||
|
||||
# Collecter les données des objets de référence
|
||||
for row in rows:
|
||||
(
|
||||
si_number, si_date, si_quantity, si_unit,
|
||||
container_number, container_type,
|
||||
loading_name, destination_name,
|
||||
agent_name, carrier_name,
|
||||
vessel_name, bl_number,
|
||||
etd_date, bl_date, controller,
|
||||
comments, fintrade_booking_key
|
||||
) = row
|
||||
|
||||
# Fonction pour obtenir ou créer un Party
|
||||
def get_or_create_party(name):
|
||||
if not name:
|
||||
return None
|
||||
name_upper = str(name).strip().upper()
|
||||
if name_upper in parties_cache:
|
||||
return parties_cache[name_upper]
|
||||
|
||||
# Chercher d'abord dans la base
|
||||
existing = Party.search([('name', '=', name_upper)], limit=1)
|
||||
if existing:
|
||||
parties_cache[name_upper] = existing[0]
|
||||
return existing[0]
|
||||
|
||||
# Créer un nouveau
|
||||
new_p = Party()
|
||||
new_p.name = name_upper
|
||||
parties_cache[name_upper] = new_p
|
||||
parties_to_save.append(new_p)
|
||||
return new_p
|
||||
|
||||
# Fonction pour obtenir ou créer un Vessel
|
||||
def get_or_create_vessel(name):
|
||||
if not name:
|
||||
return None
|
||||
name_upper = str(name).strip().upper()
|
||||
if name_upper in vessels_cache:
|
||||
return vessels_cache[name_upper]
|
||||
|
||||
existing = Vessel.search([('vessel_name', '=', name_upper)], limit=1)
|
||||
if existing:
|
||||
vessels_cache[name_upper] = existing[0]
|
||||
return existing[0]
|
||||
|
||||
new_v = Vessel()
|
||||
new_v.vessel_name = name_upper
|
||||
vessels_cache[name_upper] = new_v
|
||||
vessels_to_save.append(new_v)
|
||||
return new_v
|
||||
|
||||
# Fonction pour obtenir ou créer une Location
|
||||
def get_or_create_location(name, type_):
|
||||
if not name:
|
||||
return None
|
||||
name_upper = str(name).strip().upper()
|
||||
key = f"{name_upper}_{type_}"
|
||||
if key in locations_cache:
|
||||
return locations_cache[key]
|
||||
|
||||
existing = Location.search([
|
||||
('name', '=', name_upper),
|
||||
('type', '=', type_)
|
||||
], limit=1)
|
||||
|
||||
if existing:
|
||||
locations_cache[key] = existing[0]
|
||||
return existing[0]
|
||||
|
||||
new_loc = Location()
|
||||
new_loc.name = name_upper
|
||||
new_loc.type = type_
|
||||
locations_cache[key] = new_loc
|
||||
locations_to_save.append(new_loc)
|
||||
return new_loc
|
||||
|
||||
# Collecter les objets à créer
|
||||
_ = get_or_create_party(carrier_name)
|
||||
_ = get_or_create_party(agent_name)
|
||||
_ = get_or_create_vessel(vessel_name)
|
||||
_ = get_or_create_location(loading_name, 'supplier')
|
||||
_ = get_or_create_location(destination_name, 'customer')
|
||||
|
||||
# Sauvegarder tous les objets de référence
|
||||
if parties_to_save:
|
||||
logger.info(f"Création de {len(parties_to_save)} parties...")
|
||||
Party.save(parties_to_save)
|
||||
|
||||
if vessels_to_save:
|
||||
logger.info(f"Création de {len(vessels_to_save)} vessels...")
|
||||
Vessel.save(vessels_to_save)
|
||||
|
||||
if locations_to_save:
|
||||
logger.info(f"Création de {len(locations_to_save)} locations...")
|
||||
Location.save(locations_to_save)
|
||||
|
||||
trans1.commit()
|
||||
logger.info("Première transaction commitée : objets de référence créés")
|
||||
|
||||
except Exception as e:
|
||||
trans1.rollback()
|
||||
logger.error(f"Erreur dans la création des objets de référence : {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
raise
|
||||
|
||||
# ---- TRANSACTIONS INDIVIDUELLES pour chaque shipment ----
|
||||
successful_shipments = 0
|
||||
failed_shipments = []
|
||||
|
||||
# Recréer le curseur après la nouvelle transaction
|
||||
cursor2 = Transaction().connection.cursor()
|
||||
cursor2.execute(*t.select(
|
||||
t.ShippingInstructionNumber,
|
||||
t.ShippingInstructionDate,
|
||||
t.ShippingInstructionQuantity,
|
||||
t.ShippingInstructionQuantityUnit,
|
||||
t.NumberOfContainers,
|
||||
t.ContainerType,
|
||||
t.Loading,
|
||||
t.Destination,
|
||||
t.BookingAgent,
|
||||
t.Carrier,
|
||||
t.Vessel,
|
||||
t.BL_Number,
|
||||
t.ETD_Date,
|
||||
t.BL_Date,
|
||||
t.ExpectedController,
|
||||
t.Comments,
|
||||
t.FintradeBookingKey,
|
||||
))
|
||||
|
||||
rows2 = cursor2.fetchall()
|
||||
|
||||
for i, row in enumerate(rows2, 1):
|
||||
(
|
||||
si_number, si_date, si_quantity, si_unit,
|
||||
container_number, container_type,
|
||||
loading_name, destination_name,
|
||||
agent_name, carrier_name,
|
||||
vessel_name, bl_number,
|
||||
etd_date, bl_date, controller,
|
||||
comments, fintrade_booking_key
|
||||
) = row
|
||||
|
||||
logger.info(f"Traitement shipment {i}/{len(rows2)} : SI {si_number}")
|
||||
|
||||
# ---- TRANSACTION INDIVIDUELLE pour ce shipment ----
|
||||
try:
|
||||
with Transaction().new_transaction() as trans_shipment:
|
||||
logger.info(f"Début transaction pour SI {si_number}")
|
||||
|
||||
# Vérifier si le shipment existe déjà
|
||||
existing_shipment = ShipmentIn.search([
|
||||
('reference', '=', si_number)
|
||||
], limit=1)
|
||||
|
||||
if existing_shipment:
|
||||
logger.info(f"Shipment {si_number} existe déjà, ignoré")
|
||||
trans_shipment.commit()
|
||||
continue
|
||||
|
||||
# Récupérer les objets (maintenant ils existent dans la base)
|
||||
carrier = None
|
||||
if carrier_name:
|
||||
carrier_list = Party.search([('name', '=', str(carrier_name).strip().upper())], limit=1)
|
||||
if carrier_list:
|
||||
carrier = carrier_list[0]
|
||||
logger.info(f"Carrier trouvé pour {si_number}: {carrier.name}")
|
||||
else:
|
||||
logger.warning(f"Carrier NON TROUVÉ pour {si_number}: '{carrier_name}'")
|
||||
|
||||
agent = None
|
||||
|
||||
agent_list = Party.search([('name', '=', str(agent_name or 'TBN').strip().upper())], limit=1)
|
||||
if agent_list:
|
||||
agent = agent_list[0]
|
||||
|
||||
vessel = None
|
||||
if vessel_name:
|
||||
vessel_list = Vessel.search([('vessel_name', '=', str(vessel_name).strip().upper())], limit=1)
|
||||
if vessel_list:
|
||||
vessel = vessel_list[0]
|
||||
|
||||
loc_from = None
|
||||
if loading_name:
|
||||
loc_from_list = Location.search([
|
||||
('name', '=', str(loading_name).strip().upper()),
|
||||
('type', '=', 'supplier')
|
||||
], limit=1)
|
||||
if loc_from_list:
|
||||
loc_from = loc_from_list[0]
|
||||
|
||||
loc_to = None
|
||||
if destination_name:
|
||||
loc_to_list = Location.search([
|
||||
('name', '=', str(destination_name).strip().upper()),
|
||||
('type', '=', 'customer')
|
||||
], limit=1)
|
||||
if loc_to_list:
|
||||
loc_to = loc_to_list[0]
|
||||
|
||||
# Vérification critique du carrier
|
||||
if not carrier:
|
||||
error_msg = f"ERREUR CRITIQUE: Carrier manquant pour SI {si_number} (valeur: '{carrier_name}')"
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
|
||||
# Créer le shipment
|
||||
shipment = ShipmentIn()
|
||||
shipment.reference = si_number
|
||||
shipment.from_location = loc_from
|
||||
shipment.to_location = loc_to
|
||||
shipment.carrier = None #carrier
|
||||
shipment.supplier = agent
|
||||
shipment.agent = agent
|
||||
shipment.vessel = vessel
|
||||
shipment.cargo_mode = 'bulk'
|
||||
shipment.bl_number = bl_number
|
||||
shipment.bl_date = bl_date
|
||||
shipment.etd = etd_date
|
||||
shipment.etad = shipment.bl_date + timedelta(days=20)
|
||||
|
||||
# Sauvegarder ce shipment uniquement
|
||||
ShipmentIn.save([shipment])
|
||||
inv_date,inv_nb = shipment._create_lots_from_fintrade()
|
||||
shipment.controller = shipment.get_controller()
|
||||
shipment.controller_target = controller
|
||||
shipment.create_fee(shipment.controller)
|
||||
shipment.instructions = shipment.get_instructions_html(inv_date,inv_nb)
|
||||
ShipmentIn.save([shipment])
|
||||
trans_shipment.commit()
|
||||
successful_shipments += 1
|
||||
logger.info(f"✓ Shipment {si_number} créé avec succès")
|
||||
|
||||
except Exception as e:
|
||||
# Cette transaction échoue mais les autres continuent
|
||||
error_details = {
|
||||
'si_number': si_number,
|
||||
'carrier_name': carrier_name,
|
||||
'error': str(e),
|
||||
'traceback': traceback.format_exc()
|
||||
}
|
||||
failed_shipments.append(error_details)
|
||||
|
||||
logger.error(f"✗ ERREUR pour shipment {si_number}: {e}")
|
||||
logger.error(f" Carrier: '{carrier_name}'")
|
||||
logger.error(f" Agent: '{agent_name}'")
|
||||
logger.error(f" Vessel: '{vessel_name}'")
|
||||
logger.error(" Traceback complet:")
|
||||
for line in traceback.format_exc().split('\n'):
|
||||
if line.strip():
|
||||
logger.error(f" {line}")
|
||||
|
||||
# ---- RÉSUMÉ FINAL ----
|
||||
logger.info("=" * 60)
|
||||
logger.info("RÉSUMÉ DE L'EXÉCUTION")
|
||||
logger.info("=" * 60)
|
||||
logger.info(f"Total de shipments à traiter : {len(rows2)}")
|
||||
logger.info(f"Shipments créés avec succès : {successful_shipments}")
|
||||
logger.info(f"Shipments en échec : {len(failed_shipments)}")
|
||||
|
||||
if failed_shipments:
|
||||
logger.info("\nDétail des échecs :")
|
||||
for i, error in enumerate(failed_shipments, 1):
|
||||
logger.info(f" {i}. SI {error['si_number']}:")
|
||||
logger.info(f" Carrier: '{error['carrier_name']}'")
|
||||
logger.info(f" Erreur: {error['error']}")
|
||||
|
||||
# Log supplémentaire pour debug
|
||||
logger.info("\nAnalyse des carriers problématiques :")
|
||||
problematic_carriers = {}
|
||||
for error in failed_shipments:
|
||||
carrier = error['carrier_name']
|
||||
if carrier in problematic_carriers:
|
||||
problematic_carriers[carrier] += 1
|
||||
else:
|
||||
problematic_carriers[carrier] = 1
|
||||
|
||||
for carrier, count in problematic_carriers.items():
|
||||
logger.info(f" Carrier '{carrier}' : {count} échec(s)")
|
||||
|
||||
# Vérifier si ce carrier existe dans la base
|
||||
existing = Party.search([('name', '=', str(carrier).strip().upper())], limit=1)
|
||||
if existing:
|
||||
logger.info(f" → EXISTE DANS LA BASE (ID: {existing[0].id})")
|
||||
else:
|
||||
logger.info(f" → N'EXISTE PAS DANS LA BASE")
|
||||
|
||||
logger.info("=" * 60)
|
||||
37
modules/automation/cron.xml
Normal file
37
modules/automation/cron.xml
Normal file
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0"?>
|
||||
<tryton>
|
||||
<data>
|
||||
<record model="ir.ui.view" id="cron_view_list">
|
||||
<field name="model">automation.cron</field>
|
||||
<field name="type">tree</field>
|
||||
<field name="name">cron_list</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.ui.view" id="cron_view_form">
|
||||
<field name="model">automation.cron</field>
|
||||
<field name="type">form</field>
|
||||
<field name="name">cron_form</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.action.act_window" id="act_cron_form">
|
||||
<field name="name">Update shipment from freight booking</field>
|
||||
<field name="res_model">automation.cron</field>
|
||||
</record>
|
||||
<record model="ir.action.act_window.view" id="act_cron_form_view1">
|
||||
<field name="sequence" eval="10"/>
|
||||
<field name="view" ref="cron_view_list"/>
|
||||
<field name="act_window" ref="act_cron_form"/>
|
||||
</record>
|
||||
<record model="ir.action.act_window.view" id="act_cron_form_view2">
|
||||
<field name="sequence" eval="20"/>
|
||||
<field name="view" ref="cron_view_form"/>
|
||||
<field name="act_window" ref="act_cron_form"/>
|
||||
</record>
|
||||
|
||||
<record model="ir.cron" id="cron_cron">
|
||||
<field name="method">automation.cron|update_shipment</field>
|
||||
<field name="interval_number" eval="1"/>
|
||||
<field name="interval_type">days</field>
|
||||
</record>
|
||||
</data>
|
||||
</tryton>
|
||||
72
modules/automation/execution_automation.py
Normal file
72
modules/automation/execution_automation.py
Normal file
@@ -0,0 +1,72 @@
|
||||
from trytond.model import ModelSQL, fields
|
||||
|
||||
class ExecutionFollowUp(ModelSQL):
|
||||
"Execution Follow Up"
|
||||
__name__ = 'execution.automation'
|
||||
|
||||
port_of_loading = fields.Char("Port of Loading")
|
||||
fb_loading = fields.Char("FB Loading")
|
||||
warehouse = fields.Char("Warehouse")
|
||||
origin = fields.Char("Origin")
|
||||
agent = fields.Char("Agent")
|
||||
operator = fields.Char("Operator")
|
||||
|
||||
fintrade_lc_nb = fields.Char("Fintrade LC Nb")
|
||||
lc_number = fields.Char("LC Number")
|
||||
si = fields.Char("SI")
|
||||
|
||||
port_of_destination = fields.Char("Port of Destination")
|
||||
fb_destination = fields.Char("FB Destination")
|
||||
status = fields.Char("Status")
|
||||
|
||||
etd_date = fields.Date("ETD Date")
|
||||
bl_date = fields.Date("BL Date")
|
||||
etd_sob = fields.Date("ETD SOB")
|
||||
|
||||
sale_contract_no = fields.Char("Sale Contract No")
|
||||
customer = fields.Char("Customer")
|
||||
elt_quantity = fields.Float("Elt Quantity")
|
||||
|
||||
number_of_container = fields.Integer("Containers")
|
||||
vessel = fields.Char("Vessel")
|
||||
shipping_company = fields.Char("Shipping Company")
|
||||
booking_ref = fields.Char("Booking Ref")
|
||||
freight_forwarder = fields.Char("Freight Forwarder")
|
||||
|
||||
instrument_status = fields.Char("Instrument Status")
|
||||
ip_date = fields.Date("IP Date")
|
||||
ip_status = fields.Char("IP Status")
|
||||
latest_shipment_date = fields.Date("Latest Shipment Date")
|
||||
|
||||
countersigned = fields.Boolean("Countersigned")
|
||||
comments = fields.Text("Comments")
|
||||
docs_internal_comments = fields.Text("Docs Internal Comments")
|
||||
|
||||
alloc_quantity = fields.Float("Allocated Quantity")
|
||||
si_comments = fields.Text("SI Comments")
|
||||
is_archived = fields.Boolean("Archived")
|
||||
|
||||
price_cont = fields.Numeric("Price / Cont")
|
||||
price_cont_curr = fields.Char("Currency")
|
||||
|
||||
ct_period_start = fields.Date("CT Start")
|
||||
ct_period_end = fields.Date("CT End")
|
||||
|
||||
lsd_check = fields.Char("LSD Check")
|
||||
bl2lsd_delta = fields.Integer("BL → LSD Delta")
|
||||
|
||||
fintrade_booking = fields.Char("Fintrade Booking")
|
||||
alloc_unit_price = fields.Numeric("Alloc Unit Price")
|
||||
alloc_price_curr = fields.Char("Alloc Price Curr")
|
||||
alloc_price_unit = fields.Char("Alloc Price Unit")
|
||||
|
||||
left_time = fields.Integer("Days Left")
|
||||
|
||||
@classmethod
|
||||
def table_query(cls):
|
||||
return (
|
||||
"SELECT "
|
||||
"row_number() OVER () AS id, "
|
||||
"* "
|
||||
"FROM singa_execution_follow_up"
|
||||
)
|
||||
9
modules/automation/execution_automation.xml
Normal file
9
modules/automation/execution_automation.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<tryton>
|
||||
<data>
|
||||
<record model="ir.ui.view" id="execution_followup_tree">
|
||||
<field name="model">execution.automation</field>
|
||||
<field name="type">tree</field>
|
||||
<field name="name">execution_automation_tree</field>
|
||||
</record>
|
||||
</data>
|
||||
</tryton>
|
||||
56
modules/automation/freight_booking.py
Normal file
56
modules/automation/freight_booking.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from trytond.model import ModelSQL, ModelView, fields
|
||||
from sql import Table
|
||||
from sql.functions import CurrentTimestamp
|
||||
from sql import Column, Literal
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class FreightBookingInfo(ModelSQL, ModelView):
|
||||
"Freight Booking"
|
||||
__name__ = 'freight.booking.info'
|
||||
|
||||
booking_number = fields.Char("Booking Number")
|
||||
agent = fields.Char("Agent")
|
||||
controller = fields.Char("Customer")
|
||||
origin = fields.Char("Origin")
|
||||
destination = fields.Char("Destination")
|
||||
etd = fields.Date("ETD")
|
||||
bl_date = fields.Date("BL date")
|
||||
bl_number = fields.Char("BL Nb")
|
||||
carrier = fields.Char("Carrier")
|
||||
vessel = fields.Char("Vessel")
|
||||
container_count = fields.Float("Containers")
|
||||
quantity = fields.Float("Gross Weight")
|
||||
|
||||
@classmethod
|
||||
def table_query(cls):
|
||||
t = Table('freight_booking_info')
|
||||
|
||||
query = t.select(
|
||||
Literal(None).as_('create_uid'),
|
||||
CurrentTimestamp().as_('create_date'),
|
||||
Literal(None).as_('write_uid'),
|
||||
Literal(None).as_('write_date'),
|
||||
Column(t, 'FintradeBookingKey').as_('id'),
|
||||
Column(t, 'ShippingInstructionNumber').as_('booking_number'),
|
||||
Column(t, 'BookingAgent').as_('agent'),
|
||||
Column(t, 'ExpectedController').as_('controller'),
|
||||
Column(t, 'Loading').as_('origin'),
|
||||
Column(t, 'Destination').as_('destination'),
|
||||
Column(t, 'ETD_Date').as_('etd'),
|
||||
Column(t, 'BL_Date').as_('bl_date'),
|
||||
Column(t, 'BL_Number').as_('bl_number'),
|
||||
Column(t, 'Carrier').as_('carrier'),
|
||||
Column(t, 'Vessel').as_('vessel'),
|
||||
Column(t, 'NumberOfContainers').as_('container_count'),
|
||||
Column(t, 'ShippingInstructionQuantity').as_('quantity'),
|
||||
)
|
||||
#logger.info("*****QUERY*****:%s",query)
|
||||
return query
|
||||
|
||||
@classmethod
|
||||
def __setup__(cls):
|
||||
super().__setup__()
|
||||
cls._order = [
|
||||
('etd', 'DESC'),
|
||||
]
|
||||
25
modules/automation/freight_booking.xml
Normal file
25
modules/automation/freight_booking.xml
Normal file
@@ -0,0 +1,25 @@
|
||||
<tryton>
|
||||
<data>
|
||||
<record model="ir.ui.view" id="freight_booking_info_tree">
|
||||
<field name="model">freight.booking.info</field>
|
||||
<field name="type">tree</field>
|
||||
<field name="name">freight_booking_info_tree</field>
|
||||
</record>
|
||||
<record model="ir.action.act_window" id="act_freight_booking_info">
|
||||
<field name="name">Freight Bookings</field>
|
||||
<field name="res_model">freight.booking.info</field>
|
||||
</record>
|
||||
<record model="ir.action.act_window.view" id="act_freight_booking_info_view1">
|
||||
<field name="sequence" eval="10"/>
|
||||
<field name="view" ref="freight_booking_info_tree"/>
|
||||
<field name="act_window" ref="act_freight_booking_info"/>
|
||||
</record>
|
||||
|
||||
<menuitem
|
||||
name="Freight Booking"
|
||||
action="act_freight_booking_info"
|
||||
parent="menu_automation"
|
||||
sequence="10"
|
||||
id="menu_freight_booking" />
|
||||
</data>
|
||||
</tryton>
|
||||
@@ -5,4 +5,6 @@ depends:
|
||||
res
|
||||
document_incoming
|
||||
xml:
|
||||
automation.xml
|
||||
automation.xml
|
||||
freight_booking.xml
|
||||
cron.xml
|
||||
60
modules/automation/view/execution_automation_tree.xml
Normal file
60
modules/automation/view/execution_automation_tree.xml
Normal file
@@ -0,0 +1,60 @@
|
||||
<tree>
|
||||
<field name="port_of_loading"/>
|
||||
<field name="fb_loading"/>
|
||||
<field name="warehouse"/>
|
||||
<field name="origin"/>
|
||||
<field name="agent"/>
|
||||
<field name="operator"/>
|
||||
|
||||
<field name="fintrade_lc_nb"/>
|
||||
<field name="lc_number"/>
|
||||
<field name="si"/>
|
||||
|
||||
<field name="port_of_destination"/>
|
||||
<field name="fb_destination"/>
|
||||
<field name="status"/>
|
||||
|
||||
<field name="etd_date"/>
|
||||
<field name="bl_date"/>
|
||||
<field name="etd_sob"/>
|
||||
|
||||
<field name="sale_contract_no"/>
|
||||
<field name="customer"/>
|
||||
<field name="elt_quantity"/>
|
||||
|
||||
<field name="number_of_container"/>
|
||||
<field name="vessel"/>
|
||||
<field name="shipping_company"/>
|
||||
<field name="booking_ref"/>
|
||||
<field name="freight_forwarder"/>
|
||||
|
||||
<field name="instrument_status"/>
|
||||
<field name="ip_date"/>
|
||||
<field name="ip_status"/>
|
||||
<field name="latest_shipment_date"/>
|
||||
|
||||
<field name="countersigned"/>
|
||||
<field name="comments"/>
|
||||
<field name="docs_internal_comments"/>
|
||||
|
||||
<field name="alloc_quantity"/>
|
||||
<field name="si_comments"/>
|
||||
<field name="is_archived"/>
|
||||
|
||||
<field name="price_cont"/>
|
||||
<field name="price_cont_curr"/>
|
||||
|
||||
<field name="ct_period_start"/>
|
||||
<field name="ct_period_end"/>
|
||||
|
||||
<field name="lsd_check"/>
|
||||
<field name="bl2lsd_delta"/>
|
||||
|
||||
<field name="fintrade_booking"/>
|
||||
<field name="alloc_unit_price"/>
|
||||
<field name="alloc_price_curr"/>
|
||||
<field name="alloc_price_unit"/>
|
||||
|
||||
<field name="left_time"/>
|
||||
|
||||
</tree>
|
||||
13
modules/automation/view/freight_booking_info_tree.xml
Normal file
13
modules/automation/view/freight_booking_info_tree.xml
Normal file
@@ -0,0 +1,13 @@
|
||||
<tree>
|
||||
<field name="booking_number"/>
|
||||
<field name="agent"/>
|
||||
<field name="controller"/>
|
||||
<field name="origin"/>
|
||||
<field name="destination"/>
|
||||
<field name="etd"/>
|
||||
<field name="bl_date"/>
|
||||
<field name="bl_number"/>
|
||||
<field name="carrier"/>
|
||||
<field name="vessel"/>
|
||||
<field name="container_count"/>
|
||||
</tree>
|
||||
@@ -27,6 +27,14 @@ class Sale(metaclass=PoolMeta):
|
||||
invoice.save()
|
||||
return invoice
|
||||
|
||||
@property
|
||||
def report_agent(self):
|
||||
if self.agent:
|
||||
return (self.agent.party.address_get(
|
||||
type='delivery')).full_address
|
||||
else:
|
||||
return ''
|
||||
|
||||
@classmethod
|
||||
@ModelView.button
|
||||
@Workflow.transition('quotation')
|
||||
|
||||
@@ -47,6 +47,8 @@ class Company(ModelSQL, ModelView):
|
||||
help="Used to compute the today date.")
|
||||
employees = fields.One2Many('company.employee', 'company', 'Employees',
|
||||
help="Add employees to the company.")
|
||||
|
||||
logo = fields.Binary("Logo")
|
||||
|
||||
@property
|
||||
def header_used(self):
|
||||
|
||||
@@ -17,6 +17,8 @@ this repository contains the full copyright notices and license terms. -->
|
||||
<field name="header"/>
|
||||
<separator name="footer"/>
|
||||
<field name="footer"/>
|
||||
<separator name="logo"/>
|
||||
<field name="logo" widget="image" stretch="true"/>
|
||||
</page>
|
||||
</notebook>
|
||||
</form>
|
||||
|
||||
@@ -137,6 +137,13 @@ class Currency(
|
||||
closer = date
|
||||
return res
|
||||
|
||||
@classmethod
|
||||
def get_by_name(cls, name):
|
||||
currencies = cls.search([('symbol', '=', name)], limit=1)
|
||||
if not currencies:
|
||||
return None
|
||||
return currencies[0]
|
||||
|
||||
@staticmethod
|
||||
def _get_rate(currencies, tdate=None):
|
||||
'''
|
||||
|
||||
@@ -17,6 +17,8 @@ from trytond.transaction import Transaction
|
||||
from trytond.wizard import Button, StateTransition, StateView, Wizard
|
||||
|
||||
from .exceptions import DocumentIncomingSplitError
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if config.getboolean('document_incoming', 'filestore', default=True):
|
||||
file_id = 'file_id'
|
||||
@@ -179,30 +181,112 @@ class Incoming(DeactivableMixin, Workflow, ModelSQL, ModelView):
|
||||
def _split_mime_types(cls):
|
||||
return ['application/pdf']
|
||||
|
||||
# @classmethod
|
||||
# def from_inbound_email(cls, email_, rule):
|
||||
# message = email_.as_dict()
|
||||
# attachments = message.get('attachments')
|
||||
# active = False
|
||||
# data = message.get('text', message.get('html'))
|
||||
# logger.info("DATA_FROM_INBOUND_MAIL:%s",data)
|
||||
# if isinstance(data, str):
|
||||
# data = data.encode()
|
||||
# body = message.get('text') or message.get('html') or ''
|
||||
# if isinstance(body, str):
|
||||
# body_bytes = body.encode('utf-8')
|
||||
# else:
|
||||
# body_bytes = body
|
||||
# document = cls(
|
||||
# active=active,
|
||||
# name=message.get('subject', 'No Subject'),
|
||||
# company=rule.document_incoming_company,
|
||||
# data=data,
|
||||
# type=rule.document_incoming_type if active else None,
|
||||
# source='inbound_email',
|
||||
# )
|
||||
# children = []
|
||||
# if attachments:
|
||||
# for attachment in attachments:
|
||||
# child = cls(
|
||||
# name=attachment['filename'] or 'data.bin',
|
||||
# company=rule.document_incoming_company,
|
||||
# data=attachment['data'],
|
||||
# type=rule.document_incoming_type,
|
||||
# source='inbound_email')
|
||||
# children.append(child)
|
||||
# else:
|
||||
# child = cls(
|
||||
# name='mail_' + message.get('subject', 'No Subject') + '.txt',
|
||||
# company=rule.document_incoming_company,
|
||||
# data=body_bytes,
|
||||
# type=rule.document_incoming_type,
|
||||
# source='inbound_email',
|
||||
# )
|
||||
# children.append(child)
|
||||
# document.children = children
|
||||
# document.save()
|
||||
# return document
|
||||
@classmethod
|
||||
def from_inbound_email(cls, email_, rule):
|
||||
message = email_.as_dict()
|
||||
active = not message.get('attachments')
|
||||
|
||||
def clean(value):
|
||||
if not value:
|
||||
return value
|
||||
return (
|
||||
value
|
||||
.replace('\n', ' ')
|
||||
.replace('\r', ' ')
|
||||
.replace("'", '')
|
||||
.replace('"', '')
|
||||
.strip()
|
||||
)
|
||||
|
||||
subject = clean(message.get('subject', 'No Subject'))
|
||||
|
||||
attachments = message.get('attachments')
|
||||
active = False
|
||||
data = message.get('text', message.get('html'))
|
||||
logger.info("DATA_FROM_INBOUND_MAIL:%s", data)
|
||||
|
||||
if isinstance(data, str):
|
||||
data = data.encode()
|
||||
|
||||
body = message.get('text') or message.get('html') or ''
|
||||
if isinstance(body, str):
|
||||
body_bytes = body.encode('utf-8')
|
||||
else:
|
||||
body_bytes = body
|
||||
|
||||
document = cls(
|
||||
active=active,
|
||||
name=message.get('subject', 'No Subject'),
|
||||
name=subject,
|
||||
company=rule.document_incoming_company,
|
||||
data=data,
|
||||
type=rule.document_incoming_type if active else None,
|
||||
source='inbound_email',
|
||||
)
|
||||
)
|
||||
|
||||
children = []
|
||||
for attachment in message.get('attachments', []):
|
||||
if attachments:
|
||||
for attachment in attachments:
|
||||
filename = clean(attachment['filename'] or 'data.bin')
|
||||
child = cls(
|
||||
name=filename,
|
||||
company=rule.document_incoming_company,
|
||||
data=attachment['data'],
|
||||
type=rule.document_incoming_type,
|
||||
source='inbound_email')
|
||||
children.append(child)
|
||||
else:
|
||||
child = cls(
|
||||
name=attachment['filename'] or 'data.bin',
|
||||
name='mail_' + subject + '.txt',
|
||||
company=rule.document_incoming_company,
|
||||
data=attachment['data'],
|
||||
data=body_bytes,
|
||||
type=rule.document_incoming_type,
|
||||
source='inbound_email')
|
||||
source='inbound_email',
|
||||
)
|
||||
children.append(child)
|
||||
|
||||
document.children = children
|
||||
document.save()
|
||||
return document
|
||||
@@ -265,7 +349,6 @@ class Incoming(DeactivableMixin, Workflow, ModelSQL, ModelView):
|
||||
default.setdefault('children')
|
||||
return super().copy(documents, default=default)
|
||||
|
||||
|
||||
def iter_pages(expression, size):
|
||||
ranges = set()
|
||||
for pages in expression.split(','):
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
from trytond.model import fields
|
||||
from trytond.pool import Pool, PoolMeta
|
||||
from trytond.pyson import Eval
|
||||
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class Rule(metaclass=PoolMeta):
|
||||
__name__ = 'inbound.email.rule'
|
||||
@@ -53,4 +54,4 @@ class Rule(metaclass=PoolMeta):
|
||||
if (self.action == 'document.incoming|from_inbound_email'
|
||||
and self.document_incoming_process):
|
||||
document = email_.result
|
||||
DocumentIncoming.process([document], with_children=True)
|
||||
DocumentIncoming.process([document], with_children=True)
|
||||
15
modules/document_incoming_wr/__init__.py
Normal file
15
modules/document_incoming_wr/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
# This file is part of Tryton. The COPYRIGHT file at the top level of
|
||||
# this repository contains the full copyright notices and license terms.
|
||||
|
||||
from trytond.pool import Pool
|
||||
|
||||
from . import document
|
||||
|
||||
__all__ = ['register']
|
||||
|
||||
|
||||
def register():
|
||||
Pool.register(
|
||||
document.IncomingConfiguration,
|
||||
document.Incoming,
|
||||
module='document_incoming_wr', type_='model')
|
||||
63
modules/document_incoming_wr/document.py
Normal file
63
modules/document_incoming_wr/document.py
Normal file
@@ -0,0 +1,63 @@
|
||||
# This file is part of Tryton. The COPYRIGHT file at the top level of
|
||||
# this repository contains the full copyright notices and license terms.
|
||||
|
||||
from trytond.i18n import gettext
|
||||
from trytond.model import fields
|
||||
from trytond.modules.document_incoming.exceptions import (
|
||||
DocumentIncomingProcessError)
|
||||
from trytond.pool import Pool, PoolMeta
|
||||
import json
|
||||
|
||||
|
||||
class IncomingConfiguration(metaclass=PoolMeta):
|
||||
__name__ = 'document.incoming.configuration'
|
||||
|
||||
default_controller = fields.Many2One('party.party', "Default Controller")
|
||||
|
||||
|
||||
class Incoming(metaclass=PoolMeta):
|
||||
__name__ = 'document.incoming'
|
||||
|
||||
@classmethod
|
||||
def __setup__(cls):
|
||||
super().__setup__()
|
||||
cls.type.selection.append(
|
||||
('weight_report', "Weight Report"))
|
||||
cls.type.selection.append(
|
||||
('controller', "Controller"))
|
||||
|
||||
@classmethod
|
||||
def _get_results(cls):
|
||||
return super()._get_results() | {'automation.document'}
|
||||
|
||||
def _process_weight_report(self):
|
||||
WR = Pool().get('automation.document')
|
||||
wr = WR()
|
||||
wr.document = self.id
|
||||
wr.type = 'weight_report'
|
||||
wr.state = 'draft'
|
||||
WR.save([wr])
|
||||
WR.run_ocr([wr])
|
||||
WR.run_metadata([wr])
|
||||
|
||||
return wr
|
||||
|
||||
def _process_controller(self):
|
||||
WR = Pool().get('automation.document')
|
||||
wr = WR()
|
||||
wr.document = self.id
|
||||
wr.type = 'controller'
|
||||
wr.state = 'draft'
|
||||
WR.save([wr])
|
||||
WR.run_ocr([wr])
|
||||
# WR.run_metadata([wr])
|
||||
|
||||
return wr
|
||||
|
||||
# @property
|
||||
# def supplier_invoice_company(self):
|
||||
# pass
|
||||
|
||||
# @property
|
||||
# def supplier_invoice_party(self):
|
||||
# pass
|
||||
12
modules/document_incoming_wr/document.xml
Normal file
12
modules/document_incoming_wr/document.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0"?>
|
||||
<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
|
||||
this repository contains the full copyright notices and license terms. -->
|
||||
<tryton>
|
||||
<data>
|
||||
<record model="ir.ui.view" id="ddocument_incoming_configuration_view_form">
|
||||
<field name="model">document.incoming.configuration</field>
|
||||
<field name="inherit" ref="document_incoming.document_incoming_configuration_view_form"/>
|
||||
<field name="name">document_incoming_configuration_form</field>
|
||||
</record>
|
||||
</data>
|
||||
</tryton>
|
||||
8
modules/document_incoming_wr/tryton.cfg
Normal file
8
modules/document_incoming_wr/tryton.cfg
Normal file
@@ -0,0 +1,8 @@
|
||||
[tryton]
|
||||
version=7.2.0
|
||||
depends:
|
||||
document_incoming
|
||||
ir
|
||||
party
|
||||
xml:
|
||||
document.xml
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0"?>
|
||||
<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
|
||||
this repository contains the full copyright notices and license terms. -->
|
||||
<data>
|
||||
<xpath expr="/form" position="inside">
|
||||
<separator string="Weight Report" id="weight_report" colspan="4"/>
|
||||
<label name="default_controller"/>
|
||||
<field name="default_controller"/>
|
||||
</xpath>
|
||||
</data>
|
||||
@@ -17,6 +17,9 @@ from trytond.pyson import Eval
|
||||
from trytond.transaction import Transaction
|
||||
from trytond.url import http_host
|
||||
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if config.getboolean('inbound_email', 'filestore', default=True):
|
||||
file_id = 'data_id'
|
||||
store_prefix = config.get('inbound_email', 'store_prefix', default=None)
|
||||
@@ -74,6 +77,7 @@ class Inbox(ModelSQL, ModelView):
|
||||
assert email_.inbox == self
|
||||
for rule in self.rules:
|
||||
if rule.match(email_.as_dict()):
|
||||
logger.info("RULE_MATCHED:%s",rule)
|
||||
email_.rule = rule
|
||||
rule.run(email_)
|
||||
return
|
||||
|
||||
@@ -12,7 +12,7 @@ __all__ = ['IncotermMixin', 'IncotermAvailableMixin']
|
||||
class IncotermMixin(Model):
|
||||
|
||||
incoterm = fields.Many2One(
|
||||
'incoterm.incoterm', lazy_gettext('incoterm.msg_incoterm'),
|
||||
'incoterm.incoterm', lazy_gettext('incoterm.msg_incoterm'), required=False,
|
||||
ondelete='RESTRICT')
|
||||
incoterm_location = fields.Many2One(
|
||||
'party.address', lazy_gettext('incoterm.msg_incoterm_location'),
|
||||
|
||||
@@ -477,15 +477,16 @@ class Lot(ModelSQL, ModelView):
|
||||
else:
|
||||
return str(self.line.currency.symbol) + "/" + str(self.line.unit.symbol)
|
||||
|
||||
def get_hist_quantity(self,seq):
|
||||
def get_hist_quantity(self,state_id=0):
|
||||
qt = Decimal(0)
|
||||
gross_qt = Decimal(0)
|
||||
if self.lot_state:
|
||||
if self.lot_hist:
|
||||
if seq != 0:
|
||||
st = seq
|
||||
if state_id != 0:
|
||||
st = state_id
|
||||
else:
|
||||
st = self.lot_state.id
|
||||
logger.info("GET_HIST_QT:%s",st)
|
||||
lot = [e for e in self.lot_hist if e.quantity_type.id == st][0]
|
||||
qt = round(lot.quantity,5)
|
||||
gross_qt = round(lot.gross_quantity,5)
|
||||
@@ -499,24 +500,48 @@ class Lot(ModelSQL, ModelView):
|
||||
physic_sum = Decimal(0)
|
||||
for l in line.lots:
|
||||
if l.lot_type == 'physic' :
|
||||
physic_sum += round(Decimal(Uom.compute_qty(Uom(l.lot_unit_line),float(l.get_current_quantity()),l.line.unit)),5)
|
||||
factor = None
|
||||
rate = None
|
||||
if l.lot_unit_line.category.id != l.line.unit.category.id:
|
||||
factor = 1
|
||||
rate = 1
|
||||
physic_sum += round(Decimal(Uom.compute_qty(Uom(l.lot_unit_line),float(l.get_current_quantity()),l.line.unit, True, factor, rate)),5)
|
||||
return line.quantity_theorical - physic_sum
|
||||
|
||||
def get_current_quantity(self,name=None):
|
||||
# if self.lot_type == 'physic':
|
||||
qt, gross_qt = self.get_hist_quantity(0)
|
||||
qt, gross_qt = self.get_hist_quantity()
|
||||
return qt
|
||||
# else:
|
||||
# return self.get_virtual_diff()
|
||||
|
||||
def get_current_quantity_converted(self,name=None):
|
||||
def get_current_quantity_converted(self,state_id=0,unit=None):
|
||||
Uom = Pool().get('product.uom')
|
||||
unit = self.line.unit if self.line else self.sale_line.unit
|
||||
return round(Decimal(Uom.compute_qty(self.lot_unit_line, float(self.get_current_quantity()), unit)),5)
|
||||
if not unit:
|
||||
unit = self.line.unit if self.line else self.sale_line.unit
|
||||
qt, gross_qt = self.get_hist_quantity(state_id)
|
||||
factor = None
|
||||
rate = None
|
||||
if self.lot_unit_line.category.id != unit.category.id:
|
||||
factor = 1
|
||||
rate = 1
|
||||
return round(Decimal(Uom.compute_qty(self.lot_unit_line, float(qt), unit, True, factor, rate)),5)
|
||||
|
||||
def get_current_gross_quantity_converted(self,state_id=0,unit=None):
|
||||
Uom = Pool().get('product.uom')
|
||||
if not unit:
|
||||
unit = self.line.unit if self.line else self.sale_line.unit
|
||||
qt, gross_qt = self.get_hist_quantity(state_id)
|
||||
factor = None
|
||||
rate = None
|
||||
if self.lot_unit_line.category.id != unit.category.id:
|
||||
factor = 1
|
||||
rate = 1
|
||||
return round(Decimal(Uom.compute_qty(self.lot_unit_line, float(gross_qt), unit, True, factor, rate)),5)
|
||||
|
||||
def get_current_gross_quantity(self,name=None):
|
||||
if self.lot_type == 'physic':
|
||||
qt, gross_qt = self.get_hist_quantity(0)
|
||||
qt, gross_qt = self.get_hist_quantity()
|
||||
return gross_qt
|
||||
else:
|
||||
return None
|
||||
@@ -526,6 +551,7 @@ class Lot(ModelSQL, ModelView):
|
||||
lqh = LotQtHist()
|
||||
lqh.quantity_type = qt_type
|
||||
lqh.quantity = net
|
||||
logger.info("ADD_QUANTITY_TO_HIST:%s",gross)
|
||||
lqh.gross_quantity = gross
|
||||
lqh.lot = self
|
||||
return lqh
|
||||
@@ -542,6 +568,7 @@ class Lot(ModelSQL, ModelView):
|
||||
if existing:
|
||||
hist = existing[0]
|
||||
hist.quantity = net
|
||||
logger.info("SET_CURRENT_HIST:%s",gross)
|
||||
hist.gross_quantity = gross
|
||||
else:
|
||||
lot_hist.append(self.add_quantity_to_hist(net, gross, lqtt[0]))
|
||||
@@ -633,6 +660,7 @@ class SplitLine(ModelView):
|
||||
weight = fields.Numeric('Weight', digits=(16,5))
|
||||
|
||||
class SplitWizardStart(ModelView):
|
||||
"Split Line Start"
|
||||
__name__ = 'lot.split.wizard.start'
|
||||
|
||||
mode = fields.Selection([
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
this repository contains the full copyright notices and license terms. -->
|
||||
<tryton>
|
||||
<data grouped="1">
|
||||
<record model="ir.message" id="msg_price_value_unique">
|
||||
<field name="text">Only one price value is allowed for each price index and price date.</field>
|
||||
</record>
|
||||
<record model="ir.message" id="msg_party_code_unique">
|
||||
<field name="text">The code on party must be unique.</field>
|
||||
</record>
|
||||
|
||||
@@ -44,7 +44,7 @@ class Price(
|
||||
price_composite = fields.One2Many('price.composite','price',"Composites")
|
||||
price_product = fields.One2Many('price.product', 'price', "Product")
|
||||
price_ct_size = fields.Numeric("Ct size")
|
||||
|
||||
|
||||
def get_qt(self,nb_ct,unit):
|
||||
Uom = Pool().get('product.uom')
|
||||
return round(Decimal(Uom.compute_qty(self.price_unit, float(self.price_ct_size * nb_ct), unit)),4)
|
||||
@@ -71,7 +71,6 @@ class Price(
|
||||
def get_price(self,dt,unit,currency,last=False):
|
||||
price = float(0)
|
||||
PV = Pool().get('price.price_value')
|
||||
logger.info("ASKED_PRICE_FOR:%s",dt)
|
||||
if self.price_values:
|
||||
dt = dt.strftime("%Y-%m-%d")
|
||||
pv = PV.search([('price','=',self.id),('price_date','=',dt)])
|
||||
@@ -115,7 +114,6 @@ class Calendar(DeactivableMixin,ModelSQL,ModelView,MultiValueMixin):
|
||||
dt = dt.strftime("%Y-%m-%d")
|
||||
cl = CL.search([('calendar','=',self.id),('price_date','=',dt)])
|
||||
if cl:
|
||||
#logger.info("ISQUOTE:%s",cl)
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
@@ -136,3 +134,20 @@ class Product(ModelSQL,ModelView):
|
||||
__name__ = 'price.product'
|
||||
price = fields.Many2One('price.price',"Price index")
|
||||
product = fields.Many2One('product.product',"Product")
|
||||
attributes = fields.Many2One('product.attribute',"Attribute",domain=[
|
||||
('sets', '=', Eval('attribute_set')),
|
||||
],
|
||||
states={
|
||||
'readonly': ~Eval('attribute_set'),
|
||||
},
|
||||
depends=['product', 'attribute_set'])
|
||||
attribute_set = fields.Function(
|
||||
fields.Many2One('product.attribute.set', "Attribute Set"),
|
||||
'on_change_with_attribute_set'
|
||||
)
|
||||
|
||||
@fields.depends('product')
|
||||
def on_change_with_attribute_set(self, name=None):
|
||||
if self.product and self.product.template and self.product.template.attribute_set:
|
||||
return self.product.template.attribute_set.id
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from trytond.i18n import gettext
|
||||
from trytond.model import (
|
||||
DeactivableMixin, Index, ModelSQL, ModelView, MultiValueMixin, Unique,
|
||||
ValueMixin, convert_from, fields, sequence_ordered)
|
||||
from trytond.model.exceptions import AccessError
|
||||
from trytond.model.exceptions import AccessError, ValidationError
|
||||
from trytond.pool import Pool
|
||||
from trytond.pyson import Bool, Eval
|
||||
from trytond.tools import is_full_text, lstrip_wildcard
|
||||
@@ -38,12 +38,34 @@ class PriceValue(
|
||||
price_value = fields.Float("Price value")
|
||||
open_price = fields.Float("Open price")
|
||||
low_price = fields.Float("Low price")
|
||||
high_price = fields.Float("High price")
|
||||
|
||||
def get_price_index(self, name):
|
||||
if self.price:
|
||||
return self.price.price_index
|
||||
return None
|
||||
high_price = fields.Float("High price")
|
||||
|
||||
@classmethod
|
||||
def validate(cls, price_values):
|
||||
super().validate(price_values)
|
||||
cls.check_unique_price_date(price_values)
|
||||
|
||||
@classmethod
|
||||
def check_unique_price_date(cls, price_values):
|
||||
domains = []
|
||||
for price_value in price_values:
|
||||
if not price_value.price or not price_value.price_date:
|
||||
continue
|
||||
domain = [
|
||||
('price', '=', price_value.price.id),
|
||||
('price_date', '=', price_value.price_date),
|
||||
]
|
||||
if price_value.id:
|
||||
domain.append(('id', '!=', price_value.id))
|
||||
domains.append(['AND'] + domain)
|
||||
if domains and cls.search(['OR'] + domains, limit=1):
|
||||
raise ValidationError(
|
||||
gettext('price.msg_price_value_unique'))
|
||||
|
||||
def get_price_index(self, name):
|
||||
if self.price:
|
||||
return self.price.price_index
|
||||
return None
|
||||
|
||||
class PriceValueReport(
|
||||
ModelSQL, ModelView):
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<form>
|
||||
<label name="price"/>
|
||||
<field name="price"/>
|
||||
<label name="product"/>
|
||||
<field name="product"/>
|
||||
<label name="attributes"/>
|
||||
<field name="attributes"/>
|
||||
</form>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<tree>
|
||||
<field name="price"/>
|
||||
<field name="product"/>
|
||||
<field name="attributes"/>
|
||||
</tree>
|
||||
|
||||
@@ -609,6 +609,26 @@ class Product(
|
||||
('template.code', operator, code_value, *extra),
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def get_by_name(cls, name, type_='goods'):
|
||||
pool = Pool()
|
||||
Template = pool.get('product.template')
|
||||
Uom = pool.get('product.uom')
|
||||
|
||||
templates = Template.search([('name', '=', name)], limit=1)
|
||||
if templates:
|
||||
return templates[0].products[0]
|
||||
|
||||
unit_uom, = Uom.search([('name', '=', 'Mt')], limit=1)
|
||||
|
||||
template, = Template.create([{
|
||||
'name': name,
|
||||
'type': type_,
|
||||
'default_uom': unit_uom.id,
|
||||
'cost_price_method': 'fixed',
|
||||
}])
|
||||
return template.products[0]
|
||||
|
||||
@staticmethod
|
||||
def get_price_uom(products, name):
|
||||
Uom = Pool().get('product.uom')
|
||||
|
||||
@@ -92,6 +92,13 @@ class Uom(SymbolMixin, DigitsMixin, DeactivableMixin, ModelSQL, ModelView):
|
||||
def default_digits():
|
||||
return 2
|
||||
|
||||
@classmethod
|
||||
def get_by_name(cls, name):
|
||||
uom = cls.search([('symbol', '=', name)], limit=1)
|
||||
if not uom:
|
||||
return None
|
||||
return uom[0]
|
||||
|
||||
@fields.depends('factor')
|
||||
def on_change_factor(self):
|
||||
if (self.factor or 0.0) == 0.0:
|
||||
|
||||
@@ -22,6 +22,7 @@ class Month(ModelView, ModelSQL):
|
||||
is_cotation = fields.Boolean("Cotation month")
|
||||
beg_date = fields.Date("Date from")
|
||||
end_date = fields.Date("Date end")
|
||||
description = fields.Char("Description")
|
||||
|
||||
class ProductMonth(ModelView, ModelSQL):
|
||||
"Product month"
|
||||
|
||||
105
modules/purchase/AGENTS.md
Normal file
105
modules/purchase/AGENTS.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# AGENTS.md - Module `purchase`
|
||||
|
||||
Ce guide complete le `AGENTS.md` racine.
|
||||
Pour ce module, les regles locales ci-dessous priment.
|
||||
|
||||
## 1) Perimetre metier
|
||||
|
||||
Le module `purchase` gere le cycle d'achat fournisseur:
|
||||
|
||||
- commande d'achat (`purchase.purchase`, `purchase.line`)
|
||||
- facturation fournisseur (`account.invoice` liee a l'achat)
|
||||
- reception/retour de stock (`stock.move`, `stock.shipment.in`, `stock.shipment.in.return`)
|
||||
- reporting achats (axes temporels, fournisseur, produit)
|
||||
|
||||
## 2) Fichiers pivots
|
||||
|
||||
- Logique coeur:
|
||||
- `modules/purchase/purchase.py`
|
||||
- Extensions metier connexes:
|
||||
- `modules/purchase/product.py`
|
||||
- `modules/purchase/stock.py`
|
||||
- `modules/purchase/invoice.py`
|
||||
- `modules/purchase/party.py`
|
||||
- `modules/purchase/configuration.py`
|
||||
- `modules/purchase/purchase_reporting.py`
|
||||
- Vues et actions:
|
||||
- `modules/purchase/purchase.xml`
|
||||
- `modules/purchase/stock.xml`
|
||||
- `modules/purchase/invoice.xml`
|
||||
- `modules/purchase/purchase_reporting.xml`
|
||||
- Manifest et dependances:
|
||||
- `modules/purchase/tryton.cfg`
|
||||
- Documentation metier:
|
||||
- `modules/purchase/docs/business-rules.template.md` (template)
|
||||
- `modules/purchase/docs/business-rules.md` (instance a remplir)
|
||||
|
||||
## 3) Etats et flux critiques a preserver
|
||||
|
||||
Workflow de commande (dans `purchase.py`):
|
||||
|
||||
- `draft -> quotation -> confirmed -> processing -> done`
|
||||
- transitions de retour existent aussi (`cancelled`, retour a `draft`, etc.)
|
||||
|
||||
Invariants importants:
|
||||
|
||||
- `invoice_state` et `shipment_state` doivent rester coherents apres `process()`.
|
||||
- `process()` orchestre facture + stock + recalcul d'etats, ne pas contourner sans raison.
|
||||
- `delete()` exige une commande annulee.
|
||||
- Les methodes `create_invoice()` et `create_move()` sont sensibles (gestion `lots` et `action`).
|
||||
|
||||
## 4) Couplages a surveiller
|
||||
|
||||
- Facture:
|
||||
- `purchase.py` <-> `invoice.py`
|
||||
- gestion des exceptions facture (`purchase_exception_state`)
|
||||
- Stock:
|
||||
- `purchase.py` <-> `stock.py`
|
||||
- liens `moves`, expeditions entrantes, retours
|
||||
- Produit/fournisseur/prix:
|
||||
- `product.py` impacte prix d'achat, UoM, fournisseurs
|
||||
- Tiers:
|
||||
- `party.py` impacte adresses/parametres fournisseur et contraintes d'effacement
|
||||
|
||||
## 5) Convention de modification pour ce module
|
||||
|
||||
1. Modifier d'abord le coeur minimal dans `purchase.py` ou le fichier specialise adequat.
|
||||
2. Mettre a jour XML uniquement si comportement UI/action change.
|
||||
3. Si regle metier impactee, mettre a jour `docs/business-rules.md`.
|
||||
4. Ajouter un test proche du flux reel (scenario `.rst` prioritaire si possible).
|
||||
5. Verifier les impacts transverses facture/stock avant rendu.
|
||||
|
||||
## 6) Strategie de test recommandee
|
||||
|
||||
Priorite 1 (rapide):
|
||||
|
||||
- `modules/purchase/tests/test_module.py`
|
||||
|
||||
Priorite 2 (comportement metier):
|
||||
|
||||
- `modules/purchase/tests/test_scenario.py`
|
||||
- Scenarios cibles selon la modif:
|
||||
- `scenario_purchase.rst`
|
||||
- `scenario_purchase_manual_invoice.rst`
|
||||
- `scenario_purchase_line_cancelled.rst`
|
||||
- `scenario_purchase_line_cancelled_on_shipment.rst`
|
||||
- `scenario_purchase_return_wizard.rst`
|
||||
- `scenario_purchase_reporting.rst`
|
||||
|
||||
Si la modif touche prix/UoM/fournisseur:
|
||||
|
||||
- ajouter un cas dans `test_module.py` ou un scenario dedie.
|
||||
|
||||
## 7) Cas qui exigent validation humaine
|
||||
|
||||
- Changement du workflow d'etats
|
||||
- Changement des regles de creation facture/mouvement
|
||||
- Changement de logique sur retours fournisseur
|
||||
- Changement qui altere les ecritures comptables ou le statut de paiement
|
||||
|
||||
## 8) Definition of done (module `purchase`)
|
||||
|
||||
- Le flux metier cible fonctionne de bout en bout.
|
||||
- Les etats `state`, `invoice_state`, `shipment_state` restent coherents.
|
||||
- Les tests du module pertinents passent.
|
||||
- Le patch est limite aux fichiers necessaires.
|
||||
122
modules/purchase/docs/business-rules.template.md
Normal file
122
modules/purchase/docs/business-rules.template.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# Business Rules Template - Purchase
|
||||
|
||||
Statut: `draft` | `reviewed` | `approved`
|
||||
Version: `v0.1`
|
||||
Derniere mise a jour: `YYYY-MM-DD`
|
||||
Owner metier: `Nom / Equipe`
|
||||
Owner technique: `Nom / Equipe`
|
||||
|
||||
## 1) Scope
|
||||
|
||||
- Domaine: `ex: achats fournisseur`
|
||||
- Hors scope: `ex: achats intercompany`
|
||||
- Modules impactes:
|
||||
- `purchase`
|
||||
- `stock` (si applicable)
|
||||
- `account_invoice` (si applicable)
|
||||
|
||||
## 2) Glossaire
|
||||
|
||||
- `Purchase`: commande d'achat fournisseur.
|
||||
- `Line`: ligne de commande.
|
||||
- `Invoice State`: etat facture calcule.
|
||||
- `Shipment State`: etat reception calcule.
|
||||
- Ajouter ici les termes metier propres a ton contexte.
|
||||
|
||||
## 3) Regles metier (source de verite)
|
||||
|
||||
### BR-001 - [Titre court]
|
||||
|
||||
- Intent: `Pourquoi cette regle existe`
|
||||
- Description:
|
||||
- `Enonce clair et testable`
|
||||
- Conditions d'entree:
|
||||
- `Etat`
|
||||
- `Type de ligne (goods/service)`
|
||||
- `Contexte (societe, devise, fournisseur, lot, etc.)`
|
||||
- Resultat attendu:
|
||||
- `Etat/valeur/action attendue`
|
||||
- Exceptions:
|
||||
- `Cas ou la regle ne s'applique pas`
|
||||
- Priorite:
|
||||
- `bloquante | importante | informative`
|
||||
- Source:
|
||||
- `Ticket / spec / decision metier`
|
||||
|
||||
### BR-002 - [Titre court]
|
||||
|
||||
- Intent:
|
||||
- Description:
|
||||
- Conditions d'entree:
|
||||
- Resultat attendu:
|
||||
- Exceptions:
|
||||
- Priorite:
|
||||
- Source:
|
||||
|
||||
## 4) Matrice d'etats (optionnel mais recommande)
|
||||
|
||||
| Regle | Etat initial | Evenement | Etat attendu | Notes |
|
||||
|---|---|---|---|---|
|
||||
| BR-001 | `draft` | `quote` | `quotation` | |
|
||||
| BR-002 | `quotation` | `confirm` | `confirmed/processing` | |
|
||||
|
||||
## 5) Exemples concrets
|
||||
|
||||
### Exemple E1 - Cas nominal
|
||||
|
||||
- Donnees:
|
||||
- `fournisseur = X`
|
||||
- `produit = Y`
|
||||
- `quantite = 10`
|
||||
- Attendu:
|
||||
- `invoice_state = pending`
|
||||
- `shipment_state = waiting`
|
||||
|
||||
### Exemple E2 - Cas limite
|
||||
|
||||
- Donnees:
|
||||
- Attendu:
|
||||
|
||||
## 6) Impact code attendu
|
||||
|
||||
- Fichiers Python potentiellement concernes:
|
||||
- `modules/purchase/purchase.py`
|
||||
- `modules/purchase/stock.py`
|
||||
- `modules/purchase/invoice.py`
|
||||
- `modules/purchase/product.py`
|
||||
- Fichiers XML potentiellement concernes:
|
||||
- `modules/purchase/purchase.xml`
|
||||
- `modules/purchase/stock.xml`
|
||||
- `modules/purchase/invoice.xml`
|
||||
|
||||
## 7) Strategie de tests
|
||||
|
||||
- Unitaires:
|
||||
- `modules/purchase/tests/test_module.py`
|
||||
- Scenarios:
|
||||
- `modules/purchase/tests/scenario_purchase.rst`
|
||||
- `modules/purchase/tests/scenario_purchase_manual_invoice.rst`
|
||||
- `modules/purchase/tests/scenario_purchase_return_wizard.rst`
|
||||
|
||||
Pour chaque regle BR-xxx, lister le test associe:
|
||||
|
||||
| Regle | Test existant | Nouveau test a ajouter | Statut |
|
||||
|---|---|---|---|
|
||||
| BR-001 | `...` | `...` | `todo` |
|
||||
|
||||
## 8) Compatibilite et migration
|
||||
|
||||
- Effet retroactif sur commandes existantes: `oui/non`
|
||||
- Migration necessaire: `oui/non`
|
||||
- Plan de rollback:
|
||||
- `comment revenir en arriere sans corruption metier`
|
||||
|
||||
## 9) Validation
|
||||
|
||||
- Valide par metier:
|
||||
- `Nom` - `date`
|
||||
- Valide par technique:
|
||||
- `Nom` - `date`
|
||||
- Decision finale:
|
||||
- `approved / rejected / needs update`
|
||||
|
||||
@@ -89,14 +89,14 @@ class Purchase(
|
||||
number = fields.Char("Number", readonly=True)
|
||||
reference = fields.Char("Reference")
|
||||
description = fields.Char('Description', size=None, states=_states)
|
||||
purchase_date = fields.Date('Purchase Date',
|
||||
purchase_date = fields.Date('Purchase Date', required=True,
|
||||
states={
|
||||
'readonly': ~Eval('state').in_(['draft', 'quotation']),
|
||||
'required': ~Eval('state').in_(
|
||||
['draft', 'quotation', 'cancelled']),
|
||||
})
|
||||
payment_term = fields.Many2One(
|
||||
'account.invoice.payment_term', "Payment Term", ondelete='RESTRICT',
|
||||
'account.invoice.payment_term', "Payment Term", required=True, ondelete='RESTRICT',
|
||||
states={
|
||||
'readonly': ~Eval('state').in_(['draft', 'quotation']),
|
||||
})
|
||||
@@ -389,6 +389,11 @@ class Purchase(
|
||||
def default_state():
|
||||
return 'draft'
|
||||
|
||||
@classmethod
|
||||
def default_purchase_date(cls):
|
||||
Date = Pool().get('ir.date')
|
||||
return Date.today()
|
||||
|
||||
@classmethod
|
||||
def default_currency(cls, **pattern):
|
||||
pool = Pool()
|
||||
@@ -462,6 +467,8 @@ class Purchase(
|
||||
self.tol_min = self.party.tol_min
|
||||
if self.party.tol_max:
|
||||
self.tol_max = self.party.tol_max
|
||||
if self.party.origin:
|
||||
self.product_origin = self.party.origin
|
||||
if self.party.wb:
|
||||
self.wb = self.party.wb
|
||||
if self.party.association:
|
||||
@@ -734,6 +741,7 @@ class Purchase(
|
||||
|
||||
@classmethod
|
||||
def copy(cls, purchases, default=None):
|
||||
Date = Pool().get('ir.date')
|
||||
if default is None:
|
||||
default = {}
|
||||
else:
|
||||
@@ -742,7 +750,7 @@ class Purchase(
|
||||
default.setdefault('invoice_state', 'none')
|
||||
default.setdefault('invoices_ignored', None)
|
||||
default.setdefault('shipment_state', 'none')
|
||||
default.setdefault('purchase_date', None)
|
||||
default.setdefault('purchase_date', Date.today())
|
||||
default.setdefault('quoted_by')
|
||||
default.setdefault('confirmed_by')
|
||||
default.setdefault('untaxed_amount_cache')
|
||||
@@ -1021,9 +1029,15 @@ class Purchase(
|
||||
pool = Pool()
|
||||
Invoice = pool.get('account.invoice')
|
||||
|
||||
Invoice.save(invoices.values())
|
||||
|
||||
Invoice.save(invoices.values())
|
||||
|
||||
for purchase, invoice in invoices.items():
|
||||
#check if forex
|
||||
forex_rate = invoice.get_forex()
|
||||
if forex_rate:
|
||||
invoice.selection_rate = 'forex'
|
||||
invoice.rate = invoice.on_change_with_rate()
|
||||
Invoice.save([invoice])
|
||||
purchase.copy_resources_to(invoice)
|
||||
if len(invoices)==1:
|
||||
if prepayment:
|
||||
@@ -1215,7 +1229,7 @@ class Line(sequence_ordered(), ModelSQL, ModelView):
|
||||
()),
|
||||
If(Eval('type') != 'line',
|
||||
('id', '=', None),
|
||||
()),
|
||||
())
|
||||
],
|
||||
states={
|
||||
'invisible': Eval('type') != 'line',
|
||||
@@ -1685,7 +1699,7 @@ class Line(sequence_ordered(), ModelSQL, ModelView):
|
||||
|
||||
@fields.depends(
|
||||
'type', 'quantity', 'unit_price',
|
||||
'purchase', '_parent_purchase.currency')
|
||||
'purchase', '_parent_purchase.currency','premium')
|
||||
def on_change_with_amount(self):
|
||||
if (self.type == 'line'
|
||||
and self.quantity is not None
|
||||
@@ -1857,77 +1871,93 @@ class Line(sequence_ordered(), ModelSQL, ModelView):
|
||||
else:
|
||||
lots_to_invoice = self.lots
|
||||
for l in lots_to_invoice:
|
||||
#if l.lot_type == 'physic':
|
||||
invoice_line = InvoiceLine()
|
||||
invoice_line.type = self.type
|
||||
invoice_line.currency = self.currency
|
||||
invoice_line.company = self.company
|
||||
invoice_line.description = self.description
|
||||
invoice_line.note = self.note
|
||||
invoice_line.origin = self
|
||||
qt, gross_qt = l.get_hist_quantity(0)
|
||||
quantity = float(qt)
|
||||
quantity = Uom.compute_qty(l.lot_unit_line, quantity, self.unit)
|
||||
if self.unit:
|
||||
quantity = self.unit.round(quantity)
|
||||
invoice_line.unit_price = l.get_lot_price()
|
||||
invoice_line.product = l.lot_product
|
||||
invoice_line.quantity = quantity
|
||||
if not invoice_line.quantity:
|
||||
return []
|
||||
invoice_line.unit = self.unit
|
||||
invoice_line.taxes = self.taxes
|
||||
if self.company.purchase_taxes_expense:
|
||||
invoice_line.taxes_deductible_rate = 0
|
||||
elif self.product:
|
||||
invoice_line.taxes_deductible_rate = (
|
||||
self.product.supplier_taxes_deductible_rate_used)
|
||||
invoice_line.invoice_type = 'in'
|
||||
if self.product:
|
||||
invoice_line.account = self.product.account_stock_in_used
|
||||
if not invoice_line.account:
|
||||
raise AccountError(
|
||||
gettext('purchase'
|
||||
'.msg_purchase_product_missing_account_expense',
|
||||
purchase=self.purchase.rec_name,
|
||||
product=self.product.rec_name))
|
||||
else:
|
||||
invoice_line.account = account_config.get_multivalue(
|
||||
'default_category_account_expense', company=self.company.id)
|
||||
if not invoice_line.account:
|
||||
raise AccountError(
|
||||
gettext('purchase'
|
||||
'.msg_purchase_missing_account_expense',
|
||||
purchase=self.purchase.rec_name))
|
||||
if action == 'prov':
|
||||
invoice_line.description = 'Pro forma'
|
||||
elif action == 'final':
|
||||
invoice_line.description = 'Final'
|
||||
elif action == 'service':
|
||||
invoice_line.description = 'Service'
|
||||
#invoice_line.stock_moves = self._get_invoice_line_moves()
|
||||
#invoice_line.stock_moves = [l.get_current_supplier_move()]
|
||||
invoice_line.lot = l.id
|
||||
if self.product.type == 'service':
|
||||
invoice_line.unit_price = self.unit_price
|
||||
invoice_line.product = self.product
|
||||
invoice_line.stock_moves = []
|
||||
Fee = Pool().get('fee.fee')
|
||||
fee = Fee.search(['purchase','=',self.purchase.id])
|
||||
if fee:
|
||||
invoice_line.fee = fee[0]
|
||||
lines.append(invoice_line)
|
||||
logger.info("GETINVLINE:%s",self.product.type)
|
||||
logger.info("GETINVLINE2:%s",l.invoice_line_prov)
|
||||
if l.invoice_line_prov and self.product.type != 'service':
|
||||
invoice_line_, = InvoiceLine.copy([l.invoice_line_prov], default={
|
||||
'invoice': None,
|
||||
'quantity': -l.invoice_line_prov.quantity,
|
||||
'unit_price': l.invoice_line_prov.unit_price,
|
||||
'party': l.invoice_line_prov.invoice.party,
|
||||
'origin': str(self),
|
||||
})
|
||||
lines.append(invoice_line_)
|
||||
if l.lot_type == 'physic':
|
||||
invoice_line = InvoiceLine()
|
||||
invoice_line.type = self.type
|
||||
invoice_line.currency = self.currency
|
||||
invoice_line.company = self.company
|
||||
invoice_line.description = self.description
|
||||
invoice_line.note = self.note
|
||||
invoice_line.origin = self
|
||||
qt, gross_qt = l.get_hist_quantity(0)
|
||||
quantity = float(qt)
|
||||
quantity = Uom.compute_qty(l.lot_unit_line, quantity, self.unit)
|
||||
if self.unit:
|
||||
quantity = self.unit.round(quantity)
|
||||
invoice_line.unit_price = l.get_lot_price()
|
||||
invoice_line.product = l.lot_product
|
||||
invoice_line.quantity = quantity
|
||||
logger.info("GETINVOICELINE_QT:%s",quantity)
|
||||
if not invoice_line.quantity:
|
||||
return []
|
||||
invoice_line.unit = self.unit
|
||||
invoice_line.taxes = self.taxes
|
||||
if self.company.purchase_taxes_expense:
|
||||
invoice_line.taxes_deductible_rate = 0
|
||||
elif self.product:
|
||||
invoice_line.taxes_deductible_rate = (
|
||||
self.product.supplier_taxes_deductible_rate_used)
|
||||
invoice_line.invoice_type = 'in'
|
||||
if self.product:
|
||||
if self.product.type == 'service' and not self.product.landed_cost:
|
||||
invoice_line.account = self.product.account_stock_in_used
|
||||
else:
|
||||
invoice_line.account = self.product.account_stock_in_used
|
||||
if not invoice_line.account:
|
||||
raise AccountError(
|
||||
gettext('purchase'
|
||||
'.msg_purchase_product_missing_account_expense',
|
||||
purchase=self.purchase.rec_name,
|
||||
product=self.product.rec_name))
|
||||
else:
|
||||
invoice_line.account = account_config.get_multivalue(
|
||||
'default_category_account_expense', company=self.company.id)
|
||||
if not invoice_line.account:
|
||||
raise AccountError(
|
||||
gettext('purchase'
|
||||
'.msg_purchase_missing_account_expense',
|
||||
purchase=self.purchase.rec_name))
|
||||
if action == 'prov':
|
||||
invoice_line.description = 'Pro forma'
|
||||
elif action == 'final':
|
||||
invoice_line.description = 'Final'
|
||||
elif action == 'service':
|
||||
invoice_line.description = 'Service'
|
||||
#invoice_line.stock_moves = self._get_invoice_line_moves()
|
||||
#invoice_line.stock_moves = [l.get_current_supplier_move()]
|
||||
invoice_line.lot = l.id
|
||||
if self.product.type == 'service':
|
||||
invoice_line.unit_price = self.unit_price
|
||||
invoice_line.product = self.product
|
||||
invoice_line.stock_moves = []
|
||||
Fee = Pool().get('fee.fee')
|
||||
fee = Fee.search(['purchase','=',self.purchase.id])
|
||||
if fee:
|
||||
invoice_line.fee = fee[0]
|
||||
if fee[0].mode == 'lumpsum':
|
||||
invoice_line.quantity = 1
|
||||
elif fee[0].mode == 'ppack':
|
||||
invoice_line.quantity = fee[0].quantity
|
||||
else:
|
||||
state_id = 0
|
||||
LotQtType = Pool().get('lot.qt.type')
|
||||
lqt = LotQtType.search([('name','=','BL')])
|
||||
if lqt:
|
||||
state_id = lqt[0].id
|
||||
invoice_line.quantity = fee[0].get_fee_lots_qt(state_id)
|
||||
|
||||
lines.append(invoice_line)
|
||||
logger.info("GETINVLINE:%s",self.product.type)
|
||||
logger.info("GETINVLINE2:%s",l.invoice_line_prov)
|
||||
if l.invoice_line_prov and self.product.type != 'service':
|
||||
invoice_line_, = InvoiceLine.copy([l.invoice_line_prov], default={
|
||||
'invoice': None,
|
||||
'quantity': -l.invoice_line_prov.quantity,
|
||||
'unit_price': l.invoice_line_prov.unit_price,
|
||||
'party': l.invoice_line_prov.invoice.party,
|
||||
'origin': str(self),
|
||||
})
|
||||
lines.append(invoice_line_)
|
||||
return lines
|
||||
|
||||
def _get_invoice_line_quantity(self):
|
||||
@@ -1990,8 +2020,7 @@ class Line(sequence_ordered(), ModelSQL, ModelView):
|
||||
'''
|
||||
pool = Pool()
|
||||
Move = pool.get('stock.move')
|
||||
InvoiceLine = pool.get('account.invoice.line')
|
||||
Uom = pool.get('product.uom')
|
||||
Location = pool.get('stock.location')
|
||||
if self.type != 'line':
|
||||
return
|
||||
if not self.product:
|
||||
@@ -2047,26 +2076,28 @@ class Line(sequence_ordered(), ModelSQL, ModelView):
|
||||
to_location = self.purchase.to_location
|
||||
|
||||
move.from_location = from_location
|
||||
|
||||
if to_location.type != 'customer':
|
||||
move.to_location = 8
|
||||
else:
|
||||
move.to_location = to_location
|
||||
logger.info("FROM_LOCATION:%s",self.purchase.from_location)
|
||||
logger.info("TO_LOCATION:%s",self.purchase.to_location)
|
||||
if to_location:
|
||||
if to_location.type != 'customer':
|
||||
move.to_location = Location.get_transit_id()
|
||||
else:
|
||||
move.to_location = to_location
|
||||
|
||||
unit_price = l.get_lot_price()
|
||||
if l.invoice_line_prov != None :
|
||||
prov_inv = InvoiceLine(l.invoice_line_prov)
|
||||
quantity -= prov_inv.quantity
|
||||
if quantity < 0 :
|
||||
move.from_location = self.purchase.to_location
|
||||
move.to_location = 16
|
||||
elif quantity > 0 :
|
||||
move.from_location = 16
|
||||
move.to_location = self.purchase.to_location
|
||||
quantity = abs(quantity)
|
||||
unit_price = prov_inv.unit_price
|
||||
if quantity == 0:
|
||||
continue
|
||||
# if l.invoice_line_prov != None :
|
||||
# prov_inv = InvoiceLine(l.invoice_line_prov)
|
||||
# quantity -= prov_inv.quantity
|
||||
# if quantity < 0 :
|
||||
# move.from_location = self.purchase.to_location
|
||||
# move.to_location = 16
|
||||
# elif quantity > 0 :
|
||||
# move.from_location = 16
|
||||
# move.to_location = self.purchase.to_location
|
||||
# quantity = abs(quantity)
|
||||
# unit_price = prov_inv.unit_price
|
||||
# if quantity == 0:
|
||||
# continue
|
||||
move.quantity = quantity
|
||||
move.unit = self.unit
|
||||
move.product = l.lot_product
|
||||
@@ -2086,7 +2117,7 @@ class Line(sequence_ordered(), ModelSQL, ModelView):
|
||||
moves.append(move)
|
||||
if move.to_location.type != 'customer':
|
||||
move_to, = Move.copy([move.id], default={
|
||||
'from_location': 8,
|
||||
'from_location': Location.get_transit_id(),
|
||||
'to_location': to_location,
|
||||
})
|
||||
moves.append(move_to)
|
||||
|
||||
@@ -17,9 +17,6 @@ this repository contains the full copyright notices and license terms. -->
|
||||
<field name="payment_term"/>
|
||||
<label name="currency"/>
|
||||
<field name="currency"/>
|
||||
<newline/>
|
||||
<label name="certif"/>
|
||||
<field name="certif"/>
|
||||
</group>
|
||||
<group col="2" colspan="2" id="hd" yfill="1">
|
||||
<field name="viewer" widget="html_viewer" height="300" width="600"/>
|
||||
|
||||
@@ -16,13 +16,21 @@ this repository contains the full copyright notices and license terms. -->
|
||||
<label name="product_supplier"/>
|
||||
<field name="product_supplier"/>
|
||||
<newline/>
|
||||
<label name="attributes_name"/>
|
||||
<field name="attributes_name"/>
|
||||
<label name="concentration"/>
|
||||
<field name="concentration"/>
|
||||
<newline/>
|
||||
<label name="del_period"/>
|
||||
<field name="del_period"/>
|
||||
<label name="period_at"/>
|
||||
<field name="period_at"/>
|
||||
<newline/>
|
||||
<label id="delivery_date" string="Delivery Date:"/>
|
||||
<group id="delivery_date" col="-1">
|
||||
<field name="delivery_date" xexpand="0"/>
|
||||
<field name="delivery_date_edit" xexpand="0" xalign="0"/>
|
||||
</group>
|
||||
<label name="del_period"/>
|
||||
<field name="del_period"/>
|
||||
<newline/>
|
||||
<label name="from_del"/>
|
||||
<field name="from_del"/>
|
||||
@@ -40,6 +48,10 @@ this repository contains the full copyright notices and license terms. -->
|
||||
<separator name="description" colspan="4"/>
|
||||
<field name="description" colspan="4"/>
|
||||
</page>
|
||||
<page string="Attributes" id="att">
|
||||
<label name="attributes"/>
|
||||
<field name="attributes"/>
|
||||
</page>
|
||||
<page string="Taxes" id="taxes">
|
||||
<field name="taxes" colspan="4"/>
|
||||
</page>
|
||||
|
||||
@@ -3,7 +3,38 @@
|
||||
|
||||
from trytond.pool import Pool
|
||||
|
||||
from . import purchase,sale,global_reporting,stock,derivative,lot,pricing,workflow,lc,dashboard,fee,payment_term,purchase_prepayment,cron,party,forex,outgoing,incoming,optional,association_tables, document_tracking, open_position, credit_risk
|
||||
from . import (
|
||||
account,
|
||||
purchase,
|
||||
sale,
|
||||
global_reporting,
|
||||
stock,
|
||||
derivative,
|
||||
lot,
|
||||
pricing,
|
||||
workflow,
|
||||
lc,
|
||||
dashboard,
|
||||
fee,
|
||||
payment_term,
|
||||
purchase_prepayment,
|
||||
cron,
|
||||
party,
|
||||
forex,
|
||||
outgoing,
|
||||
incoming,
|
||||
optional,
|
||||
association_tables,
|
||||
document_tracking,
|
||||
open_position,
|
||||
credit_risk,
|
||||
valuation,
|
||||
dimension,
|
||||
weight_report,
|
||||
backtoback,
|
||||
service,
|
||||
invoice,
|
||||
)
|
||||
|
||||
def register():
|
||||
Pool.register(
|
||||
@@ -24,15 +55,18 @@ def register():
|
||||
lc.LCMT700,
|
||||
lc.LCMessage,
|
||||
lc.CreateLCStart,
|
||||
global_reporting.GRConfiguration,
|
||||
module='purchase_trade', type_='model')
|
||||
global_reporting.GRConfiguration,
|
||||
pricing.ImportPricesStart,
|
||||
pricing.ImportPricesResult,
|
||||
module='purchase_trade', type_='model')
|
||||
Pool.register(
|
||||
incoming.ImportSwift,
|
||||
incoming.PrepareDocuments,
|
||||
incoming.AnalyzeConditions,
|
||||
lc.CreateLCWizard,
|
||||
module='purchase_trade', type_='wizard'
|
||||
)
|
||||
incoming.ImportSwift,
|
||||
incoming.PrepareDocuments,
|
||||
incoming.AnalyzeConditions,
|
||||
lc.CreateLCWizard,
|
||||
pricing.ImportPrices,
|
||||
module='purchase_trade', type_='wizard'
|
||||
)
|
||||
Pool.register(
|
||||
credit_risk.Party,
|
||||
credit_risk.CreditRiskRule,
|
||||
@@ -47,6 +81,9 @@ def register():
|
||||
dashboard.News,
|
||||
dashboard.Demos,
|
||||
party.Party,
|
||||
party.PartyExecution,
|
||||
party.PartyExecutionSla,
|
||||
party.PartyExecutionPlace,
|
||||
payment_term.PaymentTerm,
|
||||
payment_term.PaymentTermLine,
|
||||
purchase.Purchase,
|
||||
@@ -69,8 +106,11 @@ def register():
|
||||
fee.Fee,
|
||||
fee.FeeLots,
|
||||
purchase.FeeLots,
|
||||
fee.Valuation,
|
||||
fee.ValuationDyn,
|
||||
valuation.Valuation,
|
||||
valuation.ValuationLine,
|
||||
valuation.ValuationDyn,
|
||||
valuation.ValuationReport,
|
||||
valuation.ValuationReportContext,
|
||||
derivative.Derivative,
|
||||
derivative.DerivativeMatch,
|
||||
derivative.MatchWizardStart,
|
||||
@@ -82,9 +122,12 @@ def register():
|
||||
forex.PForex,
|
||||
forex.ForexBI,
|
||||
purchase.PnlBI,
|
||||
purchase.PositionBI,
|
||||
stock.Move,
|
||||
stock.Location,
|
||||
stock.InvoiceLine,
|
||||
stock.ShipmentIn,
|
||||
stock.ShipmentWR,
|
||||
stock.ShipmentInternal,
|
||||
stock.ShipmentOut,
|
||||
stock.StatementOfFacts,
|
||||
@@ -128,14 +171,41 @@ def register():
|
||||
purchase.ContractDocumentType,
|
||||
purchase.DocTemplate,
|
||||
purchase.DocTypeTemplate,
|
||||
purchase.Mtm,
|
||||
purchase.PurchaseStrategy,
|
||||
purchase.PriceComposition,
|
||||
purchase.QualityAnalysis,
|
||||
purchase.Assay,
|
||||
purchase.AssayLine,
|
||||
purchase.AssayElement,
|
||||
purchase.AssayUnit,
|
||||
purchase.PayableRule,
|
||||
purchase.PenaltyRule,
|
||||
purchase.PenaltyRuleTier,
|
||||
purchase.ConcentrateTerm,
|
||||
backtoback.Backtoback,
|
||||
dimension.AnalyticDimension,
|
||||
dimension.AnalyticDimensionValue,
|
||||
dimension.AnalyticDimensionAssignment,
|
||||
weight_report.WeightReport,
|
||||
module='purchase', type_='model')
|
||||
Pool.register(
|
||||
account.PhysicalTradeIFRS,
|
||||
module='purchase_trade', type_='model')
|
||||
Pool.register(
|
||||
invoice.Invoice,
|
||||
invoice.InvoiceLine,
|
||||
module='account_invoice', type_='model')
|
||||
Pool.register(
|
||||
forex.Forex,
|
||||
forex.ForexCoverFees,
|
||||
forex.ForexCategory,
|
||||
pricing.Component,
|
||||
pricing.Mtm,
|
||||
pricing.MtmStrategy,
|
||||
pricing.MtmScenario,
|
||||
pricing.MtmSnapshot,
|
||||
pricing.PriceMatrix,
|
||||
pricing.PriceMatrixLine,
|
||||
pricing.Estimated,
|
||||
pricing.Pricing,
|
||||
pricing.Period,
|
||||
@@ -151,6 +221,9 @@ def register():
|
||||
sale.SaleCreatePurchaseInput,
|
||||
sale.Derivative,
|
||||
sale.Valuation,
|
||||
sale.ValuationLine,
|
||||
sale.ValuationDyn,
|
||||
sale.ValuationReport,
|
||||
sale.Fee,
|
||||
sale.Lot,
|
||||
sale.FeeLots,
|
||||
@@ -161,8 +234,11 @@ def register():
|
||||
forex.SForex,
|
||||
forex.ForexCoverPhysicalSale,
|
||||
sale.ContractDocumentType,
|
||||
sale.Mtm,
|
||||
sale.SaleStrategy,
|
||||
sale.OpenPosition,
|
||||
sale.Backtoback,
|
||||
sale.AnalyticDimensionAssignment,
|
||||
sale.PriceComposition,
|
||||
module='sale', type_='model')
|
||||
Pool.register(
|
||||
lot.LotShipping,
|
||||
@@ -188,6 +264,7 @@ def register():
|
||||
dashboard.DashboardLoader,
|
||||
forex.ForexReport,
|
||||
purchase.PnlReport,
|
||||
purchase.PositionReport,
|
||||
derivative.DerivativeMatchWizard,
|
||||
module='purchase', type_='wizard')
|
||||
Pool.register(
|
||||
|
||||
30
modules/purchase_trade/account.py
Normal file
30
modules/purchase_trade/account.py
Normal file
@@ -0,0 +1,30 @@
|
||||
# account.py
|
||||
from trytond.model import ModelSQL, ModelView, fields
|
||||
from trytond.pool import PoolMeta
|
||||
from trytond.pyson import Eval
|
||||
|
||||
__all__ = ['PhysicalTradeIFRS']
|
||||
__metaclass__ = PoolMeta
|
||||
|
||||
|
||||
class PhysicalTradeIFRS(ModelSQL, ModelView):
|
||||
'Physical Trade - IFRS Adjustment'
|
||||
__name__ = 'account.physical_trade_ifrs'
|
||||
|
||||
date = fields.Date('Date', required=True)
|
||||
comment = fields.Text('Comment', required=True)
|
||||
currency = fields.Many2One('currency.currency', 'Currency', required=True)
|
||||
currency_digits = fields.Function(
|
||||
fields.Integer('Currency Digits'),
|
||||
'on_change_with_currency_digits')
|
||||
amount = fields.Numeric(
|
||||
'Amount',
|
||||
digits=(16, Eval('currency_digits', 2)),
|
||||
depends=['currency_digits'],
|
||||
required=True)
|
||||
|
||||
@fields.depends('currency')
|
||||
def on_change_with_currency_digits(self, name=None):
|
||||
if self.currency:
|
||||
return self.currency.digits
|
||||
return 2
|
||||
69
modules/purchase_trade/account.xml
Normal file
69
modules/purchase_trade/account.xml
Normal file
@@ -0,0 +1,69 @@
|
||||
<?xml version="1.0"?>
|
||||
<tryton>
|
||||
<data>
|
||||
<record model="res.group" id="group_physical_trade_ifrs">
|
||||
<field name="name">Physical Trade IFRS</field>
|
||||
</record>
|
||||
<record model="res.group" id="group_physical_trade_ifrs_admin">
|
||||
<field name="name">Physical Trade IFRS Administration</field>
|
||||
<field name="parent" ref="group_physical_trade_ifrs"/>
|
||||
</record>
|
||||
<record model="res.user-res.group" id="user_admin_group_physical_trade_ifrs">
|
||||
<field name="user" ref="res.user_admin"/>
|
||||
<field name="group" ref="group_physical_trade_ifrs"/>
|
||||
</record>
|
||||
<record model="res.user-res.group" id="user_admin_group_physical_trade_ifrs_admin">
|
||||
<field name="user" ref="res.user_admin"/>
|
||||
<field name="group" ref="group_physical_trade_ifrs_admin"/>
|
||||
</record>
|
||||
|
||||
<record model="ir.ui.view" id="view_physical_trade_ifrs_form">
|
||||
<field name="model">account.physical_trade_ifrs</field>
|
||||
<field name="type">form</field>
|
||||
<field name="name">physical_trade_IFRS_form</field>
|
||||
</record>
|
||||
<record model="ir.ui.view" id="view_physical_trade_ifrs_tree">
|
||||
<field name="model">account.physical_trade_ifrs</field>
|
||||
<field name="type">tree</field>
|
||||
<field name="name">physical_trade_IFRS_tree</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.action.act_window" id="act_physical_trade_ifrs_form">
|
||||
<field name="name">Physical Trade - IFRS Adjustment</field>
|
||||
<field name="res_model">account.physical_trade_ifrs</field>
|
||||
</record>
|
||||
<record model="ir.action.act_window.view" id="act_physical_trade_ifrs_view_tree">
|
||||
<field name="sequence" eval="10"/>
|
||||
<field name="view" ref="view_physical_trade_ifrs_tree"/>
|
||||
<field name="act_window" ref="act_physical_trade_ifrs_form"/>
|
||||
</record>
|
||||
<record model="ir.action.act_window.view" id="act_physical_trade_ifrs_view_form">
|
||||
<field name="sequence" eval="20"/>
|
||||
<field name="view" ref="view_physical_trade_ifrs_form"/>
|
||||
<field name="act_window" ref="act_physical_trade_ifrs_form"/>
|
||||
</record>
|
||||
|
||||
<menuitem
|
||||
name="Physical Trade - IFRS Adjustment"
|
||||
parent="account.menu_processing"
|
||||
action="act_physical_trade_ifrs_form"
|
||||
sequence="30"
|
||||
id="menu_physical_trade_ifrs"/>
|
||||
|
||||
<record model="ir.model.access" id="access_physical_trade_ifrs">
|
||||
<field name="model">account.physical_trade_ifrs</field>
|
||||
<field name="perm_read" eval="False"/>
|
||||
<field name="perm_write" eval="False"/>
|
||||
<field name="perm_create" eval="False"/>
|
||||
<field name="perm_delete" eval="False"/>
|
||||
</record>
|
||||
<record model="ir.model.access" id="access_physical_trade_ifrs_group">
|
||||
<field name="model">account.physical_trade_ifrs</field>
|
||||
<field name="group" ref="group_physical_trade_ifrs"/>
|
||||
<field name="perm_read" eval="True"/>
|
||||
<field name="perm_write" eval="True"/>
|
||||
<field name="perm_create" eval="True"/>
|
||||
<field name="perm_delete" eval="True"/>
|
||||
</record>
|
||||
</data>
|
||||
</tryton>
|
||||
22
modules/purchase_trade/backtoback.py
Normal file
22
modules/purchase_trade/backtoback.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from trytond.model import fields
|
||||
from trytond.pool import Pool, PoolMeta
|
||||
from trytond.pyson import Bool, Eval, Id
|
||||
from trytond.model import (ModelSQL, ModelView)
|
||||
from trytond.tools import is_full_text, lstrip_wildcard
|
||||
from trytond.transaction import Transaction, inactive_records
|
||||
from decimal import getcontext, Decimal, ROUND_HALF_UP
|
||||
from sql.aggregate import Count, Max, Min, Sum, Avg, BoolOr
|
||||
from sql.conditionals import Case
|
||||
from sql import Column, Literal
|
||||
from sql.functions import CurrentTimestamp, DateTrunc
|
||||
from trytond.wizard import Button, StateTransition, StateView, Wizard
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
class Backtoback(ModelSQL, ModelView):
|
||||
'Back To Back'
|
||||
__name__ = 'back.to.back'
|
||||
|
||||
reference = fields.Char("Reference")
|
||||
purchase = fields.One2Many('purchase.purchase','btb', "Purchase")
|
||||
|
||||
65
modules/purchase_trade/backtoback.xml
Normal file
65
modules/purchase_trade/backtoback.xml
Normal file
@@ -0,0 +1,65 @@
|
||||
<tryton>
|
||||
<data>
|
||||
<record model="ir.ui.icon" id="btb_icon">
|
||||
<field name="name">tradon-btb</field>
|
||||
<field name="path">icons/tradon-btb.svg</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.ui.view" id="btb_view_form">
|
||||
<field name="model">back.to.back</field>
|
||||
<field name="type">form</field>
|
||||
<field name="name">btb_form</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.ui.view" id="purchase_btb_view_form">
|
||||
<field name="model">purchase.purchase</field>
|
||||
<field name="type">form</field>
|
||||
<field name="name">purchase_btb_form</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.ui.view" id="purchase_line_btb_view_form">
|
||||
<field name="model">purchase.line</field>
|
||||
<field name="type">form</field>
|
||||
<field name="name">purchase_line_btb_form</field>
|
||||
</record>
|
||||
<record model="ir.ui.view" id="sale_line_btb_view_form">
|
||||
<field name="model">sale.line</field>
|
||||
<field name="type">form</field>
|
||||
<field name="name">sale_line_btb_form</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.ui.view" id="btb_view_list">
|
||||
<field name="model">back.to.back</field>
|
||||
<field name="type">tree</field>
|
||||
<field name="name">btb_tree</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.action.act_window" id="act_btb_form">
|
||||
<field name="name">Back to back</field>
|
||||
<field name="res_model">back.to.back</field>
|
||||
</record>
|
||||
<record model="ir.action.act_window.view" id="act_btb_form_view1">
|
||||
<field name="sequence" eval="10"/>
|
||||
<field name="view" ref="btb_view_list"/>
|
||||
<field name="act_window" ref="act_btb_form"/>
|
||||
</record>
|
||||
<record model="ir.action.act_window.view" id="act_btb_form_view2">
|
||||
<field name="sequence" eval="20"/>
|
||||
<field name="view" ref="btb_view_form"/>
|
||||
<field name="act_window" ref="act_btb_form"/>
|
||||
</record>
|
||||
|
||||
<menuitem
|
||||
name="Back to back"
|
||||
sequence="99"
|
||||
id="menu_btb_main"
|
||||
icon="tradon-btb" />
|
||||
<menuitem
|
||||
name="Back to back"
|
||||
action="act_btb_form"
|
||||
parent="menu_btb_main"
|
||||
sequence="10"
|
||||
id="menu_btb" />
|
||||
|
||||
</data>
|
||||
</tryton>
|
||||
@@ -155,7 +155,7 @@ class Party(metaclass=PoolMeta):
|
||||
if overdue > 0:
|
||||
# scale by overdue relative to limit
|
||||
limit = self.credit_limit or 1
|
||||
score += int(min(40, (overdue / float(limit)) * 100))
|
||||
score += int(min(40, (float(overdue) / float(limit)) * 100))
|
||||
|
||||
# cap
|
||||
if score > 100:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# This file is part of Tryton. The COPYRIGHT file at the top level of
|
||||
# this repository contains the full copyright notices and license terms.
|
||||
# this repository contains the full copyright notices and license terms.
|
||||
from trytond.model import ModelSQL, ModelView, fields, sequence_ordered, ModelSingleton
|
||||
from trytond.pyson import Eval
|
||||
from trytond.transaction import Transaction
|
||||
@@ -193,7 +193,7 @@ class Dashboard(ModelSQL, ModelView):
|
||||
self.chatbot = 'chatbot:' + json.dumps(dial, ensure_ascii=False)
|
||||
logger.info("EXITONCHANGE",self.chatbot)
|
||||
|
||||
def get_last_two_fx_rates(self, from_code='USD', to_code='EUR'):
|
||||
def get_last_five_fx_rates(self, from_code='USD', to_code='EUR'):
|
||||
"""
|
||||
Retourne (dernier_taux, avant_dernier_taux) pour le couple de devises.
|
||||
"""
|
||||
@@ -208,29 +208,93 @@ class Dashboard(ModelSQL, ModelView):
|
||||
rates = CurrencyRate.search(
|
||||
[('currency', '=', to_currency.id)],
|
||||
order=[('date', 'DESC')],
|
||||
limit=2,
|
||||
limit=5,
|
||||
)
|
||||
|
||||
if not rates:
|
||||
return None, None
|
||||
return None, None, None, None, None
|
||||
|
||||
# Calcul du taux EUR/USD
|
||||
# Si la devise principale de la société est EUR, et que le taux stocké est
|
||||
# "1 USD = X EUR", on veut l'inverse pour avoir EUR/USD
|
||||
last_rate = rates[0].rate
|
||||
prev_rate = rates[1].rate if len(rates) > 1 else None
|
||||
f1 = rates[0].rate
|
||||
f2 = rates[1].rate if len(rates) > 1 else None
|
||||
f3 = rates[2].rate if len(rates) > 2 else None
|
||||
f4 = rates[3].rate if len(rates) > 3 else None
|
||||
f5 = rates[4].rate if len(rates) > 4 else None
|
||||
d1 = rates[0].date
|
||||
d2 = rates[1].date if len(rates) > 1 else None
|
||||
d3 = rates[2].date if len(rates) > 2 else None
|
||||
d4 = rates[3].date if len(rates) > 3 else None
|
||||
d5 = rates[4].date if len(rates) > 4 else None
|
||||
|
||||
# if from_currency != to_currency:
|
||||
# last_rate = 1 / last_rate if last_rate else None
|
||||
# prev_rate = 1 / prev_rate if prev_rate else None
|
||||
|
||||
if last_rate and prev_rate:
|
||||
return round(1/last_rate,6), round(1/prev_rate,6)
|
||||
return round(1/f1,6), round(1/f2,6) if f2 else None, round(1/f3,6) if f3 else None, round(1/f4,6) if f4 else None, round(1/f5,6) if f5 else None, d1, d2, d3, d4, d5
|
||||
|
||||
def get_tremor(self,name):
|
||||
Pnl = Pool().get('valuation.valuation')
|
||||
pnls = Pnl.search(['id','>',0])
|
||||
pnl_amount = "{:,.0f}".format(round(sum([e.amount for e in pnls]),0))
|
||||
Date = Pool().get('ir.date')
|
||||
Configuration = Pool().get('gr.configuration')
|
||||
config = Configuration.search(['id','>',0])[0]
|
||||
Shipment = Pool().get('stock.shipment.in')
|
||||
DocumentIncoming = Pool().get('document.incoming')
|
||||
Fee = Pool().get('fee.fee')
|
||||
WR = Pool().get('weight.report')
|
||||
if config.automation:
|
||||
shipment = Shipment.search([('state','!=','received')])
|
||||
shipment_trend = [sh for sh in shipment if sh.create_date == Date.today()]
|
||||
controller = Shipment.search([('controller','!=',None)])
|
||||
controller_trend = [co for co in controller if co.create_date == Date.today()]
|
||||
instruction = Shipment.search([('result','!=',None)])
|
||||
instruction_trend = [si for si in instruction if si.create_date == Date.today()]
|
||||
id_received = Shipment.search([('returned_id','!=',None)])
|
||||
id_received_trend = [i for i in id_received if i.create_date == Date.today()]
|
||||
wr = WR.search([('id','>',0)])
|
||||
wr_trend = [w for w in wr if w.create_date == Date.today()]
|
||||
so = Fee.search(['id','=',25])
|
||||
so_trend = [s for s in so if s.create_date == Date.today()]
|
||||
di = DocumentIncoming.search(['id','>',0])
|
||||
di_trend = [d for d in di if d.create_date == Date.today()]
|
||||
return (
|
||||
config.dashboard +
|
||||
"/dashboard/index.html?shipment="
|
||||
+ str(len(shipment))
|
||||
+ "&shipment_trend="
|
||||
+ str(len(shipment_trend))
|
||||
+ "&controller="
|
||||
+ str(len(controller))
|
||||
+ "&controller_trend="
|
||||
+ str(len(controller_trend))
|
||||
+ "&instruction="
|
||||
+ str(len(instruction))
|
||||
+ "&instruction_trend="
|
||||
+ str(len(instruction_trend))
|
||||
+ "&wr="
|
||||
+ str(len(wr))
|
||||
+ "&wr_trend="
|
||||
+ str(len(wr_trend))
|
||||
+ "&so="
|
||||
+ str(len(so))
|
||||
+ "&so_trend="
|
||||
+ str(len(so_trend))
|
||||
+ "&di="
|
||||
+ str(len(di))
|
||||
+ "&di_trend="
|
||||
+ str(len(di_trend))
|
||||
+ "&id_received="
|
||||
+ str(len(id_received))
|
||||
+ "&id_received_trend="
|
||||
+ str(len(id_received_trend)))
|
||||
|
||||
f1,f2,f3,f4,f5,d1,d2,d3,d4,d5 = self.get_last_five_fx_rates()
|
||||
Valuation = Pool().get('valuation.valuation')
|
||||
last_total,last_variation = Valuation.get_totals()
|
||||
pnl_amount = "{:,.0f}".format(round(last_total,0))
|
||||
pnl_variation = 0
|
||||
if last_total and last_variation:
|
||||
pnl_variation = "{:,.2f}".format(round((last_variation/last_total)*100,0))
|
||||
Open = Pool().get('open.position')
|
||||
opens = Open.search(['id','>',0])
|
||||
exposure = "{:,.0f}".format(round(sum([e.net_exposure for e in opens]),0))
|
||||
@@ -258,38 +322,69 @@ class Dashboard(ModelSQL, ModelView):
|
||||
val_s = len(val)
|
||||
conf = Sale.search(['state','=','confirmed'])
|
||||
conf_s = len(conf)
|
||||
Shipment = Pool().get('stock.shipment.in')
|
||||
|
||||
draft = Shipment.search(['state','=','draft'])
|
||||
shipment_d = len(draft)
|
||||
val = Purchase.search(['state','=','started'])
|
||||
val = Shipment.search(['state','=','started'])
|
||||
shipment_s = len(val)
|
||||
conf = Purchase.search(['state','=','received'])
|
||||
conf = Shipment.search(['state','=','received'])
|
||||
shipment_r = len(conf)
|
||||
Lot = Pool().get('lot.lot')
|
||||
lots = Lot.search(['sale_line','!=',None])
|
||||
lots = Lot.search([('sale_line','!=',None),('line','!=',None),('lot_type','=','physic')])
|
||||
lot_m = len(lots)
|
||||
val = Lot.search(['sale_line','=',None])
|
||||
val = Lot.search([('sale_line','=',None),('line','!=',None),('lot_type','=','physic')])
|
||||
lot_a = len(val)
|
||||
conf = Lot.search(['lot_type','=','physic'])
|
||||
lot_al = len(conf)
|
||||
Invoice = Pool().get('account.invoice')
|
||||
invs = Invoice.search(['type','=','in'])
|
||||
inv_p = len(invs)
|
||||
invs = Invoice.search([('type','=','in'),('state','=','paid')])
|
||||
inv_p_p = len(invs)
|
||||
invs = Invoice.search([('type','=','in'),('state','!=','paid')])
|
||||
inv_p_np = len(invs)
|
||||
invs = Invoice.search(['type','=','out'])
|
||||
inv_s = len(invs)
|
||||
invs = Invoice.search([('type','=','out'),('state','=','paid')])
|
||||
inv_s_p = len(invs)
|
||||
invs = Invoice.search([('type','=','out'),('state','!=','paid')])
|
||||
inv_s_np = len(invs)
|
||||
AccountMove = Pool().get('account.move')
|
||||
accs = AccountMove.search(['id','>',0])
|
||||
move_cash = len(accs)
|
||||
accs = AccountMove.search([('journal','=',3),('state','!=','posted')])
|
||||
pay_val = len(accs)
|
||||
accs = AccountMove.search([('journal','=',3),('state','=','posted')])
|
||||
pay_posted = len(accs)
|
||||
|
||||
return (
|
||||
"https://srv413259.hstgr.cloud/dashboard/index.html?pnl_amount="
|
||||
config.dashboard +
|
||||
"/dashboard/index.html?pnl_amount="
|
||||
+ str(pnl_amount)
|
||||
+ "&pnl_variation="
|
||||
+ str(pnl_variation)
|
||||
+ "&exposure="
|
||||
+ str(exposure)
|
||||
+ "&topay="
|
||||
+ str(topay)
|
||||
+ "&toreceive="
|
||||
+ str(toreceive)
|
||||
+ "&eurusd="
|
||||
+ str(f1)
|
||||
+ "&eurusd="
|
||||
+ str(f2)
|
||||
+ "&eurusd="
|
||||
+ str(f3)
|
||||
+ "&eurusd="
|
||||
+ str(f4)
|
||||
+ "&eurusd="
|
||||
+ str(f5)
|
||||
+ "&eurusd_date="
|
||||
+ str(d1)
|
||||
+ "&eurusd_date="
|
||||
+ str(d2)
|
||||
+ "&eurusd_date="
|
||||
+ str(d3)
|
||||
+ "&eurusd_date="
|
||||
+ str(d4)
|
||||
+ "&eurusd_date="
|
||||
+ str(d5)
|
||||
+ "&draft_p="
|
||||
+ str(draft_p)
|
||||
+ "&val_p="
|
||||
@@ -312,14 +407,22 @@ class Dashboard(ModelSQL, ModelView):
|
||||
+ str(lot_m)
|
||||
+ "&lot_a="
|
||||
+ str(lot_a)
|
||||
+ "&lot_al="
|
||||
+ str(lot_al)
|
||||
+ "&inv_p="
|
||||
+ str(inv_p)
|
||||
+ "&inv_p_p="
|
||||
+ str(inv_p_p)
|
||||
+ "&inv_p_np="
|
||||
+ str(inv_p_np)
|
||||
+ "&inv_s="
|
||||
+ str(inv_s)
|
||||
+ "&move_cash="
|
||||
+ str(move_cash)
|
||||
+ "&inv_s_p="
|
||||
+ str(inv_s_p)
|
||||
+ "&inv_s_np="
|
||||
+ str(inv_s_np)
|
||||
+ "&pay_val="
|
||||
+ str(pay_val)
|
||||
+ "&pay_posted="
|
||||
+ str(pay_posted)
|
||||
)
|
||||
|
||||
|
||||
@@ -327,7 +430,7 @@ class Dashboard(ModelSQL, ModelView):
|
||||
News = Pool().get('news.news')
|
||||
Date = Pool().get('ir.date')
|
||||
news_list = News.search([('active', '=', True)], limit=5, order=[('publish_date', 'DESC')])
|
||||
last_rate,prev_rate = self.get_last_two_fx_rates()
|
||||
last_rate,prev_rate, = self.get_last_five_fx_rates()
|
||||
if last_rate and prev_rate:
|
||||
variation = ((last_rate - prev_rate) / prev_rate) * 100 if prev_rate else 0
|
||||
direction = "📈" if variation > 0 else "📉"
|
||||
@@ -412,7 +515,7 @@ class Dashboard(ModelSQL, ModelView):
|
||||
' <div class="demos-title" style="font-size:1.2em; font-weight:bold; margin-bottom:10px;">🎬 Available Demo</div>'
|
||||
]
|
||||
|
||||
demos = Demos.search([('active', '=', True)])
|
||||
demos = Demos.search([('active', '=', True)],order=[('id', 'DESC')])
|
||||
for n in demos:
|
||||
icon = n.icon or "📰"
|
||||
category = n.category or "General"
|
||||
@@ -499,12 +602,14 @@ class Dashboard(ModelSQL, ModelView):
|
||||
return pu
|
||||
|
||||
def gen_url(self,name=None):
|
||||
Configuration = Pool().get('gr.configuration')
|
||||
config = Configuration.search(['id','>',0])[0]
|
||||
payload = {
|
||||
"resource": {"dashboard": self.bi_id},
|
||||
"params": {},
|
||||
"exp": datetime.datetime.utcnow() + datetime.timedelta(minutes=30),
|
||||
}
|
||||
token = jwt.encode(payload, "798f256d3119a3292bf121196c2a38dddf2cad155c0b6b0b444efc34c6db197c", algorithm="HS256")
|
||||
token = jwt.encode(payload, config.payload, algorithm="HS256")
|
||||
logger.info("TOKEN:%s",token)
|
||||
return f"metabase:http://vps107.geneva.hosting:3000/embed/dashboard/{token}#bordered=true&titled=true"
|
||||
|
||||
@@ -663,6 +768,7 @@ class BotWizard(Wizard):
|
||||
l.lot_quantity = l.lot_qt
|
||||
l.lot_gross_quantity = l.lot_qt
|
||||
l.lot_premium = Decimal(0)
|
||||
l.lot_chunk_key = None
|
||||
lot_id = LotQt.add_physical_lots(lqt,[l])
|
||||
d.action_return = 'lot.lot,' + str(lot_id) + ',' + str(lot_id)
|
||||
Dashboard.save([d])
|
||||
|
||||
79
modules/purchase_trade/dimension.py
Normal file
79
modules/purchase_trade/dimension.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from trytond.model import fields
|
||||
from trytond.pool import Pool, PoolMeta
|
||||
from trytond.pyson import Bool, Eval, Id
|
||||
from trytond.model import (ModelSQL, ModelView)
|
||||
from trytond.tools import is_full_text, lstrip_wildcard
|
||||
from trytond.transaction import Transaction, inactive_records
|
||||
from decimal import getcontext, Decimal, ROUND_HALF_UP
|
||||
from sql.aggregate import Count, Max, Min, Sum, Avg, BoolOr
|
||||
from sql.conditionals import Case
|
||||
from sql import Column, Literal
|
||||
from sql.functions import CurrentTimestamp, DateTrunc
|
||||
from trytond.wizard import Button, StateTransition, StateView, Wizard
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
class AnalyticDimension(ModelSQL, ModelView):
|
||||
'Analytic Dimension'
|
||||
__name__ = 'analytic.dimension'
|
||||
|
||||
name = fields.Char('Name', required=True)
|
||||
code = fields.Char('Code', required=True)
|
||||
active = fields.Boolean('Active')
|
||||
|
||||
class AnalyticDimensionValue(ModelSQL, ModelView):
|
||||
'Analytic Dimension Value'
|
||||
__name__ = 'analytic.dimension.value'
|
||||
|
||||
dimension = fields.Many2One(
|
||||
'analytic.dimension',
|
||||
'Dimension',
|
||||
required=True,
|
||||
ondelete='CASCADE'
|
||||
)
|
||||
|
||||
name = fields.Char('Name', required=True)
|
||||
code = fields.Char('Code')
|
||||
|
||||
parent = fields.Many2One(
|
||||
'analytic.dimension.value',
|
||||
'Parent',
|
||||
domain=[
|
||||
('dimension', '=', Eval('dimension')),
|
||||
],
|
||||
depends=['dimension']
|
||||
)
|
||||
|
||||
children = fields.One2Many(
|
||||
'analytic.dimension.value',
|
||||
'parent',
|
||||
'Children'
|
||||
)
|
||||
|
||||
active = fields.Boolean('Active')
|
||||
|
||||
class AnalyticDimensionAssignment(ModelSQL, ModelView):
|
||||
'Analytic Dimension Assignment'
|
||||
__name__ = 'analytic.dimension.assignment'
|
||||
|
||||
dimension = fields.Many2One(
|
||||
'analytic.dimension',
|
||||
'Dimension',
|
||||
required=True
|
||||
)
|
||||
|
||||
value = fields.Many2One(
|
||||
'analytic.dimension.value',
|
||||
'Value',
|
||||
required=True,
|
||||
domain=[
|
||||
('dimension', '=', Eval('dimension')),
|
||||
],
|
||||
depends=['dimension']
|
||||
)
|
||||
|
||||
purchase = fields.Many2One(
|
||||
'purchase.purchase',
|
||||
'Purchase',
|
||||
ondelete='CASCADE'
|
||||
)
|
||||
34
modules/purchase_trade/dimension.xml
Normal file
34
modules/purchase_trade/dimension.xml
Normal file
@@ -0,0 +1,34 @@
|
||||
<tryton>
|
||||
<data>
|
||||
<record model="ir.ui.view" id="dimension_view_form">
|
||||
<field name="model">analytic.dimension</field>
|
||||
<field name="type">form</field>
|
||||
<field name="name">dimension_form</field>
|
||||
</record>
|
||||
<record model="ir.ui.view" id="dimension_view_list">
|
||||
<field name="model">analytic.dimension</field>
|
||||
<field name="type">tree</field>
|
||||
<field name="name">dimension_tree</field>
|
||||
</record>
|
||||
<record model="ir.ui.view" id="dimension_value_view_form">
|
||||
<field name="model">analytic.dimension.value</field>
|
||||
<field name="type">form</field>
|
||||
<field name="name">dimension_value_form</field>
|
||||
</record>
|
||||
<record model="ir.ui.view" id="dimension_value_view_list">
|
||||
<field name="model">analytic.dimension.value</field>
|
||||
<field name="type">tree</field>
|
||||
<field name="name">dimension_value_tree</field>
|
||||
</record>
|
||||
<record model="ir.ui.view" id="dimension_ass_view_form">
|
||||
<field name="model">analytic.dimension.assignment</field>
|
||||
<field name="type">form</field>
|
||||
<field name="name">dimension_ass_form</field>
|
||||
</record>
|
||||
<record model="ir.ui.view" id="dimension_ass_view_list">
|
||||
<field name="model">analytic.dimension.assignment</field>
|
||||
<field name="type">tree</field>
|
||||
<field name="name">dimension_ass_tree</field>
|
||||
</record>
|
||||
</data>
|
||||
</tryton>
|
||||
@@ -0,0 +1,80 @@
|
||||
# Backlog - Market Price Import
|
||||
|
||||
Scope: code changes related only to `BR-PT-004 - Market Price Import`.
|
||||
|
||||
## PT-BL-001 - Enforce uniqueness at model level
|
||||
|
||||
- Status: to be tested
|
||||
- Implementation commit: `eaae2e5`
|
||||
- Priority: high
|
||||
- Source: [rules/market-price-import.md](../rules/market-price-import.md)
|
||||
- Requirement: enforce uniqueness of `(price, price_date)` at model level without adding a database constraint yet.
|
||||
- Work done:
|
||||
- added model-level validation on `price.price_value`
|
||||
- added Tryton message `price.msg_price_value_unique`
|
||||
- added targeted tests for duplicate create and duplicate write validation
|
||||
- Validation status:
|
||||
- `git diff --check` passed
|
||||
- automated Python tests still need to be run in a working Tryton Python environment
|
||||
- Expected code impact:
|
||||
- `modules/purchase_trade/pricing.py`
|
||||
- possibly `modules/price/price_value.py` if the validation belongs on the target model
|
||||
- Expected tests:
|
||||
- importing a row for an existing `(price, price_date)` skips or updates according to `Overwrite existing price`
|
||||
- creating/editing duplicate `price.price_value` records at model level is rejected if validation is implemented on `price.price_value`
|
||||
|
||||
## PT-BL-002 - Use locale-aware slash date parsing
|
||||
|
||||
- Status: open
|
||||
- Priority: medium
|
||||
- Source: [rules/market-price-import.md](../rules/market-price-import.md)
|
||||
- Requirement: prefer the user's/default locale date format for ambiguous slash dates, then fall back to the current parsing order only if no locale preference is available.
|
||||
- Current behavior: `DD/MM/YYYY` is tried before `MM/DD/YYYY`.
|
||||
- Expected code impact:
|
||||
- `modules/purchase_trade/pricing.py`
|
||||
- Expected tests:
|
||||
- slash dates follow the configured/default locale when available
|
||||
- slash dates keep a deterministic fallback when locale is unavailable
|
||||
- existing accepted formats still work
|
||||
|
||||
## PT-BL-003 - Reject missing price_value
|
||||
|
||||
- Status: open
|
||||
- Priority: high
|
||||
- Source: [rules/market-price-import.md](../rules/market-price-import.md)
|
||||
- Requirement: `price_value` is mandatory for imported rows. Missing `price_value` must be reported as a row error.
|
||||
- Current behavior: empty `price_value` is imported as an empty value.
|
||||
- Expected code impact:
|
||||
- `modules/purchase_trade/pricing.py`
|
||||
- Expected tests:
|
||||
- missing `price_value` is reported in `errors`
|
||||
- missing `high_price`, `low_price`, and `open_price` remain allowed
|
||||
- valid `price_value` still imports and updates correctly
|
||||
|
||||
## PT-BL-004 - Detect duplicate rows inside the same Excel file
|
||||
|
||||
- Status: open
|
||||
- Priority: high
|
||||
- Source: [rules/market-price-import.md](../rules/market-price-import.md)
|
||||
- Requirement: duplicate rows for the same `(price_index, price_date)` inside the same Excel file must be reported as row errors. The duplicate row must not import or update data.
|
||||
- Expected code impact:
|
||||
- `modules/purchase_trade/pricing.py`
|
||||
- Expected tests:
|
||||
- duplicate `(price_index, price_date)` rows in the same import produce row errors
|
||||
- first occurrence behavior remains unchanged
|
||||
- duplicate rows do not overwrite previous imported or updated values
|
||||
|
||||
## PT-BL-005 - Categorize import result errors
|
||||
|
||||
- Status: open
|
||||
- Priority: medium
|
||||
- Source: [rules/market-price-import.md](../rules/market-price-import.md)
|
||||
- Requirement: distinguish business validation errors from file, parsing, and technical errors in the import result.
|
||||
- Current behavior: row errors are grouped under one `Errors` section.
|
||||
- Expected code impact:
|
||||
- `modules/purchase_trade/pricing.py`
|
||||
- `modules/purchase_trade/view/import_prices_result_form.xml` only if the result display needs more structure
|
||||
- Expected tests:
|
||||
- result message separates business validation errors from parsing/technical errors
|
||||
- invalid workbook and missing required columns remain blocking `UserError`
|
||||
- row-level errors still let the import continue
|
||||
57
modules/purchase_trade/docs/business-rules.md
Normal file
57
modules/purchase_trade/docs/business-rules.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Business Rules - Purchase Trade
|
||||
|
||||
Statut: `draft`
|
||||
Version: `v0.3`
|
||||
Derniere mise a jour: `2026-05-07`
|
||||
Owner metier: `a completer`
|
||||
Owner technique: `a completer`
|
||||
|
||||
## 1) Scope
|
||||
|
||||
- Domaine: `purchase_trade`
|
||||
- Hors scope:
|
||||
- Modules impactes:
|
||||
- `purchase_trade`
|
||||
- `lot`
|
||||
- `price`
|
||||
|
||||
## 2) Glossaire
|
||||
|
||||
- `Purchase Line`: ligne d'achat.
|
||||
- `quantity_theorical`: quantite theorique contractuelle de la ligne.
|
||||
- `Virtual Lot`: lot unique de type `virtual` rattache a une `purchase.line`.
|
||||
- `Physical Lot`: lot physique de type `physic` utilise comme pont entre achat, vente et shipment.
|
||||
- `lot.qt`: table des quantites ouvertes, matchees ou shippees par lot.
|
||||
- `lot.qt ouvert`: enregistrement `lot.qt` avec `lot_p = virtual lot`, `lot_s = None` et sans shipment.
|
||||
- `price.price`: index de prix marche.
|
||||
- `price.price_value`: valeur datee d'un index de prix marche.
|
||||
|
||||
## 3) Catalogue des regles
|
||||
|
||||
| ID | Titre | Domaine | Detail |
|
||||
| --- | --- | --- | --- |
|
||||
| BR-PT-001 | Ajustement de la quantite theorique apres creation du contrat | Lot / Purchase | [rules/lot-quantity.md](rules/lot-quantity.md) |
|
||||
| BR-PT-002 | Le lot physique est le pont metier entre purchase, sale et shipment | Lot / Navigation | [rules/lot-navigation.md](rules/lot-navigation.md) |
|
||||
| BR-PT-003 | Le freight amount des templates facture vient du fee de shipment | Invoice / Freight | [rules/invoice-freight.md](rules/invoice-freight.md) |
|
||||
| BR-PT-004 | Market Price Import | Pricing | [rules/market-price-import.md](rules/market-price-import.md) |
|
||||
|
||||
## 4) Convention pour les nouvelles regles
|
||||
|
||||
Voir aussi [documentation-management.md](documentation-management.md) pour le workflow complet de gestion documentaire.
|
||||
|
||||
Ajouter une ligne au catalogue puis creer une fiche detaillee dans `docs/rules/`.
|
||||
|
||||
Structure recommandee:
|
||||
|
||||
- Intent
|
||||
- Scope
|
||||
- Inputs
|
||||
- Expected Behavior
|
||||
- Edge Cases
|
||||
- Impacted Files
|
||||
- Tests
|
||||
- Open Questions
|
||||
|
||||
Les regles courtes peuvent rester dans ce catalogue temporairement, mais toute regle avec impact code, tests, edge cases ou flux transverse doit avoir son fichier dedie.
|
||||
|
||||
Dans les sections `Open Questions`, prefixer chaque question avec `Q:` et chaque reponse documentee avec `A:`.
|
||||
82
modules/purchase_trade/docs/documentation-management.md
Normal file
82
modules/purchase_trade/docs/documentation-management.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# Documentation Management - Purchase Trade
|
||||
|
||||
This guide records the documentation workflow to apply for future `purchase_trade` developments.
|
||||
|
||||
## Rule Documentation Structure
|
||||
|
||||
- Keep `business-rules.md` as the entry point and rule catalog.
|
||||
- Store detailed business rules in `docs/rules/`, one file per substantial rule.
|
||||
- Use stable business rule IDs such as `BR-PT-004`.
|
||||
- Add every new detailed rule to the catalog in `business-rules.md`.
|
||||
- Keep rule files focused on business behavior, expected outcomes, edge cases, impacted files, tests, and open questions.
|
||||
|
||||
## Rule File Template
|
||||
|
||||
Use this structure for detailed rule files when applicable:
|
||||
|
||||
- Intent
|
||||
- Scope
|
||||
- Inputs
|
||||
- Date and Numeric Parsing, when relevant
|
||||
- Expected Behavior
|
||||
- Defaults or Derived Values, when relevant
|
||||
- Result Reporting, when relevant
|
||||
- Edge Cases
|
||||
- Impacted Files
|
||||
- Tests
|
||||
- Open Questions
|
||||
|
||||
## Open Questions
|
||||
|
||||
- In `Open Questions`, prefix every question with `Q:`.
|
||||
- Prefix every documented answer with `A:`.
|
||||
- When an answer differs from current code behavior, add a short `Comment:` explaining that a code change is required.
|
||||
- Once all questions have answers, keep the section as decision history unless the rule becomes noisy.
|
||||
|
||||
Example:
|
||||
|
||||
```md
|
||||
- Q: Should missing `price_value` be allowed?
|
||||
- A: Missing `price_value` is not allowed. Report the row as an error.
|
||||
- Comment: current code allows empty `price_value`. Implementing this answer requires a code change.
|
||||
```
|
||||
|
||||
## Backlog From Documentation
|
||||
|
||||
- When answered questions imply code changes, create a dedicated backlog file for the feature or rule.
|
||||
- Store all backlog files in `docs/backlog/`.
|
||||
- Name backlog files by feature, for example `docs/backlog/market-price-import-backlog.md`.
|
||||
- Do not use a generic module backlog when the changes belong to one feature.
|
||||
- After coding a backlog item, update that item with the work done and mark its `Status` as `to be tested` until the targeted validation has run successfully in the proper environment.
|
||||
- After committing implemented backlog work, record the implementation commit hash in the relevant backlog item for future reference.
|
||||
- Each backlog item should include:
|
||||
- stable ID
|
||||
- status
|
||||
- implementation commit, once committed
|
||||
- priority
|
||||
- source rule file
|
||||
- requirement
|
||||
- work done, once implemented
|
||||
- validation status, once attempted
|
||||
- current behavior, when useful
|
||||
- expected code impact
|
||||
- expected tests
|
||||
|
||||
## Review Workflow
|
||||
|
||||
For future development work:
|
||||
|
||||
1. Read the relevant rule file before changing code.
|
||||
2. Review the current code and tests related to the rule.
|
||||
3. Update the rule documentation if code review reveals missing behavior, edge cases, defaults, or test gaps.
|
||||
4. Record unresolved decisions as `Q:` entries.
|
||||
5. Record accepted decisions as `A:` entries.
|
||||
6. Convert accepted decisions that require code changes into backlog items.
|
||||
7. Implement code only after the requested documentation/specification step is complete, or after explicit approval when the user asks to wait.
|
||||
|
||||
## Scope Discipline
|
||||
|
||||
- Keep documentation changes close to the affected module and feature.
|
||||
- Do not mix unrelated features in one rule file or one backlog file.
|
||||
- Link or list impacted files so future agents can work in a narrow code scope.
|
||||
- Keep tests listed near the rule they validate.
|
||||
50
modules/purchase_trade/docs/rules/invoice-freight.md
Normal file
50
modules/purchase_trade/docs/rules/invoice-freight.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# BR-PT-003 - Le freight amount des templates facture vient du fee de shipment
|
||||
|
||||
## Intent
|
||||
|
||||
Afficher dans les documents facture la vraie valeur de fret maritime rattachee au shipment du lot physique.
|
||||
|
||||
## Scope
|
||||
|
||||
- Domaine: `purchase_trade`
|
||||
- Flux: templates facture achat et vente
|
||||
- Priorite: `importante`
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
Le `FREIGHT VALUE` d'une facture ne doit pas etre pris sur la facture elle-meme.
|
||||
|
||||
Il doit etre calcule a partir du `fee.fee` rattache au shipment (`shipment_in`) du lot physique relie a la facture.
|
||||
|
||||
Regle de navigation:
|
||||
|
||||
- retrouver le lot physique pertinent depuis la facture
|
||||
- retrouver son shipment
|
||||
- chercher le `fee.fee` avec:
|
||||
- `shipment_in = shipment.id`
|
||||
- `product.name = 'Maritime freight'`
|
||||
- utiliser `fee.get_amount()` comme montant de fret
|
||||
|
||||
La regle s'applique aussi bien aux factures d'achat qu'aux factures de vente.
|
||||
|
||||
Cote vente, la remontee doit passer par le lot physique qui fait le lien entre `purchase.line` et `sale.line`.
|
||||
|
||||
## Impacted Files
|
||||
|
||||
- `modules/purchase_trade/invoice.py`
|
||||
- `modules/purchase_trade/fee.py`
|
||||
- `modules/purchase_trade/lot.py`
|
||||
- Templates facture `.fodt` concernes par `FREIGHT VALUE`
|
||||
|
||||
## Tests
|
||||
|
||||
Couvrir les changements au plus proche du flux modifie:
|
||||
|
||||
- facture achat avec lot physique, shipment et fee `Maritime freight`
|
||||
- facture vente qui remonte au lot physique puis au shipment
|
||||
- cas sans fee maritime
|
||||
- cas sans lot physique pertinent
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Preciser les templates exacts quand une demande cible un document facture particulier.
|
||||
59
modules/purchase_trade/docs/rules/lot-navigation.md
Normal file
59
modules/purchase_trade/docs/rules/lot-navigation.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# BR-PT-002 - Le lot physique est le pont metier entre purchase, sale et shipment
|
||||
|
||||
## Intent
|
||||
|
||||
Disposer d'un chemin unique et stable pour retrouver les informations logistiques et de facturation reliees a un contrat d'achat ou de vente.
|
||||
|
||||
## Scope
|
||||
|
||||
- Domaine: `purchase_trade`
|
||||
- Flux: navigation metier entre achat, vente, shipment et facture
|
||||
- Priorite: `structurante`
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
Le lot physique (`lot_type = physic`) porte simultanement le lien vers:
|
||||
|
||||
- la `purchase.line` via `lot.line`
|
||||
- la `sale.line` via `lot.sale_line`
|
||||
- le shipment via `lot.lot_shipment_in` / `lot.lot_shipment_internal` / `lot.lot_shipment_out`
|
||||
|
||||
Pour toute logique qui doit naviguer entre achat, vente, shipment et facture, il faut privilegier ce lot physique comme source de verite.
|
||||
|
||||
Depuis une facture d'achat:
|
||||
|
||||
- remonter a la `purchase.line`
|
||||
- puis au lot physique de la ligne
|
||||
- puis au shipment et aux donnees logistiques associees
|
||||
|
||||
Depuis une facture de vente:
|
||||
|
||||
- remonter a la `sale.line`
|
||||
- puis au lot physique matchant qui porte aussi la `purchase.line`
|
||||
- puis au shipment et aux donnees logistiques associees
|
||||
|
||||
## Typical Use Cases
|
||||
|
||||
- recuperer `bl_date`, `bl_number`, `controller`, `from_location`, `to_location`
|
||||
- retrouver une facture provisoire liee au lot
|
||||
- retrouver des fees rattaches au shipment
|
||||
|
||||
## Impacted Files
|
||||
|
||||
- `modules/purchase_trade/lot.py`
|
||||
- `modules/purchase_trade/purchase.py`
|
||||
- `modules/purchase_trade/sale.py`
|
||||
- `modules/purchase_trade/invoice.py`
|
||||
- Templates facture relies aux donnees logistiques, si le flux documentaire est concerne.
|
||||
|
||||
## Tests
|
||||
|
||||
Couvrir les changements au plus proche du flux modifie:
|
||||
|
||||
- navigation achat vers lot physique puis shipment
|
||||
- navigation vente vers lot physique puis shipment
|
||||
- cas sans lot physique pertinent
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Documenter au cas par cas les champs exposes au reporting facture quand ils dependent de cette navigation.
|
||||
106
modules/purchase_trade/docs/rules/lot-quantity.md
Normal file
106
modules/purchase_trade/docs/rules/lot-quantity.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# BR-PT-001 - Ajustement de la quantite theorique apres creation du contrat
|
||||
|
||||
## Intent
|
||||
|
||||
Conserver la coherence entre la quantite theorique de la ligne d'achat, le lot virtuel associe et les quantites ouvertes stockees dans `lot.qt`.
|
||||
|
||||
## Scope
|
||||
|
||||
- Domaine: `purchase_trade`
|
||||
- Flux: modification de `purchase.line.quantity_theorical` apres creation du contrat
|
||||
- Priorite: `bloquante`
|
||||
|
||||
## Inputs
|
||||
|
||||
- Une `purchase.line` existe deja.
|
||||
- Son champ `quantity_theorical` est modifie via `write`.
|
||||
- Un lot unique de type `virtual` est rattache a la ligne.
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
Quand `purchase.line.quantity_theorical` est modifiee apres creation du contrat, le systeme doit recalculer le delta entre l'ancienne et la nouvelle valeur.
|
||||
|
||||
La regle s'applique au lot unique de type `virtual` rattache a la `purchase.line`.
|
||||
|
||||
Si `delta > 0`:
|
||||
|
||||
- augmenter la quantite courante du lot `virtual` via `set_current_quantity` pour conserver l'historique `lot.qt.hist`
|
||||
- augmenter le `lot.qt` ouvert existant
|
||||
- si aucun `lot.qt` ouvert n'existe, en creer un nouveau avec le delta
|
||||
|
||||
Si `delta < 0`:
|
||||
|
||||
- diminuer le `lot.qt` ouvert uniquement si la quantite ouverte disponible est suffisante
|
||||
- diminuer la quantite courante du lot `virtual` du meme delta
|
||||
- si aucun `lot.qt` ouvert n'existe ou si sa quantite est insuffisante, bloquer avec l'erreur `Please unlink or unmatch lot`
|
||||
|
||||
Definition du `lot.qt` ouvert:
|
||||
|
||||
- `lot_p = virtual lot`
|
||||
- `lot_s = None`
|
||||
- `lot_shipment_in = None`
|
||||
- `lot_shipment_internal = None`
|
||||
- `lot_shipment_out = None`
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- Si aucun lot `virtual` n'est trouve sur la ligne, la regle ne fait rien.
|
||||
|
||||
## Examples
|
||||
|
||||
### Exemple E1 - Augmentation simple
|
||||
|
||||
- Donnees:
|
||||
- `ancienne quantity_theorical = 100`
|
||||
- `nouvelle quantity_theorical = 120`
|
||||
- `lot.qt ouvert = 40`
|
||||
- Attendu:
|
||||
- lot `virtual` augmente de `20`
|
||||
- `lot.qt ouvert` passe de `40` a `60`
|
||||
|
||||
### Exemple E2 - Augmentation sans lot.qt ouvert
|
||||
|
||||
- Donnees:
|
||||
- `ancienne quantity_theorical = 100`
|
||||
- `nouvelle quantity_theorical = 110`
|
||||
- aucun `lot.qt` ouvert
|
||||
- Attendu:
|
||||
- lot `virtual` augmente de `10`
|
||||
- creation d'un `lot.qt` ouvert a `10`
|
||||
|
||||
### Exemple E3 - Diminution possible
|
||||
|
||||
- Donnees:
|
||||
- `ancienne quantity_theorical = 100`
|
||||
- `nouvelle quantity_theorical = 90`
|
||||
- `lot.qt ouvert = 25`
|
||||
- Attendu:
|
||||
- lot `virtual` diminue de `10`
|
||||
- `lot.qt ouvert` passe de `25` a `15`
|
||||
|
||||
### Exemple E4 - Diminution impossible
|
||||
|
||||
- Donnees:
|
||||
- `ancienne quantity_theorical = 100`
|
||||
- `nouvelle quantity_theorical = 80`
|
||||
- `lot.qt ouvert = 5`
|
||||
- Attendu:
|
||||
- blocage avec `Please unlink or unmatch lot`
|
||||
|
||||
## Impacted Files
|
||||
|
||||
- `modules/purchase_trade/purchase.py`
|
||||
- `modules/purchase_trade/lot.py`
|
||||
|
||||
## Tests
|
||||
|
||||
Couvrir au minimum:
|
||||
|
||||
- augmentation avec `lot.qt` ouvert existant
|
||||
- augmentation sans `lot.qt` ouvert
|
||||
- diminution possible
|
||||
- diminution impossible avec erreur
|
||||
|
||||
## Source
|
||||
|
||||
- Decision metier documentee dans les commentaires de `purchase_trade.purchase.Line.write`.
|
||||
171
modules/purchase_trade/docs/rules/market-price-import.md
Normal file
171
modules/purchase_trade/docs/rules/market-price-import.md
Normal file
@@ -0,0 +1,171 @@
|
||||
# BR-PT-004 - Market Price Import
|
||||
|
||||
## Intent
|
||||
|
||||
Import dated market prices from an `.xlsx` Excel file into `price.price_value`, using `price.price` as the market price index.
|
||||
|
||||
## Scope
|
||||
|
||||
- Wizard: `purchase_trade.import_prices`
|
||||
- Input model: `purchase_trade.import_prices.start`
|
||||
- Result model: `purchase_trade.import_prices.result`
|
||||
- Target models:
|
||||
- `price.price`
|
||||
- `price.price_value`
|
||||
- Menu entry: under `price.menu_price`
|
||||
|
||||
## Inputs
|
||||
|
||||
The wizard reads only the first worksheet of an `.xlsx` file.
|
||||
|
||||
The first row is treated as the header row. Header names are normalized by lowercasing and removing non-alphanumeric characters, so labels such as `price_index`, `price index`, and `Price Index` map to the same field.
|
||||
|
||||
Required columns:
|
||||
|
||||
- `price_index`
|
||||
- `price_date`
|
||||
- `high_price`
|
||||
- `low_price`
|
||||
- `open_price`
|
||||
- `price_value`
|
||||
|
||||
The wizard options are:
|
||||
|
||||
- `Create price index if missing`
|
||||
- `Overwrite existing price`
|
||||
|
||||
## Date and Numeric Parsing
|
||||
|
||||
Accepted `price_date` values:
|
||||
|
||||
- Excel serial date numbers
|
||||
- `YYYY-MM-DD`
|
||||
- `DD/MM/YYYY`
|
||||
- `MM/DD/YYYY`
|
||||
|
||||
Numeric price fields accept decimal commas or decimal points. Empty numeric cells are imported as empty values. Invalid numeric values are reported as row errors.
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
For each non-empty data row, starting from Excel row 2:
|
||||
|
||||
1. Trim and validate `price_index`.
|
||||
2. Parse `price_date`.
|
||||
3. Search `price.price` by exact `price_index`.
|
||||
4. If the price index is missing:
|
||||
- create it when `Create price index if missing` is checked
|
||||
- otherwise skip the row with `price_index missing`
|
||||
5. Search `price.price_value` by `(price, price_date)`.
|
||||
6. If an existing price value is found:
|
||||
- update it when `Overwrite existing price` is checked
|
||||
- otherwise skip the row with `price_date already exists`
|
||||
7. If no existing price value is found, create a new `price.price_value`.
|
||||
|
||||
## Created Price Index Defaults
|
||||
|
||||
When the wizard creates a missing `price.price`, it sets:
|
||||
|
||||
- `price_index = imported price_index`
|
||||
- `price_desc = imported price_index`
|
||||
- `price_curve_type = future`
|
||||
|
||||
It also tries to default these references when matching records exist:
|
||||
|
||||
- `price_type`: `price.fixtype` where `name = Market price`
|
||||
- `price_currency`: `currency.currency` where `code = USD`
|
||||
- `price_calendar`: `price.calendar` where `name = Argus EU`
|
||||
- `price_unit`: `product.uom` where `name = Mt`
|
||||
|
||||
If the `price_index` contains a `YYYY-MM` style period, the wizard derives a `product.month`:
|
||||
|
||||
- pattern accepted in the name: `YYYY-MM`, `YYYY/MM`, `YYYY_MM`, `YYYY.MM`, or `YYYY MM`
|
||||
- month name format: `MONYY`, for example `JUL26`
|
||||
- if no matching `product.month` exists, it is created with `is_cotation = True`
|
||||
|
||||
## Result Reporting
|
||||
|
||||
The result screen always shows counts and detail sections for:
|
||||
|
||||
- created price indexes
|
||||
- imported prices
|
||||
- updated existing prices
|
||||
- skipped records
|
||||
- errors
|
||||
|
||||
Row-level errors do not stop the whole import; the wizard records the error and continues with the next row.
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- Missing `price_index`: skipped.
|
||||
- Missing `price_date`: skipped.
|
||||
- Invalid `.xlsx` file: blocking `UserError`.
|
||||
- Missing required columns: blocking `UserError`.
|
||||
- Invalid date: row error.
|
||||
- Invalid numeric value: row error.
|
||||
- Existing `(price, price_date)` without overwrite option: skipped.
|
||||
- Existing `(price, price_date)` with overwrite option: updated.
|
||||
- Empty rows are ignored.
|
||||
|
||||
## Impacted Files
|
||||
|
||||
Direct `purchase_trade` files:
|
||||
|
||||
- `modules/purchase_trade/pricing.py`
|
||||
- `modules/purchase_trade/pricing.xml`
|
||||
- `modules/purchase_trade/view/import_prices_start_form.xml`
|
||||
- `modules/purchase_trade/view/import_prices_result_form.xml`
|
||||
- `modules/purchase_trade/__init__.py`
|
||||
- `modules/purchase_trade/tryton.cfg`
|
||||
- `modules/purchase_trade/tests/test_module.py`
|
||||
|
||||
External model dependencies:
|
||||
|
||||
- `modules/price/price.py`
|
||||
- `modules/price/price_value.py`
|
||||
- `modules/price/view/price_value_form.xml`
|
||||
|
||||
## Tests
|
||||
|
||||
Existing focused tests cover:
|
||||
|
||||
- missing price index skipped when creation is disabled
|
||||
- missing price index created when creation is enabled
|
||||
- default fields on newly created price indexes
|
||||
- period reuse/creation from `price_index`
|
||||
- existing `price_date` skipped when overwrite is disabled
|
||||
- existing `price_date` updated when overwrite is enabled
|
||||
- invalid row values collected as errors
|
||||
- result screen formatting
|
||||
|
||||
Recommended additional tests:
|
||||
|
||||
- `.xlsx` header normalization
|
||||
- missing required columns
|
||||
- Excel serial date parsing
|
||||
- empty row ignored
|
||||
- invalid workbook raises `UserError`
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Q: Should `(price, price_date)` be enforced unique at model/database level?
|
||||
- A: Enforce uniqueness at model level for now. Do not add a database constraint yet.
|
||||
|
||||
- Q: Should created price index defaults remain hardcoded to `Market price`, `USD`, `Argus EU`, and `Mt`?
|
||||
- A: Yes. Keep these defaults hardcoded for this import.
|
||||
|
||||
- Q: Should ambiguous slash dates prefer `DD/MM/YYYY` over `MM/DD/YYYY`, as currently implemented?
|
||||
- A: Prefer the user's/default locale date format when possible. Fall back to the current order only if no locale preference is available.
|
||||
- Comment: current code tries `DD/MM/YYYY` before `MM/DD/YYYY` and does not inspect locale. Implementing this answer requires a code change.
|
||||
|
||||
- Q: Should missing `price_value` be allowed, or should it skip/error while high/low/open remain optional?
|
||||
- A: Missing `price_value` is not allowed. Report the row as an error. `high_price`, `low_price`, and `open_price` remain optional.
|
||||
- Comment: current code allows empty `price_value` and imports it as an empty value. Implementing this answer requires a code change.
|
||||
|
||||
- Q: Should duplicate rows for the same `(price_index, price_date)` inside the same Excel file be treated as an error, skipped after the first row, or resolved by the overwrite option?
|
||||
- A: Report duplicate rows as row errors and do not import or update the duplicate row.
|
||||
|
||||
- Q: When `Create price index if missing` is enabled, should missing default reference records (`Market price`, `USD`, `Argus EU`, `Mt`) block index creation or remain optional as currently implemented?
|
||||
- A: Remain optional. Create the price index with the reference records that can be found.
|
||||
|
||||
- Q: Should the import result distinguish business validation errors from technical parsing errors?
|
||||
- A: Yes. Distinguish business validation errors from file, parsing, and technical errors in the import result.
|
||||
149
modules/purchase_trade/docs/template-rules.md
Normal file
149
modules/purchase_trade/docs/template-rules.md
Normal file
@@ -0,0 +1,149 @@
|
||||
# Template Rules - Purchase Trade
|
||||
|
||||
Statut: `draft`
|
||||
Version: `v0.2`
|
||||
Derniere mise a jour: `2026-03-27`
|
||||
|
||||
## 1) Scope
|
||||
|
||||
- Domaine: `templates Relatorio .fodt`
|
||||
- Modules concernes:
|
||||
- `purchase_trade`
|
||||
- `sale`
|
||||
- `account_invoice`
|
||||
|
||||
## 2) Objectif
|
||||
|
||||
- Eviter les erreurs de parsing Relatorio/Genshi lors de la generation des documents.
|
||||
- Standardiser la maniere d'alimenter les templates metier a partir du code Python.
|
||||
|
||||
## 3) Regles pratiques
|
||||
|
||||
### TR-001 - Toujours partir du template standard voisin
|
||||
|
||||
- Avant de modifier un template metier (`invoice_ict.fodt`, `sale_ict.fodt`, etc.), comparer avec le template standard du module source:
|
||||
- `modules/account_invoice/invoice.fodt`
|
||||
- `modules/sale/sale.fodt`
|
||||
- Reprendre en priorite la syntaxe Relatorio deja validee dans ces templates.
|
||||
|
||||
### TR-002 - Eviter les expressions Genshi trop complexes dans le `.fodt`
|
||||
|
||||
- Preferer des proprietes Python simples exposees par le modele.
|
||||
- Le template doit consommer au maximum des champs ou proprietes du type:
|
||||
- `record.report_address`
|
||||
- `record.report_price`
|
||||
- `record.report_payment_date`
|
||||
- Si un template a besoin de donnees issues d'un autre modele lie, creer un petit pont Python.
|
||||
|
||||
### TR-003 - Regles de syntaxe XML/Relatorio dans les placeholders
|
||||
|
||||
- Dans un `text:placeholder`, utiliser:
|
||||
- `"..."` pour les guillemets doubles
|
||||
- `'...'` pour les apostrophes
|
||||
- Eviter les formes avec antislashs:
|
||||
- interdit: `\'\'`
|
||||
- interdit: `\'value\'`
|
||||
- Exemples corrects:
|
||||
- `<replace text:p="set_lang(invoice.party.lang)">`
|
||||
- `<if test="invoice.report_payment_description">`
|
||||
- `<tax.description or ''>`
|
||||
|
||||
### TR-004 - Pour une facture issue d'une vente, preferer un pont `account.invoice -> sale`
|
||||
|
||||
- Si le template facture doit reutiliser la logique de la pro forma vente, ne pas dupliquer les calculs directement dans le `.fodt`.
|
||||
- Ajouter plutot dans `purchase_trade` une extension `account.invoice` avec des proprietes `report_*` qui relaient vers `invoice.sales[0]`.
|
||||
- Exemple de proprietes utiles:
|
||||
- `report_address`
|
||||
- `report_contract_number`
|
||||
- `report_shipment`
|
||||
- `report_product_description`
|
||||
- `report_crop_name`
|
||||
- `report_attributes_name`
|
||||
- `report_price`
|
||||
- `report_payment_date`
|
||||
- `report_nb_bale`
|
||||
- `report_gross`
|
||||
- `report_net`
|
||||
- `report_lbs`
|
||||
|
||||
### TR-005 - Reutiliser les proprietes existantes du module `purchase_trade.sale`
|
||||
|
||||
- Avant d'ajouter une nouvelle logique pour un template vente ou facture issue d'une vente, verifier si une propriete existe deja sur `sale.sale`.
|
||||
- Proprietes deja utiles:
|
||||
- `report_terms`
|
||||
- `report_gross`
|
||||
- `report_net`
|
||||
- `report_qt`
|
||||
- `report_nb_bale`
|
||||
- `report_deal`
|
||||
- `report_packing`
|
||||
- `report_price`
|
||||
- `report_delivery`
|
||||
- `report_payment_date`
|
||||
- `report_shipment`
|
||||
|
||||
### TR-006 - Penser au cache des reports facture avant d'accuser le `.fodt`
|
||||
|
||||
- Les actions de report `account.invoice` peuvent partager le meme moteur de rendu.
|
||||
- Dans `modules/account_invoice/invoice.py`, le champ `invoice_report_cache` peut reutiliser un document deja genere.
|
||||
- Symptome typique:
|
||||
- plusieurs actions differentes (`Provisional Invoice`, `Final Invoice`, `Prepayment`, etc.) semblent ouvrir le meme template ou le meme rendu
|
||||
- Reflexe a avoir:
|
||||
- verifier si le probleme vient du cache avant de modifier le `.fodt`
|
||||
- pour un report alternatif, ne pas reutiliser le cache du report standard `account_invoice/invoice.fodt`
|
||||
- si besoin, bypasser la lecture/ecriture du cache pour les templates alternatifs
|
||||
|
||||
### TR-007 - Pour une facture trade, privilegier le lot physique comme chemin de navigation
|
||||
|
||||
- Pour remonter d'une facture vers des donnees logistiques ou metier, ne pas dupliquer de chemins differents selon achat/vente.
|
||||
- Regle pratique:
|
||||
- partir de la ligne metier (`purchase.line` ou `sale.line`)
|
||||
- retrouver le lot physique associe
|
||||
- utiliser ce lot comme pont vers le shipment et les autres objets lies
|
||||
- Ce chemin doit etre privilegie pour exposer des proprietes `report_*` comme:
|
||||
- `report_bl_date`
|
||||
- `report_loading_port`
|
||||
- `report_discharge_port`
|
||||
- `report_controller_name`
|
||||
- `report_si_number`
|
||||
- `report_proforma_invoice_number`
|
||||
- `report_proforma_invoice_date`
|
||||
|
||||
### TR-008 - Le freight amount d'un template facture vient du fee de shipment
|
||||
|
||||
- Ne pas lire le fret directement sur `account.invoice`.
|
||||
- Pour les templates `invoice_ict*`, le `FREIGHT VALUE` doit etre expose par une propriete Python du type `invoice.report_freight_amount`.
|
||||
- La logique attendue est:
|
||||
- retrouver le lot physique pertinent
|
||||
- retrouver son shipment
|
||||
- chercher le `fee.fee` du shipment avec `product.name = 'Maritime freight'`
|
||||
- utiliser `fee.get_amount()`
|
||||
- Si le fee a sa propre devise, preferer aussi exposer le symbole de devise depuis le fee plutot que depuis la facture.
|
||||
|
||||
## 4) Workflow recommande pour corriger un template en erreur
|
||||
|
||||
1. Identifier le placeholder exact qui provoque l'erreur Relatorio.
|
||||
2. Comparer sa syntaxe avec le template standard equivalent.
|
||||
3. Remplacer les guillemets/quotes non valides par `"` / `'`.
|
||||
4. Si l'expression devient trop longue, la deplacer dans une propriete Python `report_*`.
|
||||
5. Ne modifier que les placeholders necessaires.
|
||||
6. Regenerer le document pour verifier la prochaine erreur eventuelle.
|
||||
7. Si plusieurs actions affichent le meme rendu, verifier ensuite le cache `invoice_report_cache`.
|
||||
|
||||
## 5) Cas documentes dans ce repo
|
||||
|
||||
### Invoice ICT
|
||||
|
||||
- Fichier: `modules/account_invoice/invoice_ict.fodt`
|
||||
- Strategie retenue:
|
||||
- aligner la syntaxe sur `modules/account_invoice/invoice.fodt`
|
||||
- reutiliser au maximum les proprietes metier deja exposees
|
||||
- exposer dans `modules/purchase_trade/invoice.py` des proprietes de pont `account.invoice -> sale/purchase`
|
||||
- pour les donnees shipment et freight, passer par le lot physique comme pont achat/vente
|
||||
|
||||
### Sale ICT
|
||||
|
||||
- Fichier: `modules/sale/sale_ict.fodt`
|
||||
- Usage:
|
||||
- reference principale pour les champs metier proches d'une pro forma / facture commerciale
|
||||
- source de verite pratique pour les placeholders `report_*` issus de `purchase_trade.sale`
|
||||
@@ -6,7 +6,7 @@ from trytond.pyson import Bool, Eval, Id, If
|
||||
from trytond.model import (ModelSQL, ModelView)
|
||||
from trytond.tools import is_full_text, lstrip_wildcard
|
||||
from trytond.transaction import Transaction, inactive_records
|
||||
from decimal import getcontext, Decimal, ROUND_HALF_UP
|
||||
from decimal import getcontext, Decimal, ROUND_UP, ROUND_HALF_UP
|
||||
from sql.aggregate import Count, Max, Min, Sum, Avg, BoolOr
|
||||
from sql.conditionals import Case
|
||||
from sql import Column, Literal
|
||||
@@ -18,120 +18,11 @@ import datetime
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from trytond.exceptions import UserWarning, UserError
|
||||
from trytond.modules.account.exceptions import PeriodNotFoundError
|
||||
from trytond.modules.purchase_trade.finance_tools import InterestCalculator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
VALTYPE = [
|
||||
('priced', 'Price'),
|
||||
('pur. priced', 'Pur. price'),
|
||||
('pur. efp', 'Pur. efp'),
|
||||
('sale priced', 'Sale price'),
|
||||
('sale efp', 'Sale efp'),
|
||||
('line fee', 'Line fee'),
|
||||
('pur. fee', 'Pur. fee'),
|
||||
('sale fee', 'Sale fee'),
|
||||
('shipment fee', 'Shipment fee'),
|
||||
('market', 'Market'),
|
||||
('derivative', 'Derivative'),
|
||||
]
|
||||
|
||||
class Valuation(ModelSQL,ModelView):
|
||||
"Valuation"
|
||||
__name__ = 'valuation.valuation'
|
||||
|
||||
purchase = fields.Many2One('purchase.purchase',"Purchase")
|
||||
line = fields.Many2One('purchase.line',"Purch. Line")
|
||||
date = fields.Date("Date")
|
||||
type = fields.Selection(VALTYPE, "Type")
|
||||
reference = fields.Char("Reference")
|
||||
counterparty = fields.Many2One('party.party',"Counterparty")
|
||||
product = fields.Many2One('product.product',"Product")
|
||||
state = fields.Char("State")
|
||||
price = fields.Numeric("Price",digits='unit')
|
||||
currency = fields.Many2One('currency.currency',"Cur")
|
||||
quantity = fields.Numeric("Quantity",digits='unit')
|
||||
unit = fields.Many2One('product.uom',"Unit")
|
||||
amount = fields.Numeric("Amount",digits='unit')
|
||||
mtm = fields.Numeric("Mtm",digits='unit')
|
||||
lot = fields.Many2One('lot.lot',"Lot")
|
||||
|
||||
class ValuationDyn(ModelSQL,ModelView):
|
||||
"Valuation"
|
||||
__name__ = 'valuation.valuation.dyn'
|
||||
|
||||
r_purchase = fields.Many2One('purchase.purchase',"Purchase")
|
||||
r_line = fields.Many2One('purchase.line',"Line")
|
||||
r_date = fields.Date("Date")
|
||||
r_type = fields.Selection(VALTYPE, "Type")
|
||||
r_reference = fields.Char("Reference")
|
||||
r_counterparty = fields.Many2One('party.party',"Counterparty")
|
||||
r_product = fields.Many2One('product.product',"Product")
|
||||
r_state = fields.Char("State")
|
||||
r_price = fields.Numeric("Price",digits='r_unit')
|
||||
r_currency = fields.Many2One('currency.currency',"Cur")
|
||||
r_quantity = fields.Numeric("Quantity",digits='r_unit')
|
||||
r_unit = fields.Many2One('product.uom',"Unit")
|
||||
r_amount = fields.Numeric("Amount",digits='r_unit')
|
||||
r_mtm = fields.Numeric("Mtm",digits='r_unit')
|
||||
r_lot = fields.Many2One('lot.lot',"Lot")
|
||||
|
||||
@classmethod
|
||||
def table_query(cls):
|
||||
Valuation = Pool().get('valuation.valuation')
|
||||
val = Valuation.__table__()
|
||||
context = Transaction().context
|
||||
group_pnl = context.get('group_pnl')
|
||||
wh = (val.id > 0)
|
||||
# query = val.select(
|
||||
# Literal(0).as_('create_uid'),
|
||||
# CurrentTimestamp().as_('create_date'),
|
||||
# Literal(None).as_('write_uid'),
|
||||
# Literal(None).as_('write_date'),
|
||||
# val.id.as_('id'),
|
||||
# val.purchase.as_('r_purchase'),
|
||||
# val.line.as_('r_line'),
|
||||
# val.date.as_('r_date'),
|
||||
# val.type.as_('r_type'),
|
||||
# val.reference.as_('r_reference'),
|
||||
# val.counterparty.as_('r_counterparty'),
|
||||
# val.product.as_('r_product'),
|
||||
# val.state.as_('r_state'),
|
||||
# val.price.as_('r_price'),
|
||||
# val.currency.as_('r_currency'),
|
||||
# val.quantity.as_('r_quantity'),
|
||||
# val.unit.as_('r_unit'),
|
||||
# val.amount.as_('r_amount'),
|
||||
# val.mtm.as_('r_mtm'),
|
||||
# val.lot.as_('r_lot'),
|
||||
# where=wh)
|
||||
|
||||
#if group_pnl==True:
|
||||
query = val.select(
|
||||
Literal(0).as_('create_uid'),
|
||||
CurrentTimestamp().as_('create_date'),
|
||||
Literal(None).as_('write_uid'),
|
||||
Literal(None).as_('write_date'),
|
||||
Max(val.id).as_('id'),
|
||||
Max(val.purchase).as_('r_purchase'),
|
||||
Max(val.line).as_('r_line'),
|
||||
Max(val.date).as_('r_date'),
|
||||
val.type.as_('r_type'),
|
||||
Max(val.reference).as_('r_reference'),
|
||||
val.counterparty.as_('r_counterparty'),
|
||||
Max(val.product).as_('r_product'),
|
||||
val.state.as_('r_state'),
|
||||
Avg(val.price).as_('r_price'),
|
||||
Max(val.currency).as_('r_currency'),
|
||||
Sum(val.quantity).as_('r_quantity'),
|
||||
Max(val.unit).as_('r_unit'),
|
||||
Sum(val.amount).as_('r_amount'),
|
||||
Sum(val.mtm).as_('r_mtm'),
|
||||
Max(val.lot).as_('r_lot'),
|
||||
where=wh,
|
||||
group_by=[val.type,val.counterparty,val.state])
|
||||
|
||||
return query
|
||||
|
||||
def filter_state(state):
|
||||
def filter(func):
|
||||
@wraps(func)
|
||||
@@ -166,16 +57,28 @@ class Fee(ModelSQL,ModelView):
|
||||
('lumpsum', 'Lump sum'),
|
||||
('perqt', 'Per qt'),
|
||||
('pprice', '% price'),
|
||||
('rate', '% rate'),
|
||||
('pcost', '% cost price'),
|
||||
('ppack', 'Per packing'),
|
||||
], 'Mode', required=True)
|
||||
inherit_qt = fields.Boolean("Inh Qt")
|
||||
quantity = fields.Function(fields.Numeric("Qt",digits='unit'),'get_quantity')
|
||||
unit = fields.Function(fields.Many2One('product.uom',"Unit"),'get_unit')
|
||||
auto_calculation = fields.Boolean("Auto",states={'readonly': (Eval('mode') != 'ppack')})
|
||||
inherit_qt = fields.Boolean("Inh Qt",states={'readonly': Eval('mode') != 'ppack'})
|
||||
quantity = fields.Numeric("Qt",digits='unit',states={'readonly': (Eval('mode') != 'ppack') | Bool(Eval('auto_calculation'))})
|
||||
unit = fields.Many2One('product.uom',"Unit",domain=[
|
||||
If(Eval('mode') == 'ppack',
|
||||
('category', '=', Eval('packing_category')),
|
||||
()),
|
||||
],
|
||||
states={
|
||||
'readonly': (Bool(Eval('mode') != 'ppack') & Bool(Eval('mode') != 'perqt')),
|
||||
},
|
||||
depends=['mode', 'packing_category'])
|
||||
packing_category = fields.Function(fields.Many2One('product.uom.category',"Packing Category"),'on_change_with_packing_category')
|
||||
inherit_shipment = fields.Boolean("Inh Sh",states={
|
||||
'invisible': (Eval('shipment_in')),
|
||||
})
|
||||
purchase = fields.Many2One('purchase.purchase',"Purchase", ondelete='CASCADE')
|
||||
|
||||
qt_state = fields.Many2One('lot.qt.type',"Qt State")
|
||||
amount = fields.Function(fields.Numeric("Amount", digits='currency'),'get_amount')
|
||||
fee_lots = fields.Function(fields.Many2Many('lot.lot', None, None, "Lots"),'get_lots')#, searcher='search_lots')
|
||||
lots = fields.Many2Many('fee.lots', 'fee', 'lot',"Lots",domain=[('id', 'in', Eval('fee_lots',-1))] )
|
||||
@@ -191,9 +94,94 @@ class Fee(ModelSQL,ModelView):
|
||||
|
||||
weight_type = fields.Selection([
|
||||
('net', 'Net'),
|
||||
('brut', 'Brut'),
|
||||
('brut', 'Gross'),
|
||||
], string='W. type')
|
||||
|
||||
fee_date = fields.Date("Date")
|
||||
|
||||
@classmethod
|
||||
def default_fee_date(cls):
|
||||
Date = Pool().get('ir.date')
|
||||
return Date.today()
|
||||
|
||||
@classmethod
|
||||
def default_qt_state(cls):
|
||||
LotQtType = Pool().get('lot.qt.type')
|
||||
lqt = LotQtType.search([('name','=','BL')])
|
||||
if lqt:
|
||||
return lqt[0].id
|
||||
|
||||
@fields.depends('mode','unit')
|
||||
def on_change_with_packing_category(self, name=None):
|
||||
UnitCategory = Pool().get('product.uom.category')
|
||||
packing = UnitCategory.search(['name','=','Packing'])
|
||||
if packing:
|
||||
return packing[0]
|
||||
|
||||
@fields.depends('line','sale_line','shipment_in','lots','price','unit','auto_calculation','mode','_parent_line.unit','_parent_line.lots','_parent_sale_line.unit','_parent_sale_line.lots','_parent_shipment_in.id')
|
||||
def on_change_with_quantity(self, name=None):
|
||||
qt = None
|
||||
unit = None
|
||||
line = self.line
|
||||
logger.info("ON_CHANGE_WITH_LINE:%s",line)
|
||||
if not line:
|
||||
line = self.sale_line
|
||||
if line:
|
||||
if line.lots:
|
||||
qt = sum([e.get_current_quantity_converted(0,self.unit) for e in line.lots])
|
||||
qt_ = sum([e.get_current_quantity_converted(0) for e in line.lots])
|
||||
unit = line.lots[0].lot_unit
|
||||
logger.info("ON_CHANGE_WITH_QT0:%s",qt)
|
||||
logger.info("ON_CHANGE_WITH_SI:%s",self.shipment_in)
|
||||
if self.shipment_in:
|
||||
Lot = Pool().get('lot.lot')
|
||||
lots = Lot.search([('lot_shipment_in','=',self.shipment_in.id)])
|
||||
logger.info("ON_CHANGE_WITH_LOTS:%s",lots)
|
||||
if lots:
|
||||
qt = sum([e.get_current_quantity_converted(0,self.unit) for e in lots])
|
||||
qt_ = sum([e.get_current_quantity_converted(0) for e in lots])
|
||||
unit = lots[0].lot_unit
|
||||
if not qt:
|
||||
logger.info("ON_CHANGE_WITH_QT1:%s",qt)
|
||||
LotQt = Pool().get('lot.qt')
|
||||
if self.shipment_in:
|
||||
lqts = LotQt.search(['lot_shipment_in','=',self.shipment_in.id])
|
||||
if lqts:
|
||||
qt = Decimal(lqts[0].lot_quantity)
|
||||
qt_ = qt
|
||||
unit = lqts[0].lot_unit
|
||||
logger.info("ON_CHANGE_WITH_QT2:%s",qt)
|
||||
if self.mode != 'ppack':
|
||||
return qt
|
||||
else:
|
||||
if self.auto_calculation:
|
||||
logger.info("AUTOCALCULATION:%s",qt)
|
||||
logger.info("AUTOCALCULATION2:%s",qt_)
|
||||
logger.info("AUTOCALCULATION3:%s",Decimal(unit.factor))
|
||||
logger.info("AUTOCALCULATION4:%s",Decimal(self.unit.factor))
|
||||
return (qt_ * Decimal(unit.factor) / Decimal(self.unit.factor)).to_integral_value(rounding=ROUND_UP)
|
||||
|
||||
@fields.depends('price','mode','_parent_line.lots','_parent_sale_line.lots','shipment_in')
|
||||
def on_change_with_unit(self, name=None):
|
||||
if self.mode != 'ppack' and self.mode != 'perqt':
|
||||
line = self.line
|
||||
if not line:
|
||||
line = self.sale_line
|
||||
if line:
|
||||
if line.lots:
|
||||
if len(line.lots) == 1:
|
||||
return line.lots[0].lot_unit_line
|
||||
else:
|
||||
return line.lots[1].lot_unit_line
|
||||
if self.shipment_in:
|
||||
Lot = Pool().get('lot.lot')
|
||||
lots = Lot.search([('lot_shipment_in','=',self.shipment_in.id)])
|
||||
logger.info("ON_CHANGE_WITH_UNIT:%s",lots)
|
||||
if lots:
|
||||
return lots[0].lot_unit_line
|
||||
else:
|
||||
return self.unit
|
||||
|
||||
def get_lots(self, name):
|
||||
logger.info("GET_LOTS_LINE:%s",self.line)
|
||||
logger.info("GET_LOTS_SHIPMENT_IN:%s",self.shipment_in)
|
||||
@@ -230,6 +218,23 @@ class Fee(ModelSQL,ModelView):
|
||||
if ml:
|
||||
return round(Decimal(sum([e.credit-e.debit for e in ml if e.description != 'Delivery fee'])),2)
|
||||
|
||||
def get_non_cog(self,lot):
|
||||
MoveLine = Pool().get('account.move.line')
|
||||
Currency = Pool().get('currency.currency')
|
||||
Date = Pool().get('ir.date')
|
||||
AccountConfiguration = Pool().get('account.configuration')
|
||||
account_configuration = AccountConfiguration(1)
|
||||
Uom = Pool().get('product.uom')
|
||||
ml = MoveLine.search([
|
||||
('lot', '=', lot.id),
|
||||
('fee', '=', self.id),
|
||||
('account', '=', self.product.account_stock_in_used.id),
|
||||
])
|
||||
|
||||
logger.info("GET_NON_COG_FEE:%s",ml)
|
||||
if ml:
|
||||
return round(Decimal(sum([e.credit-e.debit for e in ml])),2)
|
||||
|
||||
@classmethod
|
||||
def __setup__(cls):
|
||||
super().__setup__()
|
||||
@@ -248,13 +253,14 @@ class Fee(ModelSQL,ModelView):
|
||||
def default_p_r(cls):
|
||||
return 'pay'
|
||||
|
||||
def get_unit(self, name):
|
||||
Lot = Pool().get('lot.lot')
|
||||
if self.lots:
|
||||
if self.lots[0].line:
|
||||
return self.lots[0].line.unit
|
||||
if self.lots[0].sale_line:
|
||||
return self.lots[0].sale_line.unit
|
||||
def get_unit(self, name=None):
|
||||
FeeLots = Pool().get('fee.lots')
|
||||
fl = FeeLots.search(['fee','=',self.id])
|
||||
if fl:
|
||||
if fl[0].lot.line:
|
||||
return fl[0].lot.line.unit
|
||||
if fl[0].lot.sale_line:
|
||||
return fl[0].lot.sale_line.unit
|
||||
|
||||
@classmethod
|
||||
@ModelView.button
|
||||
@@ -285,7 +291,11 @@ class Fee(ModelSQL,ModelView):
|
||||
return round(self.price / self.quantity,4)
|
||||
elif self.mode == 'perqt':
|
||||
return self.price
|
||||
elif self.mode == 'pprice':
|
||||
elif self.mode == 'ppack':
|
||||
unit = self.get_unit()
|
||||
if unit and self.unit:
|
||||
return round(self.price / Decimal(self.unit.factor) * Decimal(unit.factor),4)
|
||||
elif self.mode == 'pprice' or self.mode == 'pcost':
|
||||
if self.line and self.price:
|
||||
return round(self.price * Decimal(self.line.unit_price) / 100,4)
|
||||
if self.sale_line and self.price:
|
||||
@@ -305,8 +315,8 @@ class Fee(ModelSQL,ModelView):
|
||||
|
||||
def get_landed_status(self,name):
|
||||
if self.product:
|
||||
return self.product.landed_cost
|
||||
|
||||
return self.product.template.landed_cost
|
||||
|
||||
def get_quantity(self,name=None):
|
||||
qt = self.get_fee_lots_qt()
|
||||
if qt:
|
||||
@@ -317,6 +327,7 @@ class Fee(ModelSQL,ModelView):
|
||||
return Decimal(lqts[0].lot_quantity)
|
||||
|
||||
def get_amount(self,name=None):
|
||||
Date = Pool().get('ir.date')
|
||||
sign = Decimal(1)
|
||||
if self.price:
|
||||
# if self.p_r:
|
||||
@@ -324,13 +335,54 @@ class Fee(ModelSQL,ModelView):
|
||||
# sign = -1
|
||||
if self.mode == 'lumpsum':
|
||||
return self.price * sign
|
||||
elif self.mode == 'ppack':
|
||||
return round(self.price * self.quantity,2)
|
||||
elif self.mode == 'rate':
|
||||
#take period with estimated trigger date
|
||||
if self.line:
|
||||
if self.line.estimated_date:
|
||||
beg_date = self.fee_date if self.fee_date else Date.today()
|
||||
est_lines = [dd for dd in self.line.estimated_date if dd.trigger == 'bldate']
|
||||
est_line = est_lines[0] if est_lines else None
|
||||
if est_line and est_line.estimated_date:
|
||||
est_date = est_line.estimated_date + datetime.timedelta(
|
||||
days=est_line.fin_int_delta or 0
|
||||
)
|
||||
if est_date and beg_date:
|
||||
factor = InterestCalculator.calculate(
|
||||
start_date=beg_date,
|
||||
end_date=est_date,
|
||||
rate=self.price/100,
|
||||
rate_type='annual',
|
||||
convention='ACT/360',
|
||||
compounding='simple'
|
||||
)
|
||||
|
||||
return round(factor * self.line.unit_price * (self.quantity if self.quantity else 0) * sign,2)
|
||||
if self.sale_line:
|
||||
if self.sale_line.sale.payment_term:
|
||||
beg_date = self.fee_date if self.fee_date else Date.today()
|
||||
est_date = self.sale_line.sale.payment_term.lines[0].get_date(beg_date,self.sale_line)
|
||||
logger.info("EST_DATE:%s",est_date)
|
||||
if est_date and beg_date:
|
||||
factor = InterestCalculator.calculate(
|
||||
start_date=beg_date,
|
||||
end_date=est_date,
|
||||
rate=self.price/100,
|
||||
rate_type='annual',
|
||||
convention='ACT/360',
|
||||
compounding='simple'
|
||||
)
|
||||
logger.info("FACTOR:%s",factor)
|
||||
return round(factor * self.sale_line.unit_price * (self.quantity if self.quantity else 0) * sign,2)
|
||||
|
||||
elif self.mode == 'perqt':
|
||||
if self.shipment_in:
|
||||
StockMove = Pool().get('stock.move')
|
||||
sm = StockMove.search(['shipment','=','stock.shipment.in,'+str(self.shipment_in.id)])
|
||||
if sm:
|
||||
unique_lots = {e.lot for e in sm if e.lot}
|
||||
return round(self.price * Decimal(sum([e.get_current_quantity_converted() for e in unique_lots])) * sign,2)
|
||||
return round(self.price * Decimal(sum([e.get_current_quantity_converted(0,self.unit) for e in unique_lots])) * sign,2)
|
||||
LotQt = Pool().get('lot.qt')
|
||||
lqts = LotQt.search(['lot_shipment_in','=',self.shipment_in.id])
|
||||
if lqts:
|
||||
@@ -373,12 +425,12 @@ class Fee(ModelSQL,ModelView):
|
||||
|
||||
return super().copy(fees, default=default)
|
||||
|
||||
def get_fee_lots_qt(self):
|
||||
def get_fee_lots_qt(self,state_id=0):
|
||||
qt = Decimal(0)
|
||||
FeeLots = Pool().get('fee.lots')
|
||||
fee_lots = FeeLots.search([('fee', '=', self.id)])
|
||||
if fee_lots:
|
||||
qt = sum([e.lot.get_current_quantity_converted() for e in fee_lots])
|
||||
qt = sum([e.lot.get_current_quantity_converted(state_id,self.unit) for e in fee_lots])
|
||||
logger.info("GET_FEE_LOTS_QT:%s",qt)
|
||||
return qt
|
||||
|
||||
@@ -388,10 +440,18 @@ class Fee(ModelSQL,ModelView):
|
||||
logger.info("ADJUST_PURCHASE_VALUES:%s",self)
|
||||
if self.type == 'ordered' and self.state == 'not invoiced' and self.purchase:
|
||||
logger.info("ADJUST_PURCHASE_VALUES_QT:%s",self.purchase.lines[0].quantity)
|
||||
if self.price != self.purchase.lines[0].unit_price:
|
||||
self.purchase.lines[0].unit_price = self.price
|
||||
if self.quantity != self.purchase.lines[0].quantity:
|
||||
self.purchase.lines[0].quantity = self.quantity
|
||||
if self.mode == 'lumpsum':
|
||||
if self.amount != self.purchase.lines[0].unit_price:
|
||||
self.purchase.lines[0].unit_price = self.amount
|
||||
elif self.mode == 'ppack':
|
||||
if self.amount != self.purchase.lines[0].amount:
|
||||
self.purchase.lines[0].unit_price = self.price
|
||||
self.purchase.lines[0].quantity = self.quantity
|
||||
else:
|
||||
if self.get_price_per_qt() != self.purchase.lines[0].unit_price:
|
||||
self.purchase.lines[0].unit_price = self.get_price_per_qt()
|
||||
if self.quantity != self.purchase.lines[0].quantity:
|
||||
self.purchase.lines[0].quantity = self.quantity
|
||||
if self.product != self.purchase.lines[0].product:
|
||||
self.purchase.lines[0].product = self.product
|
||||
PurchaseLine.save([self.purchase.lines[0]])
|
||||
@@ -408,46 +468,47 @@ class Fee(ModelSQL,ModelView):
|
||||
@classmethod
|
||||
def create(cls, vlist):
|
||||
vlist = [x.copy() for x in vlist]
|
||||
records = super(Fee, cls).create(vlist)
|
||||
fees = super(Fee, cls).create(vlist)
|
||||
qt_sh = Decimal(0)
|
||||
qt_line = Decimal(0)
|
||||
unit = None
|
||||
for record in records:
|
||||
for fee in fees:
|
||||
FeeLots = Pool().get('fee.lots')
|
||||
Lots = Pool().get('lot.lot')
|
||||
LotQt = Pool().get('lot.qt')
|
||||
if record.line:
|
||||
for l in record.line.lots:
|
||||
#if l.lot_type == 'physic':
|
||||
fl = FeeLots()
|
||||
fl.fee = record.id
|
||||
fl.lot = l.id
|
||||
fl.line = l.line.id
|
||||
FeeLots.save([fl])
|
||||
qt_line += l.get_current_quantity_converted()
|
||||
unit = l.line.unit
|
||||
if record.sale_line:
|
||||
for l in record.sale_line.lots:
|
||||
#if l.lot_type == 'physic':
|
||||
fl = FeeLots()
|
||||
fl.fee = record.id
|
||||
fl.lot = l.id
|
||||
fl.sale_line = l.sale_line.id
|
||||
FeeLots.save([fl])
|
||||
if record.shipment_in:
|
||||
if record.shipment_in.state == 'draft'or record.shipment_in.state == 'started':
|
||||
lots = Lots.search(['lot_shipment_in','=',record.shipment_in.id])
|
||||
if fee.line:
|
||||
for l in fee.line.lots:
|
||||
if (l.lot_type == 'virtual' and len(fee.line.lots)==1) or (l.lot_type == 'physic' and len(fee.line.lots)>1):
|
||||
fl = FeeLots()
|
||||
fl.fee = fee.id
|
||||
fl.lot = l.id
|
||||
fl.line = l.line.id
|
||||
FeeLots.save([fl])
|
||||
qt_line += l.get_current_quantity_converted()
|
||||
unit = l.line.unit
|
||||
if fee.sale_line:
|
||||
for l in fee.sale_line.lots:
|
||||
if (l.lot_type == 'virtual' and len(fee.sale_line.lots)==1) or (l.lot_type == 'physic' and len(fee.sale_line.lots)>1):
|
||||
fl = FeeLots()
|
||||
fl.fee = fee.id
|
||||
fl.lot = l.id
|
||||
fl.sale_line = l.sale_line.id
|
||||
FeeLots.save([fl])
|
||||
qt_line += l.get_current_quantity_converted()
|
||||
unit = l.sale_line.unit
|
||||
if fee.shipment_in:
|
||||
if fee.shipment_in.state == 'draft'or fee.shipment_in.state == 'started':
|
||||
lots = Lots.search(['lot_shipment_in','=',fee.shipment_in.id])
|
||||
if lots:
|
||||
for l in lots:
|
||||
#if l.lot_type == 'physic':
|
||||
fl = FeeLots()
|
||||
fl.fee = record.id
|
||||
fl.fee = fee.id
|
||||
fl.lot = l.id
|
||||
FeeLots.save([fl])
|
||||
qt_sh += l.get_current_quantity_converted()
|
||||
unit = l.line.unit
|
||||
else:
|
||||
lqts = LotQt.search(['lot_shipment_in','=',record.shipment_in.id])
|
||||
lqts = LotQt.search(['lot_shipment_in','=',fee.shipment_in.id])
|
||||
if lqts:
|
||||
for l in lqts:
|
||||
qt_sh += l.lot_p.get_current_quantity_converted()
|
||||
@@ -455,32 +516,154 @@ class Fee(ModelSQL,ModelView):
|
||||
else:
|
||||
raise UserError("You cannot add fee on received shipment!")
|
||||
|
||||
type = record.type
|
||||
type = fee.type
|
||||
if type == 'ordered':
|
||||
Purchase = Pool().get('purchase.purchase')
|
||||
PurchaseLine = Pool().get('purchase.line')
|
||||
pl = PurchaseLine()
|
||||
pl.product = record.product
|
||||
if record.line:
|
||||
pl.product = fee.product
|
||||
if fee.line or fee.sale_line:
|
||||
pl.quantity = round(qt_line,5)
|
||||
if record.shipment_in:
|
||||
if fee.shipment_in:
|
||||
pl.quantity = round(qt_sh,5)
|
||||
logger.info("CREATE_PURHCASE_FOR_FEE_QT:%s",pl.quantity)
|
||||
pl.unit = unit
|
||||
pl.fee_ = record.id
|
||||
if record.price:
|
||||
pl.unit_price = round(Decimal(record.price),4)
|
||||
pl.fee_ = fee.id
|
||||
if fee.price:
|
||||
fee_price = fee.get_price_per_qt()
|
||||
logger.info("GET_FEE_PRICE_PER_QT:%s",fee_price)
|
||||
pl.unit_price = round(Decimal(fee_price),4)
|
||||
if fee.mode == 'lumpsum':
|
||||
pl.quantity = 1
|
||||
pl.unit_price = round(Decimal(fee.amount),4)
|
||||
elif fee.mode == 'ppack':
|
||||
pl.unit_price = fee.price
|
||||
p = Purchase()
|
||||
p.lines = [pl]
|
||||
p.party = record.supplier
|
||||
p.party = fee.supplier
|
||||
if p.party.addresses:
|
||||
p.invoice_address = p.party.addresses[0]
|
||||
p.currency = record.currency
|
||||
p.currency = fee.currency
|
||||
p.line_type = 'service'
|
||||
p.from_location = fee.shipment_in.from_location if fee.shipment_in else (fee.line.purchase.from_location if fee.line else fee.sale_line.sale.from_location)
|
||||
p.to_location = fee.shipment_in.to_location if fee.shipment_in else (fee.line.purchase.to_location if fee.line else fee.sale_line.sale.to_location)
|
||||
if fee.shipment_in and fee.shipment_in.lotqt:
|
||||
p.payment_term = fee.shipment_in.lotqt[0].lot_p.line.purchase.payment_term
|
||||
elif fee.line:
|
||||
p.payment_term = fee.line.purchase.payment_term
|
||||
elif fee.sale_line:
|
||||
p.payment_term = fee.sale_line.sale.payment_term
|
||||
Purchase.save([p])
|
||||
|
||||
return records
|
||||
#if reception of moves done we need to generate accrual for fee
|
||||
if not fee.sale_line:
|
||||
feelots = FeeLots.search(['fee','=',fee.id])
|
||||
for fl in feelots:
|
||||
if fee.product.template.landed_cost:
|
||||
move = fl.lot.get_received_move()
|
||||
if move:
|
||||
Warning = Pool().get('res.user.warning')
|
||||
warning_name = Warning.format("Lot ever received", [])
|
||||
if Warning.check(warning_name):
|
||||
raise UserWarning(warning_name,
|
||||
"By clicking yes, an accrual for this fee will be created")
|
||||
AccountMove = Pool().get('account.move')
|
||||
account_move = move._get_account_stock_move_fee(fee)
|
||||
AccountMove.save([account_move])
|
||||
else:
|
||||
AccountMove = Pool().get('account.move')
|
||||
account_move = fee._get_account_move_fee(fl.lot)
|
||||
AccountMove.save([account_move])
|
||||
|
||||
return fees
|
||||
|
||||
def _get_account_move_fee(self,lot,in_out='in',amt = None):
|
||||
pool = Pool()
|
||||
AccountMove = pool.get('account.move')
|
||||
Date = pool.get('ir.date')
|
||||
Period = pool.get('account.period')
|
||||
AccountConfiguration = pool.get('account.configuration')
|
||||
|
||||
if self.product.type != 'service':
|
||||
return
|
||||
|
||||
today = Date.today()
|
||||
company = lot.line.purchase.company if lot.line else lot.sale_line.sale.company
|
||||
for date in [today]:
|
||||
try:
|
||||
period = Period.find(company, date=date, test_state=False)
|
||||
except PeriodNotFoundError:
|
||||
if date < today:
|
||||
return
|
||||
continue
|
||||
break
|
||||
else:
|
||||
return
|
||||
|
||||
if period.state != 'open':
|
||||
date = today
|
||||
period = Period.find(company, date=date)
|
||||
|
||||
AccountMoveLine = pool.get('account.move.line')
|
||||
Currency = pool.get('currency.currency')
|
||||
move_line = AccountMoveLine()
|
||||
move_line.lot = lot
|
||||
move_line.fee = self
|
||||
move_line.origin = None
|
||||
move_line_ = AccountMoveLine()
|
||||
move_line_.lot = lot
|
||||
move_line_.fee = self
|
||||
move_line_.origin = None
|
||||
amount = amt if amt else self.amount
|
||||
|
||||
if self.currency != company.currency:
|
||||
with Transaction().set_context(date=today):
|
||||
amount_converted = amount
|
||||
amount = Currency.compute(self.currency,
|
||||
amount, company.currency)
|
||||
move_line.second_currency = self.currency
|
||||
|
||||
if self.p_r == 'pay':
|
||||
move_line.debit = amount
|
||||
move_line.credit = Decimal(0)
|
||||
move_line.account = self.product.account_stock_used if in_out == 'in' else self.product.account_cogs_used
|
||||
if hasattr(move_line, 'second_currency') and move_line.second_currency:
|
||||
move_line.amount_second_currency = amount_converted
|
||||
move_line_.debit = Decimal(0)
|
||||
move_line_.credit = amount
|
||||
move_line_.account = self.product.account_stock_in_used if in_out == 'in' else self.product.account_stock_out_used
|
||||
if hasattr(move_line_, 'second_currency') and move_line_.second_currency:
|
||||
move_line_.amount_second_currency = -amount_converted
|
||||
else:
|
||||
move_line.debit = Decimal(0)
|
||||
move_line.credit = amount
|
||||
move_line.account = self.product.account_stock_used if in_out == 'in' else self.product.account_cogs_used
|
||||
if hasattr(move_line, 'second_currency') and move_line.second_currency:
|
||||
move_line.amount_second_currency = -amount_converted
|
||||
move_line_.debit = amount
|
||||
move_line_.credit = Decimal(0)
|
||||
move_line_.account = self.product.account_stock_in_used if in_out == 'in' else self.product.account_stock_out_used
|
||||
if hasattr(move_line_, 'second_currency') and move_line_.second_currency:
|
||||
move_line_.amount_second_currency = amount_converted
|
||||
|
||||
logger.info("FEE_MOVELINES_1:%s",move_line)
|
||||
logger.info("FEE_MOVELINES_2:%s",move_line_)
|
||||
|
||||
AccountJournal = Pool().get('account.journal')
|
||||
journal = AccountJournal.search(['type','=','expense'])
|
||||
if journal:
|
||||
journal = journal[0]
|
||||
|
||||
description = None
|
||||
description = 'Fee'
|
||||
return AccountMove(
|
||||
journal=journal,
|
||||
period=period,
|
||||
date=date,
|
||||
origin=None,
|
||||
description=description,
|
||||
lines=[move_line,move_line_],
|
||||
)
|
||||
|
||||
class FeeLots(ModelSQL,ModelView):
|
||||
|
||||
"Fee lots"
|
||||
|
||||
@@ -19,26 +19,6 @@ this repository contains the full copyright notices and license terms. -->
|
||||
<field name="name">fee_tree_sequence</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.ui.view" id="valuation_view_tree_sequence3">
|
||||
<field name="model">valuation.valuation</field>
|
||||
<field name="type">tree</field>
|
||||
<field name="name">valuation_tree_sequence3</field>
|
||||
</record>
|
||||
<record model="ir.ui.view" id="valuation_view_graph">
|
||||
<field name="model">valuation.valuation</field>
|
||||
<field name="type">graph</field>
|
||||
<field name="name">valuation_graph</field>
|
||||
</record>
|
||||
<record model="ir.ui.view" id="valuation_view_graph2">
|
||||
<field name="model">valuation.valuation</field>
|
||||
<field name="type">graph</field>
|
||||
<field name="name">valuation_graph2</field>
|
||||
</record>
|
||||
<record model="ir.ui.view" id="valuation_view_tree_sequence4">
|
||||
<field name="model">valuation.valuation.dyn</field>
|
||||
<field name="type">tree</field>
|
||||
<field name="name">valuation_tree_sequence4</field>
|
||||
</record>
|
||||
<record model="ir.ui.view" id="fee_view_tree_sequence2">
|
||||
<field name="model">fee.fee</field>
|
||||
<field name="type">tree</field>
|
||||
|
||||
141
modules/purchase_trade/finance_tools.py
Normal file
141
modules/purchase_trade/finance_tools.py
Normal file
@@ -0,0 +1,141 @@
|
||||
from decimal import Decimal, getcontext
|
||||
from datetime import date
|
||||
from calendar import isleap
|
||||
|
||||
getcontext().prec = 28
|
||||
|
||||
|
||||
class DayCount:
|
||||
|
||||
@staticmethod
|
||||
def year_fraction(start_date, end_date, convention):
|
||||
if end_date <= start_date:
|
||||
return Decimal('0')
|
||||
|
||||
if convention == 'ACT/360':
|
||||
return Decimal((end_date - start_date).days) / Decimal(360)
|
||||
|
||||
elif convention in ('ACT/365', 'ACT/365F'):
|
||||
return Decimal((end_date - start_date).days) / Decimal(365)
|
||||
|
||||
elif convention == 'ACT/ACT_ISDA':
|
||||
return DayCount._act_act_isda(start_date, end_date)
|
||||
|
||||
elif convention == '30/360_US':
|
||||
return DayCount._30_360_us(start_date, end_date)
|
||||
|
||||
elif convention == '30E/360':
|
||||
return DayCount._30e_360(start_date, end_date)
|
||||
|
||||
elif convention == '30E/360_ISDA':
|
||||
return DayCount._30e_360_isda(start_date, end_date)
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unsupported convention {convention}")
|
||||
|
||||
# ---------- IMPLEMENTATIONS ----------
|
||||
|
||||
@staticmethod
|
||||
def _act_act_isda(start_date, end_date):
|
||||
total = Decimal('0')
|
||||
current = start_date
|
||||
|
||||
while current < end_date:
|
||||
year_end = date(current.year, 12, 31)
|
||||
period_end = min(year_end.replace(day=31) +
|
||||
(date(current.year + 1, 1, 1) - year_end),
|
||||
end_date)
|
||||
|
||||
days_in_period = (period_end - current).days
|
||||
days_in_year = 366 if isleap(current.year) else 365
|
||||
|
||||
total += Decimal(days_in_period) / Decimal(days_in_year)
|
||||
current = period_end
|
||||
|
||||
return total
|
||||
|
||||
@staticmethod
|
||||
def _30_360_us(d1, d2):
|
||||
d1_day = 30 if d1.day == 31 else d1.day
|
||||
if d1.day in (30, 31) and d2.day == 31:
|
||||
d2_day = 30
|
||||
else:
|
||||
d2_day = d2.day
|
||||
|
||||
days = ((d2.year - d1.year) * 360 +
|
||||
(d2.month - d1.month) * 30 +
|
||||
(d2_day - d1_day))
|
||||
|
||||
return Decimal(days) / Decimal(360)
|
||||
|
||||
@staticmethod
|
||||
def _30e_360(d1, d2):
|
||||
d1_day = min(d1.day, 30)
|
||||
d2_day = min(d2.day, 30)
|
||||
|
||||
days = ((d2.year - d1.year) * 360 +
|
||||
(d2.month - d1.month) * 30 +
|
||||
(d2_day - d1_day))
|
||||
|
||||
return Decimal(days) / Decimal(360)
|
||||
|
||||
@staticmethod
|
||||
def _30e_360_isda(d1, d2):
|
||||
d1_day = 30 if d1.day == 31 else d1.day
|
||||
d2_day = 30 if d2.day == 31 else d2.day
|
||||
|
||||
days = ((d2.year - d1.year) * 360 +
|
||||
(d2.month - d1.month) * 30 +
|
||||
(d2_day - d1_day))
|
||||
|
||||
return Decimal(days) / Decimal(360)
|
||||
|
||||
|
||||
class InterestCalculator:
|
||||
|
||||
@staticmethod
|
||||
def calculate(
|
||||
start_date,
|
||||
end_date,
|
||||
rate,
|
||||
rate_type='annual', # 'annual' or 'monthly'
|
||||
convention='ACT/360',
|
||||
compounding='simple', # simple, annual, monthly, continuous
|
||||
):
|
||||
"""
|
||||
Retourne le facteur d'intérêt (pas le montant).
|
||||
"""
|
||||
|
||||
if not start_date or not end_date:
|
||||
return Decimal('0')
|
||||
|
||||
if end_date <= start_date:
|
||||
return Decimal('0')
|
||||
|
||||
rate = Decimal(str(rate))
|
||||
|
||||
# Conversion en taux annuel si besoin
|
||||
if rate_type == 'monthly':
|
||||
annual_rate = rate * Decimal(12)
|
||||
else:
|
||||
annual_rate = rate
|
||||
|
||||
yf = DayCount.year_fraction(start_date, end_date, convention)
|
||||
|
||||
if compounding == 'simple':
|
||||
return annual_rate * yf
|
||||
|
||||
elif compounding == 'annual':
|
||||
return (Decimal(1) + annual_rate) ** yf - Decimal(1)
|
||||
|
||||
elif compounding == 'monthly':
|
||||
monthly_rate = annual_rate / Decimal(12)
|
||||
months = yf * Decimal(12)
|
||||
return (Decimal(1) + monthly_rate) ** months - Decimal(1)
|
||||
|
||||
elif compounding == 'continuous':
|
||||
from math import exp
|
||||
return Decimal(exp(float(annual_rate * yf))) - Decimal(1)
|
||||
|
||||
else:
|
||||
raise ValueError("Unsupported compounding mode")
|
||||
259
modules/purchase_trade/financing_tools.py
Normal file
259
modules/purchase_trade/financing_tools.py
Normal file
@@ -0,0 +1,259 @@
|
||||
from decimal import Decimal, getcontext
|
||||
from datetime import datetime, date
|
||||
from calendar import isleap
|
||||
from typing import Callable, Dict
|
||||
import uuid
|
||||
|
||||
getcontext().prec = 28
|
||||
|
||||
# {
|
||||
# "computation_type": "INTEREST_ACCRUAL",
|
||||
# "input": {
|
||||
# "start_date": "2026-01-01",
|
||||
# "end_date": "2026-06-30",
|
||||
# "notional": "1000000",
|
||||
# "rate": {
|
||||
# "value": "0.08",
|
||||
# "type": "ANNUAL"
|
||||
# },
|
||||
# "day_count_convention": "ACT/360",
|
||||
# "compounding_method": "SIMPLE"
|
||||
# }
|
||||
# }
|
||||
|
||||
# result = FinancialComputationService.execute(payload)
|
||||
# interest = Decimal(result["result"]["interest_amount"])
|
||||
# interest = currency.round(interest)
|
||||
|
||||
# ============================================================
|
||||
# VERSIONING
|
||||
# ============================================================
|
||||
|
||||
ENGINE_VERSION = "1.0.0"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# REGISTRY (PLUGIN SYSTEM)
|
||||
# ============================================================
|
||||
|
||||
DAY_COUNT_REGISTRY: Dict[str, Callable] = {}
|
||||
COMPOUNDING_REGISTRY: Dict[str, Callable] = {}
|
||||
|
||||
|
||||
def register_day_count(name: str):
|
||||
def decorator(func):
|
||||
DAY_COUNT_REGISTRY[name] = func
|
||||
return func
|
||||
return decorator
|
||||
|
||||
|
||||
def register_compounding(name: str):
|
||||
def decorator(func):
|
||||
COMPOUNDING_REGISTRY[name] = func
|
||||
return func
|
||||
return decorator
|
||||
|
||||
|
||||
# ============================================================
|
||||
# DOMAIN – DAY COUNT CONVENTIONS
|
||||
# ============================================================
|
||||
|
||||
@register_day_count("ACT/360")
|
||||
def act_360(start: date, end: date) -> Decimal:
|
||||
return Decimal((end - start).days) / Decimal(360)
|
||||
|
||||
|
||||
@register_day_count("ACT/365F")
|
||||
def act_365f(start: date, end: date) -> Decimal:
|
||||
return Decimal((end - start).days) / Decimal(365)
|
||||
|
||||
|
||||
@register_day_count("ACT/ACT_ISDA")
|
||||
def act_act_isda(start: date, end: date) -> Decimal:
|
||||
total = Decimal("0")
|
||||
current = start
|
||||
|
||||
while current < end:
|
||||
year_end = date(current.year, 12, 31)
|
||||
next_year = date(current.year + 1, 1, 1)
|
||||
period_end = min(next_year, end)
|
||||
|
||||
days = (period_end - current).days
|
||||
year_days = 366 if isleap(current.year) else 365
|
||||
|
||||
total += Decimal(days) / Decimal(year_days)
|
||||
current = period_end
|
||||
|
||||
return total
|
||||
|
||||
|
||||
@register_day_count("30E/360")
|
||||
def thirty_e_360(start: date, end: date) -> Decimal:
|
||||
d1 = min(start.day, 30)
|
||||
d2 = min(end.day, 30)
|
||||
|
||||
days = (
|
||||
(end.year - start.year) * 360 +
|
||||
(end.month - start.month) * 30 +
|
||||
(d2 - d1)
|
||||
)
|
||||
|
||||
return Decimal(days) / Decimal(360)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# DOMAIN – COMPOUNDING STRATEGIES
|
||||
# ============================================================
|
||||
|
||||
@register_compounding("SIMPLE")
|
||||
def simple(rate: Decimal, yf: Decimal) -> Decimal:
|
||||
return rate * yf
|
||||
|
||||
|
||||
@register_compounding("ANNUAL")
|
||||
def annual(rate: Decimal, yf: Decimal) -> Decimal:
|
||||
return (Decimal(1) + rate) ** yf - Decimal(1)
|
||||
|
||||
|
||||
@register_compounding("MONTHLY")
|
||||
def monthly(rate: Decimal, yf: Decimal) -> Decimal:
|
||||
monthly_rate = rate / Decimal(12)
|
||||
months = yf * Decimal(12)
|
||||
return (Decimal(1) + monthly_rate) ** months - Decimal(1)
|
||||
|
||||
|
||||
@register_compounding("CONTINUOUS")
|
||||
def continuous(rate: Decimal, yf: Decimal) -> Decimal:
|
||||
from math import exp
|
||||
return Decimal(exp(float(rate * yf))) - Decimal(1)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# DOMAIN – INTEREST COMPUTATION OBJECT
|
||||
# ============================================================
|
||||
|
||||
class InterestComputation:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
start_date: date,
|
||||
end_date: date,
|
||||
notional: Decimal,
|
||||
rate_value: Decimal,
|
||||
rate_type: str,
|
||||
day_count: str,
|
||||
compounding: str,
|
||||
):
|
||||
self.start_date = start_date
|
||||
self.end_date = end_date
|
||||
self.notional = notional
|
||||
self.rate_value = rate_value
|
||||
self.rate_type = rate_type
|
||||
self.day_count = day_count
|
||||
self.compounding = compounding
|
||||
|
||||
def compute(self):
|
||||
|
||||
if self.end_date <= self.start_date:
|
||||
raise ValueError("end_date must be after start_date")
|
||||
|
||||
if self.day_count not in DAY_COUNT_REGISTRY:
|
||||
raise ValueError("Unsupported day count convention")
|
||||
|
||||
if self.compounding not in COMPOUNDING_REGISTRY:
|
||||
raise ValueError("Unsupported compounding method")
|
||||
|
||||
yf = DAY_COUNT_REGISTRY[self.day_count](
|
||||
self.start_date,
|
||||
self.end_date
|
||||
)
|
||||
|
||||
# Normalize rate to annual
|
||||
if self.rate_type == "MONTHLY":
|
||||
annual_rate = self.rate_value * Decimal(12)
|
||||
else:
|
||||
annual_rate = self.rate_value
|
||||
|
||||
factor = COMPOUNDING_REGISTRY[self.compounding](
|
||||
annual_rate,
|
||||
yf
|
||||
)
|
||||
|
||||
interest_amount = self.notional * factor
|
||||
|
||||
return {
|
||||
"year_fraction": yf,
|
||||
"interest_factor": factor,
|
||||
"interest_amount": interest_amount,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# APPLICATION LAYER – JSON SERVICE (Camunda Ready)
|
||||
# ============================================================
|
||||
|
||||
class FinancialComputationService:
|
||||
|
||||
@staticmethod
|
||||
def execute(payload: dict) -> dict:
|
||||
"""
|
||||
Stateless JSON entrypoint.
|
||||
Compatible Camunda / REST / Tryton bridge.
|
||||
"""
|
||||
|
||||
try:
|
||||
request_id = str(uuid.uuid4())
|
||||
|
||||
input_data = payload["input"]
|
||||
|
||||
start_date = datetime.strptime(
|
||||
input_data["start_date"], "%Y-%m-%d"
|
||||
).date()
|
||||
|
||||
end_date = datetime.strptime(
|
||||
input_data["end_date"], "%Y-%m-%d"
|
||||
).date()
|
||||
|
||||
notional = Decimal(input_data["notional"])
|
||||
rate_value = Decimal(input_data["rate"]["value"])
|
||||
rate_type = input_data["rate"]["type"].upper()
|
||||
day_count = input_data["day_count_convention"]
|
||||
compounding = input_data["compounding_method"]
|
||||
|
||||
computation = InterestComputation(
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
notional=notional,
|
||||
rate_value=rate_value,
|
||||
rate_type=rate_type,
|
||||
day_count=day_count,
|
||||
compounding=compounding,
|
||||
)
|
||||
|
||||
result = computation.compute()
|
||||
|
||||
return {
|
||||
"metadata": {
|
||||
"engine_version": ENGINE_VERSION,
|
||||
"request_id": request_id,
|
||||
"timestamp": datetime.utcnow().isoformat() + "Z"
|
||||
},
|
||||
"result": {
|
||||
"year_fraction": str(result["year_fraction"]),
|
||||
"interest_factor": str(result["interest_factor"]),
|
||||
"interest_amount": str(result["interest_amount"]),
|
||||
},
|
||||
"explainability": {
|
||||
"formula": "Interest = Notional × Factor",
|
||||
"factor_definition": f"{compounding} compounding applied to annualized rate",
|
||||
"day_count_used": day_count
|
||||
},
|
||||
"status": "SUCCESS"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"status": "ERROR",
|
||||
"message": str(e),
|
||||
"engine_version": ENGINE_VERSION
|
||||
}
|
||||
@@ -385,11 +385,11 @@ class ForexBI(ModelSingleton,ModelSQL, ModelView):
|
||||
config = Configuration.search(['id','>',0])[0]
|
||||
|
||||
payload = {
|
||||
"resource": {"dashboard": 3},
|
||||
"resource": {"dashboard": config.forex_id},
|
||||
"params": {},
|
||||
"exp": datetime.datetime.utcnow() + datetime.timedelta(minutes=30),
|
||||
}
|
||||
token = jwt.encode(payload, "798f256d3119a3292bf121196c2a38dddf2cad155c0b6b0b444efc34c6db197c", algorithm="HS256")
|
||||
token = jwt.encode(payload, config.payload, algorithm="HS256")
|
||||
logger.info("TOKEN:%s",token)
|
||||
if config.dark:
|
||||
url = f"metabase:{config.bi}/embed/dashboard/{token}#theme=night&bordered=true&titled=true"
|
||||
|
||||
@@ -12,4 +12,10 @@ class GRConfiguration(ModelSingleton, ModelSQL, ModelView):
|
||||
__name__ = 'gr.configuration'
|
||||
|
||||
bi = fields.Char("BI connexion")
|
||||
dark = fields.Boolean("Dark mode")
|
||||
dashboard = fields.Char("Dashboard connexion")
|
||||
dark = fields.Boolean("Dark mode")
|
||||
pnl_id = fields.Integer("Pnl ID")
|
||||
position_id = fields.Integer("Position ID")
|
||||
forex_id = fields.Integer("Forex ID")
|
||||
payload = fields.Char("Metabase payload")
|
||||
automation = fields.Boolean("Automation")
|
||||
14
modules/purchase_trade/icons/tradon-btb.svg
Normal file
14
modules/purchase_trade/icons/tradon-btb.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||
<path d="M0 0h24v24H0z" fill="none"/>
|
||||
|
||||
<!-- Barre centrale (contrat / position) -->
|
||||
<rect x="11" y="4" width="2" height="16" fill="#267F82"/>
|
||||
|
||||
<!-- Flèche gauche (achat) -->
|
||||
<path d="M9 7 L5 12 L9 17 L9 14 L11 14 L11 10 L9 10 Z"
|
||||
fill="#267F82"/>
|
||||
|
||||
<!-- Flèche droite (vente) -->
|
||||
<path d="M15 7 L19 12 L15 17 L15 14 L13 14 L13 10 L15 10 Z"
|
||||
fill="#267F82"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 496 B |
16
modules/purchase_trade/icons/tradon-mtm.svg
Normal file
16
modules/purchase_trade/icons/tradon-mtm.svg
Normal file
@@ -0,0 +1,16 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||
<path fill="none" d="M0 0h24v24H0z"/>
|
||||
<path d="
|
||||
M3 18 V7
|
||||
L8 12
|
||||
L12 8
|
||||
L16 12
|
||||
L21 7
|
||||
V18
|
||||
H18 V11
|
||||
L12 15
|
||||
L6 11
|
||||
V18
|
||||
Z
|
||||
"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 257 B |
13
modules/purchase_trade/icons/tradon-mtm_.svg
Normal file
13
modules/purchase_trade/icons/tradon-mtm_.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||
<path fill="none" d="M0 0h24v24H0z"/>
|
||||
<path d="
|
||||
M4 18 V6
|
||||
H7 L12 13 L17 6
|
||||
H20 V18
|
||||
H17 V10.5
|
||||
L12 16
|
||||
L7 10.5
|
||||
V18
|
||||
Z
|
||||
" fill="#267F82"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 259 B |
430
modules/purchase_trade/invoice.py
Normal file
430
modules/purchase_trade/invoice.py
Normal file
@@ -0,0 +1,430 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from trytond.pool import Pool, PoolMeta
|
||||
|
||||
|
||||
class Invoice(metaclass=PoolMeta):
|
||||
__name__ = 'account.invoice'
|
||||
|
||||
def _get_report_invoice_line(self):
|
||||
for line in self.lines or []:
|
||||
if getattr(line, 'type', None) == 'line':
|
||||
return line
|
||||
return self.lines[0] if self.lines else None
|
||||
|
||||
def _get_report_purchase(self):
|
||||
purchases = list(self.purchases or [])
|
||||
return purchases[0] if purchases else None
|
||||
|
||||
def _get_report_sale(self):
|
||||
# Bridge invoice templates to the originating sale so FODT files can
|
||||
# reuse stable sale.report_* properties instead of complex expressions.
|
||||
sales = list(self.sales or [])
|
||||
return sales[0] if sales else None
|
||||
|
||||
def _get_report_trade(self):
|
||||
return self._get_report_sale() or self._get_report_purchase()
|
||||
|
||||
def _get_report_purchase_line(self):
|
||||
purchase = self._get_report_purchase()
|
||||
if purchase and purchase.lines:
|
||||
return purchase.lines[0]
|
||||
|
||||
def _get_report_sale_line(self):
|
||||
sale = self._get_report_sale()
|
||||
if sale and sale.lines:
|
||||
return sale.lines[0]
|
||||
|
||||
def _get_report_trade_line(self):
|
||||
return self._get_report_sale_line() or self._get_report_purchase_line()
|
||||
|
||||
def _get_report_lot(self):
|
||||
line = self._get_report_trade_line()
|
||||
if line and line.lots:
|
||||
for lot in line.lots:
|
||||
if lot.lot_type == 'physic':
|
||||
return lot
|
||||
return line.lots[0]
|
||||
|
||||
def _get_report_freight_fee(self):
|
||||
pool = Pool()
|
||||
Fee = pool.get('fee.fee')
|
||||
shipment = self._get_report_shipment()
|
||||
if not shipment:
|
||||
return None
|
||||
fees = Fee.search([
|
||||
('shipment_in', '=', shipment.id),
|
||||
('product.name', '=', 'Maritime freight'),
|
||||
], limit=1)
|
||||
return fees[0] if fees else None
|
||||
|
||||
def _get_report_shipment(self):
|
||||
lot = self._get_report_lot()
|
||||
if not lot:
|
||||
return None
|
||||
return (
|
||||
getattr(lot, 'lot_shipment_in', None)
|
||||
or getattr(lot, 'lot_shipment_out', None)
|
||||
or getattr(lot, 'lot_shipment_internal', None)
|
||||
)
|
||||
|
||||
@property
|
||||
def report_address(self):
|
||||
trade = self._get_report_trade()
|
||||
if trade and trade.report_address:
|
||||
return trade.report_address
|
||||
if self.invoice_address and self.invoice_address.full_address:
|
||||
return self.invoice_address.full_address
|
||||
return ''
|
||||
|
||||
@property
|
||||
def report_contract_number(self):
|
||||
trade = self._get_report_trade()
|
||||
if trade and trade.full_number:
|
||||
return trade.full_number
|
||||
return self.origins or ''
|
||||
|
||||
@property
|
||||
def report_shipment(self):
|
||||
trade = self._get_report_trade()
|
||||
if trade and trade.report_shipment:
|
||||
return trade.report_shipment
|
||||
return self.description or ''
|
||||
|
||||
@property
|
||||
def report_trader_initial(self):
|
||||
trade = self._get_report_trade()
|
||||
if trade and getattr(trade, 'trader', None):
|
||||
return trade.trader.initial or ''
|
||||
return ''
|
||||
|
||||
@property
|
||||
def report_origin(self):
|
||||
trade = self._get_report_trade()
|
||||
if trade and getattr(trade, 'product_origin', None):
|
||||
return trade.product_origin or ''
|
||||
return ''
|
||||
|
||||
@property
|
||||
def report_operator_initial(self):
|
||||
trade = self._get_report_trade()
|
||||
if trade and getattr(trade, 'operator', None):
|
||||
return trade.operator.initial or ''
|
||||
return ''
|
||||
|
||||
@property
|
||||
def report_product_description(self):
|
||||
line = self._get_report_trade_line()
|
||||
if line and line.product:
|
||||
return line.product.description or ''
|
||||
return ''
|
||||
|
||||
@property
|
||||
def report_description_upper(self):
|
||||
if self.lines:
|
||||
return (self.lines[0].description or '').upper()
|
||||
return ''
|
||||
|
||||
@property
|
||||
def report_crop_name(self):
|
||||
trade = self._get_report_trade()
|
||||
if trade and getattr(trade, 'crop', None):
|
||||
return trade.crop.name or ''
|
||||
return ''
|
||||
|
||||
@property
|
||||
def report_attributes_name(self):
|
||||
line = self._get_report_trade_line()
|
||||
if line:
|
||||
return getattr(line, 'attributes_name', '') or ''
|
||||
return ''
|
||||
|
||||
@property
|
||||
def report_price(self):
|
||||
trade = self._get_report_trade()
|
||||
if trade and trade.report_price:
|
||||
return trade.report_price
|
||||
return ''
|
||||
|
||||
@property
|
||||
def report_rate_currency_upper(self):
|
||||
line = self._get_report_invoice_line()
|
||||
if line:
|
||||
return line.report_rate_currency_upper
|
||||
return ''
|
||||
|
||||
@property
|
||||
def report_rate_value(self):
|
||||
line = self._get_report_invoice_line()
|
||||
if line:
|
||||
return line.report_rate_value
|
||||
return ''
|
||||
|
||||
@property
|
||||
def report_rate_unit_upper(self):
|
||||
line = self._get_report_invoice_line()
|
||||
if line:
|
||||
return line.report_rate_unit_upper
|
||||
return ''
|
||||
|
||||
@property
|
||||
def report_rate_price_words(self):
|
||||
line = self._get_report_invoice_line()
|
||||
if line:
|
||||
return line.report_rate_price_words
|
||||
return self.report_price or ''
|
||||
|
||||
@property
|
||||
def report_rate_pricing_text(self):
|
||||
line = self._get_report_invoice_line()
|
||||
if line:
|
||||
return line.report_rate_pricing_text
|
||||
return ''
|
||||
|
||||
@property
|
||||
def report_payment_date(self):
|
||||
trade = self._get_report_trade()
|
||||
if trade and trade.report_payment_date:
|
||||
return trade.report_payment_date
|
||||
return ''
|
||||
|
||||
@property
|
||||
def report_payment_description(self):
|
||||
trade = self._get_report_trade()
|
||||
if trade and trade.payment_term:
|
||||
return trade.payment_term.description or ''
|
||||
if self.payment_term:
|
||||
return self.payment_term.description or ''
|
||||
return ''
|
||||
|
||||
@property
|
||||
def report_nb_bale(self):
|
||||
sale = self._get_report_sale()
|
||||
if sale and sale.report_nb_bale:
|
||||
return sale.report_nb_bale
|
||||
line = self._get_report_trade_line()
|
||||
if line and line.lots:
|
||||
nb_bale = sum(
|
||||
lot.lot_qt for lot in line.lots if lot.lot_type == 'physic'
|
||||
)
|
||||
return 'NB BALES: ' + str(int(nb_bale))
|
||||
return ''
|
||||
|
||||
@property
|
||||
def report_gross(self):
|
||||
sale = self._get_report_sale()
|
||||
if sale and sale.report_gross != '':
|
||||
return sale.report_gross
|
||||
line = self._get_report_trade_line()
|
||||
if line and line.lots:
|
||||
return sum(
|
||||
lot.get_current_gross_quantity()
|
||||
for lot in line.lots if lot.lot_type == 'physic'
|
||||
)
|
||||
return ''
|
||||
|
||||
@property
|
||||
def report_net(self):
|
||||
trade = self._get_report_trade()
|
||||
if trade and getattr(trade, 'report_net', '') != '':
|
||||
return trade.report_net
|
||||
line = self._get_report_trade_line()
|
||||
if line and line.lots:
|
||||
return sum(
|
||||
lot.get_current_quantity()
|
||||
for lot in line.lots if lot.lot_type == 'physic'
|
||||
)
|
||||
if self.lines:
|
||||
return self.lines[0].quantity
|
||||
return ''
|
||||
|
||||
@property
|
||||
def report_lbs(self):
|
||||
net = self.report_net
|
||||
if net == '':
|
||||
return ''
|
||||
return round(Decimal(net) * Decimal('2204.62'),2)
|
||||
|
||||
@property
|
||||
def report_bl_date(self):
|
||||
shipment = self._get_report_shipment()
|
||||
if shipment:
|
||||
return shipment.bl_date
|
||||
|
||||
@property
|
||||
def report_bl_nb(self):
|
||||
shipment = self._get_report_shipment()
|
||||
if shipment:
|
||||
return shipment.bl_number
|
||||
|
||||
@property
|
||||
def report_vessel(self):
|
||||
shipment = self._get_report_shipment()
|
||||
if shipment and shipment.vessel:
|
||||
return shipment.vessel.vessel_name
|
||||
|
||||
@property
|
||||
def report_loading_port(self):
|
||||
shipment = self._get_report_shipment()
|
||||
if shipment and shipment.from_location:
|
||||
return shipment.from_location.rec_name
|
||||
return ''
|
||||
|
||||
@property
|
||||
def report_discharge_port(self):
|
||||
shipment = self._get_report_shipment()
|
||||
if shipment and shipment.to_location:
|
||||
return shipment.to_location.rec_name
|
||||
return ''
|
||||
|
||||
@property
|
||||
def report_incoterm(self):
|
||||
trade = self._get_report_trade()
|
||||
if not trade:
|
||||
return ''
|
||||
incoterm = trade.incoterm.code if getattr(trade, 'incoterm', None) else ''
|
||||
location = (
|
||||
trade.incoterm_location.party_name
|
||||
if getattr(trade, 'incoterm_location', None) else ''
|
||||
)
|
||||
if incoterm and location:
|
||||
return f"{incoterm} {location}"
|
||||
return incoterm or location
|
||||
|
||||
@property
|
||||
def report_proforma_invoice_number(self):
|
||||
lot = self._get_report_lot()
|
||||
if lot:
|
||||
line = (
|
||||
getattr(lot, 'sale_invoice_line_prov', None)
|
||||
or getattr(lot, 'invoice_line_prov', None)
|
||||
)
|
||||
if line and line.invoice:
|
||||
return line.invoice.number or ''
|
||||
return ''
|
||||
|
||||
@property
|
||||
def report_proforma_invoice_date(self):
|
||||
lot = self._get_report_lot()
|
||||
if lot:
|
||||
line = (
|
||||
getattr(lot, 'sale_invoice_line_prov', None)
|
||||
or getattr(lot, 'invoice_line_prov', None)
|
||||
)
|
||||
if line and line.invoice:
|
||||
return line.invoice.invoice_date
|
||||
|
||||
@property
|
||||
def report_controller_name(self):
|
||||
shipment = self._get_report_shipment()
|
||||
if shipment and shipment.controller:
|
||||
return shipment.controller.rec_name
|
||||
return ''
|
||||
|
||||
@property
|
||||
def report_si_number(self):
|
||||
shipment = self._get_report_shipment()
|
||||
if shipment:
|
||||
return shipment.number or ''
|
||||
return ''
|
||||
|
||||
@property
|
||||
def report_freight_amount(self):
|
||||
fee = self._get_report_freight_fee()
|
||||
if fee:
|
||||
return fee.get_amount()
|
||||
return ''
|
||||
|
||||
@property
|
||||
def report_freight_currency_symbol(self):
|
||||
fee = self._get_report_freight_fee()
|
||||
if fee and fee.currency:
|
||||
return fee.currency.symbol or ''
|
||||
if self.currency:
|
||||
return self.currency.symbol or ''
|
||||
return 'USD'
|
||||
|
||||
|
||||
class InvoiceLine(metaclass=PoolMeta):
|
||||
__name__ = 'account.invoice.line'
|
||||
|
||||
def _get_report_trade(self):
|
||||
origin = getattr(self, 'origin', None)
|
||||
if not origin:
|
||||
return None
|
||||
return getattr(origin, 'sale', None) or getattr(origin, 'purchase', None)
|
||||
|
||||
def _get_report_trade_line(self):
|
||||
return getattr(self, 'origin', None)
|
||||
|
||||
@property
|
||||
def report_product_description(self):
|
||||
if self.product:
|
||||
return self.product.description or ''
|
||||
origin = getattr(self, 'origin', None)
|
||||
if origin and getattr(origin, 'product', None):
|
||||
return origin.product.description or ''
|
||||
return ''
|
||||
|
||||
@property
|
||||
def report_description_upper(self):
|
||||
return (self.description or '').upper()
|
||||
|
||||
@property
|
||||
def report_rate_currency_upper(self):
|
||||
origin = self._get_report_trade_line()
|
||||
currency = getattr(origin, 'linked_currency', None) or self.currency
|
||||
if currency and currency.rec_name:
|
||||
return currency.rec_name.upper()
|
||||
return ''
|
||||
|
||||
@property
|
||||
def report_rate_value(self):
|
||||
return self.unit_price if self.unit_price is not None else ''
|
||||
|
||||
@property
|
||||
def report_rate_unit_upper(self):
|
||||
origin = self._get_report_trade_line()
|
||||
unit = getattr(origin, 'linked_unit', None) or self.unit
|
||||
if unit and unit.rec_name:
|
||||
return unit.rec_name.upper()
|
||||
return ''
|
||||
|
||||
@property
|
||||
def report_rate_price_words(self):
|
||||
trade = self._get_report_trade()
|
||||
if trade and getattr(trade, 'report_price', None):
|
||||
return trade.report_price
|
||||
return ''
|
||||
|
||||
@property
|
||||
def report_rate_pricing_text(self):
|
||||
origin = self._get_report_trade_line()
|
||||
return getattr(origin, 'get_pricing_text', '') or ''
|
||||
|
||||
@property
|
||||
def report_crop_name(self):
|
||||
trade = self._get_report_trade()
|
||||
if trade and getattr(trade, 'crop', None):
|
||||
return trade.crop.name or ''
|
||||
return ''
|
||||
|
||||
@property
|
||||
def report_attributes_name(self):
|
||||
origin = getattr(self, 'origin', None)
|
||||
if origin:
|
||||
return getattr(origin, 'attributes_name', '') or ''
|
||||
return ''
|
||||
|
||||
@property
|
||||
def report_net(self):
|
||||
if self.type == 'line':
|
||||
return self.quantity
|
||||
return ''
|
||||
|
||||
@property
|
||||
def report_lbs(self):
|
||||
net = self.report_net
|
||||
if net == '':
|
||||
return ''
|
||||
return round(Decimal(net) * Decimal('2204.62'),2)
|
||||
@@ -20,6 +20,7 @@ import datetime
|
||||
import json
|
||||
import logging
|
||||
from trytond.exceptions import UserWarning, UserError
|
||||
from trytond.modules.purchase_trade.service import ContractFactory
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -47,7 +48,7 @@ class LotMove(ModelSQL,ModelView):
|
||||
class Lot(metaclass=PoolMeta):
|
||||
__name__ = 'lot.lot'
|
||||
|
||||
line = fields.Many2One('purchase.line',"Purchase")
|
||||
line = fields.Many2One('purchase.line',"Purchase",ondelete='CASCADE')
|
||||
move = fields.Function(fields.Many2One('stock.move',"Move"),'get_current_move')
|
||||
lot_move = fields.One2Many('lot.move','lot',"Move")
|
||||
invoice_line = fields.Many2One('account.invoice.line',"Purch.Invoice line")
|
||||
@@ -58,6 +59,7 @@ class Lot(metaclass=PoolMeta):
|
||||
delta_pr = fields.Numeric("Delta Pr")
|
||||
delta_amt = fields.Numeric("Delta Amt")
|
||||
warrant_nb = fields.Char("Warrant Nb")
|
||||
lot_chunk_key = fields.Integer("Chunk key")
|
||||
#fees = fields.Many2Many('fee.lots', 'lot', 'fee',"Fees")
|
||||
dashboard = fields.Many2One('purchase.dashboard',"Dashboard")
|
||||
pivot = fields.Function(
|
||||
@@ -201,7 +203,7 @@ class Lot(metaclass=PoolMeta):
|
||||
)
|
||||
|
||||
pivot_data['options'] = {
|
||||
"rows": ["lot","ct type","event_date","event","move","curr","rate"],
|
||||
"rows": ["lot","ct type","event_date","event","move","Curr","rate"],
|
||||
"cols": ["account"],
|
||||
"aggregatorName": "Sum",
|
||||
"vals": ["amount"]
|
||||
@@ -578,6 +580,14 @@ class Lot(metaclass=PoolMeta):
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_received_move(self):
|
||||
if self.lot_move:
|
||||
lm = sorted(self.lot_move, key=lambda x: x.sequence, reverse=True)
|
||||
for m in lm:
|
||||
if m.move.from_location.type == 'supplier' and m.move.state == 'done':
|
||||
return m.move
|
||||
return None
|
||||
|
||||
def GetShipment(self,type):
|
||||
if type == 'in':
|
||||
m = self.get_current_supplier_move()
|
||||
@@ -1087,6 +1097,7 @@ class LotQt(
|
||||
newlot.lot_shipment_internal = self.lot_shipment_internal
|
||||
newlot.lot_shipment_out = self.lot_shipment_out
|
||||
newlot.lot_product = self.lot_p.line.product
|
||||
newlot.lot_chunk_key = l.lot_chunk_key
|
||||
if self.lot_s:
|
||||
newlot.sale_line = self.lot_s.sale_line if self.lot_s.sale_line else None
|
||||
newlot.lot_type = 'physic'
|
||||
@@ -1168,6 +1179,7 @@ class LotQt(
|
||||
@classmethod
|
||||
def validate(cls, lotqts):
|
||||
super(LotQt, cls).validate(lotqts)
|
||||
Date = Pool().get('ir.date')
|
||||
#Update Move
|
||||
for lqt in lotqts:
|
||||
cls.updateMove(lqt.lot_move)
|
||||
@@ -1177,23 +1189,23 @@ class LotQt(
|
||||
if lqt.lot_p and lqt.lot_quantity > 0:
|
||||
pl = lqt.lot_p.line
|
||||
logger.info("VALIDATE_LQT_PL:%s",pl)
|
||||
Pnl = Pool().get('valuation.valuation')
|
||||
pnl = Pnl.search([('line','=',pl.id)])
|
||||
if pnl:
|
||||
Pnl.delete(pnl)
|
||||
pnl_lines = []
|
||||
pnl_lines.extend(pl.get_pnl_fee_lines())
|
||||
pnl_lines.extend(pl.get_pnl_price_lines())
|
||||
pnl_lines.extend(pl.get_pnl_der_lines())
|
||||
Pnl.save(pnl_lines)
|
||||
# Pnl = Pool().get('valuation.valuation')
|
||||
# pnl = Pnl.search([('line','=',pl.id),('date','=',Date.today())])
|
||||
# if pnl:
|
||||
# Pnl.delete(pnl)
|
||||
# pnl_lines = []
|
||||
# pnl_lines.extend(pl.get_pnl_fee_lines())
|
||||
# pnl_lines.extend(pl.get_pnl_price_lines())
|
||||
# pnl_lines.extend(pl.get_pnl_der_lines())
|
||||
# Pnl.save(pnl_lines)
|
||||
|
||||
#Open position update
|
||||
if pl.quantity_theorical:
|
||||
OpenPosition = Pool().get('open.position')
|
||||
OpenPosition.create_from_purchase_line(pl)
|
||||
# if pl.quantity_theorical:
|
||||
# OpenPosition = Pool().get('open.position')
|
||||
# OpenPosition.create_from_purchase_line(pl)
|
||||
|
||||
@classmethod
|
||||
def getQuery(cls,purchase=None,sale=None,shipment=None,type=None,state=None,qttype=None,supplier=None,client=None,ps=None,lot_status=None,group=None,product=None,location=None,origin=None):
|
||||
def getQuery(cls,purchase=None,sale=None,shipment=None,type=None,state=None,qttype=None,supplier=None,client=None,ps=None,lot_status=None,group=None,product=None,location=None,origin=None,finished=False):
|
||||
pool = Pool()
|
||||
LotQt = pool.get('lot.qt')
|
||||
lqt = LotQt.__table__()
|
||||
@@ -1241,8 +1253,12 @@ class LotQt(
|
||||
#wh &= (((lqt.create_date >= asof) & ((lqt.create_date-datetime.timedelta(1)) <= todate)))
|
||||
if ps == 'P':
|
||||
wh &= ((lqt.lot_p != None) & (lqt.lot_s == None))
|
||||
if not finished:
|
||||
wh &= (pl.finished == False)
|
||||
elif ps == 'S':
|
||||
wh &= (((lqt.lot_s != None) & (lqt.lot_p == None)) | ((lqt.lot_s != None) & (lqt.lot_p != None) & (lp.lot_type == 'virtual')))
|
||||
if not finished:
|
||||
wh &= (sl.finished == False)
|
||||
if purchase:
|
||||
wh &= (pu.id == purchase)
|
||||
if sale:
|
||||
@@ -1832,7 +1848,8 @@ class LotReport(
|
||||
supplier = context.get('supplier')
|
||||
#asof = context.get('asof')
|
||||
#todate = context.get('todate')
|
||||
query = LotQt.getQuery(purchase,sale,shipment,type,state,None,supplier,None,None,wh,group,product,location,origin)
|
||||
finished = context.get('finished')
|
||||
query = LotQt.getQuery(purchase,sale,shipment,type,state,None,supplier,None,None,wh,group,product,location,origin,finished)
|
||||
return query
|
||||
|
||||
@classmethod
|
||||
@@ -1922,6 +1939,12 @@ class LotContext(ModelView):
|
||||
('pnl', 'Pnl'),
|
||||
],'Mode')
|
||||
|
||||
finished = fields.Boolean("Display finished")
|
||||
|
||||
@classmethod
|
||||
def default_finished(cls):
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def default_asof(cls):
|
||||
pool = Pool()
|
||||
@@ -2002,17 +2025,24 @@ class LotShipping(Wizard):
|
||||
if r.r_lot_shipment_in:
|
||||
raise UserError("Please unlink before linking to a new shipment !")
|
||||
else:
|
||||
shipped_quantity = Decimal(r.r_lot_quantity)
|
||||
shipped_quantity = Decimal(str(r.r_lot_quantity)).quantize(Decimal("0.00001"))
|
||||
logger.info("LotShipping:%s",shipped_quantity)
|
||||
shipment_origin = None
|
||||
if self.ship.quantity:
|
||||
shipped_quantity = self.ship.quantity
|
||||
if shipped_quantity == 0:
|
||||
shipped_quantity = Decimal(r.r_lot_matched)
|
||||
shipped_quantity = Decimal(str(r.r_lot_matched)).quantize(Decimal("0.00001"))
|
||||
if self.ship.shipment == 'in':
|
||||
if not self.ship.shipment_in:
|
||||
UserError("Shipment not known!")
|
||||
shipment_origin = 'stock.shipment.in,'+str(self.ship.shipment_in.id)
|
||||
elif self.ship.shipment == 'out':
|
||||
if not self.ship.shipment_out:
|
||||
UserError("Shipment not known!")
|
||||
shipment_origin = 'stock.shipment.out,'+str(self.ship.shipment_out.id)
|
||||
elif self.ship.shipment == 'int':
|
||||
if not self.ship.shipment_internal:
|
||||
UserError("Shipment not known!")
|
||||
shipment_origin = 'stock.shipment.internal,'+str(self.ship.shipment_internal.id)
|
||||
if r.id < 10000000 :
|
||||
l = Lot(r.id)
|
||||
@@ -2030,15 +2060,22 @@ class LotShipping(Wizard):
|
||||
move = Move(l.move)
|
||||
move.shipment = shipment_origin
|
||||
Move.save([move])
|
||||
linked_transit_move = move.get_linked_transit_move()
|
||||
if linked_transit_move:
|
||||
linked_transit_move.shipment = shipment_origin
|
||||
Move.save([linked_transit_move])
|
||||
#Decrease forecasted virtual part shipped
|
||||
vlot_p = l.getVlot_p()
|
||||
l.updateVirtualPart(-l.get_current_quantity_converted(),shipment_origin,l.getVlot_s())
|
||||
l.lot_av = 'reserved'
|
||||
Lot.save([l])
|
||||
l.set_current_quantity(l.lot_quantity,l.lot_gross_quantity,2)
|
||||
Lot.save([l])
|
||||
else:
|
||||
lqt = LotQt(r.id - 10000000)
|
||||
#Increase forecasted virtual part shipped
|
||||
if not lqt.lot_p.updateVirtualPart(shipped_quantity,shipment_origin,lqt.lot_s):
|
||||
logger.info("LotShipping2:%s",shipped_quantity)
|
||||
lqt.lot_p.createVirtualPart(shipped_quantity,shipment_origin,lqt.lot_s)
|
||||
#Decrease forecasted virtual part non shipped
|
||||
lqt.lot_p.updateVirtualPart(-shipped_quantity,None,lqt.lot_s)
|
||||
@@ -2442,6 +2479,7 @@ class LotAddLine(ModelView):
|
||||
lot_gross_quantity = fields.Numeric("Gross weight")
|
||||
lot_unit_line = fields.Many2One('product.uom', "Unit",required=True)
|
||||
lot_premium = fields.Numeric("Premium")
|
||||
lot_chunk_key = fields.Integer("Chunk key")
|
||||
|
||||
# @fields.depends('lot_qt')
|
||||
# def on_change_with_lot_quantity(self):
|
||||
@@ -2604,6 +2642,17 @@ class LotInvoice(Wizard):
|
||||
|
||||
invoicing = StateTransition()
|
||||
|
||||
message = StateView(
|
||||
'purchase.create_prepayment.message',
|
||||
'purchase_trade.create_prepayment_message_form',
|
||||
[
|
||||
Button('OK', 'end', 'tryton-ok'),
|
||||
Button('See Invoice', 'see_invoice', 'tryton-go-next'),
|
||||
]
|
||||
)
|
||||
|
||||
see_invoice = StateAction('account_invoice.act_invoice_form')
|
||||
|
||||
def transition_start(self):
|
||||
return 'inv'
|
||||
|
||||
@@ -2650,7 +2699,7 @@ class LotInvoice(Wizard):
|
||||
val['lot_diff_quantity'] = val['lot_quantity'] - Decimal(lot.invoice_line_prov.quantity)
|
||||
val['lot_diff_price'] = val['lot_price'] - Decimal(lot.invoice_line_prov.unit_price)
|
||||
val['lot_diff_amount'] = val['lot_amount'] - Decimal(lot.invoice_line_prov.amount)
|
||||
val['lot_unit'] = lot.lot_unit_line.id
|
||||
val['lot_unit'] = line.unit.id #lot.lot_unit_line.id
|
||||
unit = val['lot_unit']
|
||||
val['lot_currency'] = lot.lot_price_ct_symbol
|
||||
lot_p.append(val)
|
||||
@@ -2666,6 +2715,7 @@ class LotInvoice(Wizard):
|
||||
val_s['lot_diff_price'] = val_s['lot_price'] - Decimal(lot.sale_invoice_line_prov.unit_price)
|
||||
val_s['lot_diff_amount'] = val_s['lot_amount'] - Decimal(lot.sale_invoice_line_prov.amount)
|
||||
val_s['lot_currency'] = lot.lot_price_ct_symbol_sale
|
||||
val_s['lot_unit'] = sale_line.unit.id if sale_line else None
|
||||
lot_s.append(val_s)
|
||||
if line:
|
||||
if line.fees:
|
||||
@@ -2747,12 +2797,27 @@ class LotInvoice(Wizard):
|
||||
continue
|
||||
lots.append(lot)
|
||||
|
||||
invoice_line = None
|
||||
if self.inv.type == 'purchase':
|
||||
Purchase._process_invoice([purchase],lots,action,self.inv.pp_pur)
|
||||
invoice_line = r.r_lot_p.invoice_line if r.r_lot_p.invoice_line else r.r_lot_p.invoice_line_prov
|
||||
else:
|
||||
if sale:
|
||||
Sale._process_invoice([sale],lots,action,self.inv.pp_sale)
|
||||
return 'end'
|
||||
Sale._process_invoice([sale],lots,action,self.inv.pp_sale)
|
||||
invoice_line = r.r_lot_p.invoice_line if r.r_lot_p.sale_invoice_line else r.r_lot_p.sale_invoice_line_prov
|
||||
self.message.invoice = invoice_line.invoice
|
||||
|
||||
return 'message'
|
||||
|
||||
def default_message(self, fields):
|
||||
return {
|
||||
'message': 'The invoice has been successfully created.',
|
||||
}
|
||||
|
||||
def do_see_invoice(self, action):
|
||||
action['views'].reverse() # pour ouvrir en form directement
|
||||
logger.info("*************SEE_INVOICE******************:%s",self.message.invoice)
|
||||
return action, {'res_id':self.message.invoice.id}
|
||||
|
||||
def end(self):
|
||||
return 'reload'
|
||||
@@ -3129,136 +3194,13 @@ class CreateContracts(Wizard):
|
||||
}
|
||||
|
||||
def transition_creating(self):
|
||||
SaleLine = Pool().get('sale.line')
|
||||
Sale = Pool().get('sale.sale')
|
||||
PurchaseLine = Pool().get('purchase.line')
|
||||
Purchase = Pool().get('purchase.purchase')
|
||||
LotQt = Pool().get('lot.qt')
|
||||
LotQtHist = Pool().get('lot.qt.hist')
|
||||
LotQtType = Pool().get('lot.qt.type')
|
||||
Lot = Pool().get('lot.lot')
|
||||
Date = Pool().get('ir.date')
|
||||
self.sale_lines = []
|
||||
type = self.ct.type
|
||||
base_contract = self.ct.lot.sale_line.sale if type == 'Purchase' else self.ct.lot.line.purchase
|
||||
for c in self.ct.contracts:
|
||||
contract = Purchase() if type == 'Purchase' else Sale()
|
||||
contract_line = PurchaseLine() if type == 'Purchase' else SaleLine()
|
||||
parts = c.currency_unit.split("_")
|
||||
if int(parts[0]) != 0:
|
||||
contract.currency = int(parts[0])
|
||||
else:
|
||||
contract.currency = 1
|
||||
contract.party = c.party
|
||||
contract.crop = c.crop
|
||||
contract.tol_min = c.tol_min
|
||||
contract.tol_max = c.tol_max
|
||||
if type == 'Purchase':
|
||||
contract.purchase_date = Date.today()
|
||||
else:
|
||||
contract.sale_date = Date.today()
|
||||
contract.reference = c.reference
|
||||
if base_contract.from_location and base_contract.to_location:
|
||||
if type == 'Purchase':
|
||||
contract.to_location = base_contract.from_location
|
||||
else:
|
||||
contract.from_location = base_contract.to_location
|
||||
if base_contract.from_location.type == 'supplier' and base_contract.to_location.type == 'customer':
|
||||
contract.from_location = base_contract.from_location
|
||||
contract.to_location = base_contract.to_location
|
||||
if c.party.wb:
|
||||
contract.wb = c.party.wb
|
||||
if c.party.association:
|
||||
contract.association = c.party.association
|
||||
if type == 'Purchase':
|
||||
if c.party.supplier_payment_term:
|
||||
contract.payment_term = c.party.supplier_payment_term
|
||||
else:
|
||||
if c.party.customer_payment_term:
|
||||
contract.payment_term = c.party.customer_payment_term
|
||||
contract.incoterm = c.incoterm
|
||||
if c.party.addresses:
|
||||
contract.invoice_address = c.party.addresses[0]
|
||||
if type == 'Sale':
|
||||
contract.shipment_address = c.party.addresses[0]
|
||||
contract.__class__.save([contract])
|
||||
contract_line.quantity = c.quantity
|
||||
contract_line.quantity_theorical = c.quantity
|
||||
contract_line.product = self.ct.product
|
||||
contract_line.price_type = c.price_type
|
||||
contract_line.unit = self.ct.unit
|
||||
if type == 'Purchase':
|
||||
contract_line.purchase = contract.id
|
||||
else:
|
||||
contract_line.sale = contract.id
|
||||
contract_line.created_by_code = self.ct.matched
|
||||
contract_line.premium = Decimal(0)
|
||||
if int(parts[0]) == 0:
|
||||
contract_line.enable_linked_currency = True
|
||||
contract_line.linked_currency = 1
|
||||
contract_line.linked_unit = int(parts[1])
|
||||
contract_line.linked_price = c.price
|
||||
contract_line.unit_price = contract_line.get_price_linked_currency()
|
||||
else:
|
||||
contract_line.unit_price = c.price if c.price else Decimal(0)
|
||||
contract_line.del_period = c.del_period
|
||||
contract_line.from_del = c.from_del
|
||||
contract_line.to_del = c.to_del
|
||||
contract_line.__class__.save([contract_line])
|
||||
logger.info("CREATE_ID:%s",contract.id)
|
||||
logger.info("CREATE_LINE_ID:%s",contract_line.id)
|
||||
if self.ct.matched:
|
||||
lot = Lot()
|
||||
if type == 'Purchase':
|
||||
lot.line = contract_line.id
|
||||
else:
|
||||
lot.sale_line = contract_line.id
|
||||
lot.lot_qt = None
|
||||
lot.lot_unit = None
|
||||
lot.lot_unit_line = contract_line.unit
|
||||
lot.lot_quantity = round(contract_line.quantity,5)
|
||||
lot.lot_gross_quantity = None
|
||||
lot.lot_status = 'forecast'
|
||||
lot.lot_type = 'virtual'
|
||||
lot.lot_product = contract_line.product
|
||||
lqtt = LotQtType.search([('sequence','=',1)])
|
||||
if lqtt:
|
||||
lqh = LotQtHist()
|
||||
lqh.quantity_type = lqtt[0]
|
||||
lqh.quantity = round(lot.lot_quantity,5)
|
||||
lqh.gross_quantity = round(lot.lot_quantity,5)
|
||||
lot.lot_hist = [lqh]
|
||||
Lot.save([lot])
|
||||
vlot = self.ct.lot
|
||||
shipment_origin = None
|
||||
if self.ct.shipment_in:
|
||||
shipment_origin = 'stock.shipment.in,' + str(self.ct.shipment_in.id)
|
||||
elif self.ct.shipment_internal:
|
||||
shipment_origin = 'stock.shipment.internal,' + str(self.ct.shipment_internal.id)
|
||||
elif self.ct.shipment_out:
|
||||
shipment_origin = 'stock.shipment.out,' + str(self.ct.shipment_out.id)
|
||||
|
||||
qt = c.quantity
|
||||
if type == 'Purchase':
|
||||
if not lot.updateVirtualPart(qt,shipment_origin,vlot):
|
||||
lot.createVirtualPart(qt,shipment_origin,vlot)
|
||||
#Decrease forecasted virtual part non matched
|
||||
lot.updateVirtualPart(-qt,shipment_origin,vlot,'only sale')
|
||||
else:
|
||||
if not vlot.updateVirtualPart(qt,shipment_origin,lot):
|
||||
vlot.createVirtualPart(qt,shipment_origin,lot)
|
||||
#Decrease forecasted virtual part non matched
|
||||
vlot.updateVirtualPart(-qt,shipment_origin,None)
|
||||
|
||||
|
||||
ContractFactory.create_contracts(
|
||||
self.ct.contracts,
|
||||
type_=self.ct.type,
|
||||
ct=self.ct,
|
||||
)
|
||||
return 'end'
|
||||
|
||||
# def do_matching(self, action):
|
||||
# return action, {
|
||||
# 'ids': self.sale_lines,
|
||||
# 'model': str(self.ct.lot.id),
|
||||
# }
|
||||
|
||||
def end(self):
|
||||
return 'reload'
|
||||
|
||||
@@ -3288,7 +3230,6 @@ class ContractsStart(ModelView):
|
||||
def default_matched(cls):
|
||||
return True
|
||||
|
||||
|
||||
class ContractDetail(ModelView):
|
||||
|
||||
"Contract Detail"
|
||||
@@ -3296,26 +3237,29 @@ class ContractDetail(ModelView):
|
||||
|
||||
category = fields.Integer("Category")
|
||||
cd = fields.Many2One('contracts.start',"Contracts")
|
||||
party = fields.Many2One('party.party',"Party",domain=[('categories.parent', 'child_of', Eval('category'))],depends=['category'])
|
||||
currency = fields.Many2One('currency.currency',"Currency")
|
||||
incoterm = fields.Many2One('incoterm.incoterm',"Incoterm")
|
||||
quantity = fields.Numeric("Quantity",digits=(1,5))
|
||||
unit = fields.Many2One('product.uom',"Unit")
|
||||
party = fields.Many2One('party.party',"Party", required=True,domain=[('categories.parent', 'child_of', Eval('category'))],depends=['category'])
|
||||
currency = fields.Many2One('currency.currency',"Currency", required=True)
|
||||
incoterm = fields.Many2One('incoterm.incoterm',"Incoterm", required=True)
|
||||
quantity = fields.Numeric("Quantity",digits=(1,5), required=True)
|
||||
unit = fields.Many2One('product.uom',"Unit", required=True)
|
||||
qt_unit = fields.Many2One('product.uom',"Unit")
|
||||
tol_min = fields.Numeric("Tol - in %")
|
||||
tol_max = fields.Numeric("Tol + in %")
|
||||
tol_min = fields.Numeric("Tol - in %", required=True)
|
||||
tol_max = fields.Numeric("Tol + in %", required=True)
|
||||
crop = fields.Many2One('purchase.crop',"Crop")
|
||||
del_period = fields.Many2One('product.month',"Delivery Period")
|
||||
from_del = fields.Date("From")
|
||||
to_del = fields.Date("To")
|
||||
price = fields.Numeric("Price",digits=(1,4),states={'invisible': Eval('price_type') != 'priced'})
|
||||
price = fields.Numeric("Price", required=True,digits=(1,4),states={'invisible': Eval('price_type') != 'priced'})
|
||||
price_type = price_type = fields.Selection([
|
||||
('cash', 'Cash Price'),
|
||||
('priced', 'Priced'),
|
||||
('basis', 'Basis'),
|
||||
], 'Price type')
|
||||
], 'Price type', required=True)
|
||||
currency_unit = fields.Selection('get_currency_unit',string="Curr/Unit")
|
||||
reference = fields.Char("Reference")
|
||||
from_location = fields.Many2One('stock.location',"From location")
|
||||
to_location = fields.Many2One('stock.location',"To location")
|
||||
payment_term = fields.Many2One('account.invoice.payment_term',"Payment Term", required=True)
|
||||
|
||||
@classmethod
|
||||
def default_category(cls):
|
||||
@@ -3372,7 +3316,7 @@ class ContractDetail(ModelView):
|
||||
if lqt and lqt.lot_p and getattr(lqt.lot_p.line.purchase, 'crop', None):
|
||||
return lqt.lot_p.line.purchase.crop.id
|
||||
if lqt and lqt.lot_s and getattr(lqt.lot_s.sale_line.sale, 'crop', None):
|
||||
return lqt.lot_s.line.sale.crop.id
|
||||
return lqt.lot_s.sale_line.sale.crop.id
|
||||
|
||||
@classmethod
|
||||
def default_currency(cls):
|
||||
|
||||
152
modules/purchase_trade/numbers_to_words.py
Normal file
152
modules/purchase_trade/numbers_to_words.py
Normal file
@@ -0,0 +1,152 @@
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from datetime import date
|
||||
|
||||
UNITS = (
|
||||
"ZERO ONE TWO THREE FOUR FIVE SIX SEVEN EIGHT NINE TEN ELEVEN TWELVE "
|
||||
"THIRTEEN FOURTEEN FIFTEEN SIXTEEN SEVENTEEN EIGHTEEN NINETEEN"
|
||||
).split()
|
||||
|
||||
TENS = "ZERO TEN TWENTY THIRTY FORTY FIFTY SIXTY SEVENTY EIGHTY NINETY".split()
|
||||
|
||||
def format_date_en(d):
|
||||
if not d:
|
||||
return ''
|
||||
|
||||
day = d.day
|
||||
|
||||
# Gestion des suffixes ordinaux
|
||||
if 10 <= day % 100 <= 20:
|
||||
suffix = 'TH'
|
||||
else:
|
||||
suffix = {1: 'ST', 2: 'ND', 3: 'RD'}.get(day % 10, 'TH')
|
||||
|
||||
return f"{day}{suffix} {d.strftime('%B').upper()} {d.year}"
|
||||
|
||||
def _under_thousand(n):
|
||||
words = []
|
||||
|
||||
hundreds = n // 100
|
||||
remainder = n % 100
|
||||
|
||||
if hundreds:
|
||||
words.append(UNITS[hundreds])
|
||||
words.append("HUNDRED")
|
||||
if remainder:
|
||||
words.append("AND")
|
||||
|
||||
if remainder:
|
||||
if remainder < 20:
|
||||
words.append(UNITS[remainder])
|
||||
else:
|
||||
words.append(TENS[remainder // 10])
|
||||
if remainder % 10:
|
||||
words.append(UNITS[remainder % 10])
|
||||
|
||||
return " ".join(words)
|
||||
|
||||
|
||||
def integer_to_words(n):
|
||||
if n == 0:
|
||||
return "ZERO"
|
||||
|
||||
parts = []
|
||||
|
||||
millions = n // 1_000_000
|
||||
thousands = (n // 1_000) % 1_000
|
||||
remainder = n % 1_000
|
||||
|
||||
if millions:
|
||||
parts.append(_under_thousand(millions))
|
||||
parts.append("MILLION")
|
||||
|
||||
if thousands:
|
||||
parts.append(_under_thousand(thousands))
|
||||
parts.append("THOUSAND")
|
||||
|
||||
if remainder:
|
||||
parts.append(_under_thousand(remainder))
|
||||
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
# ==============================
|
||||
# 💰 MONETARY
|
||||
# ==============================
|
||||
|
||||
def amount_to_currency_words(amount,
|
||||
major_singular="DOLLAR",
|
||||
major_plural="DOLLARS",
|
||||
minor_singular="CENT",
|
||||
minor_plural="CENTS"):
|
||||
"""
|
||||
Example:
|
||||
1.20 → ONE DOLLAR AND TWENTY CENTS
|
||||
2.00 → TWO DOLLARS
|
||||
"""
|
||||
|
||||
amount = Decimal(str(amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
|
||||
integer_part = int(amount)
|
||||
decimal_part = int((amount - integer_part) * 100)
|
||||
|
||||
words = []
|
||||
|
||||
# Major unit
|
||||
major_words = integer_to_words(integer_part)
|
||||
words.append(major_words)
|
||||
|
||||
if integer_part == 1:
|
||||
words.append(major_singular)
|
||||
else:
|
||||
words.append(major_plural)
|
||||
|
||||
# Minor unit
|
||||
if decimal_part:
|
||||
words.append("AND")
|
||||
minor_words = integer_to_words(decimal_part)
|
||||
words.append(minor_words)
|
||||
|
||||
if decimal_part == 1:
|
||||
words.append(minor_singular)
|
||||
else:
|
||||
words.append(minor_plural)
|
||||
|
||||
return " ".join(words)
|
||||
|
||||
|
||||
# ==============================
|
||||
# ⚖️ QUANTITY WITH UNIT
|
||||
# ==============================
|
||||
|
||||
def quantity_to_words(quantity,
|
||||
unit_singular="METRIC TON",
|
||||
unit_plural="METRIC TONS"):
|
||||
"""
|
||||
Example:
|
||||
1 → ONE METRIC TON
|
||||
23 → TWENTY THREE METRIC TONS
|
||||
1.5 → ONE POINT FIVE METRIC TONS
|
||||
"""
|
||||
|
||||
quantity = Decimal(str(quantity)).normalize()
|
||||
|
||||
if quantity == quantity.to_integral():
|
||||
integer_part = int(quantity)
|
||||
words = integer_to_words(integer_part)
|
||||
|
||||
if integer_part == 1:
|
||||
unit = unit_singular
|
||||
else:
|
||||
unit = unit_plural
|
||||
|
||||
return f"{words} {unit}"
|
||||
|
||||
else:
|
||||
# lecture décimale simple pour quantités
|
||||
integer_part = int(quantity)
|
||||
decimal_str = str(quantity).split(".")[1]
|
||||
|
||||
words = integer_to_words(integer_part)
|
||||
decimal_words = " ".join(UNITS[int(d)] for d in decimal_str)
|
||||
|
||||
return f"{words} POINT {decimal_words} {unit_plural}"
|
||||
@@ -1,11 +1,60 @@
|
||||
from trytond.model import ModelSQL, ModelView, fields
|
||||
from trytond.pool import PoolMeta
|
||||
from trytond.pool import PoolMeta, Pool
|
||||
from trytond.exceptions import UserError
|
||||
from trytond.modules.purchase_trade.purchase import (TRIGGERS)
|
||||
from trytond.transaction import Transaction
|
||||
from decimal import getcontext, Decimal, ROUND_HALF_UP
|
||||
from sql import Table
|
||||
from trytond.pyson import Bool, Eval, Id, If
|
||||
|
||||
__all__ = ['Party']
|
||||
__metaclass__ = PoolMeta
|
||||
class PartyExecution(ModelSQL,ModelView):
|
||||
"Party Execution"
|
||||
__name__ = 'party.execution'
|
||||
|
||||
party = fields.Many2One('party.party',"Party")
|
||||
area = fields.Many2One('country.region',"Area")
|
||||
percent = fields.Numeric("% targeted")
|
||||
achieved_percent = fields.Function(fields.Numeric("% achieved"),'get_percent')
|
||||
|
||||
def get_percent(self,name):
|
||||
return 2
|
||||
|
||||
class PartyExecutionSla(ModelSQL,ModelView):
|
||||
"Party Execution Sla"
|
||||
__name__ = 'party.execution.sla'
|
||||
|
||||
party = fields.Many2One('party.party',"Party")
|
||||
reference = fields.Char("Reference")
|
||||
product = fields.Many2One('product.product',"Product")
|
||||
date_from = fields.Date("From")
|
||||
date_to = fields.Date("To")
|
||||
places = fields.One2Many('party.execution.place','pes',"")
|
||||
|
||||
class PartyExecutionPlace(ModelSQL,ModelView):
|
||||
"Party Sla Place"
|
||||
__name__ = 'party.execution.place'
|
||||
|
||||
pes = fields.Many2One('party.execution.sla',"Sla")
|
||||
location = fields.Many2One('stock.location',"Location")
|
||||
cost = fields.Numeric("Cost",digits=(16,4))
|
||||
mode = fields.Selection([
|
||||
('lumpsum', 'Lump sum'),
|
||||
('perqt', 'Per qt'),
|
||||
('pprice', '% price'),
|
||||
('rate', '% rate'),
|
||||
('pcost', '% cost price'),
|
||||
('ppack', 'Per packing'),
|
||||
], 'Mode', required=True)
|
||||
currency = fields.Many2One('currency.currency',"Currency")
|
||||
unit = fields.Many2One('product.uom',"Unit",domain=[
|
||||
If(Eval('mode') == 'ppack',
|
||||
('category', '=', 8),
|
||||
()),
|
||||
],
|
||||
states={
|
||||
'readonly': Eval('mode') != 'ppack',
|
||||
})
|
||||
|
||||
class Party(metaclass=PoolMeta):
|
||||
__name__ = 'party.party'
|
||||
|
||||
@@ -13,5 +62,53 @@ class Party(metaclass=PoolMeta):
|
||||
tol_max = fields.Numeric("Tol + in %")
|
||||
wb = fields.Many2One('purchase.weight.basis',"Weight basis")
|
||||
association = fields.Many2One('purchase.association',"Association")
|
||||
origin =fields.Char("Origin")
|
||||
execution = fields.One2Many('party.execution','party',"")
|
||||
sla = fields.One2Many('party.execution.sla','party', "Sla")
|
||||
initial = fields.Char("Initials")
|
||||
|
||||
|
||||
def IsAvailableForControl(self,sh):
|
||||
return True
|
||||
|
||||
def get_sla_cost(self,location):
|
||||
if self.sla:
|
||||
for sla in self.sla:
|
||||
SlaPlace = Pool().get('party.execution.place')
|
||||
sp = SlaPlace.search([('pes','=', sla.id),('location','=',location)])
|
||||
if sp:
|
||||
return sp[0].cost,sp[0].mode,sp[0].currency,sp[0].unit
|
||||
|
||||
def get_alf(self):
|
||||
if self.name == 'CARGO CONTROL':
|
||||
return 105
|
||||
t = Table('alf')
|
||||
cursor = Transaction().connection.cursor()
|
||||
cursor.execute(*t.select(
|
||||
t.ALF_CODE,
|
||||
where=t.SHORT_NAME.ilike(f'%{self.name}%')
|
||||
))
|
||||
rows = cursor.fetchall()
|
||||
if rows:
|
||||
return int(rows[0][0])
|
||||
|
||||
@classmethod
|
||||
def getPartyByName(cls, party, category=None):
|
||||
party = party.upper()
|
||||
p = cls.search([('name', '=', party)], limit=1)
|
||||
if p:
|
||||
return p[0]
|
||||
else:
|
||||
p = cls()
|
||||
p.name = party
|
||||
cls.save([p])
|
||||
if category:
|
||||
Category = Pool().get('party.category')
|
||||
cat = Category.search(['name','=',category])
|
||||
if cat:
|
||||
PartyCategory = Pool().get('party.party-party.category')
|
||||
pc = PartyCategory()
|
||||
pc.party = p.id
|
||||
pc.category = cat[0].id
|
||||
PartyCategory.save([pc])
|
||||
return p
|
||||
|
||||
@@ -1,5 +1,29 @@
|
||||
<record model="ir.ui.view" id="party_view_form">
|
||||
<field name="model">party.party</field>
|
||||
<field name="inherit" ref="party.party_view_form"/>
|
||||
<field name="name">party_form</field>
|
||||
</record>
|
||||
<tryton>
|
||||
<data>
|
||||
<record model="ir.ui.view" id="party_view_form">
|
||||
<field name="model">party.party</field>
|
||||
<field name="inherit" ref="party.party_view_form"/>
|
||||
<field name="name">party_form</field>
|
||||
</record>
|
||||
<record model="ir.ui.view" id="party_exec_view_list">
|
||||
<field name="model">party.execution</field>
|
||||
<field name="type">tree</field>
|
||||
<field name="name">party_exec_tree</field>
|
||||
</record>
|
||||
<record model="ir.ui.view" id="party_exec_sla_view_form">
|
||||
<field name="model">party.execution.sla</field>
|
||||
<field name="type">form</field>
|
||||
<field name="name">party_exec_sla_form</field>
|
||||
</record>
|
||||
<record model="ir.ui.view" id="party_exec_sla_view_list">
|
||||
<field name="model">party.execution.sla</field>
|
||||
<field name="type">tree</field>
|
||||
<field name="name">party_exec_sla_tree</field>
|
||||
</record>
|
||||
<record model="ir.ui.view" id="party_exec_place_view_form">
|
||||
<field name="model">party.execution.place</field>
|
||||
<field name="type">tree</field>
|
||||
<field name="name">party_exec_place_tree</field>
|
||||
</record>
|
||||
</data>
|
||||
</tryton>
|
||||
@@ -21,6 +21,7 @@ class PaymentTermLine(metaclass=PoolMeta):
|
||||
trigger_event = fields.Selection(TRIGGERS, 'Trigger Event')
|
||||
|
||||
term_type = fields.Selection([
|
||||
(None, ''),
|
||||
('advance', 'Advance'),
|
||||
('cad', 'CAD'),
|
||||
('open', 'Open'),
|
||||
@@ -30,18 +31,21 @@ class PaymentTermLine(metaclass=PoolMeta):
|
||||
|
||||
trigger_offset = fields.Integer('Trigger Offset')
|
||||
offset_unit = fields.Selection([
|
||||
(None, ''),
|
||||
('calendar', 'Calendar Days'),
|
||||
('business', 'Business Days'),
|
||||
], 'Offset Unit')
|
||||
|
||||
eom_flag = fields.Boolean('EOM Flag')
|
||||
eom_mode = fields.Selection([
|
||||
(None, ''),
|
||||
('standard', 'Standard'),
|
||||
('before', 'Before EOM'),
|
||||
('after', 'After EOM'),
|
||||
], 'EOM Mode')
|
||||
|
||||
risk_classification = fields.Selection([
|
||||
(None, ''),
|
||||
('fully_secured', 'Fully Secured'),
|
||||
('partially_secured', 'Partially Secured'),
|
||||
('unsecured', 'Unsecured'),
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
# This file is part of Tryton. The COPYRIGHT file at the top level of
|
||||
# this repository contains the full copyright notices and license terms.
|
||||
from trytond.model import fields
|
||||
from trytond.pool import Pool, PoolMeta
|
||||
from trytond.pyson import Bool, Eval, Id
|
||||
from trytond.model import (ModelSQL, ModelView)
|
||||
from trytond.model import fields
|
||||
from trytond.exceptions import UserError
|
||||
from trytond.pool import Pool, PoolMeta
|
||||
from trytond.pyson import Bool, Eval, Id
|
||||
from trytond.model import (ModelSQL, ModelView)
|
||||
from trytond.tools import is_full_text, lstrip_wildcard
|
||||
from trytond.transaction import Transaction, inactive_records
|
||||
from decimal import getcontext, Decimal, ROUND_HALF_UP
|
||||
@@ -11,12 +12,17 @@ from sql.aggregate import Count, Max, Min, Sum, Avg, BoolOr
|
||||
from sql.conditionals import Case
|
||||
from sql import Column, Literal
|
||||
from sql.functions import CurrentTimestamp, DateTrunc
|
||||
from trytond.wizard import Button, StateTransition, StateView, Wizard
|
||||
from itertools import chain, groupby
|
||||
from operator import itemgetter
|
||||
import datetime
|
||||
import logging
|
||||
from trytond.modules.purchase_trade.purchase import (TRIGGERS)
|
||||
from trytond.wizard import Button, StateTransition, StateView, Wizard
|
||||
from itertools import chain, groupby
|
||||
from operator import itemgetter
|
||||
import calendar
|
||||
import datetime
|
||||
import logging
|
||||
import re
|
||||
import zipfile
|
||||
from io import BytesIO
|
||||
from xml.etree import ElementTree
|
||||
from trytond.modules.purchase_trade.purchase import (TRIGGERS)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -50,37 +56,653 @@ DAYS = [
|
||||
('sunday', 'Sunday'),
|
||||
]
|
||||
|
||||
|
||||
|
||||
class Estimated(ModelSQL, ModelView):
|
||||
"Estimated date"
|
||||
__name__ = 'pricing.estimated'
|
||||
class Estimated(ModelSQL, ModelView):
|
||||
"Estimated date"
|
||||
__name__ = 'pricing.estimated'
|
||||
|
||||
trigger = fields.Selection(TRIGGERS,"Trigger")
|
||||
estimated_date = fields.Date("Estimated date")
|
||||
estimated_date = fields.Date("Estimated date")
|
||||
fin_int_delta = fields.Integer("Financing interests delta")
|
||||
|
||||
|
||||
class ImportPricesStart(ModelView):
|
||||
"Import Prices"
|
||||
__name__ = 'purchase_trade.import_prices.start'
|
||||
|
||||
file_ = fields.Binary('Excel file', required=True, filename='filename')
|
||||
filename = fields.Char('Filename')
|
||||
create_missing_price_index = fields.Boolean(
|
||||
"Create price index if missing")
|
||||
overwrite_existing_price = fields.Boolean("Overwrite existing price")
|
||||
|
||||
|
||||
class ImportPricesResult(ModelView):
|
||||
"Import Prices Result"
|
||||
__name__ = 'purchase_trade.import_prices.result'
|
||||
|
||||
message = fields.Text("Results", readonly=True)
|
||||
|
||||
|
||||
class ImportPrices(Wizard):
|
||||
"Import Prices"
|
||||
__name__ = 'purchase_trade.import_prices'
|
||||
|
||||
start = StateView(
|
||||
'purchase_trade.import_prices.start',
|
||||
'purchase_trade.import_prices_start_view_form',
|
||||
[
|
||||
Button('Cancel', 'end', 'tryton-cancel'),
|
||||
Button('Import', 'import_', 'tryton-ok', default=True),
|
||||
])
|
||||
import_ = StateTransition()
|
||||
result = StateView(
|
||||
'purchase_trade.import_prices.result',
|
||||
'purchase_trade.import_prices_result_view_form',
|
||||
[
|
||||
Button('OK', 'end', 'tryton-ok', default=True),
|
||||
])
|
||||
|
||||
REQUIRED_COLUMNS = {
|
||||
'priceindex': 'price_index',
|
||||
'pricedate': 'price_date',
|
||||
'highprice': 'high_price',
|
||||
'lowprice': 'low_price',
|
||||
'openprice': 'open_price',
|
||||
'pricevalue': 'price_value',
|
||||
}
|
||||
|
||||
def transition_import_(self):
|
||||
rows = self._read_xlsx(self.start.file_)
|
||||
stats = self._import_rows(
|
||||
rows,
|
||||
create_missing_price_index=(
|
||||
self.start.create_missing_price_index),
|
||||
overwrite_existing_price=self.start.overwrite_existing_price)
|
||||
self._result_message = self._format_result(stats)
|
||||
return 'result'
|
||||
|
||||
def default_result(self, fields):
|
||||
return {
|
||||
'message': getattr(
|
||||
self, '_result_message',
|
||||
'No import result was produced.'),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _import_rows(
|
||||
cls, rows, create_missing_price_index=False,
|
||||
overwrite_existing_price=False):
|
||||
Price = Pool().get('price.price')
|
||||
PriceValue = Pool().get('price.price_value')
|
||||
stats = {
|
||||
'created_indexes': [],
|
||||
'imported': [],
|
||||
'updated': [],
|
||||
'skipped': [],
|
||||
'errors': [],
|
||||
}
|
||||
|
||||
for row_number, row in enumerate(rows, start=2):
|
||||
price_index = (row.get('price_index') or '').strip()
|
||||
try:
|
||||
price_date = cls._as_date(row.get('price_date'))
|
||||
if not price_index:
|
||||
stats['skipped'].append(
|
||||
cls._result_line(
|
||||
row_number, '', None, 'missing price_index'))
|
||||
continue
|
||||
if not price_date:
|
||||
stats['skipped'].append(
|
||||
cls._result_line(
|
||||
row_number, price_index, None,
|
||||
'missing price_date'))
|
||||
continue
|
||||
|
||||
prices = Price.search(
|
||||
[('price_index', '=', price_index)], limit=1)
|
||||
if prices:
|
||||
price = prices[0]
|
||||
elif create_missing_price_index:
|
||||
price, = Price.create([
|
||||
cls._price_index_values(price_index)])
|
||||
stats['created_indexes'].append(
|
||||
cls._result_line(
|
||||
row_number, price_index, None,
|
||||
'price index created'))
|
||||
else:
|
||||
stats['skipped'].append(
|
||||
cls._result_line(
|
||||
row_number, price_index, None,
|
||||
'price_index missing'))
|
||||
continue
|
||||
|
||||
values = cls._price_value_values(price, row, price_date)
|
||||
existing = PriceValue.search([
|
||||
('price', '=', price.id),
|
||||
('price_date', '=', price_date),
|
||||
], limit=1)
|
||||
|
||||
if existing:
|
||||
if overwrite_existing_price:
|
||||
PriceValue.write(existing, values)
|
||||
stats['updated'].append(
|
||||
cls._result_line(
|
||||
row_number, price_index, price_date,
|
||||
cls._price_summary(values)))
|
||||
else:
|
||||
stats['skipped'].append(cls._result_line(
|
||||
row_number, price_index, price_date,
|
||||
'price_date already exists'))
|
||||
continue
|
||||
|
||||
PriceValue.create([values])
|
||||
stats['imported'].append(
|
||||
cls._result_line(
|
||||
row_number, price_index, price_date,
|
||||
cls._price_summary(values)))
|
||||
except Exception as exception:
|
||||
stats['errors'].append(
|
||||
cls._result_line(
|
||||
row_number, price_index, row.get('price_date'),
|
||||
str(exception)))
|
||||
continue
|
||||
|
||||
return stats
|
||||
|
||||
@classmethod
|
||||
def _price_index_values(cls, price_index):
|
||||
values = {
|
||||
'price_index': price_index,
|
||||
'price_desc': price_index,
|
||||
'price_curve_type': 'future',
|
||||
}
|
||||
references = [
|
||||
('price_type', 'price.fixtype', [('name', '=', 'Market price')]),
|
||||
('price_currency', 'currency.currency', [('code', '=', 'USD')]),
|
||||
('price_calendar', 'price.calendar', [('name', '=', 'Argus EU')]),
|
||||
('price_unit', 'product.uom', [('name', '=', 'Mt')]),
|
||||
]
|
||||
for field, model_name, domain in references:
|
||||
record = cls._first_record(model_name, domain)
|
||||
if record:
|
||||
values[field] = record.id
|
||||
|
||||
period = cls._period_from_price_index(price_index)
|
||||
if period:
|
||||
values['price_period'] = period.id
|
||||
return values
|
||||
|
||||
@classmethod
|
||||
def _period_from_price_index(cls, price_index):
|
||||
match = re.search(r'(?<!\d)(20\d{2})[-_/\. ](0[1-9]|1[0-2])(?!\d)',
|
||||
price_index)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
year = int(match.group(1))
|
||||
month = int(match.group(2))
|
||||
month_name = cls._period_month_name(year, month)
|
||||
periods = cls._period_model().search(
|
||||
[('month_name', '=', month_name)], limit=1)
|
||||
if periods:
|
||||
return periods[0]
|
||||
|
||||
beg_date = datetime.date(year, month, 1)
|
||||
end_date = datetime.date(
|
||||
year, month, calendar.monthrange(year, month)[1])
|
||||
period, = cls._period_model().create([{
|
||||
'month_name': month_name,
|
||||
'description': month_name,
|
||||
'beg_date': beg_date,
|
||||
'end_date': end_date,
|
||||
'is_cotation': True,
|
||||
}])
|
||||
return period
|
||||
|
||||
@staticmethod
|
||||
def _period_month_name(year, month):
|
||||
return '%s%s' % (
|
||||
calendar.month_abbr[month].upper(), str(year)[-2:])
|
||||
|
||||
@staticmethod
|
||||
def _period_model():
|
||||
return Pool().get('product.month')
|
||||
|
||||
@staticmethod
|
||||
def _first_record(model_name, domain):
|
||||
records = Pool().get(model_name).search(domain, limit=1)
|
||||
return records[0] if records else None
|
||||
|
||||
@classmethod
|
||||
def _price_value_values(cls, price, row, price_date):
|
||||
return {
|
||||
'price': price.id,
|
||||
'price_date': price_date,
|
||||
'high_price': cls._as_float(row.get('high_price')),
|
||||
'low_price': cls._as_float(row.get('low_price')),
|
||||
'open_price': cls._as_float(row.get('open_price')),
|
||||
'price_value': cls._as_float(row.get('price_value')),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _result_line(row_number, price_index, price_date, detail):
|
||||
return {
|
||||
'row': row_number,
|
||||
'price_index': price_index,
|
||||
'price_date': price_date,
|
||||
'detail': detail,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _format_result(cls, stats):
|
||||
lines = [
|
||||
'Import completed.',
|
||||
f"Created price indexes: {len(stats['created_indexes'])}",
|
||||
f"Imported prices: {len(stats['imported'])}",
|
||||
f"Updated prices: {len(stats['updated'])}",
|
||||
f"Skipped rows: {len(stats['skipped'])}",
|
||||
f"Errors: {len(stats['errors'])}",
|
||||
]
|
||||
|
||||
sections = [
|
||||
('Created price indexes', stats['created_indexes']),
|
||||
('Successfully imported prices', stats['imported']),
|
||||
('Updated existing prices', stats['updated']),
|
||||
('Skipped records', stats['skipped']),
|
||||
('Errors', stats['errors']),
|
||||
]
|
||||
for title, items in sections:
|
||||
lines.append('')
|
||||
lines.append('%s:' % title)
|
||||
if not items:
|
||||
lines.append('- None')
|
||||
continue
|
||||
for item in items:
|
||||
lines.append('- %s' % cls._format_result_line(item))
|
||||
return '\n'.join(lines)
|
||||
|
||||
@staticmethod
|
||||
def _format_result_line(item):
|
||||
label = item['price_index'] or '<empty price_index>'
|
||||
price_date = item['price_date']
|
||||
if isinstance(price_date, datetime.date):
|
||||
price_date = price_date.isoformat()
|
||||
if price_date:
|
||||
label = '%s / %s' % (label, price_date)
|
||||
return 'Row %(row)s - ' % item + '%s: %s' % (label, item['detail'])
|
||||
|
||||
@staticmethod
|
||||
def _price_summary(values):
|
||||
parts = []
|
||||
for name in ('price_value', 'open_price', 'low_price', 'high_price'):
|
||||
value = values.get(name)
|
||||
if value is not None:
|
||||
parts.append('%s=%s' % (name, value))
|
||||
return ', '.join(parts) or 'price imported'
|
||||
|
||||
@classmethod
|
||||
def _read_xlsx(cls, data):
|
||||
try:
|
||||
with zipfile.ZipFile(BytesIO(data)) as workbook:
|
||||
shared_strings = cls._read_shared_strings(workbook)
|
||||
sheet_name = cls._first_sheet_name(workbook)
|
||||
sheet = ElementTree.fromstring(workbook.read(sheet_name))
|
||||
except (KeyError, zipfile.BadZipFile, ElementTree.ParseError):
|
||||
raise UserError("The selected file is not a valid Excel .xlsx file.")
|
||||
|
||||
ns = {'s': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
|
||||
rows = sheet.findall('.//s:sheetData/s:row', ns)
|
||||
if not rows:
|
||||
return []
|
||||
|
||||
headers = {}
|
||||
for cell in rows[0].findall('s:c', ns):
|
||||
index = cls._cell_column_index(cell.get('r'))
|
||||
header = cls._cell_value(cell, shared_strings)
|
||||
normalized = cls._normalize_header(header)
|
||||
if normalized in cls.REQUIRED_COLUMNS:
|
||||
headers[index] = cls.REQUIRED_COLUMNS[normalized]
|
||||
|
||||
missing = set(cls.REQUIRED_COLUMNS.values()) - set(headers.values())
|
||||
if missing:
|
||||
raise UserError(
|
||||
"Missing columns in Excel file: %s"
|
||||
% ', '.join(sorted(missing)))
|
||||
|
||||
result = []
|
||||
for sheet_row in rows[1:]:
|
||||
values = {}
|
||||
for cell in sheet_row.findall('s:c', ns):
|
||||
index = cls._cell_column_index(cell.get('r'))
|
||||
field = headers.get(index)
|
||||
if field:
|
||||
values[field] = cls._cell_value(cell, shared_strings)
|
||||
if any(v not in (None, '') for v in values.values()):
|
||||
result.append(values)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _read_shared_strings(workbook):
|
||||
try:
|
||||
content = workbook.read('xl/sharedStrings.xml')
|
||||
except KeyError:
|
||||
return []
|
||||
root = ElementTree.fromstring(content)
|
||||
ns = {'s': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
|
||||
strings = []
|
||||
for item in root.findall('s:si', ns):
|
||||
strings.append(''.join(
|
||||
text.text or '' for text in item.findall('.//s:t', ns)))
|
||||
return strings
|
||||
|
||||
@staticmethod
|
||||
def _first_sheet_name(workbook):
|
||||
workbook_xml = ElementTree.fromstring(workbook.read('xl/workbook.xml'))
|
||||
rels_xml = ElementTree.fromstring(
|
||||
workbook.read('xl/_rels/workbook.xml.rels'))
|
||||
wb_ns = {
|
||||
's': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main',
|
||||
'r': (
|
||||
'http://schemas.openxmlformats.org/officeDocument/2006/'
|
||||
'relationships'),
|
||||
}
|
||||
rel_ns = {
|
||||
'r': (
|
||||
'http://schemas.openxmlformats.org/package/2006/'
|
||||
'relationships'),
|
||||
}
|
||||
sheet = workbook_xml.find('.//s:sheet', wb_ns)
|
||||
rel_id = sheet.get(
|
||||
'{http://schemas.openxmlformats.org/officeDocument/2006/'
|
||||
'relationships}id')
|
||||
for rel in rels_xml.findall('r:Relationship', rel_ns):
|
||||
if rel.get('Id') == rel_id:
|
||||
target = rel.get('Target')
|
||||
if not target.startswith('/'):
|
||||
target = 'xl/' + target
|
||||
return target.lstrip('/')
|
||||
raise KeyError('No worksheet found')
|
||||
|
||||
@staticmethod
|
||||
def _cell_column_index(reference):
|
||||
match = re.match(r'([A-Z]+)', reference or '')
|
||||
if not match:
|
||||
return None
|
||||
index = 0
|
||||
for char in match.group(1):
|
||||
index = index * 26 + ord(char) - ord('A') + 1
|
||||
return index
|
||||
|
||||
@classmethod
|
||||
def _cell_value(cls, cell, shared_strings):
|
||||
ns = {'s': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
|
||||
if cell.get('t') == 'inlineStr':
|
||||
text = cell.find('.//s:t', ns)
|
||||
return text.text if text is not None else ''
|
||||
value = cell.find('s:v', ns)
|
||||
if value is None:
|
||||
return ''
|
||||
if cell.get('t') == 's':
|
||||
return shared_strings[int(value.text)]
|
||||
return value.text
|
||||
|
||||
@staticmethod
|
||||
def _normalize_header(value):
|
||||
return re.sub(r'[^a-z0-9]', '', (value or '').strip().lower())
|
||||
|
||||
@staticmethod
|
||||
def _as_float(value):
|
||||
if value in (None, ''):
|
||||
return None
|
||||
return float(str(value).replace(',', '.'))
|
||||
|
||||
@staticmethod
|
||||
def _as_date(value):
|
||||
if isinstance(value, datetime.date):
|
||||
return value
|
||||
if value in (None, ''):
|
||||
return None
|
||||
text = str(value).strip()
|
||||
if re.match(r'^\d+(\.\d+)?$', text):
|
||||
return (
|
||||
datetime.date(1899, 12, 30)
|
||||
+ datetime.timedelta(days=int(float(text))))
|
||||
for fmt in ('%Y-%m-%d', '%d/%m/%Y', '%m/%d/%Y'):
|
||||
try:
|
||||
return datetime.datetime.strptime(text, fmt).date()
|
||||
except ValueError:
|
||||
pass
|
||||
raise UserError("Invalid price_date: %s" % text)
|
||||
|
||||
class MtmScenario(ModelSQL, ModelView):
|
||||
"MtM Scenario"
|
||||
__name__ = 'mtm.scenario'
|
||||
|
||||
name = fields.Char("Scenario", required=True)
|
||||
valuation_date = fields.Date("Valuation Date", required=True)
|
||||
use_last_price = fields.Boolean("Use Last Available Price")
|
||||
calendar = fields.Many2One(
|
||||
'price.calendar', "Calendar"
|
||||
)
|
||||
|
||||
class MtmStrategy(ModelSQL, ModelView):
|
||||
"Mark to Market Strategy"
|
||||
__name__ = 'mtm.strategy'
|
||||
|
||||
name = fields.Char("Name", required=True)
|
||||
active = fields.Boolean("Active")
|
||||
|
||||
scenario = fields.Many2One(
|
||||
'mtm.scenario', "Scenario", required=True
|
||||
)
|
||||
|
||||
currency = fields.Many2One(
|
||||
'currency.currency', "Valuation Currency"
|
||||
)
|
||||
|
||||
components = fields.One2Many(
|
||||
'pricing.component', 'strategy', "Components"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def default_active(cls):
|
||||
return True
|
||||
|
||||
def get_mtm(self,line,qty):
|
||||
pool = Pool()
|
||||
Currency = pool.get('currency.currency')
|
||||
total = Decimal(0)
|
||||
|
||||
scenario = self.scenario
|
||||
dt = scenario.valuation_date
|
||||
|
||||
for comp in self.components:
|
||||
value = Decimal(0)
|
||||
|
||||
if comp.price_source_type == 'curve' and comp.price_index:
|
||||
value = Decimal(
|
||||
comp.price_index.get_price(
|
||||
dt,
|
||||
line.unit,
|
||||
self.currency,
|
||||
last=scenario.use_last_price
|
||||
)
|
||||
)
|
||||
|
||||
elif comp.price_source_type == 'matrix' and comp.price_matrix:
|
||||
value = self._get_matrix_price(comp, line, dt)
|
||||
|
||||
if comp.ratio:
|
||||
value *= Decimal(comp.ratio) / Decimal(100)
|
||||
|
||||
total += value * qty
|
||||
|
||||
return Decimal(str(total)).quantize(Decimal("0.01"))
|
||||
|
||||
def _get_matrix_price(self, comp, line, dt):
|
||||
MatrixLine = Pool().get('price.matrix.line')
|
||||
|
||||
domain = [
|
||||
('matrix', '=', comp.price_matrix.id),
|
||||
]
|
||||
|
||||
if line:
|
||||
domain += [
|
||||
('origin', '=', line.purchase.from_location),
|
||||
('destination', '=', line.purchase.to_location),
|
||||
]
|
||||
|
||||
lines = MatrixLine.search(domain)
|
||||
if lines:
|
||||
return Decimal(lines[0].price_value)
|
||||
|
||||
return Decimal(0)
|
||||
|
||||
def run_daily_mtm():
|
||||
Strategy = Pool().get('mtm.strategy')
|
||||
Snapshot = Pool().get('mtm.snapshot')
|
||||
|
||||
for strat in Strategy.search([('active', '=', True)]):
|
||||
amount = strat.compute_mtm()
|
||||
Snapshot.create([{
|
||||
'strategy': strat.id,
|
||||
'valuation_date': strat.scenario.valuation_date,
|
||||
'amount': amount,
|
||||
'currency': strat.currency.id,
|
||||
}])
|
||||
|
||||
class Mtm(ModelSQL, ModelView):
|
||||
"Mtm"
|
||||
"MtM Component"
|
||||
__name__ = 'mtm.component'
|
||||
|
||||
fix_type = fields.Many2One('price.fixtype',"Fixation type")
|
||||
ratio = fields.Numeric("%")
|
||||
price_index = fields.Many2One('price.price',"Curve")
|
||||
currency = fields.Function(fields.Many2One('currency.currency',"Curr."),'get_cur')
|
||||
strategy = fields.Many2One(
|
||||
'mtm.strategy', "Strategy",
|
||||
required=True, ondelete='CASCADE'
|
||||
)
|
||||
|
||||
def get_cur(self,name):
|
||||
name = fields.Char("Component", required=True)
|
||||
|
||||
component_type = fields.Selection([
|
||||
('commodity', 'Commodity'),
|
||||
('freight', 'Freight'),
|
||||
('quality', 'Quality'),
|
||||
('fx', 'FX'),
|
||||
('storage', 'Storage'),
|
||||
('other', 'Other'),
|
||||
], "Type", required=True)
|
||||
|
||||
fix_type = fields.Many2One('price.fixtype', "Fixation Type")
|
||||
|
||||
price_source_type = fields.Selection([
|
||||
('curve', 'Curve'),
|
||||
('matrix', 'Matrix'),
|
||||
('manual', 'Manual'),
|
||||
], "Price Source", required=True)
|
||||
|
||||
price_index = fields.Many2One('price.price', "Price Curve")
|
||||
price_matrix = fields.Many2One('price.matrix', "Price Matrix")
|
||||
|
||||
ratio = fields.Numeric("Ratio / %", digits=(16, 6))
|
||||
|
||||
manual_price = fields.Numeric(
|
||||
"Manual Price",
|
||||
digits=(16, 6),
|
||||
help="Price set manually if price_source_type is 'manual'"
|
||||
)
|
||||
|
||||
currency = fields.Many2One('currency.currency', "Currency")
|
||||
|
||||
def get_cur(self, name=None):
|
||||
if self.price_index:
|
||||
PI = Pool().get('price.price')
|
||||
pi = PI(self.price_index)
|
||||
return pi.price_currency
|
||||
|
||||
return self.price_index.price_currency
|
||||
if self.price_matrix:
|
||||
return self.price_matrix.currency
|
||||
return None
|
||||
|
||||
@fields.depends('price_index','price_matrix')
|
||||
def on_change_with_currency(self):
|
||||
return self.get_cur()
|
||||
|
||||
class PriceMatrix(ModelSQL, ModelView):
|
||||
"Price Matrix"
|
||||
__name__ = 'price.matrix'
|
||||
|
||||
name = fields.Char("Name", required=True)
|
||||
|
||||
matrix_type = fields.Selection([
|
||||
('freight', 'Freight'),
|
||||
('location', 'Location Spread'),
|
||||
('quality', 'Quality'),
|
||||
('storage', 'Storage'),
|
||||
('other', 'Other'),
|
||||
], "Matrix Type", required=True)
|
||||
|
||||
unit = fields.Many2One('product.uom', "Unit")
|
||||
currency = fields.Many2One('currency.currency', "Currency")
|
||||
|
||||
calendar = fields.Many2One(
|
||||
'price.calendar', "Calendar"
|
||||
)
|
||||
|
||||
valid_from = fields.Date("Valid From")
|
||||
valid_to = fields.Date("Valid To")
|
||||
|
||||
lines = fields.One2Many(
|
||||
'price.matrix.line', 'matrix', "Lines"
|
||||
)
|
||||
|
||||
class PriceMatrixLine(ModelSQL, ModelView):
|
||||
"Price Matrix Line"
|
||||
__name__ = 'price.matrix.line'
|
||||
|
||||
matrix = fields.Many2One(
|
||||
'price.matrix', "Matrix",
|
||||
required=True, ondelete='CASCADE'
|
||||
)
|
||||
|
||||
origin = fields.Many2One('stock.location', "Origin")
|
||||
destination = fields.Many2One('stock.location', "Destination")
|
||||
|
||||
product = fields.Many2One('product.product', "Product")
|
||||
quality = fields.Many2One('product.category', "Quality")
|
||||
|
||||
price_value = fields.Numeric("Price", digits=(16, 6))
|
||||
|
||||
class MtmSnapshot(ModelSQL, ModelView):
|
||||
"MtM Snapshot"
|
||||
__name__ = 'mtm.snapshot'
|
||||
|
||||
strategy = fields.Many2One(
|
||||
'mtm.strategy', "Strategy",
|
||||
required=True, ondelete='CASCADE'
|
||||
)
|
||||
|
||||
valuation_date = fields.Date("Valuation Date", required=True)
|
||||
|
||||
amount = fields.Numeric("MtM Amount", digits=(16, 6))
|
||||
currency = fields.Many2One('currency.currency', "Currency")
|
||||
|
||||
created_at = fields.DateTime("Created At")
|
||||
|
||||
class Component(ModelSQL, ModelView):
|
||||
"Component"
|
||||
__name__ = 'pricing.component'
|
||||
|
||||
strategy = fields.Many2One(
|
||||
'mtm.strategy', "Strategy",
|
||||
required=False, ondelete='CASCADE'
|
||||
)
|
||||
|
||||
price_source_type = fields.Selection([
|
||||
('curve', 'Curve'),
|
||||
('matrix', 'Matrix'),
|
||||
# ('manual', 'Manual'),
|
||||
], "Price Source", required=True)
|
||||
|
||||
fix_type = fields.Many2One('price.fixtype',"Fixation type")
|
||||
ratio = fields.Numeric("%")
|
||||
ratio = fields.Numeric("%",digits=(16,7))
|
||||
price_index = fields.Many2One('price.price',"Curve")
|
||||
price_matrix = fields.Many2One('price.matrix', "Price Matrix")
|
||||
currency = fields.Function(fields.Many2One('currency.currency',"Curr."),'get_cur')
|
||||
auto = fields.Boolean("Auto")
|
||||
fallback = fields.Boolean("Fallback")
|
||||
@@ -194,6 +816,7 @@ class Trigger(ModelSQL,ModelView):
|
||||
'readonly': Eval('pricing_period') != None,
|
||||
})
|
||||
average = fields.Boolean("Avg")
|
||||
last = fields.Boolean("Last")
|
||||
application_period = fields.Many2One('pricing.period',"Application period")
|
||||
from_a = fields.Date("From",
|
||||
states={
|
||||
@@ -217,14 +840,11 @@ class Trigger(ModelSQL,ModelView):
|
||||
pp = PP(self.application_period)
|
||||
CO = Pool().get('pricing.component')
|
||||
co = CO(self.component)
|
||||
logger.info("DELDATEEST_:%s",co)
|
||||
if co.line:
|
||||
d = co.getEstimatedTriggerPurchase(pp.trigger)
|
||||
else:
|
||||
d = co.getEstimatedTriggerSale(pp.trigger)
|
||||
logger.info("DELDATEEST:%s",d)
|
||||
date_from,date_to,dates = pp.getDates(d)
|
||||
logger.info("DELDATEEST2:%s",dates)
|
||||
return date_from,date_to,d,pp.include,dates
|
||||
|
||||
def getApplicationListDates(self, cal):
|
||||
@@ -288,7 +908,7 @@ class Trigger(ModelSQL,ModelView):
|
||||
pi = PI(pc.price_index)
|
||||
val = {}
|
||||
val['date'] = current_date
|
||||
val['price'] = pi.get_price(current_date,pc.line.unit if pc.line else pc.sale_line.unit,pc.line.currency if pc.line else pc.sale_line.currency)
|
||||
val['price'] = pi.get_price(current_date,pc.line.unit if pc.line else pc.sale_line.unit,pc.line.currency if pc.line else pc.sale_line.currency,self.last)
|
||||
val['avg'] = val['price']
|
||||
val['avg_minus_1'] = val['price']
|
||||
val['isAvg'] = self.average
|
||||
@@ -330,8 +950,6 @@ class Period(ModelSQL,ModelView):
|
||||
date_from = None
|
||||
date_to = None
|
||||
dates = []
|
||||
logger.info("GETDATES:%s",t)
|
||||
logger.info("GETDATES:%s",self.every)
|
||||
if t:
|
||||
if self.every:
|
||||
if t:
|
||||
@@ -348,21 +966,18 @@ class Period(ModelSQL,ModelView):
|
||||
while current.month == t.month:
|
||||
dates.append(datetime.datetime(current.year, current.month, current.day))
|
||||
current += datetime.timedelta(days=7)
|
||||
logger.info("GETDATES:%s",dates)
|
||||
elif self.nb_quotation > 0:
|
||||
days_to_add = (weekday_target - t.weekday()) % 7
|
||||
current = t + datetime.timedelta(days=days_to_add)
|
||||
while len(dates) < self.nb_quotation:
|
||||
dates.append(datetime.datetime(current.year, current.month, current.day))
|
||||
current += datetime.timedelta(days=7)
|
||||
logger.info("GETDATES:%s",dates)
|
||||
elif self.nb_quotation < 0:
|
||||
days_to_sub = (t.weekday() - weekday_target) % 7
|
||||
current = t - datetime.timedelta(days=days_to_sub)
|
||||
while len(dates) < -self.nb_quotation:
|
||||
dates.append(datetime.datetime(current.year, current.month, current.day))
|
||||
current -= datetime.timedelta(days=7)
|
||||
logger.info("GETDATES:%s",dates)
|
||||
|
||||
else:
|
||||
if self.startday == 'before':
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
this repository contains the full copyright notices and license terms. -->
|
||||
<tryton>
|
||||
<data>
|
||||
<record model="ir.ui.icon" id="mtm_icon">
|
||||
<field name="name">tradon-mtm</field>
|
||||
<field name="path">icons/tradon-mtm.svg</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.ui.view" id="summary_view_tree_sequence">
|
||||
<field name="model">sale.pricing.summary</field>
|
||||
<field name="type">tree</field>
|
||||
@@ -15,12 +20,29 @@ this repository contains the full copyright notices and license terms. -->
|
||||
<field name="priority" eval="20"/>
|
||||
<field name="name">summary_tree_sequence</field>
|
||||
</record>
|
||||
<record model="ir.ui.view" id="estimated_view_tree">
|
||||
<field name="model">pricing.estimated</field>
|
||||
<field name="type">tree</field>
|
||||
<field name="priority" eval="10"/>
|
||||
<field name="name">estimated_tree</field>
|
||||
</record>
|
||||
<record model="ir.ui.view" id="estimated_view_tree">
|
||||
<field name="model">pricing.estimated</field>
|
||||
<field name="type">tree</field>
|
||||
<field name="priority" eval="10"/>
|
||||
<field name="name">estimated_tree</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.ui.view" id="import_prices_start_view_form">
|
||||
<field name="model">purchase_trade.import_prices.start</field>
|
||||
<field name="type">form</field>
|
||||
<field name="name">import_prices_start_form</field>
|
||||
</record>
|
||||
<record model="ir.ui.view" id="import_prices_result_view_form">
|
||||
<field name="model">purchase_trade.import_prices.result</field>
|
||||
<field name="type">form</field>
|
||||
<field name="name">import_prices_result_form</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.action.wizard" id="act_import_prices">
|
||||
<field name="name">Import Prices</field>
|
||||
<field name="wiz_name">purchase_trade.import_prices</field>
|
||||
<field name="window" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record model="ir.ui.view" id="component_view_tree">
|
||||
<field name="model">pricing.component</field>
|
||||
@@ -104,5 +126,89 @@ this repository contains the full copyright notices and license terms. -->
|
||||
<field name="type">form</field>
|
||||
<field name="name">period_form</field>
|
||||
</record>
|
||||
</data>
|
||||
</tryton>
|
||||
|
||||
<record model="ir.ui.view" id="mtm_scenario_view_form">
|
||||
<field name="model">mtm.scenario</field>
|
||||
<field name="type">form</field>
|
||||
<field name="name">mtm_scenario_form</field>
|
||||
</record>
|
||||
<record model="ir.ui.view" id="mtm_scenario_view_list">
|
||||
<field name="model">mtm.scenario</field>
|
||||
<field name="type">tree</field>
|
||||
<field name="name">mtm_scenario_tree</field>
|
||||
</record>
|
||||
<record model="ir.ui.view" id="mtm_strategy_view_form">
|
||||
<field name="model">mtm.strategy</field>
|
||||
<field name="type">form</field>
|
||||
<field name="name">mtm_strategy_form</field>
|
||||
</record>
|
||||
<record model="ir.ui.view" id="mtm_strategy_view_list">
|
||||
<field name="model">mtm.strategy</field>
|
||||
<field name="type">tree</field>
|
||||
<field name="name">mtm_strategy_tree</field>
|
||||
</record>
|
||||
<record model="ir.ui.view" id="price_matrix_view_form">
|
||||
<field name="model">price.matrix</field>
|
||||
<field name="type">form</field>
|
||||
<field name="name">price_matrix_form</field>
|
||||
</record>
|
||||
<record model="ir.ui.view" id="price_matrix_view_list">
|
||||
<field name="model">price.matrix</field>
|
||||
<field name="type">tree</field>
|
||||
<field name="name">price_matrix_tree</field>
|
||||
</record>
|
||||
<record model="ir.ui.view" id="price_matrix_line_view_list">
|
||||
<field name="model">price.matrix.line</field>
|
||||
<field name="type">tree</field>
|
||||
<field name="name">price_matrix_line_tree</field>
|
||||
</record>
|
||||
<!-- <record model="ir.ui.view" id="price_matrix_line_view_form">
|
||||
<field name="model">price.matrix.line</field>
|
||||
<field name="type">form</field>
|
||||
<field name="name">price_matrix_line_form</field>
|
||||
</record>
|
||||
<record model="ir.ui.view" id="mtm_snapshot_view_form">
|
||||
<field name="model">mtm.snapshot</field>
|
||||
<field name="type">form</field>
|
||||
<field name="name">mtm_snapshot_form</field>
|
||||
</record>
|
||||
<record model="ir.ui.view" id="mtm_snapshot_view_list">
|
||||
<field name="model">mtm.snapshot</field>
|
||||
<field name="type">tree</field>
|
||||
<field name="name">mtm_snapshot_tree</field>
|
||||
</record> -->
|
||||
|
||||
<record model="ir.action.act_window" id="act_strategy_form">
|
||||
<field name="name">Strategy</field>
|
||||
<field name="res_model">mtm.strategy</field>
|
||||
</record>
|
||||
<record model="ir.action.act_window.view" id="act_strategy_form_view1">
|
||||
<field name="sequence" eval="10"/>
|
||||
<field name="view" ref="mtm_strategy_view_list"/>
|
||||
<field name="act_window" ref="act_strategy_form"/>
|
||||
</record>
|
||||
<record model="ir.action.act_window.view" id="act_strategy_form_view2">
|
||||
<field name="sequence" eval="20"/>
|
||||
<field name="view" ref="mtm_strategy_view_form"/>
|
||||
<field name="act_window" ref="act_strategy_form"/>
|
||||
</record>
|
||||
|
||||
<menuitem
|
||||
name="Mtm"
|
||||
sequence="99"
|
||||
id="menu_mtm"
|
||||
icon="tradon-mtm" />
|
||||
<menuitem
|
||||
name="Strategy"
|
||||
action="act_strategy_form"
|
||||
parent="menu_mtm"
|
||||
sequence="10"
|
||||
id="menu_strategy" />
|
||||
<menuitem
|
||||
parent="price.menu_price"
|
||||
sequence="85"
|
||||
action="act_import_prices"
|
||||
id="menu_import_prices"/>
|
||||
|
||||
</data>
|
||||
</tryton>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user