759 Commits
v1.0.0 ... main

Author SHA1 Message Date
8906f00d36 Notes 2026-04-10 14:40:06 +02:00
29a719c117 template 2026-04-10 09:15:38 +02:00
d71257720e invoice seq nb 2026-04-10 08:40:53 +02:00
aee9c3277e Add bank to template 2026-04-10 08:24:59 +02:00
b68f475e22 Pricing manual 2026-04-10 07:52:59 +02:00
90eab73430 price component 2026-04-09 22:46:32 +02:00
472806ef06 pricing manuel 2026-04-09 22:38:48 +02:00
0def187750 Add manual pricing 2026-04-09 22:24:49 +02:00
229b6037fb unit correction 2026-04-09 21:55:40 +02:00
8a90216357 net gross 2026-04-09 21:46:28 +02:00
4bbd7a5e76 bug template 2026-04-09 21:38:26 +02:00
9c8d7f11ae kg lbs 2026-04-09 21:23:27 +02:00
b39607d987 del period 2026-04-09 20:58:47 +02:00
65482b4a8b bug Th qt 2026-04-09 20:31:24 +02:00
8b9787d4c0 bug th qt 2026-04-09 20:23:09 +02:00
a1ab7dec82 ICT bulk 2026-04-09 19:46:08 +02:00
5ae8af84fb Add note for bot 2026-04-08 17:15:11 +02:00
c90b14fcc1 Modification on Insurance template 2026-04-08 15:38:26 +02:00
e9ff9c76ab Add Bill template 2026-04-08 11:24:23 +02:00
63d8266a9c Add Bill template 2026-04-08 10:26:25 +02:00
add4cdc137 Add Packing list template 2026-04-08 10:08:17 +02:00
4d94aa78ed Merge branch 'main' of https://gitea.open-squared.tech/admin/tradon 2026-04-08 09:46:05 +02:00
da01249f66 Add Packing list template 2026-04-08 09:46:01 +02:00
AzureAD\SylvainDUVERNAY
5cbb57c657 VSP and SSH configuration 2026-04-07 15:35:06 +02:00
50a8c6328f Doc deploiement 2026-04-07 15:03:37 +02:00
eaa5c8b544 Add Payment order template 2026-04-07 14:15:10 +02:00
78e9e06a8b Add Payment order template 2026-04-07 13:59:16 +02:00
9f06398b2c Th qt correction 2026-04-07 13:42:17 +02:00
51a84f1f2e Add Payment order 2026-04-07 11:54:18 +02:00
00330008d1 Add Purchase order template 2026-04-07 11:42:09 +02:00
3480eb8a7a Add surveyor on shipment 2026-04-07 11:09:52 +02:00
5179d98289 Add insured amount 2026-04-07 10:54:14 +02:00
2109d7a3e4 Add insured amount 2026-04-07 10:32:29 +02:00
1f350e6207 Add session notes 2026-04-06 18:52:15 +02:00
7722292482 Add Insurance template 2026-04-06 18:45:05 +02:00
ec359f6b8a Add insurance template 2026-04-06 17:30:50 +02:00
845b9cf749 Add bank bloc 2026-04-06 16:45:53 +02:00
48b941b109 Add bank in template 2026-04-06 16:29:44 +02:00
18ece66cdb Add bank bloc on template 2026-04-06 16:18:07 +02:00
acfc2fe88a Add invoice_melya.fodt 2026-04-06 15:56:00 +02:00
888b880bd6 Add template management 2026-04-06 15:17:17 +02:00
1f62ae91dd Add multi client template management 2026-04-06 14:56:37 +02:00
05e68636ad Add WR draft 2026-04-06 11:17:24 +02:00
bfb9bb3188 Add WR draft management 2026-04-06 11:02:13 +02:00
b78e64f9f1 Add counter 2026-04-06 09:50:15 +02:00
199b8aec12 Add counter to controller 2026-04-06 09:03:10 +02:00
1757075f2b 03.04.26 2026-04-03 07:39:52 +02:00
172d38479d 02.04.26 2026-04-02 17:28:44 +02:00
4902368b15 02.04.26 2026-04-02 17:20:36 +02:00
7b4f757cb5 02.04.26 2026-04-02 17:12:53 +02:00
b37f132cdf 02.04.26 2026-04-02 17:07:58 +02:00
15f791bd92 02.04.26 2026-04-02 16:46:50 +02:00
58cd66e543 02.04.26 2026-04-02 16:31:05 +02:00
cc6ce82ec1 02.04.26 2026-04-02 16:24:18 +02:00
11526ef3ee 02.04.26 2026-04-02 13:16:17 +02:00
6d52317804 02.04.26 2026-04-02 13:09:04 +02:00
a99efcfc5b 02.04.26 2026-04-02 13:00:19 +02:00
0d5cf7dffc 02.04.26 2026-04-02 12:46:42 +02:00
613b679908 02.04.26 2026-04-02 12:32:06 +02:00
51ced23ab8 02.04.26 2026-04-02 12:12:49 +02:00
2958e1fb9e 02.04.26 2026-04-02 11:33:49 +02:00
346a34951d 02.04.26 2026-04-02 11:16:26 +02:00
b644aea007 02.04.26 2026-04-02 11:03:58 +02:00
5dbaba5f32 02.04.26 2026-04-02 11:00:32 +02:00
d133665fc7 02.04.26 2026-04-02 10:38:00 +02:00
c2cb2a874c 02.04.26 2026-04-02 10:32:36 +02:00
408970c339 01.04.26 2026-04-01 21:48:31 +02:00
ea2627c9ae 01.04.26 2026-04-01 21:40:32 +02:00
ac988a714a 01.04.26 2026-04-01 21:26:46 +02:00
97eae6e4a6 01.04.26 2026-04-01 21:19:31 +02:00
3976b387d7 01.04.26 2026-04-01 18:29:44 +02:00
9b8e8127a1 01.04.26 2026-04-01 18:02:09 +02:00
f53a9bce27 01.04.26 2026-04-01 15:22:21 +02:00
a7753b974f 01.04.26 2026-04-01 14:30:04 +02:00
c687828ba5 01.04.26 2026-04-01 14:09:42 +02:00
5054b64cd0 01.04.26 2026-04-01 11:08:02 +02:00
06922973b7 01.04.26 2026-04-01 10:03:19 +02:00
18ebf7f06c 01.04.26 2026-04-01 09:53:40 +02:00
44c4560f24 01.04.26 2026-04-01 09:47:03 +02:00
7643bf21fb 31.03.26 2026-03-31 17:54:23 +02:00
97677025d7 31.03.26 2026-03-31 17:31:23 +02:00
02fe5b3e5d 31.03.26 2026-03-31 17:28:13 +02:00
6e529deca0 31.03.26 2026-03-31 17:20:49 +02:00
efee365fc6 31.03.26 2026-03-31 17:17:12 +02:00
6bf245ac64 Merge pull request 'dev' (#8) from dev into main
Reviewed-on: #8
2026-03-31 09:57:16 +00:00
238869989a 30.03.26 2026-03-30 19:33:34 +02:00
AzureAD\SylvainDUVERNAY
2fa541e962 Remove groups 2026-03-29 18:41:12 +02:00
AzureAD\SylvainDUVERNAY
ad2f7e6f78 Commit IFRS adjustments 2026-03-29 18:34:54 +02:00
43c62607a8 29.03.26 2026-03-29 18:13:56 +02:00
5cff728d79 29.03.26 2026-03-29 18:05:00 +02:00
AzureAD\SylvainDUVERNAY
d6382f624b Commit 2 2026-03-29 18:02:31 +02:00
AzureAD\SylvainDUVERNAY
806e374ceb Commit 2026-03-29 17:48:47 +02:00
08febb904f 29.03.26 2026-03-29 17:34:42 +02:00
20d733e787 Merge pull request 'main' (#7) from main into dev
Reviewed-on: #7
2026-03-29 13:03:24 +00:00
984b2ba56f 27.03.26 2026-03-27 14:08:07 +01:00
f67e5d8ccc 27.03.26 2026-03-27 08:12:03 +01:00
2bf02e687c 27.03.26 2026-03-27 07:56:03 +01:00
3c45ebd50f 27.03.26 2026-03-27 07:40:40 +01:00
22d186f0ac 27.03.26 2026-03-27 07:30:15 +01:00
0979021f41 27.03.26 2026-03-27 06:51:43 +01:00
af4ae99dc0 26.03.26 2026-03-26 22:08:56 +01:00
11c489f79d 26.03.26 2026-03-26 21:57:02 +01:00
620d6bb604 26.03.26 2026-03-26 21:50:47 +01:00
97cfd13da2 26.03.26 2026-03-26 21:43:25 +01:00
126455bf0f 26.03.26 2026-03-26 21:01:14 +01:00
97cc447e0a 26.03.26 2026-03-26 20:54:08 +01:00
e2bbd0e522 26.03.26 2026-03-26 20:31:45 +01:00
51778bda9e 26.03.26 2026-03-26 20:09:33 +01:00
c06ea3bd99 26.03.26 2026-03-26 19:58:14 +01:00
f0b0666773 26.03.26 2026-03-26 19:50:27 +01:00
edca5fed55 26.03.26 2026-03-26 19:30:48 +01:00
8b6b93171f 26.03.26 2026-03-26 19:15:14 +01:00
841f7a1c20 26.03.26 2026-03-26 19:02:01 +01:00
91acaba3dc 26.03.26 2026-03-26 18:28:43 +01:00
3e5320cf9e 26.03.26 2026-03-26 15:43:45 +01:00
f9010ddefd 26.03.26 2026-03-26 15:23:42 +01:00
d722b58f4e 26.03.26 2026-03-26 15:20:36 +01:00
3e646ea035 26.03.26 2026-03-26 15:16:34 +01:00
90f97daa7f 26.03.26 2026-03-26 11:13:35 +01:00
e1488e9677 26.03.26 2026-03-26 09:25:34 +01:00
0bcec5d3c4 25.03.26 2026-03-25 20:32:07 +01:00
10848ed533 25.03.26 2026-03-25 20:17:57 +01:00
b90d65d245 25.03.26 2026-03-25 20:09:51 +01:00
357478e74c 25.03.26 2026-03-25 19:29:32 +01:00
1f49c00cff 25.03.26 2026-03-25 18:45:54 +01:00
d38d0324d5 25.03.26 2026-03-25 16:49:05 +01:00
b8ec7a3d2d 25.03.26 2026-03-25 11:01:26 +01:00
0ac261b670 25.03.26 2026-03-25 09:59:15 +01:00
51411faff2 24.03.26 2026-03-24 11:49:14 +01:00
a94906bb53 23.03.26 2026-03-23 21:16:51 +01:00
2262e79361 23.03.26 2026-03-23 20:10:30 +01:00
5327ca4f21 23.03.26 2026-03-23 18:27:54 +01:00
03997db3e4 23.03.26 2026-03-23 18:03:59 +01:00
74e4ac0c1b 23.03.26 2026-03-23 17:57:26 +01:00
353cf17af7 23.03.26 2026-03-23 17:47:36 +01:00
412f8f00e0 23.03.26 2026-03-23 17:38:04 +01:00
6b841db4ce 23.03.26 2026-03-23 17:09:11 +01:00
4e3818d46a 23.03.26 2026-03-23 16:51:32 +01:00
8bb34aa4fb 23.03.26 2026-03-23 16:44:46 +01:00
0392b191c5 23.03.26 2026-03-23 16:34:28 +01:00
f98b0fd010 23.03.26 2026-03-23 16:25:22 +01:00
97dd672e6a 23.03.26 2026-03-23 16:09:39 +01:00
bde600e86a 23.03.26 2026-03-23 15:55:39 +01:00
0e7cabdc00 23.03.26 2026-03-23 14:13:09 +01:00
15f9f4b724 23.03.26 2026-03-23 14:00:28 +01:00
c24de18fcd 23.03.26 2026-03-23 13:49:32 +01:00
cb9c868e88 23.03.26 2026-03-23 11:26:23 +01:00
0e76aa6312 23.03.26 2026-03-23 11:18:58 +01:00
062ca10340 23.03.26 2026-03-23 11:13:50 +01:00
175c7a5e30 23.03.26 2026-03-23 10:49:36 +01:00
e6519d30ee 23.03.26 2026-03-23 10:43:02 +01:00
f9ee416d99 23.03.26 2026-03-23 10:36:33 +01:00
80deac62f8 23.03.26 2026-03-23 10:16:56 +01:00
b5d771341d 23.03.26 2026-03-23 10:10:53 +01:00
afdbd05375 23.03.26 2026-03-23 09:31:27 +01:00
36b94b98af 23.03.26 2026-03-23 09:10:55 +01:00
c655eb4170 22.03.26 2026-03-22 21:10:26 +01:00
ed11b23f79 22.03.26 2026-03-22 20:53:42 +01:00
140eae06a0 22.03.26 2026-03-22 19:11:12 +01:00
234084f073 22.03.26 2026-03-22 19:06:19 +01:00
2fc6fbbd76 22.03.26 2026-03-22 19:02:53 +01:00
0b9c85f5ad 22.03.26 2026-03-22 18:58:46 +01:00
717b51ad19 22.03.26 2026-03-22 18:51:48 +01:00
dfdaf7a1cd 22.03.26 2026-03-22 17:03:58 +01:00
7dba25c6d7 22.03.26 2026-03-22 15:25:08 +01:00
765d90512d 22.03.26 2026-03-22 15:02:24 +01:00
090b4ea5c6 22.03.26 2026-03-22 14:53:53 +01:00
1326c8df4c 22.03.26 2026-03-22 11:27:48 +01:00
d10d805753 22.03.26 2026-03-22 11:22:09 +01:00
85bb272edf 22.03.26 2026-03-22 09:43:36 +01:00
5f475d5714 22.03.26 2026-03-22 09:17:30 +01:00
8cc091ef57 22.03.26 2026-03-22 09:15:19 +01:00
7ba170e072 21.03.26 2026-03-21 22:18:29 +01:00
931b5b9ea2 21.03.26 2026-03-21 22:11:23 +01:00
eb98ef87bb 21.03.26 2026-03-21 22:03:43 +01:00
834ae46a14 21.03.26 2026-03-21 21:56:40 +01:00
7c746ad931 21.03.26 2026-03-21 20:10:55 +01:00
95f5c4af57 21.03.26 2026-03-21 20:00:42 +01:00
f83c62ffa8 21.03.26 2026-03-21 19:46:50 +01:00
1f4b36633b 21.03.26 2026-03-21 19:39:15 +01:00
36dc48bee0 21.03.26 2026-03-21 19:10:08 +01:00
fc52aa8bbb 21.03.26 2026-03-21 18:48:17 +01:00
5c516465e2 21.03.26 2026-03-21 18:36:48 +01:00
d827fa6140 21.03.26 2026-03-21 18:31:04 +01:00
93d27059af 21.03.26 2026-03-21 18:25:30 +01:00
d2a13b3c01 20.03.26 2026-03-20 14:38:42 +01:00
22c4766e66 20.03.26 2026-03-20 14:36:43 +01:00
73eeee0f72 20.03.26 2026-03-20 14:29:46 +01:00
260300681f 20.03.26 2026-03-20 13:53:10 +01:00
a2e0c36a3c 20.03.26 2026-03-20 13:50:31 +01:00
4f6641ceeb 20.03.26 2026-03-20 13:43:47 +01:00
cabd032bc6 20.03.26 2026-03-20 10:45:34 +01:00
b2e095aad5 20.03.26 2026-03-20 10:36:27 +01:00
8c3ba0a23d 20.03.26 2026-03-20 10:28:09 +01:00
13072de28f 20.03.26 2026-03-20 10:19:50 +01:00
a63a46cc40 20.03.26 2026-03-20 10:05:30 +01:00
bf6e9f6a78 20.03.26 2026-03-20 10:01:22 +01:00
6c7947c7c1 20.03.26 2026-03-20 09:11:57 +01:00
6954044f7e 20.03.26 2026-03-20 09:08:13 +01:00
d6207855a0 18.03.26 2026-03-18 18:27:29 +01:00
a41ec4412d 18.03.26 2026-03-18 18:02:49 +01:00
8c3c224c07 18.03.26 2026-03-18 11:06:05 +01:00
3581b7ea80 18.03.26 2026-03-18 10:53:29 +01:00
f7d87d3a78 17.03.26 2026-03-17 20:55:55 +01:00
5a52f09f97 17.03.26 2026-03-17 20:53:32 +01:00
420577cd72 17.03.26 2026-03-17 20:26:11 +01:00
01777f1536 17.03.26 2026-03-17 20:04:11 +01:00
24c8cd8075 17.03.26 2026-03-17 18:16:29 +01:00
7ace327d3f 17.03.26 2026-03-17 14:36:55 +01:00
a8d37bd766 17.03.26 2026-03-17 14:11:33 +01:00
6b96cdbc88 17.03.26 2026-03-17 13:58:08 +01:00
5bb1618b30 16.03.26 2026-03-16 22:35:09 +01:00
3ef819bf86 16.03.26 2026-03-16 22:26:45 +01:00
7ae54f9977 16.03.26 2026-03-16 22:19:15 +01:00
410604d890 16.03.26 2026-03-16 22:14:30 +01:00
c8bbf9a12e 16.03.26 2026-03-16 22:04:21 +01:00
f32cce6177 16.03.26 2026-03-16 21:33:57 +01:00
32b48cf6d1 16.03.26 2026-03-16 21:01:43 +01:00
eebcf936fe 16.03.26 2026-03-16 20:43:05 +01:00
127a38f857 16.03.26 2026-03-16 20:34:16 +01:00
1cedd11304 16.03.26 2026-03-16 20:28:10 +01:00
8881caccd0 16.03.26 2026-03-16 19:47:43 +01:00
59a6554fa3 16.03.26 2026-03-16 19:35:22 +01:00
3d80a6ba5e 16.03.26 2026-03-16 19:25:56 +01:00
07816b9cfe 16.03.26 2026-03-16 19:19:32 +01:00
5bbd68448a 16.03.26 2026-03-16 18:38:15 +01:00
185f26f31b 16.03.26 2026-03-16 15:40:19 +01:00
bbb88dbd22 16.03.26 2026-03-16 15:26:08 +01:00
3ad2ae2624 16.03.26 2026-03-16 15:24:46 +01:00
e78eb80d08 16.03.26 2026-03-16 14:46:11 +01:00
498da9a728 16.03.26 2026-03-16 14:41:29 +01:00
eac35b849f 16.03.26 2026-03-16 14:21:41 +01:00
13770cc299 16.03.26 2026-03-16 14:15:42 +01:00
72b71675ae 16.03.26 2026-03-16 11:08:47 +01:00
5cf5ca520a 16.03.26 2026-03-16 10:59:28 +01:00
5fe0be5c59 16.03.26 2026-03-16 10:48:54 +01:00
c504b4b2d7 16.03.26 2026-03-16 10:41:50 +01:00
bbd3d30b37 16.03.26 2026-03-16 10:32:27 +01:00
b4d09d3a69 16.03.26 2026-03-16 10:23:13 +01:00
8e7f9648fc 16.03.26 2026-03-16 10:06:58 +01:00
2183d8d5a4 16.03.26 2026-03-16 09:52:15 +01:00
8398a8c212 15.03.26 2026-03-15 15:59:16 +01:00
8859ccad1a 15.03.26 2026-03-15 15:53:38 +01:00
c2adc96fbf 15.03.26 2026-03-15 15:26:56 +01:00
9330662b30 15.03.26 2026-03-15 15:21:23 +01:00
eadbbbbbc8 15.03.26 2026-03-15 15:15:22 +01:00
5b12e5f8db 15.03.26 2026-03-15 13:06:10 +01:00
1d10591cf6 15.03.26 2026-03-15 12:02:50 +01:00
fd3ce2188a 15.03.26 2026-03-15 11:59:15 +01:00
4695a93e44 15.03.26 2026-03-15 11:48:43 +01:00
93f0e6b0af 15.03.26 2026-03-15 11:45:42 +01:00
319d8a4afb 15.03.26 2026-03-15 11:28:29 +01:00
b6e85ee710 15.03.26 2026-03-15 10:58:53 +01:00
c349441874 13.03.26 2026-03-13 14:58:00 +01:00
73fdbe9eba 13.03.26 2026-03-13 14:51:55 +01:00
b20b0c8df6 13.03.26 2026-03-13 14:47:13 +01:00
cc5f4da38c 13.03.26 2026-03-13 14:28:23 +01:00
016112a355 13.03.26 2026-03-13 10:43:58 +01:00
0cf03a75b0 13.03.26 2026-03-13 10:24:54 +01:00
7f6b93094f 12.03.26 2026-03-12 15:20:29 +01:00
ad58d40da7 12.03.26 2026-03-12 15:14:03 +01:00
f0c1b8909e 12.03.26 2026-03-12 14:56:09 +01:00
8e8afe39d0 12.03.26 2026-03-12 14:44:04 +01:00
8da50c72c7 12.03.26 2026-03-12 11:39:43 +01:00
683d3600ac 12.03.26 2026-03-12 10:56:52 +01:00
e2cb840844 12.03.26 2026-03-12 10:51:39 +01:00
19a4363d10 12.03.26 2026-03-12 10:46:48 +01:00
23c4edfec5 12.03.26 2026-03-12 10:24:34 +01:00
b07ad57a36 11.03.26 2026-03-11 22:17:48 +01:00
6d06125360 11.03.26 2026-03-11 22:12:38 +01:00
4a14a78f78 11.03.26 2026-03-11 21:33:42 +01:00
2fba795b11 11.03.26 2026-03-11 21:27:43 +01:00
b23dba865f 11.03.26 2026-03-11 21:14:53 +01:00
0b1bb2ffa5 11.03.26 2026-03-11 21:06:45 +01:00
6890d0de07 11.03.26 2026-03-11 20:53:20 +01:00
c180e8926f 11.03.26 2026-03-11 20:51:46 +01:00
0fe16df326 11.03.26 2026-03-11 20:31:47 +01:00
3e15052520 11.03.26 2026-03-11 20:14:47 +01:00
dee1896a6c 10.03.26 2026-03-10 21:18:19 +01:00
faf5fa605e 10.03.26 2026-03-10 15:33:22 +01:00
a7846b359c 10.03.26 2026-03-10 15:21:47 +01:00
c0cffde079 10.03.26 2026-03-10 15:01:09 +01:00
814071e4c1 10.03.26 2026-03-10 14:49:10 +01:00
30ae86a987 10.03.26 2026-03-10 14:44:16 +01:00
1de11a846b 10.03.26 2026-03-10 14:29:12 +01:00
93acacb955 10.03.26 2026-03-10 13:04:13 +01:00
6d053dfe03 10.03.26 2026-03-10 12:59:07 +01:00
5f49e01495 10.03.26 2026-03-10 12:54:46 +01:00
d609864822 10.03.26 2026-03-10 12:46:03 +01:00
aff4459942 09.03.26 2026-03-09 20:35:42 +01:00
840f03e5d8 09.03.26 2026-03-09 20:32:51 +01:00
039b278757 09.03.26 2026-03-09 20:27:08 +01:00
265b41b206 09.03.26 2026-03-09 20:15:09 +01:00
5235b381c6 09.03.26 2026-03-09 20:10:53 +01:00
52c58df547 09.03.26 2026-03-09 20:01:20 +01:00
cc8540ee91 09.03.26 2026-03-09 18:17:32 +01:00
d70a784db8 09.03.26 2026-03-09 18:05:08 +01:00
e6984e3393 09.03.26 2026-03-09 17:59:26 +01:00
f4ae7ebd7c 09.03.26 2026-03-09 17:56:21 +01:00
5f744ec25c 09.03.26 2026-03-09 17:53:54 +01:00
1dc69008d8 09.03.26 2026-03-09 17:50:26 +01:00
96aab15a7a 09.03.26 2026-03-09 16:52:19 +01:00
dd06fc523c 09.03.26 2026-03-09 16:11:42 +01:00
7cafd8381f 09.03.26 2026-03-09 15:49:51 +01:00
c009b15ed0 09.03.26 2026-03-09 15:35:27 +01:00
6717feb22d 09.03.26 2026-03-09 15:29:29 +01:00
7998467adf 09.03.26 2026-03-09 15:24:15 +01:00
83ef3c6ae9 09.03.26 2026-03-09 15:17:34 +01:00
372d04b30f 08.03.26 2026-03-08 09:09:10 +01:00
4e6ddc39c1 08.03.26 2026-03-08 08:53:02 +01:00
a3f15c30e6 06.03.26 2026-03-06 14:24:18 +01:00
cdb0bf7254 05.03.26 2026-03-05 19:49:16 +01:00
843ade6f3d 05.03.26 2026-03-05 19:38:03 +01:00
62f731530b 05.03.26 2026-03-05 19:34:46 +01:00
14c2548c16 05.03.26 2026-03-05 19:32:55 +01:00
13000f110d 05.03.26 2026-03-05 19:25:17 +01:00
915fd17d83 05.03.26 2026-03-05 18:38:24 +01:00
12de27d105 05.03.26 2026-03-05 18:16:22 +01:00
1a1f675cf6 05.03.26 2026-03-05 18:12:22 +01:00
3b67f3c251 05.03.26 2026-03-05 18:10:49 +01:00
551a040df0 05.03.26 2026-03-05 18:07:54 +01:00
b6c3279917 05.03.26 2026-03-05 18:01:05 +01:00
2b956e6142 05.03.26 2026-03-05 17:53:49 +01:00
ae0817e0ac 05.03.26 2026-03-05 15:30:11 +01:00
7451e19125 05.03.26 2026-03-05 12:30:47 +01:00
52418b6244 05.03.26 2026-03-05 12:27:01 +01:00
a577595ee6 05.03.26 2026-03-05 11:24:40 +01:00
8db31ddaf0 05.03.26 2026-03-05 11:03:44 +01:00
e371b116f7 05.03.26 2026-03-05 10:58:01 +01:00
3212d551d2 05.03.26 2026-03-05 10:54:10 +01:00
8d557a63a0 05.03.26 2026-03-05 10:50:48 +01:00
ee9866c28e 05.03.26 2026-03-05 10:37:46 +01:00
8d9dd6c275 05.03.26 2026-03-05 10:21:37 +01:00
0841fb4609 05.03.26 2026-03-05 10:13:43 +01:00
5b059b70e6 04.03.26 2026-03-04 18:59:39 +01:00
3319563b22 04.06.26 2026-03-04 18:52:02 +01:00
ddc2884f61 04.03.26 2026-03-04 18:29:56 +01:00
5dfd24b5de 04.03.26 2026-03-04 18:05:42 +01:00
7ae145b6c1 04.03.26 2026-03-04 17:56:58 +01:00
7909b10636 04.03.26 2026-03-04 17:43:53 +01:00
bab6c14499 04.03.26 2026-03-04 17:32:49 +01:00
c816ecdbb7 04.03.26 2026-03-04 17:23:14 +01:00
024f8f78bd 04.03.26 2026-03-04 17:09:32 +01:00
94595346b7 04.03.26 2026-03-04 16:54:32 +01:00
8a4d730c5a 04.03.26 2026-03-04 16:35:29 +01:00
3226f55a7d 04.03.26 2026-03-04 16:34:25 +01:00
8eba77b26c 04.03.26 2026-03-04 16:19:40 +01:00
a0c1408f8e 04.03.26 2026-03-04 16:18:42 +01:00
7e726010b8 03.03.26 2026-03-03 20:10:02 +01:00
4abd9b7e85 03.03.26 2026-03-03 19:49:15 +01:00
e984c026b9 03.03.26 2026-03-03 19:48:09 +01:00
4affffaf0a 03.03.26 2026-03-03 19:34:31 +01:00
c55338e879 03.03.26 2026-03-03 16:30:37 +01:00
1c288d9646 03.03.26 2026-03-03 16:20:38 +01:00
d37bad643d 03.03.26 2026-03-03 16:08:37 +01:00
f493901e8d 03.03.26 2026-03-03 14:00:05 +01:00
934f3511f7 03.03.26 2026-03-03 13:56:57 +01:00
62a1bc69f7 03.03.26 2026-03-03 13:54:04 +01:00
741389b71c 03.03.26 2026-03-03 13:50:58 +01:00
9dd4543fc1 03.03.26 2026-03-03 13:47:46 +01:00
af89743b40 03.03.26 2026-03-03 13:23:19 +01:00
7f0400400c 03.03.26 2026-03-03 12:05:07 +01:00
d9cf6c9a49 03.03.26 2026-03-03 12:00:35 +01:00
1a9114da7e 02.03.26 2026-03-02 10:11:37 +01:00
b5bc559b11 27.02.26 2026-02-27 07:21:16 +01:00
8e67c7aecb 27.02.26 2026-02-27 07:17:24 +01:00
6b884af9cc 27.02.26 2026-02-27 07:04:54 +01:00
e768fe71a3 27.02.26 2026-02-27 06:55:07 +01:00
5f8d081b0e 27.02.26 2026-02-27 06:32:45 +01:00
20c3dacded 27.02.26 2026-02-27 06:24:01 +01:00
6fdf5e1994 27.02.26 2026-02-27 06:17:55 +01:00
92210a381a 27.02.26 2026-02-27 06:12:39 +01:00
cd2a82f61c 27.02.26 2026-02-27 05:56:00 +01:00
4eae444c93 27.02.26 2026-02-27 05:48:07 +01:00
02993336de 26.02.26 2026-02-26 21:32:41 +01:00
2c38d2742b 26.02.26 2026-02-26 20:32:42 +01:00
a724e0a086 26.02.26 2026-02-26 20:21:42 +01:00
399d9da9e5 26.02.26 2026-02-26 16:38:38 +01:00
6f0e0d65ee 26.02.26 2026-02-26 16:28:43 +01:00
20f2b87d99 24.02.26 2026-02-24 20:25:32 +01:00
56f2c3a4a6 24.02.26 2026-02-24 19:48:12 +01:00
a1a13a6846 24.02.26 2026-02-24 19:24:31 +01:00
c244cf658c 23.02.26 2026-02-23 15:33:09 +01:00
6fc0e5982a 19.02.26 2026-02-19 22:53:36 +01:00
84fa03bf77 19.02.26 2026-02-19 22:19:27 +01:00
11cabcee32 18.02.26 2026-02-18 16:58:53 +01:00
1cdc54e59e 18.02.26 2026-02-18 16:37:20 +01:00
d2288e009f 18.02.26 2026-02-18 16:04:35 +01:00
d81b19bfc6 18.02.26 2026-02-18 15:13:50 +01:00
b2fb1e9c4c 18.02.26 2026-02-18 13:32:48 +01:00
cac84abbef 18.02.26 2026-02-18 12:14:35 +01:00
191abf022f 18.02.26 2026-02-18 12:03:40 +01:00
0011a7f943 18.02.26 2026-02-18 11:03:27 +01:00
697b53e68a 16.02.26 2026-02-16 20:01:57 +01:00
0e5c40e9a2 16.02.26 2026-02-16 19:55:09 +01:00
bb313d29bf 1602.26 2026-02-16 15:46:43 +01:00
3f54cbef6a 15.02.26 2026-02-15 15:54:44 +01:00
00cae2f82b 15.02.26 2026-02-15 15:49:40 +01:00
d1d37ee4b3 15.02.26 2026-02-15 15:33:32 +01:00
08a66bc219 15.02.26 2026-02-15 15:20:55 +01:00
35c9731a85 15.02.26 2026-02-15 15:02:42 +01:00
d458e9849d 15.02.26 2026-02-15 14:54:10 +01:00
b94d6e911d 15.02.26 2026-02-15 14:45:31 +01:00
4fb39d564e 15.02.26 2026-02-15 14:37:04 +01:00
10b370f11b 15.02.26 2026-02-15 14:33:14 +01:00
479e0d4d5a 15.02.26 2026-02-15 14:23:33 +01:00
4059cd591e 15.02.26 2026-02-15 12:47:07 +01:00
9565e82850 15.02.26 2026-02-15 11:37:46 +01:00
29ada1899e 15.02.26 2026-02-15 11:27:59 +01:00
67d3ce545e 15.02.26 2026-02-15 10:16:08 +01:00
9b8d372fa6 15.02.26 2026-02-15 10:13:02 +01:00
dda6a63e74 15.02.26 2026-02-15 10:08:31 +01:00
2fc89282b9 15.02.26 2026-02-15 10:02:55 +01:00
cca82dd9f7 14.02.26 2026-02-15 09:40:05 +01:00
1e51061ddc 14.02.26 2026-02-15 09:36:08 +01:00
267e5a3509 14.12.26 2026-02-14 21:30:28 +01:00
e040a4fc11 14.02.26 2026-02-14 20:59:35 +01:00
6f4649c778 14.02.26 2026-02-14 20:19:52 +01:00
177f862263 14.02.26 2026-02-14 20:16:28 +01:00
93456e9530 14.02.26 2026-02-14 20:12:56 +01:00
8ee9354327 14.02.26 2026-02-14 18:46:52 +01:00
6f4ad3723f 14.02.26 2026-02-14 18:41:16 +01:00
f3370e89ea 14.02.26 2026-02-14 18:36:26 +01:00
330fc8d320 14.02.26 2026-02-14 18:28:15 +01:00
f0b979ac8c 14.02.26 2026-02-14 18:16:29 +01:00
f3ebeb7cd3 14.02.26 2026-02-14 18:02:08 +01:00
981a97ed05 14.02.26 2026-02-14 17:53:36 +01:00
9ed4d5f6bb 14.02.26 2026-02-14 17:38:43 +01:00
e43189f052 14.02.26 2026-02-14 16:07:08 +01:00
0fcaca2b5a 14.02.26 2026-02-14 08:55:57 +01:00
3db2f8c96e 14.02.26 2026-02-14 08:52:31 +01:00
b0ebc02434 14.02.26 2026-02-14 08:47:57 +01:00
0be0194ab5 14.02.26 2026-02-14 08:32:38 +01:00
7d99f56fa5 13.02.26 2026-02-13 17:15:48 +01:00
6eec004781 13.02.26 2026-02-13 09:53:54 +01:00
70603d95d4 11.02.26 2026-02-11 13:14:58 +01:00
c78f8b1079 11.02.26 2026-02-11 12:10:29 +01:00
9ec76410f7 11.02.26 2026-02-11 08:41:58 +01:00
e47023c4ba 11.02.26 2026-02-11 08:32:24 +01:00
133805eab5 11.02.26 2026-02-11 08:23:00 +01:00
878588d567 11.02.26 2026-02-11 08:20:23 +01:00
d88a9f204c 11.02.26 2026-02-11 08:13:20 +01:00
1ab2b56f32 11.02.26 2026-02-11 07:58:02 +01:00
b41fe0fa54 11.02.26 2026-02-11 07:43:25 +01:00
1326669f01 11.02.26 2026-02-11 07:36:47 +01:00
e37cd4a1cc 11.02.26 2026-02-11 07:26:48 +01:00
1eb86892de 10.02.26 2026-02-10 15:08:45 +01:00
91095f28e9 09.02.26 2026-02-09 20:55:10 +01:00
e41ad51634 09.02.26 2026-02-09 20:30:56 +01:00
2eabfe2587 09.02.26 2026-02-09 15:13:57 +01:00
a71d666619 09.02.26 2026-02-09 14:50:27 +01:00
38254fee48 09.02.26 2026-02-09 14:49:01 +01:00
5cc66b3a3a 08.02.26 2026-02-08 22:49:57 +01:00
d007db7a1b 08.02.26 2026-02-08 22:46:43 +01:00
935862a66c 08.02.26 2026-02-08 22:31:50 +01:00
0c83fcf35e 08.02.26 2026-02-08 22:26:24 +01:00
bc5aa57319 08.02.26 2026-02-08 22:18:54 +01:00
231b733222 08.02.26 2026-02-08 22:06:19 +01:00
c02d7212bb 08.02.26 2026-02-08 21:54:51 +01:00
df5ef074ad 08.02.26 2026-02-08 21:51:04 +01:00
04179fde77 08.02.26 2026-02-08 21:36:01 +01:00
0cd9d63b15 08.02.26 2026-02-08 21:28:17 +01:00
b5b2f1c362 08.02.26 2026-02-08 21:25:22 +01:00
14dd557e58 08.02.26 2026-02-08 19:24:31 +01:00
1e7cd1c7be 08.02.26 2026-02-08 19:19:57 +01:00
0a04774fac 08.02.26 2026-02-08 19:15:12 +01:00
720ad804a4 08.02.26 2026-02-08 18:10:36 +01:00
175b10e59d 08.02.26 2026-02-08 18:05:29 +01:00
03d923441e 08.02.26 2026-02-08 16:55:56 +01:00
3da61070eb 08.02.26 2026-02-08 15:11:53 +01:00
6a0acb1c20 08.02.26 2026-02-08 10:58:08 +01:00
3195994444 08.02.26 2026-02-08 10:42:18 +01:00
7ab361c2b8 08.02.26 2026-02-08 10:27:59 +01:00
894375c143 08.02.26 2026-02-08 10:15:36 +01:00
225df5c6da 08.02.26 2026-02-08 10:08:37 +01:00
1a2d595303 08.02.26 2026-02-08 09:40:37 +01:00
cccff5726c 08.02.26 2026-02-08 09:37:53 +01:00
9a354181b0 08.02.26 2026-02-08 09:34:35 +01:00
c7dce8bedc 08.02.26 2026-02-08 09:31:31 +01:00
adecdbdb4f 08.02.26 2026-02-08 09:28:30 +01:00
06d40ddbae 08.02.26 2026-02-08 09:25:34 +01:00
e009a9e1e3 08.02.26 2026-02-08 09:22:43 +01:00
d22610abb3 08.02.26 2026-02-08 09:18:35 +01:00
8c033f4eab 08.02.26 2026-02-08 09:14:52 +01:00
cb6c1819f4 08.02.26 2026-02-08 09:09:03 +01:00
762cfc66c1 08.02.26 2026-02-08 09:05:47 +01:00
1d653b2756 08.02.26 2026-02-08 09:04:04 +01:00
b7927b787d 08.02.26 2026-02-08 09:00:54 +01:00
e57019b39c 08.02.26 2026-02-08 08:58:03 +01:00
a09a88ff5f 08.02.26 2026-02-08 08:52:30 +01:00
c1a4b442a3 07.02.26 2026-02-07 20:21:51 +01:00
34166dedc6 07.02.26 2026-02-07 19:31:54 +01:00
9095f3b470 07.02.26 2026-02-07 19:18:42 +01:00
5255959614 07.02.26 2026-02-07 17:11:13 +01:00
1b92b1d207 07.02.26 2026-02-07 17:08:29 +01:00
67eca35a9a 07.02.26 2026-02-07 16:40:48 +01:00
a35e304561 07.02.26 2026-02-07 16:39:34 +01:00
f93c81624d 07.02.26 2026-02-07 16:32:12 +01:00
d47c2cb5fb 07.02.26 2026-02-07 16:28:00 +01:00
45ab8e8904 07.02.26 2026-02-07 16:25:37 +01:00
0f3497e867 07.02.26 2026-02-07 16:23:58 +01:00
b0e19226eb 07.02.26 2026-02-07 16:13:36 +01:00
95589bcf73 07.02.26 2026-02-07 16:10:00 +01:00
03c7f41457 07.02.26 2026-02-07 16:07:54 +01:00
cd0c068b3f 05.02.26 2026-02-05 19:54:14 +01:00
05d5d85bee 05.02.26 2026-02-05 19:46:23 +01:00
d213328fd9 05.02.26 2026-02-05 19:40:38 +01:00
9235c22e04 05.02.26 2026-02-05 19:36:55 +01:00
c1ca8c707c 05.02.26 2026-02-05 19:29:06 +01:00
6c4ded50d0 05.02.26 2026-02-05 19:19:45 +01:00
86b8d3e250 05.02.26 2026-02-05 18:19:09 +01:00
7b94c56e7c 05.02.26 2026-02-05 18:13:10 +01:00
c6f963b6be 05.02.26 2026-02-05 18:04:07 +01:00
181217e755 05.02.26 2026-02-05 17:09:54 +01:00
00a6a5debe 05.02.26 2026-02-05 17:06:00 +01:00
91f85ea7c1 05.02.26 2026-02-05 16:48:16 +01:00
aaf4a264a1 04.02.26 2026-02-04 21:43:53 +01:00
b03a97d02d 04.02.26 2026-02-04 21:34:46 +01:00
a69a9dcb57 04.02.26 2026-02-04 21:20:34 +01:00
0a88b26160 04.02.26 2026-02-04 21:06:10 +01:00
3a79652c59 04.02.26 2026-02-04 18:40:28 +01:00
a0ed097b6c 04.02.26 2026-02-04 18:34:16 +01:00
a9d6f8fa58 04.02.26 2026-02-04 18:18:50 +01:00
2ebad01847 04.02.26 2026-02-04 18:02:09 +01:00
71716c1ad5 04.02.26 2026-02-04 15:40:30 +01:00
240f35af26 04.02.26 2026-02-04 15:18:06 +01:00
1ebbb874ef 04.02.26 2026-02-04 15:00:37 +01:00
eef972c679 03.02.26 2026-02-03 20:26:05 +01:00
011af03e8a 03.02.26 2026-02-03 20:18:55 +01:00
24843ab72d 03.02.26 2026-02-03 19:33:01 +01:00
65c0053df4 03.02.26 2026-02-03 16:01:54 +01:00
f391e17fb6 03.02.26 2026-02-03 15:57:12 +01:00
44fc7dc855 03.02.26 2026-02-03 15:53:32 +01:00
99d895c951 03.02.26 2026-02-03 15:43:38 +01:00
39e59b5b8b 03.02.26 2026-02-03 15:09:24 +01:00
7bc3e350ba 03.02.26 2026-02-03 15:06:27 +01:00
fe7c10d527 03.02.26 2026-02-03 14:33:40 +01:00
272875eb69 03.02.26 2026-02-03 14:30:36 +01:00
16df1c99a6 03.02.26 2026-02-03 14:03:46 +01:00
403db5133e 03.02.26 2026-02-03 13:58:00 +01:00
43312ba412 03.02.26 2026-02-03 13:49:05 +01:00
f4a53f2705 03.02.26 2026-02-03 12:19:44 +01:00
0a41765551 02.02.26 2026-02-02 19:27:23 +01:00
28789200c3 02.02.26 2026-02-02 18:25:39 +01:00
350e41714f 02.02.26 2026-02-02 17:56:51 +01:00
84b70b73c5 02.02.26 2026-02-02 17:51:26 +01:00
68e0000afc 02.02.26 2026-02-02 17:44:47 +01:00
33eeabf5e1 02.02.26 2026-02-02 17:33:40 +01:00
a4f0a08469 02.02.26 2026-02-02 17:27:14 +01:00
7f7b103945 02.02.26 2026-02-02 17:17:49 +01:00
8fddb599ef 02.02.26 2026-02-02 17:10:30 +01:00
313d574a08 02.02.26 2026-02-02 17:05:27 +01:00
f7d6ed00fd 02.02.26 2026-02-02 17:02:14 +01:00
6c570acfe6 02.02.26 2026-02-02 16:49:23 +01:00
2ba836c284 02.02.26 2026-02-02 16:42:57 +01:00
5b32a402a6 02.02.26 2026-02-02 16:23:27 +01:00
10aaff4cc8 02.02.26 2026-02-02 16:08:35 +01:00
40025169d4 02.02.26 2026-02-02 16:04:07 +01:00
38a903469c 02.02.26 2026-02-02 15:59:20 +01:00
70a0a41787 02.02.26 2026-02-02 15:26:05 +01:00
318f9e5564 02.02.26 2026-02-02 15:16:37 +01:00
d73816285e 02.02.26 2026-02-02 12:50:57 +01:00
86ac172cfd 02.02.26 2026-02-02 12:33:54 +01:00
703c7e46fa 02.02.26 2026-02-02 12:26:58 +01:00
747c2e4a0e 02.02.26 2026-02-02 12:08:46 +01:00
7d89418874 02.02.26 2026-02-02 10:42:08 +01:00
64806b143b 02.02.26 2026-02-02 08:44:09 +01:00
7b669a27a8 02.02.26 2026-02-02 08:42:48 +01:00
62c2080fee 01.02.26 2026-02-01 21:36:39 +01:00
f5d6d4ef55 01.02.26 2026-02-01 20:58:57 +01:00
4513ce8ac7 01.02.26 2026-02-01 18:03:12 +01:00
88eb890c46 01.02.26 2026-02-01 17:55:54 +01:00
9a66952f50 30.01.26 2026-01-30 08:07:41 +01:00
d0db0abdd4 30.01.26 2026-01-30 07:47:15 +01:00
d9cb2b7961 30.01.26 2026-01-30 07:27:45 +01:00
3eedc0b30e 30.01.26 2026-01-30 06:31:16 +01:00
b51ee12331 29.01.26 2026-01-29 17:49:20 +01:00
744e147a1a 29.01.26 2026-01-29 17:23:40 +01:00
8cc0bffcae 29.01.26 2026-01-29 14:05:17 +01:00
2b3cf5c095 29.01.26 2026-01-29 13:17:11 +01:00
94f15d66da 29.01.26 2026-01-29 12:39:47 +01:00
8edd5ea901 29.01.26 2026-01-29 11:26:21 +01:00
1e23528258 29.01.26 2026-01-29 11:06:23 +01:00
9447a685f2 29.01.26 2026-01-29 10:31:42 +01:00
7155708d4b 28.01.26 2026-01-28 16:52:54 +01:00
a76b8798ae 28.01.26 2026-01-28 15:57:49 +01:00
016b92e5aa 28.01.26 2026-01-28 15:55:04 +01:00
6085879d6f 28.01.26 2026-01-28 15:51:43 +01:00
42652d0fcb 28.01.26 2026-01-28 15:39:15 +01:00
5e8917430b 28.01.26 2026-01-28 11:46:00 +01:00
cdcefe6fbc 28.01.26 2026-01-28 11:33:18 +01:00
0678263f3e 27.01.26 2026-01-27 23:15:45 +01:00
aa69b8157e 27.01.26 2026-01-27 23:03:54 +01:00
47ce23f4e8 27.01.26 2026-01-27 22:56:39 +01:00
f6fe3fb2bd 27.01.26 2026-01-27 22:50:34 +01:00
fabc380784 27.01.26 2026-01-27 22:42:31 +01:00
7c845d0f9a 27.01.26 2026-01-27 20:36:18 +01:00
50da3d7a68 27.01.26 2026-01-27 20:31:34 +01:00
da1b268862 27.01.26 2026-01-27 19:05:30 +01:00
73e46b399b 27.01.26 2026-01-27 18:54:53 +01:00
431062f123 27.01.26 2026-01-27 18:45:09 +01:00
3846932517 27.01.26 2026-01-27 18:29:50 +01:00
aa10a58b60 27.01.26 2026-01-27 18:21:00 +01:00
9db6d2cb45 27.01.26 2026-01-27 18:13:12 +01:00
509dcac22e 27.01.26 2026-01-27 18:07:41 +01:00
f3102ee835 27.01.26 2026-01-27 18:00:46 +01:00
4c3056326b 27.01.26 2026-01-27 17:56:54 +01:00
1339f51256 27.01.26 2026-01-27 17:47:37 +01:00
408c5d82e4 27.01.26 2026-01-27 17:26:42 +01:00
71a58814b4 27.01.26 2026-01-27 17:05:39 +01:00
870f2e93f4 27.01.26 2026-01-27 16:45:55 +01:00
842a2b1da6 27.01.27 2026-01-27 10:28:36 +01:00
bf7de1e5d1 26.01.26 2026-01-26 23:06:55 +01:00
ba0dd0e0fb 26.01.26 2026-01-26 23:03:52 +01:00
e0c7480dd2 26.01.26 2026-01-26 23:01:04 +01:00
9b324ca7cd 26.01.26 2026-01-26 22:57:35 +01:00
0cb824076b 26.01.26 2026-01-26 22:51:59 +01:00
02320ce9c8 26.01.26 2026-01-26 22:45:26 +01:00
6d82621a34 26.01.26 2026-01-26 22:41:31 +01:00
0dae857364 26.01.26 2026-01-26 22:38:27 +01:00
9a974365a7 26.01.26 2026-01-26 22:32:59 +01:00
e9e57803ae 26.01.26 2026-01-26 22:29:04 +01:00
a3b7d11ae3 26.01.26 2026-01-26 22:23:08 +01:00
2248f49f9d 26.01.26 2026-01-26 22:18:39 +01:00
f96e73d7b9 26.01.26 2026-01-26 22:15:30 +01:00
ece0e55f98 26.01.26 2026-01-26 22:09:09 +01:00
db207f4995 26.01.26 2026-01-26 22:05:58 +01:00
f94f8d3abe 26.01.26 2026-01-26 21:50:32 +01:00
017eb92c6a 26.01.26 2026-01-26 21:43:41 +01:00
765d526c16 26.01.26 2026-01-26 21:31:31 +01:00
e71c6b14d0 26.01.26 2026-01-26 21:26:39 +01:00
210adad3f6 26.01.26 2026-01-26 21:18:51 +01:00
72b1d4ffcd 26.01.26 2026-01-26 21:14:21 +01:00
e2fc7178c4 26.01.26 2026-01-26 21:06:23 +01:00
58b360ed6c 26.01.26 2026-01-26 21:00:36 +01:00
985f4dc19d 26.01.26 2026-01-26 20:48:41 +01:00
757b9f724d 26.01.26 2026-01-26 17:32:33 +01:00
fbfc943301 26.01.26 2026-01-26 13:16:42 +01:00
4c70f0bc5f 26.01.26 2026-01-26 12:42:49 +01:00
ef27c020e0 25.01.26 2026-01-25 14:41:47 +01:00
23d9d88492 22.01.26 2026-01-22 13:55:09 +01:00
fffbb9708a 20.01.26 2026-01-20 22:14:35 +01:00
e7ff1fd56c 20.01.26 2026-01-20 21:56:09 +01:00
5b962eeb1d 20.01.26 2026-01-20 21:38:23 +01:00
08581f24ea 20.01.26 2026-01-20 21:35:00 +01:00
5e9141f03a 20.01.26 2026-01-20 21:30:14 +01:00
2269fc7709 20.01.26 2026-01-20 20:59:36 +01:00
b966366bc4 20.01.26 2026-01-20 20:53:34 +01:00
e2b0695cb8 20.01.26 2026-01-20 20:48:21 +01:00
000547ae21 20.01.26 2026-01-20 20:42:59 +01:00
8cc19b67f9 20.01.26 2026-01-20 20:31:49 +01:00
fcb6377b21 20.01.26 2026-01-20 20:19:56 +01:00
8a54b6fcbc 20.01.26 2026-01-20 20:13:08 +01:00
ecdc4b283d 20.01.26 2026-01-20 19:46:48 +01:00
d9ab3cfe47 20.01.26 2026-01-20 19:36:31 +01:00
72fc0c5431 20.01.26 2026-01-20 19:15:22 +01:00
044096c8d7 20.01.26 2026-01-20 19:11:43 +01:00
96b6be3398 20.01.26 2026-01-20 19:08:19 +01:00
42fb2e0d22 20.01.26 2026-01-20 19:06:46 +01:00
227eb3cf46 20.01.26 2026-01-20 19:04:54 +01:00
0e007afed3 20.01.26 2026-01-20 19:02:44 +01:00
ddf023f078 20.01.26 2026-01-20 18:59:40 +01:00
a9d1fe7baf 20.01.26 2026-01-20 17:30:13 +01:00
aeadca6f60 20.01.26 2026-01-20 17:12:07 +01:00
17233d8ded 20.01.26 2026-01-20 17:02:55 +01:00
797783b59e 20.01.26 2026-01-20 16:42:52 +01:00
e5e76e2dcb 20.01.26 2026-01-20 16:13:52 +01:00
0fc80958af 20.01.26 2026-01-20 16:06:15 +01:00
b43ab082b4 20.01.26 2026-01-20 15:36:55 +01:00
fcf61605d6 20.01.26 2026-01-20 15:30:40 +01:00
c5053638df 20.01.26 2026-01-20 15:25:15 +01:00
c5fbd3e528 20.01.26 2026-01-20 14:57:16 +01:00
117c33a2ea 20.01.26 2026-01-20 14:55:04 +01:00
327de18f1a 20.01.26 2026-01-20 14:50:32 +01:00
437904bcd8 20.01.26 2026-01-20 13:08:52 +01:00
6d75b4660e 20.01.26 2026-01-20 11:56:38 +01:00
a110019345 20.01.26 2026-01-20 11:40:44 +01:00
da530a2211 20.01.26 2026-01-20 11:30:53 +01:00
51a512e568 20.01.26 2026-01-20 11:25:29 +01:00
5df6378e80 20.01.26 2026-01-20 11:14:09 +01:00
a479dc718b 20.01.26 2026-01-20 11:04:53 +01:00
c39bb1d6d1 18.01.26 2026-01-18 23:59:24 +01:00
5c1ca6a895 18.01.26 2026-01-18 23:55:08 +01:00
24c1186061 18.01.26 2026-01-18 23:30:49 +01:00
8ae4aeb3e8 18.01.26 2026-01-18 23:00:29 +01:00
9daa701a28 18.01.26 2026-01-18 22:23:09 +01:00
336c2415b3 18.01.26 2026-01-18 22:08:41 +01:00
49731501d6 18.01.26 2026-01-18 22:00:54 +01:00
661d38404a 18.01.26 2026-01-18 21:28:40 +01:00
e869338703 18.01.26 2026-01-18 21:22:41 +01:00
b55c1e3c8f 18.01.26 2026-01-18 21:12:27 +01:00
3820f00182 18.01.26 2026-01-18 21:07:55 +01:00
178fb25a2f 18.01.26 2026-01-18 20:52:20 +01:00
6b2d5aebf8 18.01.26 2026-01-18 20:02:49 +01:00
0d3bfda08c 18.01.26 2026-01-18 12:07:27 +01:00
11f33236cf 18.01.26 2026-01-18 11:05:00 +01:00
db7288c709 18.01.26 2026-01-18 10:51:40 +01:00
a31e51152b 18.01.26 2026-01-18 10:27:55 +01:00
97b8632b74 18.01.26 2026-01-18 10:18:57 +01:00
2ebbe334bf 18.01.26 2026-01-18 10:06:48 +01:00
7571a1cb80 17.01.26 2026-01-17 18:15:29 +01:00
b4f794c275 17.01.26 2026-01-17 17:55:07 +01:00
2f1adffba3 17.01.26 2026-01-17 17:44:01 +01:00
0047e6e879 17.01.26 2026-01-17 17:36:39 +01:00
e8453c76a7 17.01.26 2026-01-17 17:00:35 +01:00
5d7d0ffe5b 17.01.26 2026-01-17 16:54:51 +01:00
39c9d83f1b 17.01.26 2026-01-17 14:12:11 +01:00
b38f7553a6 17.01.26 2026-01-17 14:03:45 +01:00
9b887a6b4c 17.01.26 2026-01-17 09:59:48 +01:00
b43db20bc4 17.01.26 2026-01-17 09:53:19 +01:00
8e38ceb13e 17.01.26 2026-01-17 09:47:06 +01:00
18c3cf21c6 17.01.26 2026-01-17 09:36:45 +01:00
a73564a24d Merge pull request '17.01.26' (#6) from dev into main
Reviewed-on: https://srv413259.hstgr.cloud/admin/tradon/pulls/6
2026-01-17 08:26:19 +00:00
9c6029d152 17.01.26 2026-01-17 09:25:00 +01:00
7a35413da5 Merge pull request 'dev' (#5) from dev into main
Reviewed-on: https://srv413259.hstgr.cloud/admin/tradon/pulls/5
2026-01-17 05:48:03 +00:00
660e714983 16.01.26 2026-01-16 21:07:56 +01:00
9edfcd5058 Merge pull request 'main' (#4) from main into dev
Reviewed-on: https://srv413259.hstgr.cloud/admin/tradon/pulls/4
2026-01-16 08:15:31 +00:00
cd8f785881 13.01.26 2026-01-13 14:45:04 +01:00
24895173d6 11.01.26 2026-01-11 14:15:12 +01:00
6a1c26ce70 11.01.26 2026-01-11 11:11:55 +01:00
ba06fb60c2 11.01.26 2026-01-11 11:06:13 +01:00
3204eee7ac 10.01.26 2026-01-10 11:04:13 +01:00
815b8696d2 10.01.26 2026-01-10 10:58:17 +01:00
2e80c19e0e 10.01.26 2026-01-10 10:34:00 +01:00
170cc09627 Merge pull request 'Prod => Dev' (#3) from main into dev
Reviewed-on: https://srv413259.hstgr.cloud/admin/tradon/pulls/3
2026-01-10 09:18:03 +00:00
8fd8de607f 10.01.26 2026-01-10 09:22:47 +01:00
ebdfd7f499 09.01.26 2026-01-09 17:49:20 +01:00
19669281d5 07.01.26 2026-01-07 18:04:18 +01:00
ccbf545fe2 07.01.26 2026-01-07 17:59:01 +01:00
550ad57354 07.01.26 2026-01-07 16:17:21 +01:00
dbb488ba52 Merge pull request 'Dev adjusted by Prod' (#2) from main into dev
Reviewed-on: https://srv413259.hstgr.cloud/admin/tradon/pulls/2
2026-01-05 20:04:35 +00:00
49d2438ea0 05.01.26 2026-01-05 20:33:56 +01:00
92ba297bb7 05.01.26 2026-01-05 17:45:16 +01:00
cad3d04d08 Merge pull request 'Merge dev from main' (#1) from main into dev
Reviewed-on: https://srv413259.hstgr.cloud/admin/tradon/pulls/1
2026-01-05 13:03:45 +00:00
f1e002998e 05.01.26 2026-01-05 13:25:24 +01:00
c42edc4efd 05.01.26 2026-01-05 13:16:06 +01:00
e5056c6ea1 05.01.26 2026-01-05 13:09:58 +01:00
56a4bd9e82 04.01.26 2026-01-04 21:06:11 +01:00
9449f4f0ec 04.01.26 2026-01-04 20:15:27 +01:00
5111265019 04.01.26 2026-01-04 19:46:26 +01:00
750db2fe4d 04.01.26 2026-01-04 18:13:35 +01:00
36f9fb48a8 04.01.26 2026-01-04 18:09:22 +01:00
b857ab3eed 04.01.26 2026-01-04 18:07:36 +01:00
dfc2ea3a4e 04.01.26 2026-01-04 18:01:15 +01:00
dfc2ae4896 04.01.26 2026-01-04 17:30:28 +01:00
272b91609c 04.01.26 2026-01-04 17:27:49 +01:00
f47036b6df 04.01.26 2026-01-04 17:24:36 +01:00
bed9c9deac 04.01.26 2026-01-04 15:47:30 +01:00
837c75f8f0 04.01.26 2026-01-04 15:37:24 +01:00
dee3d2ff90 04.01.26 2026-01-04 14:49:22 +01:00
02b99b4622 04.01.26 2026-01-04 13:31:17 +01:00
37d73b6962 03.01.26 2026-01-03 17:17:25 +01:00
35c56538dd 03.01.26 2026-01-03 14:15:50 +01:00
13b7553641 03.01.26 2026-01-03 13:35:06 +01:00
bb117d4ee6 03.01.26 2026-01-03 13:25:11 +01:00
b0b2741422 29_12_25 2025-12-29 19:57:54 +01:00
ca83f0ec7b 29_12_25 2025-12-29 16:35:43 +01:00
1ab5e57017 28_12_25 2025-12-28 11:32:32 +01:00
a34f02db00 v1.0.1 2025-12-26 20:39:32 +01:00
208 changed files with 42622 additions and 1689 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
*.pyc

141
AGENTS.md Normal file
View File

@@ -0,0 +1,141 @@
# 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.
- Conventions de champs dates:
- Dans ce projet, ne pas introduire de `fields.DateTime`.
- Utiliser `fields.Date` pour les dates metier et les champs de suivi UI, sauf demande explicite deja existante dans le module cible.
- 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 `&quot;` et `&apos;` 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.
- Pour les templates `stock.shipment.in`, preferer aussi des proprietes `report_*` sur le shipment plutot que des contextes ad hoc (`si_*`) quand le document devient metier ou client-specifique.
- 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.
- Pour les templates shipment, ne pas supposer qu'une variable locale comme `shipment` sera definie partout dans Genshi, surtout dans les headers/footers; preferer `records[0]....` ou des placeholders alignes sur le scope reel du report.
- 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()`.
- Rappels session templates (2026-04-08):
- `insurance.fodt`: le texte "insured for account of" doit afficher la compagnie courante (shipment.company.party), pas le client.
- `insurance.fodt`: exposer des proprietes Python `report_*` sur `stock.shipment.in` pour les montants (incoming moves) et les zones client-specifiques.
- `insurance.fodt`: "Amount insured" suit la regle metier 110% du montant incoming (base calculee via lot -> purchase.line.unit_price * quantite courante convertie).
- `insurance.fodt`: zone "Contact the following surveyor" alimentee par une propriete dediee, avec champ `surveyor` (party.party) cote shipment.
- `packing_list.fodt`: date en haut a droite = date du jour; unites Net/Gross = unite de `purchase.line`.
- `bill.fodt` (sale): la 2eme date doit etre une vraie maturity date (depuis `invoice.lines_to_pay.maturity_date`), pas `payment_term.rec_name`.
- `bill.fodt` (sale): le montant en lettres doit provenir du montant du bill (facture/total), pas du `unit_price` de ligne.
- Quand un template affiche les placeholders en brut, verifier que les champs sont bien des placeholders Relatorio dans le XML (pas du texte litteral).
- Eviter les apostrophes echappees style `\'` dans placeholders; preferer `&quot;` et `&apos;`.
## 4.bis) Memo templates de session
- Voir aussi `notes/template_business_rules.md` pour le recap detaille (business rules + decisions templates de la session).
## 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).
- Rappels session 2026-04-09:
- `invoice_ict.fodt` / `invoice_ict_final.fodt`: poids et unites depuis `lot.qt.hist` / `lot_unit_line`, priorite lots `physic`, sinon lot `virtual` unique.
- `invoice_ict.fodt` / `invoice_ict_final.fodt`: infos shipment depuis les lots reels des lignes facture; ne rien afficher si plusieurs shipments differents.
- `invoice_ict.fodt` / `invoice_ict_final.fodt`: `S/I` = `shipment.reference`; `NB BALES: 0` => `Unchanged` sur le final.
- `invoice_ict.fodt` / `invoice_ict_final.fodt`: quantites uniformisees a `2` decimales; conversion `LBS` via UoM, jamais via un facteur fixe aveugle.
- `invoice_ict.fodt` / `invoice_ict_final.fodt`: si plusieurs lignes reutilisent le meme lot, les lignes detaillees suivent la quantite facturee convertie, mais le `GROSS` global doit rester le vrai delta historique du lot.
- `sale_ict.fodt`: meme priorite lots; les mots suivent l'unite reelle; le total convertit vers une unite commune, qui est celle du lot virtuel seulement s'il y a un seul lot virtuel sur tout le report.
- `lot.report.r_del_period`: utiliser `sale.line.del_period` pour `lot_s` sans `lot_p`, sinon `purchase.line.del_period`.
- `lot.do_weighing`: `lot_qt` editable et ecrasement direct de `lot.lot_qt`.
- `account.invoice`: `Validate` cree aussi le `account.move` pour les factures client, attribue aussi le `number` a ce stade pour les factures client comme fournisseur; `Post` ne doit plus forcer une fresh session sur ce flux.
- `pricing.pricing`: saisie manuelle autorisee meme sans composant; en manuel, l'utilisateur saisit seulement `Qt` et `Settl. price`; `fixed_qt`, `fixed_qt_price`, `unfixed_qt`, `unfixed_qt_price` et `eod_price` sont derives automatiquement.
- `pricing.pricing`: en manuel, `fixed_qt` = cumul des `quantity`, `fixed_qt_price` = moyenne ponderee cumulee des `settl_price`, `unfixed_qt` = reste a fixer, `unfixed_qt_price` = `settl_price` de la ligne.
- `pricing.pricing`: `eod_price` reste non editable et calcule en prix moyen pondere; `last=True` gere par groupe `line + component`, choisi sur la `pricing_date` la plus grande.
- `purchase_trade`: `trader` filtre sur `TRADER`, `operator` sur `OPERATOR`; fallback sur `quantity` si `quantity_theorical` est vide dans les quotas/pricings.
- `sale.line` / `purchase.line`: en mode `basis`, sans `price_component`, le `Price` et le `Fix. progress` de la ligne doivent remonter depuis la ligne `Summary` sans component.

1
debug.log Normal file
View File

@@ -0,0 +1 @@
[0407/143111.471:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: Accès refusé. (0x5)

5
deployment/README.md Normal file
View 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`

View 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.

View File

@@ -0,0 +1,36 @@
# Fiche VPS - 46.202.173.47
Date de reference: 2026-04-07
## Identite serveur
- IP: `46.202.173.47`
- Hostname alias conseille: `vps3`
## Acces
- Cle publique Laurent Barontini (vps-deploy):
`ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEm8JMCYsk6I1IoYhIHXNrdyERHdh+eeDCJagOHaRAEK vps-deploy`
- Cle publique Sylvain Duvernay (s.duvernay@singa-associates.com):
`ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIG6Xsp/v6q6JO04ETv1880qoSPptUMxlWQvgcBz67o63 s.duvernay@singa-associates.com`
- Fichier local: `$env:USERPROFILE\.ssh\id_ed25519`
- Mot de passe fourni:
`!!OpenSquared!!`
- Utilisateur SSH:
'root'
- Port SSH:
'22'
## Commande de connexion type
- Laurent Barontini (cle vps-deploy):
`ssh -i $env:USERPROFILE\.ssh\vps_deploy_key <user>@46.202.173.47`
- Sylvain Duvernay (cle id_ed25519):
`ssh -i $env:USERPROFILE\.ssh\id_ed25519 <user>@46.202.173.47`
- Avec port custom:
`ssh -i $env:USERPROFILE\.ssh\id_ed25519 -p <port> <user>@46.202.173.47`

View 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`).

View File

@@ -93,6 +93,7 @@ class Model(
cursor.execute(*ir_model.select(ir_model.id, cursor.execute(*ir_model.select(ir_model.id,
where=ir_model.model == model.__name__)) where=ir_model.model == model.__name__))
model_id = None model_id = None
logger.info("MODEL_NAME:%s",model.__name__)
if cursor.rowcount == -1 or cursor.rowcount is None: if cursor.rowcount == -1 or cursor.rowcount is None:
data = cursor.fetchone() data = cursor.fetchone()
if data: if data:

View File

@@ -30,8 +30,8 @@ class Binary(Field):
on_change_with=None, depends=None, context=None, loading='lazy', on_change_with=None, depends=None, context=None, loading='lazy',
filename=None, file_id=None, store_prefix=None): filename=None, file_id=None, store_prefix=None):
self.filename = filename self.filename = filename
self.file_id = file_id self.file_id = None #file_id
self.store_prefix = store_prefix self.store_prefix = None #store_prefix
super(Binary, self).__init__(string=string, help=help, super(Binary, self).__init__(string=string, help=help,
required=required, readonly=readonly, domain=domain, states=states, required=required, readonly=readonly, domain=domain, states=states,
on_change=on_change, on_change_with=on_change_with, on_change=on_change, on_change_with=on_change_with,

View File

@@ -103,15 +103,26 @@ class Model(URLMixin, PoolBase, metaclass=ModelMeta):
@classmethod @classmethod
def _get_name(cls): def _get_name(cls):
''' if cls.__doc__ is None:
Returns the first non-empty line of the model docstring. print("\n💥 MODELE SANS DOCSTRING :", cls.__name__, " (module:", cls.__module__, ")")
''' raise Exception("MODELE SANS DOCSTRING")
assert cls.__doc__, '%s has no docstring' % cls
lines = cls.__doc__.splitlines() lines = cls.__doc__.splitlines()
for line in lines: if lines:
line = line.strip() return lines[0]
if line: return cls.__name__
return line
# @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 @classmethod
def __register__(cls, module_name): def __register__(cls, module_name):

View File

@@ -117,6 +117,7 @@ class Move(DescriptionOriginMixin, ModelSQL, ModelView):
date = fields.Date('Effective Date', required=True, states=_MOVE_STATES) date = fields.Date('Effective Date', required=True, states=_MOVE_STATES)
post_date = fields.Date('Post Date', readonly=True) post_date = fields.Date('Post Date', readonly=True)
description = fields.Char('Description', states=_MOVE_STATES) description = fields.Char('Description', states=_MOVE_STATES)
ext_ref = fields.Char('Ext. Ref')
origin = fields.Reference('Origin', selection='get_origin', origin = fields.Reference('Origin', selection='get_origin',
states=_MOVE_STATES) states=_MOVE_STATES)
state = fields.Selection([ state = fields.Selection([
@@ -921,6 +922,7 @@ class Line(DescriptionOriginMixin, MoveLineMixin, ModelSQL, ModelView):
fields.Reference("Move Origin", selection='get_move_origin'), fields.Reference("Move Origin", selection='get_move_origin'),
'get_move_field', searcher='search_move_field') 'get_move_field', searcher='search_move_field')
description = fields.Char('Description', states=_states) description = fields.Char('Description', states=_states)
ext_ref = fields.Char('Ext. Ref')
move_description_used = fields.Function( move_description_used = fields.Function(
fields.Char("Move Description", states=_states), fields.Char("Move Description", states=_states),
'get_move_field', 'get_move_field',

View File

@@ -21,6 +21,8 @@ this repository contains the full copyright notices and license terms. -->
<field name="origin" colspan="3"/> <field name="origin" colspan="3"/>
<label name="description_used"/> <label name="description_used"/>
<field name="description_used" colspan="3"/> <field name="description_used" colspan="3"/>
<label name="ext_ref"/>
<field name="ext_ref" colspan="3"/>
<notebook> <notebook>
<page name="lines"> <page name="lines">
<field name="lines" colspan="4" <field name="lines" colspan="4"

View File

@@ -26,6 +26,8 @@ this repository contains the full copyright notices and license terms. -->
<field name="origin"/> <field name="origin"/>
<label name="description_used"/> <label name="description_used"/>
<field name="description_used" colspan="3"/> <field name="description_used" colspan="3"/>
<label name="ext_ref"/>
<field name="ext_ref" colspan="3"/>
<notebook colspan="4"> <notebook colspan="4">
<page string="Other Info" id="info"> <page string="Other Info" id="info">
<label name="date"/> <label name="date"/>

View File

@@ -4,6 +4,7 @@ this repository contains the full copyright notices and license terms. -->
<tree editable="1"> <tree editable="1">
<field name="move"/> <field name="move"/>
<field name="account" expand="1"/> <field name="account" expand="1"/>
<field name="ext_ref" expand="1" optional="1"/>
<field name="party" expand="1"/> <field name="party" expand="1"/>
<field name="debit" sum="1"/> <field name="debit" sum="1"/>
<field name="credit" sum="1"/> <field name="credit" sum="1"/>

View File

@@ -485,7 +485,7 @@ class Invoice(Workflow, ModelSQL, ModelView, TaxableMixin, InvoiceReportMixin):
}) })
cls.__rpc__.update({ cls.__rpc__.update({
'post': RPC( 'post': RPC(
readonly=False, instantiate=0, fresh_session=True), readonly=False, instantiate=0, fresh_session=False),
}) })
@classmethod @classmethod
@@ -1286,9 +1286,14 @@ class Invoice(Workflow, ModelSQL, ModelView, TaxableMixin, InvoiceReportMixin):
remainder = sum(l.debit - l.credit for l in move_lines) remainder = sum(l.debit - l.credit for l in move_lines)
if self.payment_term: if self.payment_term:
payment_date = self.payment_term_date or self.invoice_date or today 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 model = str(self.lines[0].origin).split(",")[0] if self.lines[0].origin else None
term_lines = self.payment_term.compute( logger.info("MODEL:%s",model)
self.total_amount, self.currency, payment_date, purchase_line) 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: else:
term_lines = [(self.payment_term_date or today, self.total_amount)] term_lines = [(self.payment_term_date or today, self.total_amount)]
past_payment_term_dates = [] past_payment_term_dates = []
@@ -1885,17 +1890,15 @@ class Invoice(Workflow, ModelSQL, ModelView, TaxableMixin, InvoiceReportMixin):
cls._check_taxes(invoices) cls._check_taxes(invoices)
# cls._check_similar(invoices) # cls._check_similar(invoices)
invoices_in = cls.browse([i for i in invoices if i.type == 'in']) cls.set_number(invoices)
cls.set_number(invoices_in)
cls._store_cache(invoices) cls._store_cache(invoices)
moves = [] moves = []
for invoice in invoices: for invoice in invoices:
if invoice.type == 'in': move = invoice.get_move()
move = invoice.get_move() if move != invoice.move:
if move != invoice.move: invoice.move = move
invoice.move = move moves.append(move)
moves.append(move)
invoice.do_lot_invoicing() invoice.do_lot_invoicing()
if moves: if moves:
Move.save(moves) Move.save(moves)
@@ -1960,14 +1963,16 @@ class Invoice(Workflow, ModelSQL, ModelView, TaxableMixin, InvoiceReportMixin):
if amount < 0: if amount < 0:
move_line.debit = Decimal(0) move_line.debit = Decimal(0)
move_line.credit = -amount 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_.credit = Decimal(0)
move_line_.debit = -amount move_line_.debit = -amount
move_line_.account = gl.product.account_stock_in_used move_line_.account = gl.product.account_stock_in_used
else: else:
move_line.debit = amount move_line.debit = amount
move_line.credit = Decimal(0) 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_.debit = Decimal(0)
move_line_.credit = amount move_line_.credit = amount
move_line_.account = gl.product.account_stock_in_used move_line_.account = gl.product.account_stock_in_used
@@ -2031,7 +2036,11 @@ class Invoice(Workflow, ModelSQL, ModelView, TaxableMixin, InvoiceReportMixin):
var_qt = sum([i.quantity for i in gl]) var_qt = sum([i.quantity for i in gl])
logger.info("LOT_TO_PROCESS:%s",lot) logger.info("LOT_TO_PROCESS:%s",lot)
logger.info("FEE_TO_PROCESS:%s",gl[0].fee) 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 = [] adjust_move_lines = []
mov = None mov = None
if self.type == 'in': if self.type == 'in':
@@ -3684,13 +3693,19 @@ class InvoiceReport(Report):
Invoice = pool.get('account.invoice') Invoice = pool.get('account.invoice')
# Re-instantiate because records are TranslateModel # Re-instantiate because records are TranslateModel
invoice, = Invoice.browse(records) 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 ( return (
invoice.invoice_report_format, invoice.invoice_report_format,
invoice.invoice_report_cache) invoice.invoice_report_cache)
else: else:
result = super()._execute(records, header, data, action) 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 format_, data = result
if isinstance(data, str): if isinstance(data, str):
data = bytes(data, 'utf-8') data = bytes(data, 'utf-8')
@@ -3707,6 +3722,12 @@ class InvoiceReport(Report):
with Transaction().set_context(language=False): with Transaction().set_context(language=False):
return super().render(*args, **kwargs) 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 @classmethod
def execute(cls, ids, data): def execute(cls, ids, data):
pool = Pool() pool = Pool()

View File

@@ -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="wiz_name">account.invoice.refresh_invoice_report</field>
<field name="model">account.invoice</field> <field name="model">account.invoice</field>
</record> </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"> <record model="ir.action-res.group" id="refresh_invoice_report-group_account_admin">
<field name="action" ref="refresh_invoice_report_wizard"/> <field name="action" ref="refresh_invoice_report_wizard"/>
<field name="group" ref="account.group_account_admin"/> <field name="group" ref="account.group_account_admin"/>
@@ -318,6 +313,19 @@ this repository contains the full copyright notices and license terms. -->
<field name="action" ref="report_prepayment"/> <field name="action" ref="report_prepayment"/>
</record> </record>
<record model="ir.action.report" id="report_invoice_ict_final">
<field name="name">CN/DN</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"> <record model="ir.sequence.type" id="sequence_type_account_invoice">
<field name="name">Invoice</field> <field name="name">Invoice</field>
</record> </record>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -13,8 +13,10 @@ from trytond.pool import Pool
from trytond.pyson import Eval from trytond.pyson import Eval
from trytond.transaction import Transaction from trytond.transaction import Transaction
from trytond.wizard import Button, StateView, Wizard from trytond.wizard import Button, StateView, Wizard
from .exceptions import PaymentTermComputeError, PaymentTermValidationError from .exceptions import PaymentTermComputeError, PaymentTermValidationError
import logging
logger = logging.getLogger(__name__)
class PaymentTerm(DeactivableMixin, ModelSQL, ModelView): class PaymentTerm(DeactivableMixin, ModelSQL, ModelView):
@@ -46,7 +48,7 @@ class PaymentTerm(DeactivableMixin, ModelSQL, ModelView):
'.msg_payment_term_missing_last_remainder', '.msg_payment_term_missing_last_remainder',
payment_term=term.rec_name)) 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 """Calculate payment terms and return a list of tuples
with (date, amount) for each payment term line. with (date, amount) for each payment term line.
@@ -59,7 +61,7 @@ class PaymentTerm(DeactivableMixin, ModelSQL, ModelView):
remainder = amount remainder = amount
for line in self.lines: for line in self.lines:
value = line.get_value(remainder, amount, currency) 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: if value is None or not value_date:
continue continue
if ((remainder - value) * sign) < Decimal(0): if ((remainder - value) * sign) < Decimal(0):
@@ -155,12 +157,11 @@ class PaymentTermLine(sequence_ordered(), ModelSQL, ModelView):
self.ratio = self.round(1 / self.divisor, self.ratio = self.round(1 / self.divisor,
self.__class__.ratio.digits[1]) 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: #find date based on trigger:
if purchase_line and self.trigger_event: if line and self.trigger_event:
PurchaseLine = Pool().get('purchase.line') trigger_date = line.get_date(self.trigger_event)
purchase_line = PurchaseLine(purchase_line) logger.info("DATE_FROM_LINE:%s",trigger_date)
trigger_date = purchase_line.get_date(self.trigger_event)
if trigger_date: if trigger_date:
date = trigger_date date = trigger_date

View File

@@ -3,6 +3,7 @@
import datetime import datetime
from decimal import Decimal from decimal import Decimal
from unittest.mock import Mock, patch
from trytond.modules.account_invoice.exceptions import ( from trytond.modules.account_invoice.exceptions import (
PaymentTermValidationError) PaymentTermValidationError)
@@ -251,5 +252,70 @@ class AccountInvoiceTestCase(
(datetime.date(2012, 1, 14), Decimal('-1.0')), (datetime.date(2012, 1, 14), Decimal('-1.0')),
]) ])
def test_post_rpc_does_not_require_fresh_session(self):
'posting invoices does not force a fresh session'
Invoice = Pool().get('account.invoice')
self.assertFalse(Invoice.__rpc__['post'].fresh_session)
@with_transaction()
def test_validate_invoice_creates_move_for_customer_invoice(self):
'validating customer invoices now creates the account move'
Invoice = Pool().get('account.invoice')
move = Mock()
invoice = Invoice()
invoice.type = 'out'
invoice.move = None
invoice.get_move = Mock(return_value=move)
invoice.do_lot_invoicing = Mock()
move_model = Mock()
with patch.object(Invoice, '_check_taxes'), patch.object(
Invoice, '_store_cache'), patch.object(
Invoice, 'browse', return_value=[]), patch.object(
Invoice, 'cleanMoves') as clean_moves, patch.object(
Invoice, 'save') as save_invoices, patch(
'trytond.modules.account_invoice.invoice.Pool'
) as PoolMock:
PoolMock.return_value.get.return_value = move_model
Invoice.validate_invoice([invoice])
self.assertIs(invoice.move, move)
invoice.get_move.assert_called_once_with()
invoice.do_lot_invoicing.assert_called_once_with()
move_model.save.assert_called_once_with([move])
clean_moves.assert_called_once_with([move])
save_invoices.assert_called()
@with_transaction()
def test_validate_invoice_sets_number_for_customer_invoice(self):
'validating customer invoices now assigns the invoice number'
Invoice = Pool().get('account.invoice')
move = Mock()
invoice = Invoice()
invoice.type = 'out'
invoice.move = None
invoice.get_move = Mock(return_value=move)
invoice.do_lot_invoicing = Mock()
move_model = Mock()
with patch.object(Invoice, '_check_taxes'), patch.object(
Invoice, '_store_cache'), patch.object(
Invoice, 'set_number') as set_number, patch.object(
Invoice, 'cleanMoves'), patch.object(
Invoice, 'save'), patch(
'trytond.modules.account_invoice.invoice.Pool'
) as PoolMock:
PoolMock.return_value.get.return_value = move_model
Invoice.validate_invoice([invoice])
set_number.assert_called_once_with([invoice])
del ModuleTestCase del ModuleTestCase

View File

@@ -92,8 +92,8 @@ this repository contains the full copyright notices and license terms. -->
<field name="invoice_report_revisions" colspan="4"/> <field name="invoice_report_revisions" colspan="4"/>
</page> </page>
<page string="Rate management" id="rate"> <page string="Rate management" id="rate">
<label name="warning"/> <!-- <label name="warning"/>
<field name="warning"/> <field name="warning"/> -->
<newline/> <newline/>
<label name="rate"/> <label name="rate"/>
<field name="rate"/> <field name="rate"/>

View 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')

View 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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,10 @@
[tryton]
version=7.2.3
depends:
account
extras_depend:
account_invoice
xml:
account_itsa.xml
#tax_ict.xml

View File

@@ -6,7 +6,7 @@ from decimal import Decimal
from trytond.i18n import gettext from trytond.i18n import gettext
from trytond.pool import Pool, PoolMeta from trytond.pool import Pool, PoolMeta
from trytond.transaction import Transaction from trytond.transaction import Transaction
from trytond.exceptions import UserWarning, UserError
from .exceptions import COGSWarning from .exceptions import COGSWarning
import logging import logging
@@ -74,8 +74,8 @@ class InvoiceLine(metaclass=PoolMeta):
if move_line.second_currency: if move_line.second_currency:
move_line.amount_second_currency = amount move_line.amount_second_currency = amount
else: else:
move_line.debit = Decimal(0) move_line.debit = -amount_converted
move_line.credit = -amount_converted move_line.credit = Decimal(0)
move_line.account = self.product.account_stock_out_used move_line.account = self.product.account_stock_out_used
if move_line.second_currency: if move_line.second_currency:
move_line.amount_second_currency = amount move_line.amount_second_currency = amount
@@ -171,10 +171,28 @@ class InvoiceLine(metaclass=PoolMeta):
cost = self.amount cost = self.amount
else: else:
cost = self.lot.get_cog() 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) logger.info("GETMOVELINES_COST:%s",cost)
anglo_saxon_move_lines_ = []
with Transaction().set_context( with Transaction().set_context(
company=self.invoice.company.id, date=accounting_date): company=self.invoice.company.id, date=accounting_date):
anglo_saxon_move_lines = self._get_anglo_saxon_move_lines( anglo_saxon_move_lines = self._get_anglo_saxon_move_lines(
cost, type_) 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)
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 return result

View File

@@ -16,11 +16,11 @@ account_names = [
class Category(metaclass=PoolMeta): class Category(metaclass=PoolMeta):
__name__ = 'product.category' __name__ = 'product.category'
account_stock = fields.MultiValue(fields.Many2One( account_stock = fields.MultiValue(fields.Many2One(
'account.account', "Account Stock", 'account.account', "Account Stock/Cost Income",
domain=[ domain=[
('closed', '!=', True), ('closed', '!=', True),
('type.stock', '=', True), # ('type.stock', '=', True),
('type.statement', '=', 'balance'), # ('type.statement', '=', 'balance'),
('company', '=', Eval('context', {}).get('company', -1)), ('company', '=', Eval('context', {}).get('company', -1)),
], ],
states={ states={
@@ -29,7 +29,7 @@ class Category(metaclass=PoolMeta):
| ~Eval('accounting', False)), | ~Eval('accounting', False)),
})) }))
account_stock_in = fields.MultiValue(fields.Many2One( account_stock_in = fields.MultiValue(fields.Many2One(
'account.account', "Account Stock IN", 'account.account', "Account Stock IN/Cost liability",
domain=[ domain=[
('closed', '!=', True), ('closed', '!=', True),
('type.stock', '=', True), ('type.stock', '=', True),
@@ -41,7 +41,7 @@ class Category(metaclass=PoolMeta):
| ~Eval('accounting', False)), | ~Eval('accounting', False)),
})) }))
account_stock_out = fields.MultiValue(fields.Many2One( account_stock_out = fields.MultiValue(fields.Many2One(
'account.account', "Account Stock OUT", 'account.account', "Account Stock OUT/Cost liability",
domain=[ domain=[
('closed', '!=', True), ('closed', '!=', True),
('type.stock', '=', True), ('type.stock', '=', True),
@@ -103,8 +103,8 @@ class CategoryAccount(metaclass=PoolMeta):
'account.account', "Account Stock", 'account.account', "Account Stock",
domain=[ domain=[
('closed', '!=', True), ('closed', '!=', True),
('type.stock', '=', True), # ('type.stock', '=', True),
('type.statement', '=', 'balance'), # ('type.statement', '=', 'balance'),
('company', '=', Eval('company', -1)), ('company', '=', Eval('company', -1)),
]) ])
account_stock_in = fields.Many2One( account_stock_in = fields.Many2One(

View File

@@ -1,8 +1,11 @@
from trytond.pool import Pool from trytond.pool import Pool
from . import automation,rules #, document from . import automation,rules,freight_booking,cron #, document
def register(): def register():
Pool.register( Pool.register(
automation.AutomationDocument, automation.AutomationDocument,
rules.AutomationRuleSet, rules.AutomationRuleSet,
freight_booking.FreightBookingInfo,
cron.Cron,
cron.AutomationCron,
module='automation', type_='model') module='automation', type_='model')

View File

@@ -1,10 +1,15 @@
from trytond.model import ModelSQL, ModelView, fields, Workflow from trytond.model import ModelSQL, ModelView, fields, Workflow
from trytond.pool import Pool, PoolMeta
from trytond.pyson import Eval from trytond.pyson import Eval
from trytond.wizard import Button 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 requests
import io import io
import logging import logging
import json import json
import re
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -17,6 +22,7 @@ class AutomationDocument(ModelSQL, ModelView, Workflow):
('invoice', 'Invoice'), ('invoice', 'Invoice'),
('statement_of_facts', 'Statement of Facts'), ('statement_of_facts', 'Statement of Facts'),
('weight_report', 'Weight Report'), ('weight_report', 'Weight Report'),
('controller', 'Controller'),
('bol', 'Bill of Lading'), ('bol', 'Bill of Lading'),
('controller_invoice', 'Controller Invoice'), ('controller_invoice', 'Controller Invoice'),
], 'Type') ], 'Type')
@@ -57,25 +63,53 @@ class AutomationDocument(ModelSQL, ModelView, Workflow):
def run_ocr(cls, docs): def run_ocr(cls, docs):
for doc in docs: for doc in docs:
try: try:
# Décoder le fichier depuis le champ Binary if doc.type == 'weight_report':
file_data = doc.document.data or b"" # Décoder le fichier depuis le champ Binary
logger.info(f"File size: {len(file_data)} bytes") file_data = doc.document.data or b""
logger.info(f"First 20 bytes: {file_data[:20]}") logger.info(f"File size: {len(file_data)} bytes")
logger.info(f"Last 20 bytes: {file_data[-20:]}") 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 # Envoyer le fichier au service OCR
response = requests.post( response = requests.post(
"http://automation-service:8006/ocr", "http://automation-service:8006/ocr",
files={"file": (file_name, io.BytesIO(file_data))} files={"file": (file_name, io.BytesIO(file_data))}
) )
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
logger.info("RUN_OCR_RESPONSE:%s",data) logger.info("RUN_OCR_RESPONSE:%s",data)
doc.ocr_text = data.get("ocr_text", "") doc.ocr_text = data.get("ocr_text", "")
doc.state = "ocr_done" doc.state = "ocr_done"
doc.notes = (doc.notes or "") + "OCR done\n" 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: except Exception as e:
doc.state = "error" doc.state = "error"
@@ -154,7 +188,8 @@ class AutomationDocument(ModelSQL, ModelView, Workflow):
logger.info("Sending OCR text to metadata API: %s", doc.ocr_text) logger.info("Sending OCR text to metadata API: %s", doc.ocr_text)
response = requests.post( response = requests.post(
"http://automation-service:8006/metadata", #"http://automation-service:8006/metadata",
"http://automation-service:8006/parse",
json={"text": doc.ocr_text or ""} json={"text": doc.ocr_text or ""}
) )
response.raise_for_status() response.raise_for_status()
@@ -176,6 +211,18 @@ class AutomationDocument(ModelSQL, ModelView, Workflow):
logger.error("Metadata processing error: %s", e) logger.error("Metadata processing error: %s", e)
doc.save() 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 # FULL PIPELINE
# ------------------------------------------------------- # -------------------------------------------------------
@@ -184,18 +231,47 @@ class AutomationDocument(ModelSQL, ModelView, Workflow):
def run_pipeline(cls, docs): def run_pipeline(cls, docs):
for doc in docs: for doc in docs:
try: try:
if cls.rule_set.ocr_required: logger.info("DATA_TYPE:%s",type(doc.metadata_json))
cls.run_ocr([doc]) metadata = json.loads(str(doc.metadata_json))
if cls.rule_set.structure_required and doc.state != "error": logger.info("JSON STRUCTURE:%s",metadata)
cls.run_structure([doc])
if cls.rule_set.table_required and doc.state != "error": WeightReport = Pool().get('weight.report')
cls.run_tables([doc]) wr = WeightReport.create_from_json(metadata)
if cls.rule_set.metadata_required and doc.state != "error":
cls.run_metadata([doc]) ShipmentIn = Pool().get('stock.shipment.in')
if doc.state != "error": ShipmentWR = Pool().get('shipment.wr')
doc.state = "validated" sh = ShipmentIn.search([('bl_number','ilike',wr.bl_no)])
doc.notes = (doc.notes or "") + "Pipeline completed\n" 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)
doc.notes = (
(doc.notes or "")
+ "Global WR linked to shipment. "
+ "Create remote lot WRs from the weight report form.\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: except Exception as e:
logger.exception("PIPELINE FAILED") # 👈 TRACE COMPLETE
doc.state = "error" doc.state = "error"
doc.notes = (doc.notes or "") + f"Pipeline error: {e}\n" doc.notes = (doc.notes or "") + f"Pipeline error: {e}\n"
doc.save() doc.save()
raise
# except Exception as e:
# doc.state = "error"
# doc.notes = (doc.notes or "") + f"Pipeline error: {e}\n"
doc.save()

View File

@@ -75,7 +75,7 @@
<record model="ir.model.button" id="auto_button1"> <record model="ir.model.button" id="auto_button1">
<field name="model">automation.document</field> <field name="model">automation.document</field>
<field name="name">run_pipeline</field> <field name="name">run_pipeline</field>
<field name="string">Run Full Pipeline</field> <field name="string">Create Weight Report</field>
</record> </record>
<record model="ir.model.button" id="auto_button2"> <record model="ir.model.button" id="auto_button2">
<field name="model">automation.document</field> <field name="model">automation.document</field>

377
modules/automation/cron.py Normal file
View 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)

View 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>

View 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"
)

View 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>

View 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'),
]

View 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>

View File

@@ -5,4 +5,6 @@ depends:
res res
document_incoming document_incoming
xml: xml:
automation.xml automation.xml
freight_booking.xml
cron.xml

View 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>

View 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>

View File

@@ -27,6 +27,14 @@ class Sale(metaclass=PoolMeta):
invoice.save() invoice.save()
return invoice return invoice
@property
def report_agent(self):
if self.agent:
return (self.agent.party.address_get(
type='delivery')).full_address
else:
return ''
@classmethod @classmethod
@ModelView.button @ModelView.button
@Workflow.transition('quotation') @Workflow.transition('quotation')

View File

@@ -47,6 +47,8 @@ class Company(ModelSQL, ModelView):
help="Used to compute the today date.") help="Used to compute the today date.")
employees = fields.One2Many('company.employee', 'company', 'Employees', employees = fields.One2Many('company.employee', 'company', 'Employees',
help="Add employees to the company.") help="Add employees to the company.")
logo = fields.Binary("Logo")
@property @property
def header_used(self): def header_used(self):

View File

@@ -17,6 +17,8 @@ this repository contains the full copyright notices and license terms. -->
<field name="header"/> <field name="header"/>
<separator name="footer"/> <separator name="footer"/>
<field name="footer"/> <field name="footer"/>
<separator name="logo"/>
<field name="logo" widget="image" stretch="true"/>
</page> </page>
</notebook> </notebook>
</form> </form>

View File

@@ -137,6 +137,13 @@ class Currency(
closer = date closer = date
return res 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 @staticmethod
def _get_rate(currencies, tdate=None): def _get_rate(currencies, tdate=None):
''' '''

View File

@@ -17,6 +17,8 @@ from trytond.transaction import Transaction
from trytond.wizard import Button, StateTransition, StateView, Wizard from trytond.wizard import Button, StateTransition, StateView, Wizard
from .exceptions import DocumentIncomingSplitError from .exceptions import DocumentIncomingSplitError
import logging
logger = logging.getLogger(__name__)
if config.getboolean('document_incoming', 'filestore', default=True): if config.getboolean('document_incoming', 'filestore', default=True):
file_id = 'file_id' file_id = 'file_id'
@@ -179,30 +181,112 @@ class Incoming(DeactivableMixin, Workflow, ModelSQL, ModelView):
def _split_mime_types(cls): def _split_mime_types(cls):
return ['application/pdf'] 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 @classmethod
def from_inbound_email(cls, email_, rule): def from_inbound_email(cls, email_, rule):
message = email_.as_dict() 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')) data = message.get('text', message.get('html'))
logger.info("DATA_FROM_INBOUND_MAIL:%s", data)
if isinstance(data, str): if isinstance(data, str):
data = data.encode() 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( document = cls(
active=active, active=active,
name=message.get('subject', 'No Subject'), name=subject,
company=rule.document_incoming_company, company=rule.document_incoming_company,
data=data, data=data,
type=rule.document_incoming_type if active else None, type=rule.document_incoming_type if active else None,
source='inbound_email', source='inbound_email',
) )
children = [] 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( child = cls(
name=attachment['filename'] or 'data.bin', name='mail_' + subject + '.txt',
company=rule.document_incoming_company, company=rule.document_incoming_company,
data=attachment['data'], data=body_bytes,
type=rule.document_incoming_type, type=rule.document_incoming_type,
source='inbound_email') source='inbound_email',
)
children.append(child) children.append(child)
document.children = children document.children = children
document.save() document.save()
return document return document
@@ -265,7 +349,6 @@ class Incoming(DeactivableMixin, Workflow, ModelSQL, ModelView):
default.setdefault('children') default.setdefault('children')
return super().copy(documents, default=default) return super().copy(documents, default=default)
def iter_pages(expression, size): def iter_pages(expression, size):
ranges = set() ranges = set()
for pages in expression.split(','): for pages in expression.split(','):

View File

@@ -4,7 +4,8 @@
from trytond.model import fields from trytond.model import fields
from trytond.pool import Pool, PoolMeta from trytond.pool import Pool, PoolMeta
from trytond.pyson import Eval from trytond.pyson import Eval
import logging
logger = logging.getLogger(__name__)
class Rule(metaclass=PoolMeta): class Rule(metaclass=PoolMeta):
__name__ = 'inbound.email.rule' __name__ = 'inbound.email.rule'
@@ -53,4 +54,4 @@ class Rule(metaclass=PoolMeta):
if (self.action == 'document.incoming|from_inbound_email' if (self.action == 'document.incoming|from_inbound_email'
and self.document_incoming_process): and self.document_incoming_process):
document = email_.result document = email_.result
DocumentIncoming.process([document], with_children=True) DocumentIncoming.process([document], with_children=True)

View 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')

View 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

View 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>

View File

@@ -0,0 +1,8 @@
[tryton]
version=7.2.0
depends:
document_incoming
ir
party
xml:
document.xml

View File

@@ -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>

View File

@@ -17,6 +17,9 @@ from trytond.pyson import Eval
from trytond.transaction import Transaction from trytond.transaction import Transaction
from trytond.url import http_host from trytond.url import http_host
import logging
logger = logging.getLogger(__name__)
if config.getboolean('inbound_email', 'filestore', default=True): if config.getboolean('inbound_email', 'filestore', default=True):
file_id = 'data_id' file_id = 'data_id'
store_prefix = config.get('inbound_email', 'store_prefix', default=None) store_prefix = config.get('inbound_email', 'store_prefix', default=None)
@@ -74,6 +77,7 @@ class Inbox(ModelSQL, ModelView):
assert email_.inbox == self assert email_.inbox == self
for rule in self.rules: for rule in self.rules:
if rule.match(email_.as_dict()): if rule.match(email_.as_dict()):
logger.info("RULE_MATCHED:%s",rule)
email_.rule = rule email_.rule = rule
rule.run(email_) rule.run(email_)
return return

View File

@@ -12,7 +12,7 @@ __all__ = ['IncotermMixin', 'IncotermAvailableMixin']
class IncotermMixin(Model): class IncotermMixin(Model):
incoterm = fields.Many2One( incoterm = fields.Many2One(
'incoterm.incoterm', lazy_gettext('incoterm.msg_incoterm'), 'incoterm.incoterm', lazy_gettext('incoterm.msg_incoterm'), required=False,
ondelete='RESTRICT') ondelete='RESTRICT')
incoterm_location = fields.Many2One( incoterm_location = fields.Many2One(
'party.address', lazy_gettext('incoterm.msg_incoterm_location'), 'party.address', lazy_gettext('incoterm.msg_incoterm_location'),

View File

@@ -477,15 +477,16 @@ class Lot(ModelSQL, ModelView):
else: else:
return str(self.line.currency.symbol) + "/" + str(self.line.unit.symbol) 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) qt = Decimal(0)
gross_qt = Decimal(0) gross_qt = Decimal(0)
if self.lot_state: if self.lot_state:
if self.lot_hist: if self.lot_hist:
if seq != 0: if state_id != 0:
st = seq st = state_id
else: else:
st = self.lot_state.id 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] lot = [e for e in self.lot_hist if e.quantity_type.id == st][0]
qt = round(lot.quantity,5) qt = round(lot.quantity,5)
gross_qt = round(lot.gross_quantity,5) gross_qt = round(lot.gross_quantity,5)
@@ -499,24 +500,48 @@ class Lot(ModelSQL, ModelView):
physic_sum = Decimal(0) physic_sum = Decimal(0)
for l in line.lots: for l in line.lots:
if l.lot_type == 'physic' : 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 return line.quantity_theorical - physic_sum
def get_current_quantity(self,name=None): def get_current_quantity(self,name=None):
# if self.lot_type == 'physic': # if self.lot_type == 'physic':
qt, gross_qt = self.get_hist_quantity(0) qt, gross_qt = self.get_hist_quantity()
return qt return qt
# else: # else:
# return self.get_virtual_diff() # 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') Uom = Pool().get('product.uom')
unit = self.line.unit if self.line else self.sale_line.unit if not unit:
return round(Decimal(Uom.compute_qty(self.lot_unit_line, float(self.get_current_quantity()), unit)),5) 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): def get_current_gross_quantity(self,name=None):
if self.lot_type == 'physic': if self.lot_type == 'physic':
qt, gross_qt = self.get_hist_quantity(0) qt, gross_qt = self.get_hist_quantity()
return gross_qt return gross_qt
else: else:
return None return None
@@ -526,6 +551,7 @@ class Lot(ModelSQL, ModelView):
lqh = LotQtHist() lqh = LotQtHist()
lqh.quantity_type = qt_type lqh.quantity_type = qt_type
lqh.quantity = net lqh.quantity = net
logger.info("ADD_QUANTITY_TO_HIST:%s",gross)
lqh.gross_quantity = gross lqh.gross_quantity = gross
lqh.lot = self lqh.lot = self
return lqh return lqh
@@ -542,6 +568,7 @@ class Lot(ModelSQL, ModelView):
if existing: if existing:
hist = existing[0] hist = existing[0]
hist.quantity = net hist.quantity = net
logger.info("SET_CURRENT_HIST:%s",gross)
hist.gross_quantity = gross hist.gross_quantity = gross
else: else:
lot_hist.append(self.add_quantity_to_hist(net, gross, lqtt[0])) 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)) weight = fields.Numeric('Weight', digits=(16,5))
class SplitWizardStart(ModelView): class SplitWizardStart(ModelView):
"Split Line Start"
__name__ = 'lot.split.wizard.start' __name__ = 'lot.split.wizard.start'
mode = fields.Selection([ mode = fields.Selection([

View File

@@ -44,7 +44,7 @@ class Price(
price_composite = fields.One2Many('price.composite','price',"Composites") price_composite = fields.One2Many('price.composite','price',"Composites")
price_product = fields.One2Many('price.product', 'price', "Product") price_product = fields.One2Many('price.product', 'price', "Product")
price_ct_size = fields.Numeric("Ct size") price_ct_size = fields.Numeric("Ct size")
def get_qt(self,nb_ct,unit): def get_qt(self,nb_ct,unit):
Uom = Pool().get('product.uom') Uom = Pool().get('product.uom')
return round(Decimal(Uom.compute_qty(self.price_unit, float(self.price_ct_size * nb_ct), unit)),4) 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): def get_price(self,dt,unit,currency,last=False):
price = float(0) price = float(0)
PV = Pool().get('price.price_value') PV = Pool().get('price.price_value')
logger.info("ASKED_PRICE_FOR:%s",dt)
if self.price_values: if self.price_values:
dt = dt.strftime("%Y-%m-%d") dt = dt.strftime("%Y-%m-%d")
pv = PV.search([('price','=',self.id),('price_date','=',dt)]) 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") dt = dt.strftime("%Y-%m-%d")
cl = CL.search([('calendar','=',self.id),('price_date','=',dt)]) cl = CL.search([('calendar','=',self.id),('price_date','=',dt)])
if cl: if cl:
#logger.info("ISQUOTE:%s",cl)
return False return False
else: else:
return True return True
@@ -136,3 +134,20 @@ class Product(ModelSQL,ModelView):
__name__ = 'price.product' __name__ = 'price.product'
price = fields.Many2One('price.price',"Price index") price = fields.Many2One('price.price',"Price index")
product = fields.Many2One('product.product',"Product") 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

View File

@@ -1,6 +1,6 @@
<form> <form>
<label name="price"/>
<field name="price"/>
<label name="product"/> <label name="product"/>
<field name="product"/> <field name="product"/>
<label name="attributes"/>
<field name="attributes"/>
</form> </form>

View File

@@ -1,4 +1,5 @@
<tree> <tree>
<field name="price"/> <field name="price"/>
<field name="product"/> <field name="product"/>
<field name="attributes"/>
</tree> </tree>

View File

@@ -609,6 +609,26 @@ class Product(
('template.code', operator, code_value, *extra), ('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 @staticmethod
def get_price_uom(products, name): def get_price_uom(products, name):
Uom = Pool().get('product.uom') Uom = Pool().get('product.uom')

View File

@@ -92,6 +92,13 @@ class Uom(SymbolMixin, DigitsMixin, DeactivableMixin, ModelSQL, ModelView):
def default_digits(): def default_digits():
return 2 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') @fields.depends('factor')
def on_change_factor(self): def on_change_factor(self):
if (self.factor or 0.0) == 0.0: if (self.factor or 0.0) == 0.0:

View File

@@ -22,6 +22,7 @@ class Month(ModelView, ModelSQL):
is_cotation = fields.Boolean("Cotation month") is_cotation = fields.Boolean("Cotation month")
beg_date = fields.Date("Date from") beg_date = fields.Date("Date from")
end_date = fields.Date("Date end") end_date = fields.Date("Date end")
description = fields.Char("Description")
class ProductMonth(ModelView, ModelSQL): class ProductMonth(ModelView, ModelSQL):
"Product month" "Product month"

105
modules/purchase/AGENTS.md Normal file
View 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.

View 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`

View File

@@ -89,14 +89,14 @@ class Purchase(
number = fields.Char("Number", readonly=True) number = fields.Char("Number", readonly=True)
reference = fields.Char("Reference") reference = fields.Char("Reference")
description = fields.Char('Description', size=None, states=_states) description = fields.Char('Description', size=None, states=_states)
purchase_date = fields.Date('Purchase Date', purchase_date = fields.Date('Purchase Date', required=True,
states={ states={
'readonly': ~Eval('state').in_(['draft', 'quotation']), 'readonly': ~Eval('state').in_(['draft', 'quotation']),
'required': ~Eval('state').in_( 'required': ~Eval('state').in_(
['draft', 'quotation', 'cancelled']), ['draft', 'quotation', 'cancelled']),
}) })
payment_term = fields.Many2One( payment_term = fields.Many2One(
'account.invoice.payment_term', "Payment Term", ondelete='RESTRICT', 'account.invoice.payment_term', "Payment Term", required=True, ondelete='RESTRICT',
states={ states={
'readonly': ~Eval('state').in_(['draft', 'quotation']), 'readonly': ~Eval('state').in_(['draft', 'quotation']),
}) })
@@ -389,6 +389,11 @@ class Purchase(
def default_state(): def default_state():
return 'draft' return 'draft'
@classmethod
def default_purchase_date(cls):
Date = Pool().get('ir.date')
return Date.today()
@classmethod @classmethod
def default_currency(cls, **pattern): def default_currency(cls, **pattern):
pool = Pool() pool = Pool()
@@ -462,6 +467,8 @@ class Purchase(
self.tol_min = self.party.tol_min self.tol_min = self.party.tol_min
if self.party.tol_max: if self.party.tol_max:
self.tol_max = 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: if self.party.wb:
self.wb = self.party.wb self.wb = self.party.wb
if self.party.association: if self.party.association:
@@ -734,6 +741,7 @@ class Purchase(
@classmethod @classmethod
def copy(cls, purchases, default=None): def copy(cls, purchases, default=None):
Date = Pool().get('ir.date')
if default is None: if default is None:
default = {} default = {}
else: else:
@@ -742,7 +750,7 @@ class Purchase(
default.setdefault('invoice_state', 'none') default.setdefault('invoice_state', 'none')
default.setdefault('invoices_ignored', None) default.setdefault('invoices_ignored', None)
default.setdefault('shipment_state', 'none') default.setdefault('shipment_state', 'none')
default.setdefault('purchase_date', None) default.setdefault('purchase_date', Date.today())
default.setdefault('quoted_by') default.setdefault('quoted_by')
default.setdefault('confirmed_by') default.setdefault('confirmed_by')
default.setdefault('untaxed_amount_cache') default.setdefault('untaxed_amount_cache')
@@ -1021,9 +1029,15 @@ class Purchase(
pool = Pool() pool = Pool()
Invoice = pool.get('account.invoice') Invoice = pool.get('account.invoice')
Invoice.save(invoices.values()) Invoice.save(invoices.values())
for purchase, invoice in invoices.items(): 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) purchase.copy_resources_to(invoice)
if len(invoices)==1: if len(invoices)==1:
if prepayment: if prepayment:
@@ -1215,7 +1229,7 @@ class Line(sequence_ordered(), ModelSQL, ModelView):
()), ()),
If(Eval('type') != 'line', If(Eval('type') != 'line',
('id', '=', None), ('id', '=', None),
()), ())
], ],
states={ states={
'invisible': Eval('type') != 'line', 'invisible': Eval('type') != 'line',
@@ -1685,7 +1699,7 @@ class Line(sequence_ordered(), ModelSQL, ModelView):
@fields.depends( @fields.depends(
'type', 'quantity', 'unit_price', 'type', 'quantity', 'unit_price',
'purchase', '_parent_purchase.currency') 'purchase', '_parent_purchase.currency','premium')
def on_change_with_amount(self): def on_change_with_amount(self):
if (self.type == 'line' if (self.type == 'line'
and self.quantity is not None and self.quantity is not None
@@ -1857,77 +1871,93 @@ class Line(sequence_ordered(), ModelSQL, ModelView):
else: else:
lots_to_invoice = self.lots lots_to_invoice = self.lots
for l in lots_to_invoice: for l in lots_to_invoice:
#if l.lot_type == 'physic': if l.lot_type == 'physic':
invoice_line = InvoiceLine() invoice_line = InvoiceLine()
invoice_line.type = self.type invoice_line.type = self.type
invoice_line.currency = self.currency invoice_line.currency = self.currency
invoice_line.company = self.company invoice_line.company = self.company
invoice_line.description = self.description invoice_line.description = self.description
invoice_line.note = self.note invoice_line.note = self.note
invoice_line.origin = self invoice_line.origin = self
qt, gross_qt = l.get_hist_quantity(0) qt, gross_qt = l.get_hist_quantity(0)
quantity = float(qt) quantity = float(qt)
quantity = Uom.compute_qty(l.lot_unit_line, quantity, self.unit) quantity = Uom.compute_qty(l.lot_unit_line, quantity, self.unit)
if self.unit: if self.unit:
quantity = self.unit.round(quantity) quantity = self.unit.round(quantity)
invoice_line.unit_price = l.get_lot_price() invoice_line.unit_price = l.get_lot_price()
invoice_line.product = l.lot_product invoice_line.product = l.lot_product
invoice_line.quantity = quantity invoice_line.quantity = quantity
if not invoice_line.quantity: logger.info("GETINVOICELINE_QT:%s",quantity)
return [] if not invoice_line.quantity:
invoice_line.unit = self.unit return []
invoice_line.taxes = self.taxes invoice_line.unit = self.unit
if self.company.purchase_taxes_expense: invoice_line.taxes = self.taxes
invoice_line.taxes_deductible_rate = 0 if self.company.purchase_taxes_expense:
elif self.product: invoice_line.taxes_deductible_rate = 0
invoice_line.taxes_deductible_rate = ( elif self.product:
self.product.supplier_taxes_deductible_rate_used) invoice_line.taxes_deductible_rate = (
invoice_line.invoice_type = 'in' self.product.supplier_taxes_deductible_rate_used)
if self.product: invoice_line.invoice_type = 'in'
invoice_line.account = self.product.account_stock_in_used if self.product:
if not invoice_line.account: if self.product.type == 'service' and not self.product.landed_cost:
raise AccountError( invoice_line.account = self.product.account_stock_in_used
gettext('purchase' else:
'.msg_purchase_product_missing_account_expense', invoice_line.account = self.product.account_stock_in_used
purchase=self.purchase.rec_name, if not invoice_line.account:
product=self.product.rec_name)) raise AccountError(
else: gettext('purchase'
invoice_line.account = account_config.get_multivalue( '.msg_purchase_product_missing_account_expense',
'default_category_account_expense', company=self.company.id) purchase=self.purchase.rec_name,
if not invoice_line.account: product=self.product.rec_name))
raise AccountError( else:
gettext('purchase' invoice_line.account = account_config.get_multivalue(
'.msg_purchase_missing_account_expense', 'default_category_account_expense', company=self.company.id)
purchase=self.purchase.rec_name)) if not invoice_line.account:
if action == 'prov': raise AccountError(
invoice_line.description = 'Pro forma' gettext('purchase'
elif action == 'final': '.msg_purchase_missing_account_expense',
invoice_line.description = 'Final' purchase=self.purchase.rec_name))
elif action == 'service': if action == 'prov':
invoice_line.description = 'Service' invoice_line.description = 'Pro forma'
#invoice_line.stock_moves = self._get_invoice_line_moves() elif action == 'final':
#invoice_line.stock_moves = [l.get_current_supplier_move()] invoice_line.description = 'Final'
invoice_line.lot = l.id elif action == 'service':
if self.product.type == 'service': invoice_line.description = 'Service'
invoice_line.unit_price = self.unit_price #invoice_line.stock_moves = self._get_invoice_line_moves()
invoice_line.product = self.product #invoice_line.stock_moves = [l.get_current_supplier_move()]
invoice_line.stock_moves = [] invoice_line.lot = l.id
Fee = Pool().get('fee.fee') if self.product.type == 'service':
fee = Fee.search(['purchase','=',self.purchase.id]) invoice_line.unit_price = self.unit_price
if fee: invoice_line.product = self.product
invoice_line.fee = fee[0] invoice_line.stock_moves = []
lines.append(invoice_line) Fee = Pool().get('fee.fee')
logger.info("GETINVLINE:%s",self.product.type) fee = Fee.search(['purchase','=',self.purchase.id])
logger.info("GETINVLINE2:%s",l.invoice_line_prov) if fee:
if l.invoice_line_prov and self.product.type != 'service': invoice_line.fee = fee[0]
invoice_line_, = InvoiceLine.copy([l.invoice_line_prov], default={ if fee[0].mode == 'lumpsum':
'invoice': None, invoice_line.quantity = 1
'quantity': -l.invoice_line_prov.quantity, elif fee[0].mode == 'ppack':
'unit_price': l.invoice_line_prov.unit_price, invoice_line.quantity = fee[0].quantity
'party': l.invoice_line_prov.invoice.party, else:
'origin': str(self), state_id = 0
}) LotQtType = Pool().get('lot.qt.type')
lines.append(invoice_line_) 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 return lines
def _get_invoice_line_quantity(self): def _get_invoice_line_quantity(self):
@@ -1990,8 +2020,7 @@ class Line(sequence_ordered(), ModelSQL, ModelView):
''' '''
pool = Pool() pool = Pool()
Move = pool.get('stock.move') Move = pool.get('stock.move')
InvoiceLine = pool.get('account.invoice.line') Location = pool.get('stock.location')
Uom = pool.get('product.uom')
if self.type != 'line': if self.type != 'line':
return return
if not self.product: if not self.product:
@@ -2047,26 +2076,28 @@ class Line(sequence_ordered(), ModelSQL, ModelView):
to_location = self.purchase.to_location to_location = self.purchase.to_location
move.from_location = from_location move.from_location = from_location
logger.info("FROM_LOCATION:%s",self.purchase.from_location)
if to_location.type != 'customer': logger.info("TO_LOCATION:%s",self.purchase.to_location)
move.to_location = 8 if to_location:
else: if to_location.type != 'customer':
move.to_location = to_location move.to_location = Location.get_transit_id()
else:
move.to_location = to_location
unit_price = l.get_lot_price() unit_price = l.get_lot_price()
if l.invoice_line_prov != None : # if l.invoice_line_prov != None :
prov_inv = InvoiceLine(l.invoice_line_prov) # prov_inv = InvoiceLine(l.invoice_line_prov)
quantity -= prov_inv.quantity # quantity -= prov_inv.quantity
if quantity < 0 : # if quantity < 0 :
move.from_location = self.purchase.to_location # move.from_location = self.purchase.to_location
move.to_location = 16 # move.to_location = 16
elif quantity > 0 : # elif quantity > 0 :
move.from_location = 16 # move.from_location = 16
move.to_location = self.purchase.to_location # move.to_location = self.purchase.to_location
quantity = abs(quantity) # quantity = abs(quantity)
unit_price = prov_inv.unit_price # unit_price = prov_inv.unit_price
if quantity == 0: # if quantity == 0:
continue # continue
move.quantity = quantity move.quantity = quantity
move.unit = self.unit move.unit = self.unit
move.product = l.lot_product move.product = l.lot_product
@@ -2086,7 +2117,7 @@ class Line(sequence_ordered(), ModelSQL, ModelView):
moves.append(move) moves.append(move)
if move.to_location.type != 'customer': if move.to_location.type != 'customer':
move_to, = Move.copy([move.id], default={ move_to, = Move.copy([move.id], default={
'from_location': 8, 'from_location': Location.get_transit_id(),
'to_location': to_location, 'to_location': to_location,
}) })
moves.append(move_to) moves.append(move_to)

View File

@@ -17,9 +17,6 @@ this repository contains the full copyright notices and license terms. -->
<field name="payment_term"/> <field name="payment_term"/>
<label name="currency"/> <label name="currency"/>
<field name="currency"/> <field name="currency"/>
<newline/>
<label name="certif"/>
<field name="certif"/>
</group> </group>
<group col="2" colspan="2" id="hd" yfill="1"> <group col="2" colspan="2" id="hd" yfill="1">
<field name="viewer" widget="html_viewer" height="300" width="600"/> <field name="viewer" widget="html_viewer" height="300" width="600"/>

View File

@@ -16,13 +16,21 @@ this repository contains the full copyright notices and license terms. -->
<label name="product_supplier"/> <label name="product_supplier"/>
<field name="product_supplier"/> <field name="product_supplier"/>
<newline/> <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:"/> <label id="delivery_date" string="Delivery Date:"/>
<group id="delivery_date" col="-1"> <group id="delivery_date" col="-1">
<field name="delivery_date" xexpand="0"/> <field name="delivery_date" xexpand="0"/>
<field name="delivery_date_edit" xexpand="0" xalign="0"/> <field name="delivery_date_edit" xexpand="0" xalign="0"/>
</group> </group>
<label name="del_period"/>
<field name="del_period"/>
<newline/> <newline/>
<label name="from_del"/> <label name="from_del"/>
<field 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"/> <separator name="description" colspan="4"/>
<field name="description" colspan="4"/> <field name="description" colspan="4"/>
</page> </page>
<page string="Attributes" id="att">
<label name="attributes"/>
<field name="attributes"/>
</page>
<page string="Taxes" id="taxes"> <page string="Taxes" id="taxes">
<field name="taxes" colspan="4"/> <field name="taxes" colspan="4"/>
</page> </page>

View File

@@ -0,0 +1,183 @@
# AGENTS.md - Module `purchase_trade`
Ce guide complete le `AGENTS.md` racine.
Pour ce module, les regles locales ci-dessous priment.
## 1) Perimetre metier
Le module `purchase_trade` etend les flux achat/vente Tryton avec une logique
de negoce physique:
- contrats d'achat (`purchase.purchase`, `purchase.line`)
- contrats de vente (`sale.sale`, `sale.line`)
- lots physiques et virtuels
- matching achat/vente
- shipments et execution logistique
- frais (`fee.fee`)
- templates de documents metier et facture
## 2) Fichiers pivots
- Contrats achat:
- `modules/purchase_trade/purchase.py`
- Contrats vente:
- `modules/purchase_trade/sale.py`
- Lots / matching / invoicing:
- `modules/purchase_trade/lot.py`
- Shipments / lien facture-lot:
- `modules/purchase_trade/stock.py`
- Fees:
- `modules/purchase_trade/fee.py`
- Bridge facture / templates:
- `modules/purchase_trade/invoice.py`
- Vues:
- `modules/purchase_trade/view/*.xml`
- Actions module:
- `modules/purchase_trade/*.xml`
- Manifest:
- `modules/purchase_trade/tryton.cfg`
## 3) Documentation locale a lire en priorite
- Regles metier:
- `modules/purchase_trade/docs/business-rules.md`
- Regles templates:
- `modules/purchase_trade/docs/template-rules.md`
- Catalogue des proprietes templates:
- `modules/purchase_trade/docs/template-properties.md`
## 4) Invariants metier a preserver
- Un lot `virtual` est la reference d'ouverture de quantite pour une `purchase.line`.
- Une `sale.line` doit aussi avoir au minimum un lot `virtual`; une valuation
cote sale ne doit donc pas disparaitre juste parce que le lot est `open`.
- Le lot physique est le pont principal entre:
- `purchase.line`
- `sale.line`
- shipment
- facture
- Pour remonter d'une facture vers shipment / BL / controller / fret:
- privilegier le lot physique
- ne pas multiplier des chemins d'acces concurrents
- Pour les champs de colis (`NB BALES`) dans les templates facture:
- la source de verite est `line.lot.lot_qt`
- sur une facture, sommer les `lot_qt` des lignes de facture
- tenir compte du signe de la ligne de facture pour les notes finales
- ne pas proratiser depuis le poids (`net` / `gross`)
- Le `FREIGHT VALUE` d'un template facture vient du `fee.fee` du shipment
dont le produit est `Maritime freight`.
- Pour `stock/insurance.fodt`, le `Amount insured` doit venir en priorite de
`110%` du total des `incoming_moves` (fallback fee `Insurance` si aucun
montant incoming calculable).
- Pour le surveyor du certificat d'assurance shipment, la priorite est:
`shipment.surveyor` -> `shipment.controller` -> fournisseur du fee
`Insurance`.
- Pour `payment_order.fodt`, utiliser des proprietes
`invoice.report_payment_order_*` plutot que des tokens legacy `<...>`.
- Ajouter un champ de template dans `Document Templates` ne rend pas le report
visible dans la fiche: il faut aussi l'action `ir.action.report` +
`ir.action.keyword` (`form_print`) cote `account.invoice`.
- Le wizard `Create contracts` en mode `matched` peut maintenant partir de
plusieurs `lot.qt`, mais doit conserver un matching par lot source et laisser
`created_by_code = True` sur les lignes creees pour ne pas declencher les
creations automatiques de lots dans les validations.
- En valuation / PnL:
- la valeur stockee dans `type` est la cle technique (`pur. priced`,
`sale priced`, `pur. fee`, etc.), pas le label affiche dans l'UI
- les references doivent rester coherentes avec le type de lot:
`Purchase/Open`, `Purchase/Physic`, `Sale/Open`, `Sale/Physic`
- pour une sale matchee, les lignes de valuation purchase generees sur un lot
physique doivent aussi renseigner `sale` et `sale_line` afin de remonter
dans l'onglet PnL de la sale
- une sale non matchee doit etre valorisable "sale-first" et alimenter
`valuation.valuation` / `valuation.valuation.line`
- si une `sale.line` `basis` n'a ni `price_summary` ni `lot_price_sale`,
creer quand meme une ligne `sale priced` avec `price = 0` et `amount = 0`
plutot que de ne rien generer
- le MTM ne doit etre renseigne que pour `pur. priced`, `sale priced` et
`derivative`; jamais pour les fees
- `mtm_price` doit afficher le prix brut de valorisation (sans ratio), alors
que `mtm` reste le montant calcule selon la logique de strategie
- En pricing:
- le `unit_price` doit rester un prix de base, hors `premium`
- le `premium` doit impacter le prix total economique et donc le `amount`,
aussi bien en `priced` qu'en `basis`
- dans `pricing.pricing` en saisie manuelle, l'utilisateur renseigne
seulement `quantity` et `settl_price`
- `fixed_qt`, `fixed_qt_price`, `unfixed_qt`, `unfixed_qt_price` et
`eod_price` sont des valeurs derivees et ne doivent pas etre saisies a la
main
- en manuel, `fixed_qt` = cumul des `quantity` du groupe trie par
`pricing_date`
- en manuel, `fixed_qt_price` = moyenne ponderee cumulee des `settl_price`
- en manuel, `unfixed_qt_price` = `settl_price` de la ligne
- pour les documents commerciaux / facture, une ligne `basis` affiche le
`premium` comme prix visible, pas le prix economique total
- si `linked currency` est active, le `premium` est saisi dans la devise /
unite liee (ex: `USC/LB`) puis converti vers le repere de la ligne pour le
calcul du `amount`
- en `basis + linked currency`, le `linked_price` doit representer le prix
basis brut (hors premium) dans la devise liee; le `unit_price` reste ce
prix brut converti, et le `premium` converti est ajoute seulement dans
l'`amount`
- si `linked currency` est cochee, `linked_price`, `linked_currency` et
`linked_unit` sont requis
- dans les forms, presenter le bloc prix dans l'ordre:
`price_type` -> linked fields -> `premium` -> `unit_price` -> `amount`
- en valuation `basis`, le premium s'applique a chaque composant, pas
uniquement a une ligne de resume
- pour une ligne `basis` sans `price_summary`, la valuation fallback doit
utiliser `unit_price + premium` (et pas `unit_price` seul)
- a la validation d'une `sale.line`, si un lot virtuel est cree et qu'aucun
matching purchase n'existe, il faut lancer `generate_from_sale_line()` pour
alimenter le PnL sale-first
## 5) Conventions de modification
1. Modifier la logique metier dans le fichier pivot le plus proche.
2. Si un template `.fodt` devient complexe, deplacer la logique dans une
propriete Python `report_*`.
3. Pour une facture trade, preferer enrichir `modules/purchase_trade/invoice.py`
plutot que surcharger lourdement le `.fodt`.
4. Si une regle metier durable change, mettre a jour
`docs/business-rules.md`.
5. Si une convention de template change, mettre a jour
`docs/template-rules.md`.
6. Pour les vues XML Tryton de ce module, utiliser `editable="1"` sur les
`<tree>` editables; ne pas utiliser `editable="bottom"`.
7. Si une regle de texte par defaut durable est demandee sur achat/vente,
preferer un singleton de configuration expose dans un menu fonctionnel
existant plutot qu'un menu technique `purchase_trade`.
## 6) Pieges connus
- Plusieurs actions de report `account.invoice` peuvent sembler rendre le meme
document a cause du cache `invoice_report_cache`.
- Les reports alternatifs (`Final Invoice`, `Prepayment`, etc.) ne doivent pas
reutiliser le cache du report standard sans verification.
- Pour les donnees achat/vente partagees, ne pas supposer qu'une facture de
vente doit lire directement sur la `sale.line`: souvent, la verite metier
passe par le lot physique et/ou la `account.invoice.line`.
- Les templates `invoice_ict*` peuvent partager les memes proprietes Python;
si une regle doit valoir pour provisional et final, la mettre dans
`modules/purchase_trade/invoice.py` plutot que dupliquer dans les `.fodt`.
- Dans les ecrans PnL, le label `Sale price` correspond au type stocke
`sale priced`; idem pour `Pur. price` / `pur. priced`.
- Une ligne `basis` sans resume de pricing peut sinon disparaitre de la
valuation si aucun fallback explicite a `0` n'est prevu.
- Le calcul du prix peut diverger entre `unit_price`, `linked_price`,
`lot_price` et valuation si le premium n'est pas traite explicitement dans
chaque maillon.
- Sur `account.invoice`, le workflow `Validate` doit maintenant aligner
fournisseur et client pour:
- creation du `account.move`
- attribution du `number`
- `Post` ne doit pas reintroduire une difference de session/fresh login cote
client
## 7) Definition of done (module `purchase_trade`)
- Le flux achat/vente/lot cible reste coherent.
- Les impacts templates/facture ont ete verifies conceptuellement.
- Les docs locales ont ete mises a jour si une nouvelle regle durable a emerge.
- Le patch reste minimal et local au domaine demande.

View File

@@ -3,7 +3,39 @@
from trytond.pool import Pool 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,
configuration,
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(): def register():
Pool.register( Pool.register(
@@ -23,9 +55,10 @@ def register():
incoming.ImportSwift, incoming.ImportSwift,
lc.LCMT700, lc.LCMT700,
lc.LCMessage, lc.LCMessage,
lc.CreateLCStart, lc.CreateLCStart,
global_reporting.GRConfiguration, global_reporting.GRConfiguration,
module='purchase_trade', type_='model') configuration.Configuration,
module='purchase_trade', type_='model')
Pool.register( Pool.register(
incoming.ImportSwift, incoming.ImportSwift,
incoming.PrepareDocuments, incoming.PrepareDocuments,
@@ -47,6 +80,9 @@ def register():
dashboard.News, dashboard.News,
dashboard.Demos, dashboard.Demos,
party.Party, party.Party,
party.PartyExecution,
party.PartyExecutionSla,
party.PartyExecutionPlace,
payment_term.PaymentTerm, payment_term.PaymentTerm,
payment_term.PaymentTermLine, payment_term.PaymentTermLine,
purchase.Purchase, purchase.Purchase,
@@ -69,9 +105,15 @@ def register():
fee.Fee, fee.Fee,
fee.FeeLots, fee.FeeLots,
purchase.FeeLots, purchase.FeeLots,
fee.Valuation, valuation.Valuation,
fee.ValuationDyn, valuation.ValuationLine,
derivative.Derivative, valuation.ValuationDyn,
valuation.ValuationReport,
valuation.ValuationReportContext,
valuation.ValuationProcessDimension,
valuation.ValuationProcessStart,
valuation.ValuationProcessResult,
derivative.Derivative,
derivative.DerivativeMatch, derivative.DerivativeMatch,
derivative.MatchWizardStart, derivative.MatchWizardStart,
derivative.DerivativeReport, derivative.DerivativeReport,
@@ -82,9 +124,12 @@ def register():
forex.PForex, forex.PForex,
forex.ForexBI, forex.ForexBI,
purchase.PnlBI, purchase.PnlBI,
purchase.PositionBI,
stock.Move, stock.Move,
stock.Location,
stock.InvoiceLine, stock.InvoiceLine,
stock.ShipmentIn, stock.ShipmentIn,
stock.ShipmentWR,
stock.ShipmentInternal, stock.ShipmentInternal,
stock.ShipmentOut, stock.ShipmentOut,
stock.StatementOfFacts, stock.StatementOfFacts,
@@ -128,14 +173,41 @@ def register():
purchase.ContractDocumentType, purchase.ContractDocumentType,
purchase.DocTemplate, purchase.DocTemplate,
purchase.DocTypeTemplate, 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') 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( Pool.register(
forex.Forex, forex.Forex,
forex.ForexCoverFees, forex.ForexCoverFees,
forex.ForexCategory, forex.ForexCategory,
pricing.Component, pricing.Component,
pricing.Mtm, pricing.Mtm,
pricing.MtmStrategy,
pricing.MtmScenario,
pricing.MtmSnapshot,
pricing.PriceMatrix,
pricing.PriceMatrixLine,
pricing.Estimated, pricing.Estimated,
pricing.Pricing, pricing.Pricing,
pricing.Period, pricing.Period,
@@ -151,6 +223,9 @@ def register():
sale.SaleCreatePurchaseInput, sale.SaleCreatePurchaseInput,
sale.Derivative, sale.Derivative,
sale.Valuation, sale.Valuation,
sale.ValuationLine,
sale.ValuationDyn,
sale.ValuationReport,
sale.Fee, sale.Fee,
sale.Lot, sale.Lot,
sale.FeeLots, sale.FeeLots,
@@ -161,8 +236,11 @@ def register():
forex.SForex, forex.SForex,
forex.ForexCoverPhysicalSale, forex.ForexCoverPhysicalSale,
sale.ContractDocumentType, sale.ContractDocumentType,
sale.Mtm, sale.SaleStrategy,
sale.OpenPosition, sale.OpenPosition,
sale.Backtoback,
sale.AnalyticDimensionAssignment,
sale.PriceComposition,
module='sale', type_='model') module='sale', type_='model')
Pool.register( Pool.register(
lot.LotShipping, lot.LotShipping,
@@ -185,13 +263,23 @@ def register():
purchase.InvoicePayment, purchase.InvoicePayment,
stock.ImportSoFWizard, stock.ImportSoFWizard,
dashboard.BotWizard, dashboard.BotWizard,
dashboard.DashboardLoader, dashboard.DashboardLoader,
forex.ForexReport, forex.ForexReport,
purchase.PnlReport, purchase.PnlReport,
derivative.DerivativeMatchWizard, purchase.PositionReport,
module='purchase', type_='wizard') valuation.ValuationProcess,
Pool.register( derivative.DerivativeMatchWizard,
sale.SaleCreatePurchase, module='purchase', type_='wizard')
sale.SaleAllocationsWizard, Pool.register(
module='sale', type_='wizard') sale.SaleCreatePurchase,
sale.SaleAllocationsWizard,
module='sale', type_='wizard')
Pool.register(
invoice.InvoiceReport,
invoice.SaleReport,
invoice.PurchaseReport,
stock.ShipmentShippingReport,
stock.ShipmentInsuranceReport,
stock.ShipmentPackingListReport,
module='purchase_trade', type_='report')

View 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

View 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>

View 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")

View 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>

View File

@@ -0,0 +1,19 @@
from trytond.model import ModelSingleton, ModelSQL, ModelView, fields
class Configuration(ModelSingleton, ModelSQL, ModelView):
"Purchase Trade Configuration"
__name__ = 'purchase_trade.configuration'
pricing_rule = fields.Text("Pricing Rule")
sale_report_template = fields.Char("Sale Template")
sale_bill_report_template = fields.Char("Sale Bill Template")
sale_final_report_template = fields.Char("Sale Final Template")
invoice_report_template = fields.Char("Invoice Template")
invoice_cndn_report_template = fields.Char("CN/DN Template")
invoice_prepayment_report_template = fields.Char("Prepayment Template")
invoice_payment_order_report_template = fields.Char("Payment Order Template")
purchase_report_template = fields.Char("Purchase Template")
shipment_shipping_report_template = fields.Char("Shipping Template")
shipment_insurance_report_template = fields.Char("Insurance Template")
shipment_packing_list_report_template = fields.Char("Packing List Template")

View File

@@ -0,0 +1,48 @@
<tryton>
<data>
<record model="ir.ui.view" id="purchase_trade_configuration_view_form">
<field name="model">purchase_trade.configuration</field>
<field name="type">form</field>
<field name="name">configuration_form</field>
</record>
<record model="ir.ui.view" id="purchase_trade_template_configuration_view_form">
<field name="model">purchase_trade.configuration</field>
<field name="type">form</field>
<field name="name">template_configuration_form</field>
</record>
<record model="ir.action.act_window" id="act_purchase_trade_configuration_form">
<field name="name">Pricing Configuration</field>
<field name="res_model">purchase_trade.configuration</field>
</record>
<record model="ir.action.act_window.view" id="act_purchase_trade_configuration_form_view1">
<field name="sequence" eval="10"/>
<field name="view" ref="purchase_trade_configuration_view_form"/>
<field name="act_window" ref="act_purchase_trade_configuration_form"/>
</record>
<record model="ir.action.act_window" id="act_purchase_trade_template_configuration_form">
<field name="name">Document Templates</field>
<field name="res_model">purchase_trade.configuration</field>
</record>
<record model="ir.action.act_window.view" id="act_purchase_trade_template_configuration_form_view1">
<field name="sequence" eval="10"/>
<field name="view" ref="purchase_trade_template_configuration_view_form"/>
<field name="act_window" ref="act_purchase_trade_template_configuration_form"/>
</record>
<menuitem
name="Configuration"
parent="price.menu_price"
action="act_purchase_trade_configuration_form"
sequence="10"
id="menu_purchase_trade_configuration"
icon="tryton-settings"/>
<menuitem
name="Document Templates"
parent="document_incoming.menu_configuration"
action="act_purchase_trade_template_configuration_form"
sequence="20"
id="menu_purchase_trade_template_configuration"
icon="tryton-settings"/>
</data>
</tryton>

View File

@@ -155,7 +155,7 @@ class Party(metaclass=PoolMeta):
if overdue > 0: if overdue > 0:
# scale by overdue relative to limit # scale by overdue relative to limit
limit = self.credit_limit or 1 limit = self.credit_limit or 1
score += int(min(40, (overdue / float(limit)) * 100)) score += int(min(40, (float(overdue) / float(limit)) * 100))
# cap # cap
if score > 100: if score > 100:

View File

@@ -1,5 +1,5 @@
# This file is part of Tryton. The COPYRIGHT file at the top level of # 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.model import ModelSQL, ModelView, fields, sequence_ordered, ModelSingleton
from trytond.pyson import Eval from trytond.pyson import Eval
from trytond.transaction import Transaction from trytond.transaction import Transaction
@@ -193,7 +193,7 @@ class Dashboard(ModelSQL, ModelView):
self.chatbot = 'chatbot:' + json.dumps(dial, ensure_ascii=False) self.chatbot = 'chatbot:' + json.dumps(dial, ensure_ascii=False)
logger.info("EXITONCHANGE",self.chatbot) 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. Retourne (dernier_taux, avant_dernier_taux) pour le couple de devises.
""" """
@@ -208,29 +208,93 @@ class Dashboard(ModelSQL, ModelView):
rates = CurrencyRate.search( rates = CurrencyRate.search(
[('currency', '=', to_currency.id)], [('currency', '=', to_currency.id)],
order=[('date', 'DESC')], order=[('date', 'DESC')],
limit=2, limit=5,
) )
if not rates: if not rates:
return None, None return None, None, None, None, None
# Calcul du taux EUR/USD # Calcul du taux EUR/USD
# Si la devise principale de la société est EUR, et que le taux stocké est # 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 # "1 USD = X EUR", on veut l'inverse pour avoir EUR/USD
last_rate = rates[0].rate f1 = rates[0].rate
prev_rate = rates[1].rate if len(rates) > 1 else None 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: # if from_currency != to_currency:
# last_rate = 1 / last_rate if last_rate else None # last_rate = 1 / last_rate if last_rate else None
# prev_rate = 1 / prev_rate if prev_rate else None # prev_rate = 1 / prev_rate if prev_rate else None
if last_rate and prev_rate: 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
return round(1/last_rate,6), round(1/prev_rate,6)
def get_tremor(self,name): def get_tremor(self,name):
Pnl = Pool().get('valuation.valuation') Date = Pool().get('ir.date')
pnls = Pnl.search(['id','>',0]) Configuration = Pool().get('gr.configuration')
pnl_amount = "{:,.0f}".format(round(sum([e.amount for e in pnls]),0)) 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') Open = Pool().get('open.position')
opens = Open.search(['id','>',0]) opens = Open.search(['id','>',0])
exposure = "{:,.0f}".format(round(sum([e.net_exposure for e in opens]),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) val_s = len(val)
conf = Sale.search(['state','=','confirmed']) conf = Sale.search(['state','=','confirmed'])
conf_s = len(conf) conf_s = len(conf)
Shipment = Pool().get('stock.shipment.in')
draft = Shipment.search(['state','=','draft']) draft = Shipment.search(['state','=','draft'])
shipment_d = len(draft) shipment_d = len(draft)
val = Purchase.search(['state','=','started']) val = Shipment.search(['state','=','started'])
shipment_s = len(val) shipment_s = len(val)
conf = Purchase.search(['state','=','received']) conf = Shipment.search(['state','=','received'])
shipment_r = len(conf) shipment_r = len(conf)
Lot = Pool().get('lot.lot') 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) 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) lot_a = len(val)
conf = Lot.search(['lot_type','=','physic'])
lot_al = len(conf)
Invoice = Pool().get('account.invoice') Invoice = Pool().get('account.invoice')
invs = Invoice.search(['type','=','in']) invs = Invoice.search(['type','=','in'])
inv_p = len(invs) 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']) invs = Invoice.search(['type','=','out'])
inv_s = len(invs) 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') AccountMove = Pool().get('account.move')
accs = AccountMove.search(['id','>',0]) accs = AccountMove.search([('journal','=',3),('state','!=','posted')])
move_cash = len(accs) pay_val = len(accs)
accs = AccountMove.search([('journal','=',3),('state','=','posted')])
pay_posted = len(accs)
return ( return (
"https://srv413259.hstgr.cloud/dashboard/index.html?pnl_amount=" config.dashboard +
"/dashboard/index.html?pnl_amount="
+ str(pnl_amount) + str(pnl_amount)
+ "&pnl_variation="
+ str(pnl_variation)
+ "&exposure=" + "&exposure="
+ str(exposure) + str(exposure)
+ "&topay=" + "&topay="
+ str(topay) + str(topay)
+ "&toreceive=" + "&toreceive="
+ str(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=" + "&draft_p="
+ str(draft_p) + str(draft_p)
+ "&val_p=" + "&val_p="
@@ -312,14 +407,22 @@ class Dashboard(ModelSQL, ModelView):
+ str(lot_m) + str(lot_m)
+ "&lot_a=" + "&lot_a="
+ str(lot_a) + str(lot_a)
+ "&lot_al="
+ str(lot_al)
+ "&inv_p=" + "&inv_p="
+ str(inv_p) + str(inv_p)
+ "&inv_p_p="
+ str(inv_p_p)
+ "&inv_p_np="
+ str(inv_p_np)
+ "&inv_s=" + "&inv_s="
+ str(inv_s) + str(inv_s)
+ "&move_cash=" + "&inv_s_p="
+ str(move_cash) + 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') News = Pool().get('news.news')
Date = Pool().get('ir.date') Date = Pool().get('ir.date')
news_list = News.search([('active', '=', True)], limit=5, order=[('publish_date', 'DESC')]) 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: if last_rate and prev_rate:
variation = ((last_rate - prev_rate) / prev_rate) * 100 if prev_rate else 0 variation = ((last_rate - prev_rate) / prev_rate) * 100 if prev_rate else 0
direction = "📈" if variation > 0 else "📉" 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>' ' <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: for n in demos:
icon = n.icon or "📰" icon = n.icon or "📰"
category = n.category or "General" category = n.category or "General"
@@ -499,12 +602,14 @@ class Dashboard(ModelSQL, ModelView):
return pu return pu
def gen_url(self,name=None): def gen_url(self,name=None):
Configuration = Pool().get('gr.configuration')
config = Configuration.search(['id','>',0])[0]
payload = { payload = {
"resource": {"dashboard": self.bi_id}, "resource": {"dashboard": self.bi_id},
"params": {}, "params": {},
"exp": datetime.datetime.utcnow() + datetime.timedelta(minutes=30), "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) logger.info("TOKEN:%s",token)
return f"metabase:http://vps107.geneva.hosting:3000/embed/dashboard/{token}#bordered=true&titled=true" 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_quantity = l.lot_qt
l.lot_gross_quantity = l.lot_qt l.lot_gross_quantity = l.lot_qt
l.lot_premium = Decimal(0) l.lot_premium = Decimal(0)
l.lot_chunk_key = None
lot_id = LotQt.add_physical_lots(lqt,[l]) lot_id = LotQt.add_physical_lots(lqt,[l])
d.action_return = 'lot.lot,' + str(lot_id) + ',' + str(lot_id) d.action_return = 'lot.lot,' + str(lot_id) + ',' + str(lot_id)
Dashboard.save([d]) Dashboard.save([d])

View 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'
)

View 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>

View File

@@ -0,0 +1,495 @@
# Business Rules - Purchase Trade
Statut: `draft`
Version: `v0.5`
Derniere mise a jour: `2026-04-10`
Owner metier: `a completer`
Owner technique: `a completer`
## 1) Scope
- Domaine: `purchase_trade`
- Hors scope:
- Modules impactes:
- `purchase_trade`
- `lot`
## 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`.
- `lot.qt`: table des quantites ouvertes, matchées ou shippées par lot.
- `lot.qt ouvert`: enregistrement `lot.qt` avec `lot_p = virtual lot`, `lot_s = None` et sans shipment.
## 3) Regles metier
### 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`.
- Description:
- 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`.
- Conditions d'entree:
- Une `purchase.line` existe deja.
- Son champ `quantity_theorical` est modifie via `write`.
- Un lot `virtual` est rattache a la ligne.
- Resultat attendu:
- 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`
- Exceptions:
- si aucun lot `virtual` n'est trouve sur la ligne, la regle ne fait rien
- Priorite:
- `bloquante`
- Source:
- `Decision metier documentee dans les commentaires de purchase_trade.purchase.Line.write`
### 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.
- Description:
- 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.
- Resultat attendu:
- 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
- Cas d'usage typiques:
- recuperer `bl_date`, `bl_number`, `controller`, `from_location`, `to_location`
- retrouver une facture provisoire liee au lot
- retrouver des fees rattaches au shipment
- Priorite:
- `structurante`
### 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.
- Description:
- 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
- Portee:
- 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`
- Priorite:
- `importante`
### BR-PT-004 - La valuation doit couvrir les flux purchase et sale, y compris les sales non matchees
- Intent: obtenir un PnL coherent cote achat et cote vente, meme lorsqu'une
sale n'est pas encore matchee a une purchase.
- Description:
- Le flux historique de valuation part de `purchase.line` puis remonte vers
les ventes via les lots/lots matchants.
- Le systeme doit egalement savoir valoriser directement une `sale.line`
non matchee ("sale-first").
- Une sale non matchee doit creer des lignes dans
`valuation.valuation` et `valuation.valuation.line` afin d'apparaitre dans
l'onglet PnL de la sale.
- Resultat attendu:
- pour une `sale.line` non matchee, generer au minimum les types:
- `sale priced`
- `sale fee`
- `derivative` si la ligne porte des derives
- si la sale est matchee via un lot physique, les lignes purchase portees par
ce lot physique doivent aussi renseigner `sale` et `sale_line`
- une sale matchee doit donc voir:
- ses lignes `sale *`
- les lignes purchase portees par le lot physique partage
- Priorite:
- `structurante`
### BR-PT-005 - Les references de valuation doivent decrire la nature du lot de la ligne
- Intent: eviter les ambiguïtes dans les ecrans PnL entre lots `open` et lots
`physic`.
- Description:
- La reference affichee dans la valuation doit decrire la ligne elle-meme,
pas son vis-a-vis.
- Les references autorisees pour les lignes de prix sont:
- `Purchase/Open`
- `Purchase/Physic`
- `Sale/Open`
- `Sale/Physic`
- Resultat attendu:
- un lot `virtual` cote purchase ne doit jamais sortir avec la reference
`Purchase/Physic`
- un lot `virtual` cote sale ne doit jamais sortir avec la reference
`Sale/Physic`
- un lot physique matche peut produire:
- une ligne purchase en `Purchase/Physic`
- une ligne sale en `Sale/Physic`
- un open sale matche a un open purchase peut produire des quantites egales
tout en gardant des references differentes (`Purchase/Open` vs `Sale/Open`)
- Priorite:
- `importante`
### BR-PT-006 - Une sale basis sans prix detaille doit quand meme apparaitre en valuation
- Intent: ne pas perdre les lignes de PnL lorsque le detail de pricing n'est
pas encore renseigne.
- Description:
- Une `sale.line` de type `basis` peut exister avec un lot `virtual`, sans
`price_summary` et sans `lot_price_sale`.
- Dans ce cas, la valuation doit quand meme creer une ligne `sale priced`.
- Resultat attendu:
- si `price_summary` est vide:
- creer une ligne `sale priced`
- avec `price = 0`
- avec `amount = 0`
- avec un `state` de type `unfixed`
- si `lot_price_sale` est vide sur un lot sale, utiliser `sale_line.unit_price`
comme fallback quand il existe
- Priorite:
- `importante`
### BR-PT-007 - Le MTM de valuation ne s'applique pas aux fees
- Intent: distinguer les lignes de prix marquables au marche des lignes de
frais qui ne doivent pas etre mark-to-market.
- Description:
- Le systeme peut renseigner `mtm_price`, `mtm` et `strategy` uniquement pour:
- `pur. priced`
- `sale priced`
- `derivative`
- Les fees (`pur. fee`, `sale fee`, `shipment fee`, `line fee`) ne doivent
jamais porter de valorisation MTM.
- Resultat attendu:
- les lignes de fee doivent conserver:
- `mtm_price = NULL`
- `mtm = NULL`
- `strategy = NULL`
- `mtm_price` doit representer le prix brut de valorisation sans appliquer le
ratio de composant
- `mtm` reste le montant calcule selon la logique de strategie
- Priorite:
- `structurante`
### BR-PT-008 - Le premium fait partie du prix contractuel en `priced` et en `basis`
- Intent: garantir que le montant total valorise et facture reflete toujours le
premium/discount saisi sur la ligne.
- Description:
- Le `premium` d'une `purchase.line` ou `sale.line` doit impacter le prix
total quelle que soit la `price_type`.
- Cette regle vaut pour:
- les calculs de `amount`
- la valuation / PnL
- Resultat attendu:
- le `unit_price` reste le prix de base, hors premium
- en `priced`, le montant economique = `unit_price + premium`
- en `basis`, le premium s'ajoute aussi au prix total economique
- en valuation `basis`, le premium s'applique a chaque composant valorise
(ex: meme premium repete sur chaque bloc ICE)
- Exemple metier:
- `8.30 USC/LB 500 TONS ON ICE MCH'26`
- `8.30 USC/LB 500 TONS ON ICE MAY 26`
- le premium `8.30 USC/LB` s'applique a chaque composant
- Priorite:
- `structurante`
### BR-PT-009 - En linked currency, le premium est exprime dans la devise/unite liee
- Intent: respecter la facon dont les traders saisissent les prix sur certains
produits (ex: coton en `USC/LB`).
- Description:
- Quand `enable_linked_currency` est coche, le `premium` est saisi dans la
devise / unite liee, pas dans la devise / unite native de la ligne.
- Le systeme doit convertir ce premium vers le repere de la ligne pour les
calculs internes de montant et de valuation.
- Resultat attendu:
- `premium` est interprete dans le repere `linked_currency` / `linked_unit`
- le `unit_price` ne doit pas absorber ce premium
- les `amount` et valuations doivent refleter ce premium converti
- si `linked currency` est cochee, `linked_price`, `linked_currency` et
`linked_unit` sont obligatoires
- Priorite:
- `structurante`
### BR-PT-010 - En `basis + linked currency`, le linked price suit le basis brut
- Intent: rendre lisible la decomposition entre prix basis de marche et premium.
- Description:
- Quand une ligne est en `basis` et `linked currency`, le bloc
`linked_price` doit etre recalcule automatiquement.
- Ce `linked_price` doit representer le prix basis brut, hors premium.
- Le `unit_price` de la ligne doit rester ce prix brut converti.
- Le premium converti n'est ajoute qu'au niveau du `amount`.
- Resultat attendu:
- modification du basis -> mise a jour automatique du `linked_price`
- `linked_price` = base market / basis
- `unit_price` = `linked_price` converti
- `amount` = quantite * (`unit_price` + premium converti)
- Priorite:
- `importante`
### BR-PT-011 - Une sale line non matchee avec lot virtuel doit generer une valuation sale-first des la validation
- Intent: ne pas attendre un matching purchase pour afficher le PnL d'une sale
ouverte.
- Description:
- Lors de la validation d'une `sale.line`, le systeme peut creer un lot
`virtual`.
- Si aucun `lot.qt` ne relie ce lot a une `purchase.line`, il faut tout de
meme generer la valuation cote sale.
### BR-PT-012 - Le wizard Create contracts peut creer un seul achat matche a plusieurs open sales
- Intent: permettre la creation d'un contrat achat unique a partir de plusieurs
`lot.qt` de vente selectionnes.
- Description:
- En mode `matched`, le wizard `Create contracts` peut recevoir plusieurs
`lot.qt` selectionnes.
- Il doit creer un seul contrat, avec une ligne par lot source selectionne.
- Chaque ligne doit conserver son lot d'origine pour le matching.
- Resultat attendu:
- le wizard agrege les quantites de la selection
- il refuse une quantite saisie differente du total selectionne
- il conserve `created_by_code = True` sur les lignes creees pour ne pas
declencher les creations automatiques parasites lors des validations
- Priorite:
- `importante`
### BR-PT-013 - Le texte par defaut de pricing_rule est configure globalement
- Intent: centraliser un texte metier recurrent reutilise a la creation des
lignes achat et vente.
- Description:
- Le module expose un singleton `purchase_trade.configuration` avec un champ
texte `pricing_rule`.
- Toute nouvelle `purchase.line` et `sale.line` doit prendre ce texte comme
valeur par defaut de `pricing_rule`.
- Resultat attendu:
- la configuration est accessible depuis le menu `Prices`
- la valeur sert de defaut a la creation des lignes
- les lignes existantes ne sont pas modifiees retroactivement
- Priorite:
- `importante`
### BR-PT-014 - L'affectation d'un controller doit suivre l'ecart a l'objectif regional
- Intent: repartir les controllers selon les cibles definies dans l'onglet
`Execution` des `party.party`.
- Description:
- chaque ligne `party.execution` fixe une cible `% targeted` pour un
controller sur une `country.region`
- le `% achieved` est calcule a partir des `stock.shipment.in` deja affectes
a un controller dans cette zone
- la zone d'un shipment est determinee par `shipment.to_location.country`
- une region parente couvre aussi ses sous-regions
- Resultat attendu:
- pour une ligne `party.execution`, `achieved_percent` =
`shipments de la zone avec ce controller / shipments controles de la zone`
- le denominateur ne compte que les `stock.shipment.in` qui ont deja un
`controller`; les shipments encore non affectes ne biaisent donc pas la
statistique affichee
- lors d'un choix automatique de controller, la priorite va a la regle dont
l'ecart `targeted - achieved` est le plus eleve
- un controller a `80%` cible et `40%` reel doit donc passer avant un
controller a `50%` cible et `45%` reel sur la meme zone
- l'appartenance a la zone se lit depuis `shipment.to_location.country`, et
une region parente couvre aussi ses sous-regions
- Priorite:
- `importante`
### BR-PT-015 - Les weight reports distants par lot partent du weight report global attache au shipment
- Intent: separer la creation du `weight.report` global et l'export detaille
par lot vers le systeme distant.
- Description:
- l'automation cree le `weight.report` global et l'attache au
`stock.shipment.in`
- l'export FastAPI par lot ne part plus directement de l'automation
- l'utilisateur ouvre le `weight.report` voulu depuis le shipment et lance
l'action d'export depuis ce rapport
- Resultat attendu:
- le rapport choisi sert de base unique pour calculer les payloads par lot
- seuls les lots physiques des `incoming_moves` du shipment sont exportes
- l'action exige au minimum un `controller` et un `returned_id` sur le
shipment
- les cles renvoyees par le systeme distant et la date d'envoi sont
conservees sur le `weight.report` local
- Priorite:
- `importante`
### BR-PT-016 - En pricing manuel, seules la quantite fixee du jour et le prix de marche sont saisis
- Intent: simplifier la saisie utilisateur et garantir une coherence unique
entre les colonnes de `pricing.pricing`.
- Description:
- Pour une ligne de `pricing.pricing` en mode manuel, l'utilisateur ne doit
saisir que:
- `quantity`
- `settl_price`
- Les autres colonnes de suivi sont derivees automatiquement sur tout le
groupe metier (`line + component` ou `sale_line + component`) trie par
`pricing_date`.
- Resultat attendu:
- `fixed_qt` = cumul des `quantity`
- `fixed_qt_price` = moyenne ponderee cumulee des `settl_price`
- `unfixed_qt` = quantite de base de la ligne - `fixed_qt`
- `unfixed_qt_price` = `settl_price` de la ligne
- `eod_price` = moyenne ponderee entre jambe fixee et non fixee
- `last=True` reste unique par groupe et suit la plus grande `pricing_date`
- Hors scope:
- la generation automatique des lignes quand `pricing.component.auto = True`
ne doit pas changer de comportement
- Priorite:
- `structurante`
### BR-PT-017 - Le workflow Validate des factures client doit aussi attribuer le numero
- Intent: aligner le comportement des factures client et fournisseur au moment
de `Validate`.
- Description:
- Lors du workflow `Validate` sur `account.invoice`, une facture client
(`type = out`) doit maintenant:
- creer son `account.move`
- recevoir son `number`
- La numerotation ne doit plus etre repoussee au `Post` cote client.
- Resultat attendu:
- a l'issue de `Validate`, une facture fournisseur ou client possede deja:
- son `account.move`
- son `number`
- `Post` conserve son role de posting comptable sans reintroduire de
difference de session/fresh login cote client
- Priorite:
- `importante`
- Resultat attendu:
- apres creation du lot virtuel, si aucun matching purchase n'existe:
- appeler `Valuation.generate_from_sale_line(line)`
- creer au moins la ligne `sale priced` fallback si la ligne porte un prix
economique via le premium
- Priorite:
- `importante`
### BR-PT-012 - Fallback valuation basis sans summary: utiliser le prix economique de la ligne
- Intent: eviter qu'une valuation `basis` ouverte sorte a zero alors que la
ligne a bien une valeur economique via le premium.
- Description:
- Une ligne `basis` peut ne pas avoir encore de `price_summary`.
- Dans ce cas, la valuation fallback ne doit pas prendre `unit_price` seul si
celui-ci est brut et hors premium.
- Resultat attendu:
- le fallback valuation `basis` doit utiliser:
- `unit_price + premium converti`
- cette regle vaut au minimum pour:
- `sale.line` non matchee
- `purchase.line` sans summary
- Priorite:
- `importante`
### BR-PT-013 - Create Contracts multi-lots doit conserver un matching par lot source
- Intent: permettre la creation d'un seul contrat mirror a partir de plusieurs
open quantities sans perdre le lien lot-a-lot.
- Description:
- Le wizard `Create contracts` peut etre lance avec plusieurs `lot.qt`
selectionnes.
- En creation `matched`, le systeme doit creer un seul contrat avec une ligne
par lot source selectionne, et chaque ligne doit etre matchee avec son lot
d'origine.
- Resultat attendu:
- la quantite totale du wizard = somme des open quantities selectionnees
- le contrat cree porte plusieurs lignes si plusieurs lots source sont
selectionnes
- chaque ligne creee reutilise le `shipment_origin` et le lot source qui lui
correspondent
- `created_by_code` doit rester positionne sur les lignes creees par wizard
pour eviter la recreation automatique de lots virtuels dans les `validate`
de `purchase.line`, `sale.line` et `lot.lot`
- Priorite:
- `importante`
## 4) Exemples concrets
### 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`
## 5) Impact code attendu
- Fichiers Python concernes:
- `modules/purchase_trade/purchase.py`
- `modules/purchase_trade/lot.py`
- `modules/purchase_trade/valuation.py`
- `modules/purchase_trade/sale.py`
## 6) Strategie de tests
Pour cette regle, couvrir au minimum:
- augmentation avec `lot.qt` ouvert existant
- augmentation sans `lot.qt` ouvert
- diminution possible
- diminution impossible avec erreur
- valuation purchase/sale sur lot physique matche
- valuation sale-first sur sale non matchee avec lot virtual
- valuation sale `basis` sans `price_summary`
- absence de MTM sur les fees
- premium en `priced`
- premium en `basis`
- premium en `linked currency`
- synchro `basis` -> `linked_price` -> `unit_price`

View File

@@ -0,0 +1,417 @@
# Template Properties - Purchase Trade
Statut: `draft`
Version: `v0.2`
Derniere mise a jour: `2026-04-07`
## 1) Objectif
- Lister les proprietes Python exposees pour alimenter les templates Relatorio.
- Donner un point d'entree rapide aux createurs de templates.
- Eviter de reparser tout `modules/purchase_trade/invoice.py`, `sale.py` ou `purchase.py`.
## 2) Fichiers sources
- Bridge facture:
- `modules/purchase_trade/invoice.py`
- Proprietes de vente reutilisables:
- `modules/purchase_trade/sale.py`
- Proprietes d'achat reutilisables:
- `modules/purchase_trade/purchase.py`
## 3) Principes de lecture
- Pour une facture:
- preferer les proprietes `report_*` exposees sur `account.invoice`
- pour une facture finale detaillee, utiliser aussi les proprietes `report_*`
exposees sur `account.invoice.line`
- Pour une vente:
- reutiliser si possible les proprietes `report_*` deja presentes sur `sale.sale`
- Pour un achat:
- reutiliser si possible les proprietes `report_*` deja presentes sur `purchase.purchase`
- Pour un shipment entrant:
- reutiliser si possible les proprietes `report_*` exposees sur `stock.shipment.in`
## 4) Propriete disponibles sur `account.invoice`
Source code: `modules/purchase_trade/invoice.py`
### Identite du document / parties
- `report_address`
- Usage: adresse d'affichage de la facture
- Source de verite: `sale.report_address` ou `purchase.report_address`, fallback `invoice.invoice_address.full_address`
- `report_contract_number`
- Usage: numero de contrat
- Source de verite: `sale.full_number` ou `purchase.full_number`
- `report_trader_initial`
- Usage: initiales trader dans les templates
- Source de verite: contrat lie
- `report_operator_initial`
- Usage: initiales operator dans les templates
- Source de verite: contrat lie
### Produit / contrat / quantites
- `report_origin`
- Usage: origine produit
- Source de verite: `sale.product_origin` ou `purchase.product_origin`
- `report_product_description`
- Usage: description produit principale
- Source de verite: premiere ligne metier liee a la facture
- `report_product_name`
- Usage: nom produit principal
- Source de verite: premiere ligne metier liee a la facture
- `report_description_upper`
- Usage: description de ligne en majuscules
- Source de verite: premiere `account.invoice.line`
- `report_crop_name`
- Usage: campagne / crop
- Source de verite: contrat lie
- `report_attributes_name`
- Usage: attributs produit
- Source de verite: premiere ligne metier liee a la facture
- `report_price`
- Usage: prix en toutes lettres
- Source de verite: `sale.report_price` ou `purchase.report_price`
- `report_nb_bale`
- Usage: nombre de balles
- Source de verite: `sale.report_nb_bale` ou recalcul sur les lots physiques
- `report_gross`
- Usage: poids brut
- Source de verite: `sale.report_gross` ou recalcul sur les lots physiques
- `report_net`
- Usage: poids net
- Source de verite: `sale.report_net` ou `purchase.report_net` ou recalcul sur les lots physiques
- `report_lbs`
- Usage: poids net converti en LBS
- Source de verite: conversion de `report_net`
- `report_quantity_lines`
- Usage: detail quantite multi-lignes pour les templates facture
- Source de verite: `sale.report_quantity_lines` si vente source, sinon aggregation des `account.invoice.line`
### Bloc prix type `sale_ict`
- `report_rate_currency_upper`
- Usage: devise du bloc `At ... PER ...`
- Source de verite: premiere `account.invoice.line` de type `line`
- `report_rate_value`
- Usage: prix numerique du bloc `At ... PER ...`
- Source de verite: premiere `account.invoice.line` de type `line`
- `report_rate_unit_upper`
- Usage: unite du bloc `At ... PER ...`
- Source de verite: premiere `account.invoice.line` de type `line`
- `report_rate_price_words`
- Usage: prix en toutes lettres dans le bloc `At ... PER ...`
- Source de verite: premiere `account.invoice.line` de type `line`, fallback `report_price`
- `report_rate_pricing_text`
- Usage: texte de pricing additionnel
- Source de verite: premiere `account.invoice.line` de type `line`
- `report_rate_lines`
- Usage: detail multi-lignes du bloc `At ... PER ...`
- Source de verite: `sale.report_price_lines` si vente source, sinon aggregation des `account.invoice.line`
### Logistique / shipment
- `report_shipment`
- Usage: resume vessel / BL / shipment
- Source de verite: contrat lie
- `report_bl_date`
- Usage: date de BL
- Source de verite: shipment du lot physique
- `report_bl_nb`
- Usage: numero de BL
- Source de verite: shipment du lot physique
- `report_vessel`
- Usage: nom du vessel
- Source de verite: shipment du lot physique
- `report_loading_port`
- Usage: port of loading
- Source de verite: shipment du lot physique
- `report_discharge_port`
- Usage: port of discharge
- Source de verite: shipment du lot physique
- `report_controller_name`
- Usage: nom du controller
- Source de verite: shipment du lot physique
- `report_si_number`
- Usage: S/I number
- Source de verite: shipment du lot physique
### Conditions commerciales
- `report_incoterm`
- Usage: incoterm + location
- Source de verite: contrat lie
- `report_payment_date`
- Usage: date de paiement
- Source de verite: contrat lie
- `report_payment_description`
- Usage: description des conditions de paiement
- Source de verite: payment term du contrat ou de la facture
### Pro forma / freight
- `report_proforma_invoice_number`
- Usage: numero de facture provisoire
- Source de verite: lot physique via `invoice_line_prov` ou `sale_invoice_line_prov`
- `report_proforma_invoice_date`
- Usage: date de facture provisoire
- Source de verite: lot physique via `invoice_line_prov` ou `sale_invoice_line_prov`
- `report_freight_amount`
- Usage: `FREIGHT VALUE`
- Source de verite:
- lot physique
- shipment du lot
- `fee.fee` avec `product.name = 'Maritime freight'`
- montant = `fee.get_amount()`
- `report_freight_currency_symbol`
- Usage: devise du `FREIGHT VALUE`
- Source de verite: devise du fee `Maritime freight`, fallback devise facture
### Payment order
- `report_payment_order_short_name`
- Usage: nom court emetteur du payment order
- Source de verite: `invoice.company.party.rec_name`
- `report_payment_order_document_reference`
- Usage: reference du document payment order
- Source de verite: `invoice.number`, fallback `invoice.reference`
- `report_payment_order_from_account_nb`
- Usage: compte bancaire emetteur
- Source de verite: premier `bank.account` de la societe
- `report_payment_order_to_bank_name`
- Usage: banque destinataire
- Source de verite: banque du premier compte bancaire du partenaire facture
- `report_payment_order_to_bank_city`
- Usage: ville banque destinataire
- Source de verite: adresse de la banque destinataire
- `report_payment_order_amount`
- Usage: montant payment order
- Source de verite: `invoice.total_amount`
- `report_payment_order_currency_code`
- Usage: devise payment order
- Source de verite: `invoice.currency` (`code`, fallback `rec_name/symbol`)
- `report_payment_order_amount_text`
- Usage: montant en lettres
- Source de verite: conversion `amount_to_currency_words(invoice.total_amount)`
- `report_payment_order_value_date`
- Usage: date valeur
- Source de verite: `invoice.payment_term_date`, fallback `invoice.invoice_date`
- `report_payment_order_company_address`
- Usage: bloc beneficiaire
- Source de verite: `invoice.invoice_address.full_address`, fallback
`invoice.report_address`
- `report_payment_order_beneficiary_account_nb`
- Usage: compte beneficiaire
- Source de verite: premier compte bancaire du `invoice.party`
- `report_payment_order_beneficiary_bank_name`
- Usage: banque beneficiaire
- Source de verite: banque du compte beneficiaire
- `report_payment_order_beneficiary_bank_city`
- Usage: ville banque beneficiaire
- Source de verite: adresse banque beneficiaire
- `report_payment_order_swift_code`
- Usage: swift/bic beneficiaire
- Source de verite: `bank.bic`
- `report_payment_order_other_instructions`
- Usage: instructions complementaires
- Source de verite: `invoice.description`
- `report_payment_order_reference`
- Usage: reference business de paiement
- Source de verite: `invoice.reference`, fallback `invoice.number`
- `report_payment_order_current_user`
- Usage: signataire payment order
- Source de verite: utilisateur courant (`res.user`)
- `report_payment_order_current_user_email`
- Usage: email retour swift
- Source de verite: contact email du `party` utilisateur, fallback `user.email`
## 5) Proprietes disponibles sur `account.invoice.line`
Source code: `modules/purchase_trade/invoice.py`
- `report_product_description`
- Usage: description produit de la ligne
- Source de verite: `invoice_line.product` ou `origin.product`
- `report_description_upper`
- Usage: description de ligne en uppercase
- Source de verite: `invoice_line.description`
- `report_crop_name`
- Usage: crop de la ligne
- Source de verite: contrat relie via `origin`
- `report_attributes_name`
- Usage: attributs de la ligne
- Source de verite: `origin.attributes_name`
- `report_net`
- Usage: quantite nette de la ligne
- Source de verite: `invoice_line.quantity`
- `report_lbs`
- Usage: quantite convertie en LBS
- Source de verite: conversion de `report_net`
- `report_rate_currency_upper`
- Usage: devise de prix de la ligne
- Source de verite: `origin.linked_currency` ou `invoice_line.currency`
- `report_rate_value`
- Usage: prix numerique de la ligne
- Source de verite: `invoice_line.unit_price`
- `report_rate_unit_upper`
- Usage: unite de prix de la ligne
- Source de verite: `origin.linked_unit` ou `invoice_line.unit`
- `report_rate_price_words`
- Usage: prix en toutes lettres de la ligne
- Source de verite: contrat relie via `trade.report_price`
- `report_rate_pricing_text`
- Usage: texte de pricing de la ligne
- Source de verite: `origin.get_pricing_text`
## 6) Proprietes utiles deja presentes sur `sale.sale`
Source code: `modules/purchase_trade/sale.py`
- `report_terms`
- `report_crop_name`
- `report_gross`
- `report_net`
- `report_qt`
- `report_total_quantity`
- `report_quantity_unit_upper`
- `report_quantity_lines`
- `report_nb_bale`
- `report_deal`
- `report_packing`
- `report_price`
- `report_price_lines`
- `report_delivery`
- `report_payment_date`
- `report_shipment`
- `report_shipment_periods`
- `report_product_name`
- `report_product_description`
Usage typique:
- base de travail pour les templates de type `sale_ict.fodt`
- source de verite de plusieurs proprietes du bridge facture
## 7) Proprietes utiles deja presentes sur `purchase.purchase`
Source code: `modules/purchase_trade/purchase.py`
- `report_terms`
- `report_qt`
- `report_price`
- `report_delivery`
- `report_payment_date`
- `report_shipment`
Usage typique:
- templates et bridges pour facturation fournisseur
- fallback achat quand une facture n'est pas liee a une vente
## 8) Templates connus qui utilisent ces proprietes
- `modules/account_invoice/invoice_ict.fodt`
- `modules/account_invoice/invoice_ict_final.fodt`
- `modules/sale/sale_ict.fodt`
- `modules/stock/insurance.fodt`
## 9) Proprietes utiles deja presentes sur `stock.shipment.in`
Source code: `modules/purchase_trade/stock.py`
- `report_product_name`
- `report_product_description`
- `report_insurance_footer_ref`
- `report_insurance_certificate_number`
- `report_insurance_account_of`
- `report_insurance_goods_description`
- `report_insurance_loading_port`
- `report_insurance_discharge_port`
- `report_insurance_transport`
- `report_insurance_amount`
- `report_insurance_incoming_amount`
- `report_insurance_amount_insured`
- `report_insurance_surveyor`
- `report_insurance_contact_surveyor`
- `report_insurance_issue_place_and_date`
Usage typique:
- templates shipment relies a l'assurance
- `report_insurance_amount`: montant affiche dans `Amount insured` (priorite a
`110%` du total incoming, fallback fee `Insurance`)
- `report_insurance_incoming_amount`: somme `incoming_moves` de
`quantity * unit_price`, avec fallback lot
(`lot.line.unit_price * lot.get_current_quantity_converted()`)
- `report_insurance_amount_insured`: `110%` de
`report_insurance_incoming_amount`
- `report_insurance_contact_surveyor`: surveyor affiche sous
`Contact the following surveyor` (priorite au champ shipment `surveyor`,
puis fallback controller / fee `Insurance`)
- base de travail pour un certificat d'assurance lie a un shipment
## 10) Recommandations
- Avant d'ajouter une nouvelle expression dans un `.fodt`, verifier si une
propriete `report_*` existe deja ici.
- Si une nouvelle propriete est ajoutee pour un template, la documenter dans ce
fichier.
- Pour les donnees logistiques facture, privilegier toujours:
- facture -> ligne metier -> lot physique -> shipment / fee

View File

@@ -0,0 +1,322 @@
# Template Rules - Purchase Trade
Statut: `draft`
Version: `v0.4`
Derniere mise a jour: `2026-04-07`
## 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.
- Centraliser les proprietes `report_*` dans une documentation reutilisable.
## 2.1) Index de reference
- Catalogue des proprietes templates:
- `modules/purchase_trade/docs/template-properties.md`
## 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:
- `&quot;...&quot;` pour les guillemets doubles
- `&apos;...&apos;` pour les apostrophes
- Eviter les formes avec antislashs:
- interdit: `\'\'`
- interdit: `\'value\'`
- Exemples corrects:
- `&lt;replace text:p=&quot;set_lang(invoice.party.lang)&quot;&gt;`
- `&lt;if test=&quot;invoice.report_payment_description&quot;&gt;`
- `&lt;tax.description or &apos;&apos;&gt;`
### 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
- pour les clients multi-templates, preferer une configuration metier qui
stocke le nom du template par action (`Invoice`, `CN/DN`, `Prepayment`)
plutot qu'une modification manuelle de `ir_action_report.report`
### TR-012 - Centraliser les templates client dans `Document Templates`
- Pour les templates client-specifiques, ne pas modifier `ir_action_report.report`
en base a la main selon l'environnement ou le client.
- Preferer la configuration singleton `purchase_trade.configuration`,
exposee dans `Documents > Configuration > Document Templates`.
- Sections actuellement attendues:
- `Sale`
- `Invoice`
- `Payment`
- `Purchase`
- `Shipment`
- Dans la section `Shipment`, les templates metier attendus sont:
- `Shipping`
- `Insurance`
- `Packing List`
- Regle:
- si le champ de template correspondant est vide, le report doit echouer
explicitement avec `No template found`
- ne pas masquer dynamiquement l'action d'impression si ce n'est pas
necessaire
### TR-013 - `sale_melya.fodt` et `invoice_melya.fodt` doivent afficher nom + description produit
- Dans les templates client Melya, le bloc produit doit prevoir:
- une ligne pour le nom produit
- une ligne pour la description produit
- Ne pas dereferencer directement `line.product.name` / `line.product.description`
dans les `.fodt`.
- Preferer:
- `sale.report_product_name`
- `sale.report_product_description`
- `invoice.report_product_name`
- `invoice.report_product_description`
### TR-014 - `invoice_melya.fodt` doit afficher `Invoice` et `Reference` sur les bons champs
- Pour `modules/account_invoice/invoice_melya.fodt`:
- `Invoice` doit afficher `invoice.number`
- `Reference` doit afficher `invoice.report_contract_number`
- Ne pas reutiliser `invoice.reference` pour ce label dans ce template client
sans demande explicite
### TR-015 - Le template `stock/insurance.fodt` doit lire sur `stock.shipment.in`
- Le template `modules/stock/insurance.fodt` est pilote par le report
`stock.shipment.in.insurance`.
- Toutes les croix rouges / placeholders metier doivent etre remplacees par
des proprietes `report_*` exposees sur `stock.shipment.in`.
- Pour ce template, ne pas compter sur une variable Genshi locale `shipment`
dans tout le document; preferer `records[0]....` dans le `.fodt`.
- Source de verite du montant assure:
- sommer les montants des `incoming_moves` du shipment
- montant d'un move = `move.quantity * move.unit_price`
- si `move.unit_price` est vide, fallback via lot:
`lot.line.unit_price * lot.get_current_quantity_converted()`
- exposer au moins:
- le montant total des incoming moves
- le montant assure a `110%` de ce total
- pour le placeholder `Amount insured`, `report_insurance_amount` doit
afficher ce `110%`, avec fallback fee `Insurance` si aucun montant
incoming n'est calculable
### TR-016 - Hypotheses actuelles pour le certificat d'assurance shipment
- Tant qu'une source metier plus precise n'est pas fournie:
- numero du certificat: `shipment.bl_number`, sinon `shipment.number`
- `insured for account of`: client de la premiere ligne metier retrouvee via
lot physique, sinon `shipment.supplier`
- `surveyor`: `shipment.surveyor`, sinon `shipment.controller`, sinon
fournisseur du fee `Insurance`
- lieu/date d'emission: ville de la societe + date du jour
- Si une source differente est decidee plus tard, corriger la propriete Python
plutot que complexifier `insurance.fodt`
### TR-017 - `payment_order.fodt` doit utiliser des proprietes `report_payment_order_*`
- Pour `modules/account_invoice/payment_order.fodt`, ne pas utiliser des
placeholders externes legacy (tokens metier entre `<...>` du systeme source).
- Tous les placeholders du template doivent pointer vers des proprietes Python
stables exposees sur `account.invoice`:
- `report_payment_order_document_reference`
- `report_payment_order_from_account_nb`
- `report_payment_order_to_bank_name`
- `report_payment_order_to_bank_city`
- `report_payment_order_amount`
- `report_payment_order_currency_code`
- `report_payment_order_amount_text`
- `report_payment_order_value_date`
- `report_payment_order_company_address`
- `report_payment_order_beneficiary_account_nb`
- `report_payment_order_beneficiary_bank_name`
- `report_payment_order_beneficiary_bank_city`
- `report_payment_order_swift_code`
- `report_payment_order_other_instructions`
- `report_payment_order_reference`
- `report_payment_order_current_user`
- `report_payment_order_current_user_email`
- Eviter les marqueurs conditionnels heredites de l'ancien moteur (`++...`):
privilegier des placeholders simples avec fallback `or ''`.
### TR-018 - Un template configure n'apparait dans le form que si une action report existe
- Ajouter un champ dans `Document Templates` ne suffit pas a rendre un
template imprimable depuis la fiche.
- Pour afficher l'entree dans `account.invoice`, il faut aussi:
- un `ir.action.report` sur `model = account.invoice`
- un `ir.action.keyword` `form_print` lie a cette action
- Appliquer cette regle pour `Payment Order` comme pour `Invoice`,
`Prepayment` et `CN/DN`.
### TR-019 - Un placeholder Relatorio doit etre dans une balise `text:placeholder`
- Dans un `.fodt`, une expression du type `&lt;records[0].report_* ...&gt;`
ecrite en texte brut peut s'afficher telle quelle a l'impression.
- Regle stricte:
- encapsuler les expressions dans
`<text:placeholder text:placeholder-type="text">...</text:placeholder>`
- ne pas laisser de token `&lt;...&gt;` directement dans un `text:span`,
`text:p`, `text:h`, etc.
- Exemple:
- incorrect:
`PAYMENT ORDER &lt;records[0].report_payment_order_document_reference or &apos;&apos;&gt;`
- correct:
`PAYMENT ORDER <text:placeholder text:placeholder-type="text">&lt;records[0].report_payment_order_document_reference or &apos;&apos;&gt;</text:placeholder>`
### 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.
### TR-009 - Ne pas dereferencer directement `del_period.description` dans les templates
- Eviter les expressions du type:
- `sale.lines[0].del_period.description`
- `purchase.lines[0].del_period.description`
- Meme avec un `if ... else`, ces acces sont fragiles dans un `.fodt` et
rendent le debug plus difficile.
- Preferer une propriete Python stable:
- `sale.report_delivery_period_description`
- `purchase.report_delivery_period_description`
- `invoice.report_delivery_period_description`
### TR-010 - En template, un contrat `basis` affiche le premium comme prix
- Pour les templates commerciaux/facture (`sale_ict`, `invoice_ict`, etc.),
le prix affiche d'une ligne `basis` ne doit pas etre le prix economique total
(`unit_price`, `linked_price` ou prix basis brut).
- La valeur a afficher est uniquement le `premium`:
- en devise/unite liee si `linked currency` est active
- sinon dans la devise/unite native de la ligne
- Le texte de curve / pricing (`ON ICE ...`) reste affiche a cote, mais la
valeur numerique et sa version en lettres doivent representer le premium.
### TR-011 - Pour `NB BALES` sur une facture, sommer les `lot_qt` des lignes facture
- Pour `invoice_ict.fodt` et `invoice_ict_final.fodt`, la source de verite du
nombre de bales n'est pas le poids (`report_net`, `report_gross`) mais
`line.lot.lot_qt`.
- La regle attendue est:
- lire les lignes de facture
- recuperer leur `lot`
- sommer `lot.lot_qt`
- sur une note finale, tenir compte du signe de la ligne de facture pour que
les lignes positives et negatives se compensent
- Ne pas recalculer le nombre de bales a partir du poids:
- les poids peuvent varier (humidite, poids net/gross)
- le nombre de bales peut rester stable
## 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 `&quot;` / `&apos;`.
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`

View File

@@ -6,7 +6,7 @@ from trytond.pyson import Bool, Eval, Id, If
from trytond.model import (ModelSQL, ModelView) from trytond.model import (ModelSQL, ModelView)
from trytond.tools import is_full_text, lstrip_wildcard from trytond.tools import is_full_text, lstrip_wildcard
from trytond.transaction import Transaction, inactive_records 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.aggregate import Count, Max, Min, Sum, Avg, BoolOr
from sql.conditionals import Case from sql.conditionals import Case
from sql import Column, Literal from sql import Column, Literal
@@ -18,120 +18,11 @@ import datetime
import logging import logging
from collections import defaultdict from collections import defaultdict
from trytond.exceptions import UserWarning, UserError 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__) 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_state(state):
def filter(func): def filter(func):
@wraps(func) @wraps(func)
@@ -166,16 +57,28 @@ class Fee(ModelSQL,ModelView):
('lumpsum', 'Lump sum'), ('lumpsum', 'Lump sum'),
('perqt', 'Per qt'), ('perqt', 'Per qt'),
('pprice', '% price'), ('pprice', '% price'),
('rate', '% rate'),
('pcost', '% cost price'), ('pcost', '% cost price'),
('ppack', 'Per packing'),
], 'Mode', required=True) ], 'Mode', required=True)
inherit_qt = fields.Boolean("Inh Qt") auto_calculation = fields.Boolean("Auto",states={'readonly': (Eval('mode') != 'ppack')})
quantity = fields.Function(fields.Numeric("Qt",digits='unit'),'get_quantity') inherit_qt = fields.Boolean("Inh Qt",states={'readonly': Eval('mode') != 'ppack'})
unit = fields.Function(fields.Many2One('product.uom',"Unit"),'get_unit') 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={ inherit_shipment = fields.Boolean("Inh Sh",states={
'invisible': (Eval('shipment_in')), 'invisible': (Eval('shipment_in')),
}) })
purchase = fields.Many2One('purchase.purchase',"Purchase", ondelete='CASCADE') 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') 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') 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))] ) 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([ weight_type = fields.Selection([
('net', 'Net'), ('net', 'Net'),
('brut', 'Brut'), ('brut', 'Gross'),
], string='W. type') ], 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): def get_lots(self, name):
logger.info("GET_LOTS_LINE:%s",self.line) logger.info("GET_LOTS_LINE:%s",self.line)
logger.info("GET_LOTS_SHIPMENT_IN:%s",self.shipment_in) logger.info("GET_LOTS_SHIPMENT_IN:%s",self.shipment_in)
@@ -230,6 +218,23 @@ class Fee(ModelSQL,ModelView):
if ml: if ml:
return round(Decimal(sum([e.credit-e.debit for e in ml if e.description != 'Delivery fee'])),2) 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 @classmethod
def __setup__(cls): def __setup__(cls):
super().__setup__() super().__setup__()
@@ -248,13 +253,14 @@ class Fee(ModelSQL,ModelView):
def default_p_r(cls): def default_p_r(cls):
return 'pay' return 'pay'
def get_unit(self, name): def get_unit(self, name=None):
Lot = Pool().get('lot.lot') FeeLots = Pool().get('fee.lots')
if self.lots: fl = FeeLots.search(['fee','=',self.id])
if self.lots[0].line: if fl:
return self.lots[0].line.unit if fl[0].lot.line:
if self.lots[0].sale_line: return fl[0].lot.line.unit
return self.lots[0].sale_line.unit if fl[0].lot.sale_line:
return fl[0].lot.sale_line.unit
@classmethod @classmethod
@ModelView.button @ModelView.button
@@ -285,7 +291,11 @@ class Fee(ModelSQL,ModelView):
return round(self.price / self.quantity,4) return round(self.price / self.quantity,4)
elif self.mode == 'perqt': elif self.mode == 'perqt':
return self.price 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: if self.line and self.price:
return round(self.price * Decimal(self.line.unit_price) / 100,4) return round(self.price * Decimal(self.line.unit_price) / 100,4)
if self.sale_line and self.price: if self.sale_line and self.price:
@@ -305,8 +315,8 @@ class Fee(ModelSQL,ModelView):
def get_landed_status(self,name): def get_landed_status(self,name):
if self.product: if self.product:
return self.product.landed_cost return self.product.template.landed_cost
def get_quantity(self,name=None): def get_quantity(self,name=None):
qt = self.get_fee_lots_qt() qt = self.get_fee_lots_qt()
if qt: if qt:
@@ -317,6 +327,7 @@ class Fee(ModelSQL,ModelView):
return Decimal(lqts[0].lot_quantity) return Decimal(lqts[0].lot_quantity)
def get_amount(self,name=None): def get_amount(self,name=None):
Date = Pool().get('ir.date')
sign = Decimal(1) sign = Decimal(1)
if self.price: if self.price:
# if self.p_r: # if self.p_r:
@@ -324,13 +335,54 @@ class Fee(ModelSQL,ModelView):
# sign = -1 # sign = -1
if self.mode == 'lumpsum': if self.mode == 'lumpsum':
return self.price * sign 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': elif self.mode == 'perqt':
if self.shipment_in: if self.shipment_in:
StockMove = Pool().get('stock.move') StockMove = Pool().get('stock.move')
sm = StockMove.search(['shipment','=','stock.shipment.in,'+str(self.shipment_in.id)]) sm = StockMove.search(['shipment','=','stock.shipment.in,'+str(self.shipment_in.id)])
if sm: if sm:
unique_lots = {e.lot for e in sm if e.lot} 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') LotQt = Pool().get('lot.qt')
lqts = LotQt.search(['lot_shipment_in','=',self.shipment_in.id]) lqts = LotQt.search(['lot_shipment_in','=',self.shipment_in.id])
if lqts: if lqts:
@@ -373,12 +425,12 @@ class Fee(ModelSQL,ModelView):
return super().copy(fees, default=default) return super().copy(fees, default=default)
def get_fee_lots_qt(self): def get_fee_lots_qt(self,state_id=0):
qt = Decimal(0) qt = Decimal(0)
FeeLots = Pool().get('fee.lots') FeeLots = Pool().get('fee.lots')
fee_lots = FeeLots.search([('fee', '=', self.id)]) fee_lots = FeeLots.search([('fee', '=', self.id)])
if fee_lots: 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) logger.info("GET_FEE_LOTS_QT:%s",qt)
return qt return qt
@@ -388,10 +440,18 @@ class Fee(ModelSQL,ModelView):
logger.info("ADJUST_PURCHASE_VALUES:%s",self) logger.info("ADJUST_PURCHASE_VALUES:%s",self)
if self.type == 'ordered' and self.state == 'not invoiced' and self.purchase: if self.type == 'ordered' and self.state == 'not invoiced' and self.purchase:
logger.info("ADJUST_PURCHASE_VALUES_QT:%s",self.purchase.lines[0].quantity) logger.info("ADJUST_PURCHASE_VALUES_QT:%s",self.purchase.lines[0].quantity)
if self.price != self.purchase.lines[0].unit_price: if self.mode == 'lumpsum':
self.purchase.lines[0].unit_price = self.price if self.amount != self.purchase.lines[0].unit_price:
if self.quantity != self.purchase.lines[0].quantity: self.purchase.lines[0].unit_price = self.amount
self.purchase.lines[0].quantity = self.quantity 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: if self.product != self.purchase.lines[0].product:
self.purchase.lines[0].product = self.product self.purchase.lines[0].product = self.product
PurchaseLine.save([self.purchase.lines[0]]) PurchaseLine.save([self.purchase.lines[0]])
@@ -408,46 +468,47 @@ class Fee(ModelSQL,ModelView):
@classmethod @classmethod
def create(cls, vlist): def create(cls, vlist):
vlist = [x.copy() for x in 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_sh = Decimal(0)
qt_line = Decimal(0) qt_line = Decimal(0)
unit = None unit = None
for record in records: for fee in fees:
FeeLots = Pool().get('fee.lots') FeeLots = Pool().get('fee.lots')
Lots = Pool().get('lot.lot') Lots = Pool().get('lot.lot')
LotQt = Pool().get('lot.qt') LotQt = Pool().get('lot.qt')
if record.line: if fee.line:
for l in record.line.lots: for l in fee.line.lots:
#if l.lot_type == 'physic': 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 = FeeLots()
fl.fee = record.id fl.fee = fee.id
fl.lot = l.id fl.lot = l.id
fl.line = l.line.id fl.line = l.line.id
FeeLots.save([fl]) FeeLots.save([fl])
qt_line += l.get_current_quantity_converted() qt_line += l.get_current_quantity_converted()
unit = l.line.unit unit = l.line.unit
if record.sale_line: if fee.sale_line:
for l in record.sale_line.lots: for l in fee.sale_line.lots:
#if l.lot_type == 'physic': 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 = FeeLots()
fl.fee = record.id fl.fee = fee.id
fl.lot = l.id fl.lot = l.id
fl.sale_line = l.sale_line.id fl.sale_line = l.sale_line.id
FeeLots.save([fl]) FeeLots.save([fl])
if record.shipment_in: qt_line += l.get_current_quantity_converted()
if record.shipment_in.state == 'draft'or record.shipment_in.state == 'started': unit = l.sale_line.unit
lots = Lots.search(['lot_shipment_in','=',record.shipment_in.id]) 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: if lots:
for l in lots: for l in lots:
#if l.lot_type == 'physic':
fl = FeeLots() fl = FeeLots()
fl.fee = record.id fl.fee = fee.id
fl.lot = l.id fl.lot = l.id
FeeLots.save([fl]) FeeLots.save([fl])
qt_sh += l.get_current_quantity_converted() qt_sh += l.get_current_quantity_converted()
unit = l.line.unit unit = l.line.unit
else: else:
lqts = LotQt.search(['lot_shipment_in','=',record.shipment_in.id]) lqts = LotQt.search(['lot_shipment_in','=',fee.shipment_in.id])
if lqts: if lqts:
for l in lqts: for l in lqts:
qt_sh += l.lot_p.get_current_quantity_converted() qt_sh += l.lot_p.get_current_quantity_converted()
@@ -455,32 +516,154 @@ class Fee(ModelSQL,ModelView):
else: else:
raise UserError("You cannot add fee on received shipment!") raise UserError("You cannot add fee on received shipment!")
type = record.type type = fee.type
if type == 'ordered': if type == 'ordered':
Purchase = Pool().get('purchase.purchase') Purchase = Pool().get('purchase.purchase')
PurchaseLine = Pool().get('purchase.line') PurchaseLine = Pool().get('purchase.line')
pl = PurchaseLine() pl = PurchaseLine()
pl.product = record.product pl.product = fee.product
if record.line: if fee.line or fee.sale_line:
pl.quantity = round(qt_line,5) pl.quantity = round(qt_line,5)
if record.shipment_in: if fee.shipment_in:
pl.quantity = round(qt_sh,5) pl.quantity = round(qt_sh,5)
logger.info("CREATE_PURHCASE_FOR_FEE_QT:%s",pl.quantity) logger.info("CREATE_PURHCASE_FOR_FEE_QT:%s",pl.quantity)
pl.unit = unit pl.unit = unit
pl.fee_ = record.id pl.fee_ = fee.id
if record.price: if fee.price:
pl.unit_price = round(Decimal(record.price),4) 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 = Purchase()
p.lines = [pl] p.lines = [pl]
p.party = record.supplier p.party = fee.supplier
if p.party.addresses: if p.party.addresses:
p.invoice_address = p.party.addresses[0] p.invoice_address = p.party.addresses[0]
p.currency = record.currency p.currency = fee.currency
p.line_type = 'service' 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]) Purchase.save([p])
#if reception of moves done we need to generate accrual for fee
return records 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): class FeeLots(ModelSQL,ModelView):
"Fee lots" "Fee lots"

View File

@@ -19,26 +19,6 @@ this repository contains the full copyright notices and license terms. -->
<field name="name">fee_tree_sequence</field> <field name="name">fee_tree_sequence</field>
</record> </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"> <record model="ir.ui.view" id="fee_view_tree_sequence2">
<field name="model">fee.fee</field> <field name="model">fee.fee</field>
<field name="type">tree</field> <field name="type">tree</field>

View 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")

View 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
}

View File

@@ -385,11 +385,11 @@ class ForexBI(ModelSingleton,ModelSQL, ModelView):
config = Configuration.search(['id','>',0])[0] config = Configuration.search(['id','>',0])[0]
payload = { payload = {
"resource": {"dashboard": 3}, "resource": {"dashboard": config.forex_id},
"params": {}, "params": {},
"exp": datetime.datetime.utcnow() + datetime.timedelta(minutes=30), "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) logger.info("TOKEN:%s",token)
if config.dark: if config.dark:
url = f"metabase:{config.bi}/embed/dashboard/{token}#theme=night&bordered=true&titled=true" url = f"metabase:{config.bi}/embed/dashboard/{token}#theme=night&bordered=true&titled=true"

View File

@@ -12,4 +12,10 @@ class GRConfiguration(ModelSingleton, ModelSQL, ModelView):
__name__ = 'gr.configuration' __name__ = 'gr.configuration'
bi = fields.Char("BI connexion") 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")

View 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

View 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

View 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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,16 @@
<tryton>
<data>
<record model="ir.action.report" id="report_payment_order">
<field name="name">Payment Order</field>
<field name="model">account.invoice</field>
<field name="report_name">account.invoice</field>
<field name="report">account_invoice/payment_order.fodt</field>
<field name="single" eval="True"/>
</record>
<record model="ir.action.keyword" id="report_payment_order_keyword">
<field name="keyword">form_print</field>
<field name="model">account.invoice,-1</field>
<field name="action" ref="report_payment_order"/>
</record>
</data>
</tryton>

View File

@@ -20,6 +20,7 @@ import datetime
import json import json
import logging import logging
from trytond.exceptions import UserWarning, UserError from trytond.exceptions import UserWarning, UserError
from trytond.modules.purchase_trade.service import ContractFactory
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -47,7 +48,7 @@ class LotMove(ModelSQL,ModelView):
class Lot(metaclass=PoolMeta): class Lot(metaclass=PoolMeta):
__name__ = 'lot.lot' __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') move = fields.Function(fields.Many2One('stock.move',"Move"),'get_current_move')
lot_move = fields.One2Many('lot.move','lot',"Move") lot_move = fields.One2Many('lot.move','lot',"Move")
invoice_line = fields.Many2One('account.invoice.line',"Purch.Invoice line") 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_pr = fields.Numeric("Delta Pr")
delta_amt = fields.Numeric("Delta Amt") delta_amt = fields.Numeric("Delta Amt")
warrant_nb = fields.Char("Warrant Nb") warrant_nb = fields.Char("Warrant Nb")
lot_chunk_key = fields.Integer("Chunk key")
#fees = fields.Many2Many('fee.lots', 'lot', 'fee',"Fees") #fees = fields.Many2Many('fee.lots', 'lot', 'fee',"Fees")
dashboard = fields.Many2One('purchase.dashboard',"Dashboard") dashboard = fields.Many2One('purchase.dashboard',"Dashboard")
pivot = fields.Function( pivot = fields.Function(
@@ -201,7 +203,7 @@ class Lot(metaclass=PoolMeta):
) )
pivot_data['options'] = { 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"], "cols": ["account"],
"aggregatorName": "Sum", "aggregatorName": "Sum",
"vals": ["amount"] "vals": ["amount"]
@@ -578,6 +580,14 @@ class Lot(metaclass=PoolMeta):
return True return True
return False 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): def GetShipment(self,type):
if type == 'in': if type == 'in':
m = self.get_current_supplier_move() m = self.get_current_supplier_move()
@@ -1087,6 +1097,7 @@ class LotQt(
newlot.lot_shipment_internal = self.lot_shipment_internal newlot.lot_shipment_internal = self.lot_shipment_internal
newlot.lot_shipment_out = self.lot_shipment_out newlot.lot_shipment_out = self.lot_shipment_out
newlot.lot_product = self.lot_p.line.product newlot.lot_product = self.lot_p.line.product
newlot.lot_chunk_key = l.lot_chunk_key
if self.lot_s: if self.lot_s:
newlot.sale_line = self.lot_s.sale_line if self.lot_s.sale_line else None newlot.sale_line = self.lot_s.sale_line if self.lot_s.sale_line else None
newlot.lot_type = 'physic' newlot.lot_type = 'physic'
@@ -1168,6 +1179,7 @@ class LotQt(
@classmethod @classmethod
def validate(cls, lotqts): def validate(cls, lotqts):
super(LotQt, cls).validate(lotqts) super(LotQt, cls).validate(lotqts)
Date = Pool().get('ir.date')
#Update Move #Update Move
for lqt in lotqts: for lqt in lotqts:
cls.updateMove(lqt.lot_move) cls.updateMove(lqt.lot_move)
@@ -1177,23 +1189,23 @@ class LotQt(
if lqt.lot_p and lqt.lot_quantity > 0: if lqt.lot_p and lqt.lot_quantity > 0:
pl = lqt.lot_p.line pl = lqt.lot_p.line
logger.info("VALIDATE_LQT_PL:%s",pl) logger.info("VALIDATE_LQT_PL:%s",pl)
Pnl = Pool().get('valuation.valuation') # Pnl = Pool().get('valuation.valuation')
pnl = Pnl.search([('line','=',pl.id)]) # pnl = Pnl.search([('line','=',pl.id),('date','=',Date.today())])
if pnl: # if pnl:
Pnl.delete(pnl) # Pnl.delete(pnl)
pnl_lines = [] # pnl_lines = []
pnl_lines.extend(pl.get_pnl_fee_lines()) # pnl_lines.extend(pl.get_pnl_fee_lines())
pnl_lines.extend(pl.get_pnl_price_lines()) # pnl_lines.extend(pl.get_pnl_price_lines())
pnl_lines.extend(pl.get_pnl_der_lines()) # pnl_lines.extend(pl.get_pnl_der_lines())
Pnl.save(pnl_lines) # Pnl.save(pnl_lines)
#Open position update #Open position update
if pl.quantity_theorical: # if pl.quantity_theorical:
OpenPosition = Pool().get('open.position') # OpenPosition = Pool().get('open.position')
OpenPosition.create_from_purchase_line(pl) # OpenPosition.create_from_purchase_line(pl)
@classmethod @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() pool = Pool()
LotQt = pool.get('lot.qt') LotQt = pool.get('lot.qt')
lqt = LotQt.__table__() lqt = LotQt.__table__()
@@ -1241,8 +1253,12 @@ class LotQt(
#wh &= (((lqt.create_date >= asof) & ((lqt.create_date-datetime.timedelta(1)) <= todate))) #wh &= (((lqt.create_date >= asof) & ((lqt.create_date-datetime.timedelta(1)) <= todate)))
if ps == 'P': if ps == 'P':
wh &= ((lqt.lot_p != None) & (lqt.lot_s == None)) wh &= ((lqt.lot_p != None) & (lqt.lot_s == None))
if not finished:
wh &= (pl.finished == False)
elif ps == 'S': elif ps == 'S':
wh &= (((lqt.lot_s != None) & (lqt.lot_p == None)) | ((lqt.lot_s != None) & (lqt.lot_p != None) & (lp.lot_type == 'virtual'))) 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: if purchase:
wh &= (pu.id == purchase) wh &= (pu.id == purchase)
if sale: if sale:
@@ -1318,11 +1334,16 @@ class LotQt(
Case((lp.id>0, lp.lot_premium_sale),else_=ls.lot_premium_sale).as_('r_lot_premium_sale'), Case((lp.id>0, lp.lot_premium_sale),else_=ls.lot_premium_sale).as_('r_lot_premium_sale'),
Case((lp.id>0, lp.lot_parent),else_=ls.lot_parent).as_('r_lot_parent'), Case((lp.id>0, lp.lot_parent),else_=ls.lot_parent).as_('r_lot_parent'),
Case((lp.id>0, lp.lot_himself),else_=ls.lot_himself).as_('r_lot_himself'), Case((lp.id>0, lp.lot_himself),else_=ls.lot_himself).as_('r_lot_himself'),
Case((lp.id>0, lp.lot_container),else_=ls.lot_container).as_('r_lot_container'), Case((lp.id>0, lp.lot_container),else_=ls.lot_container).as_('r_lot_container'),
Case((lp.id>0, lp.line),else_=None).as_('r_line'), Case((lp.id>0, lp.line),else_=None).as_('r_line'),
Case((pu.id>0, pu.id),else_=None).as_('r_purchase'), Case(
Case((sa.id>0, sa.id),else_=None).as_('r_sale'), (((lqt.lot_s != None) & (lqt.lot_p == None) & (sl.id > 0)),
Case((ls.id>0, ls.sale_line),else_=None).as_('r_sale_line'), sl.del_period),
else_=Case((pl.id>0, pl.del_period),else_=None)
).as_('r_del_period'),
Case((pu.id>0, pu.id),else_=None).as_('r_purchase'),
Case((sa.id>0, sa.id),else_=None).as_('r_sale'),
Case((ls.id>0, ls.sale_line),else_=None).as_('r_sale_line'),
(MaQt + AvQt).as_('r_tot'), (MaQt + AvQt).as_('r_tot'),
pu.party.as_('r_supplier'), pu.party.as_('r_supplier'),
sa.party.as_('r_client'), sa.party.as_('r_client'),
@@ -1423,13 +1444,14 @@ class LotQt(
lp.lot_av.as_("r_lot_av"), lp.lot_av.as_("r_lot_av"),
lp.lot_premium.as_("r_lot_premium"), lp.lot_premium.as_("r_lot_premium"),
lp.lot_premium_sale.as_("r_lot_premium_sale"), lp.lot_premium_sale.as_("r_lot_premium_sale"),
lp.lot_parent.as_("r_lot_parent"), lp.lot_parent.as_("r_lot_parent"),
lp.lot_himself.as_("r_lot_himself"), lp.lot_himself.as_("r_lot_himself"),
lp.lot_container.as_("r_lot_container"), lp.lot_container.as_("r_lot_container"),
lp.line.as_("r_line"), lp.line.as_("r_line"),
Case((pu.id > 0, pu.id), else_=None).as_("r_purchase"), pl.del_period.as_("r_del_period"),
Case((sa.id > 0, sa.id), else_=None).as_("r_sale"), Case((pu.id > 0, pu.id), else_=None).as_("r_purchase"),
lp.sale_line.as_("r_sale_line"), Case((sa.id > 0, sa.id), else_=None).as_("r_sale"),
lp.sale_line.as_("r_sale_line"),
(MaQt2 + Abs(AvQt2)).as_("r_tot"), (MaQt2 + Abs(AvQt2)).as_("r_tot"),
pu.party.as_("r_supplier"), pu.party.as_("r_supplier"),
sa.party.as_("r_client"), sa.party.as_("r_client"),
@@ -1488,13 +1510,14 @@ class LotQt(
Max(lp.lot_av).as_("r_lot_av"), Max(lp.lot_av).as_("r_lot_av"),
Avg(lp.lot_premium).as_("r_lot_premium"), Avg(lp.lot_premium).as_("r_lot_premium"),
Literal(None).as_("r_lot_premium_sale"), Literal(None).as_("r_lot_premium_sale"),
Literal(None).as_("r_lot_parent"), Literal(None).as_("r_lot_parent"),
Literal(None).as_("r_lot_himself"), Literal(None).as_("r_lot_himself"),
Max(lp.lot_container).as_("r_lot_container"), Max(lp.lot_container).as_("r_lot_container"),
lp.line.as_("r_line"), lp.line.as_("r_line"),
Max(Case((pu.id > 0, pu.id), else_=None)).as_("r_purchase"), Max(pl.del_period).as_("r_del_period"),
Max(Case((sa.id > 0, sa.id), else_=None)).as_("r_sale"), Max(Case((pu.id > 0, pu.id), else_=None)).as_("r_purchase"),
lp.sale_line.as_("r_sale_line"), Max(Case((sa.id > 0, sa.id), else_=None)).as_("r_sale"),
lp.sale_line.as_("r_sale_line"),
Sum(MaQt2 + Abs(AvQt2)).as_("r_tot"), Sum(MaQt2 + Abs(AvQt2)).as_("r_tot"),
Max(pu.party).as_("r_supplier"), Max(pu.party).as_("r_supplier"),
Max(sa.party).as_("r_client"), Max(sa.party).as_("r_client"),
@@ -1541,13 +1564,14 @@ class LotQt(
union.r_lot_av.as_("r_lot_av"), union.r_lot_av.as_("r_lot_av"),
union.r_lot_premium.as_("r_lot_premium"), union.r_lot_premium.as_("r_lot_premium"),
union.r_lot_premium_sale.as_("r_lot_premium_sale"), union.r_lot_premium_sale.as_("r_lot_premium_sale"),
union.r_lot_parent.as_("r_lot_parent"), union.r_lot_parent.as_("r_lot_parent"),
union.r_lot_himself.as_("r_lot_himself"), union.r_lot_himself.as_("r_lot_himself"),
union.r_lot_container.as_("r_lot_container"), union.r_lot_container.as_("r_lot_container"),
union.r_line.as_("r_line"), union.r_line.as_("r_line"),
union.r_purchase.as_("r_purchase"), union.r_del_period.as_("r_del_period"),
union.r_sale.as_("r_sale"), union.r_purchase.as_("r_purchase"),
union.r_sale_line.as_("r_sale_line"), union.r_sale.as_("r_sale"),
union.r_sale_line.as_("r_sale_line"),
union.r_tot.as_("r_tot"), union.r_tot.as_("r_tot"),
union.r_supplier.as_("r_supplier"), union.r_supplier.as_("r_supplier"),
union.r_client.as_("r_client"), union.r_client.as_("r_client"),
@@ -1614,14 +1638,15 @@ class LotReport(
r_lot_shipment_out = fields.Many2One('stock.shipment.out', "Shipment Out") r_lot_shipment_out = fields.Many2One('stock.shipment.out', "Shipment Out")
r_lot_shipment_internal = fields.Many2One('stock.shipment.internal', "Shipment Internal") r_lot_shipment_internal = fields.Many2One('stock.shipment.internal', "Shipment Internal")
r_lot_move = fields.Many2One('stock.move', "Move") r_lot_move = fields.Many2One('stock.move', "Move")
r_lot_parent = fields.Many2One('lot.lot',"Parent") r_lot_parent = fields.Many2One('lot.lot',"Parent")
r_lot_himself = fields.Many2One('lot.lot',"Lot") r_lot_himself = fields.Many2One('lot.lot',"Lot")
r_lot_container = fields.Char("Container") r_lot_container = fields.Char("Container")
r_lot_unit_line = fields.Function(fields.Many2One('product.uom', "Unit"),'get_unit') r_lot_unit_line = fields.Function(fields.Many2One('product.uom', "Unit"),'get_unit')
r_lot_price = fields.Function(fields.Numeric("Price", digits='r_lot_unit_line'),'get_lot_price') r_lot_price = fields.Function(fields.Numeric("Price", digits='r_lot_unit_line'),'get_lot_price')
r_lot_price_sale = fields.Function(fields.Numeric("Price", digits='r_lot_unit_line'),'get_lot_sale_price') r_lot_price_sale = fields.Function(fields.Numeric("Price", digits='r_lot_unit_line'),'get_lot_sale_price')
r_sale_line = fields.Many2One('sale.line',"S. line") r_del_period = fields.Many2One('product.month', "Delivery Period")
r_sale = fields.Many2One('sale.sale',"Sale") r_sale_line = fields.Many2One('sale.line',"S. line")
r_sale = fields.Many2One('sale.sale',"Sale")
r_tot = fields.Numeric("Qt tot", digits='r_lot_unit_line') r_tot = fields.Numeric("Qt tot", digits='r_lot_unit_line')
r_supplier = fields.Many2One('party.party',"Supplier") r_supplier = fields.Many2One('party.party',"Supplier")
r_client = fields.Many2One('party.party',"Client") r_client = fields.Many2One('party.party',"Client")
@@ -1832,7 +1857,8 @@ class LotReport(
supplier = context.get('supplier') supplier = context.get('supplier')
#asof = context.get('asof') #asof = context.get('asof')
#todate = context.get('todate') #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 return query
@classmethod @classmethod
@@ -1922,6 +1948,12 @@ class LotContext(ModelView):
('pnl', 'Pnl'), ('pnl', 'Pnl'),
],'Mode') ],'Mode')
finished = fields.Boolean("Display finished")
@classmethod
def default_finished(cls):
return False
@classmethod @classmethod
def default_asof(cls): def default_asof(cls):
pool = Pool() pool = Pool()
@@ -2002,17 +2034,24 @@ class LotShipping(Wizard):
if r.r_lot_shipment_in: if r.r_lot_shipment_in:
raise UserError("Please unlink before linking to a new shipment !") raise UserError("Please unlink before linking to a new shipment !")
else: 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 shipment_origin = None
if self.ship.quantity: if self.ship.quantity:
shipped_quantity = self.ship.quantity shipped_quantity = self.ship.quantity
if shipped_quantity == 0: 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 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) shipment_origin = 'stock.shipment.in,'+str(self.ship.shipment_in.id)
elif self.ship.shipment == 'out': 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) shipment_origin = 'stock.shipment.out,'+str(self.ship.shipment_out.id)
elif self.ship.shipment == 'int': 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) shipment_origin = 'stock.shipment.internal,'+str(self.ship.shipment_internal.id)
if r.id < 10000000 : if r.id < 10000000 :
l = Lot(r.id) l = Lot(r.id)
@@ -2030,15 +2069,22 @@ class LotShipping(Wizard):
move = Move(l.move) move = Move(l.move)
move.shipment = shipment_origin move.shipment = shipment_origin
Move.save([move]) 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 #Decrease forecasted virtual part shipped
vlot_p = l.getVlot_p() vlot_p = l.getVlot_p()
l.updateVirtualPart(-l.get_current_quantity_converted(),shipment_origin,l.getVlot_s()) l.updateVirtualPart(-l.get_current_quantity_converted(),shipment_origin,l.getVlot_s())
l.lot_av = 'reserved' l.lot_av = 'reserved'
Lot.save([l]) Lot.save([l])
l.set_current_quantity(l.lot_quantity,l.lot_gross_quantity,2)
Lot.save([l])
else: else:
lqt = LotQt(r.id - 10000000) lqt = LotQt(r.id - 10000000)
#Increase forecasted virtual part shipped #Increase forecasted virtual part shipped
if not lqt.lot_p.updateVirtualPart(shipped_quantity,shipment_origin,lqt.lot_s): 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) lqt.lot_p.createVirtualPart(shipped_quantity,shipment_origin,lqt.lot_s)
#Decrease forecasted virtual part non shipped #Decrease forecasted virtual part non shipped
lqt.lot_p.updateVirtualPart(-shipped_quantity,None,lqt.lot_s) lqt.lot_p.updateVirtualPart(-shipped_quantity,None,lqt.lot_s)
@@ -2442,6 +2488,7 @@ class LotAddLine(ModelView):
lot_gross_quantity = fields.Numeric("Gross weight") lot_gross_quantity = fields.Numeric("Gross weight")
lot_unit_line = fields.Many2One('product.uom', "Unit",required=True) lot_unit_line = fields.Many2One('product.uom', "Unit",required=True)
lot_premium = fields.Numeric("Premium") lot_premium = fields.Numeric("Premium")
lot_chunk_key = fields.Integer("Chunk key")
# @fields.depends('lot_qt') # @fields.depends('lot_qt')
# def on_change_with_lot_quantity(self): # def on_change_with_lot_quantity(self):
@@ -2604,6 +2651,17 @@ class LotInvoice(Wizard):
invoicing = StateTransition() 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): def transition_start(self):
return 'inv' return 'inv'
@@ -2650,7 +2708,7 @@ class LotInvoice(Wizard):
val['lot_diff_quantity'] = val['lot_quantity'] - Decimal(lot.invoice_line_prov.quantity) 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_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_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'] unit = val['lot_unit']
val['lot_currency'] = lot.lot_price_ct_symbol val['lot_currency'] = lot.lot_price_ct_symbol
lot_p.append(val) lot_p.append(val)
@@ -2666,6 +2724,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_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_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_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) lot_s.append(val_s)
if line: if line:
if line.fees: if line.fees:
@@ -2724,35 +2783,64 @@ class LotInvoice(Wizard):
'action': act 'action': act
} }
def transition_invoicing(self): def transition_invoicing(self):
Lot = Pool().get('lot.lot') Lot = Pool().get('lot.lot')
Purchase = Pool().get('purchase.purchase') Purchase = Pool().get('purchase.purchase')
Sale = Pool().get('sale.sale') Sale = Pool().get('sale.sale')
lots = [] lots = []
action = self.inv.action purchases = []
for r in self.records: sales = []
purchase = r.r_line.purchase action = self.inv.action
sale = None for r in self.records:
if r.r_sale_line: purchase = r.r_line.purchase if r.r_line else None
sale = r.r_sale_line.sale sale = r.r_sale_line.sale if r.r_sale_line else None
lot = Lot(r.r_lot_p) if purchase and purchase not in purchases:
# if lot.move == None: purchases.append(purchase)
# Warning = Pool().get('res.user.warning') if sale and sale not in sales:
# warning_name = Warning.format("Lot not confirmed", []) sales.append(sale)
# if Warning.check(warning_name): lot = Lot(r.r_lot_p)
# if lot.move == None:
# Warning = Pool().get('res.user.warning')
# warning_name = Warning.format("Lot not confirmed", [])
# if Warning.check(warning_name):
# raise QtWarning(warning_name, # raise QtWarning(warning_name,
# "Lot not confirmed, click yes to confirm and invoice") # "Lot not confirmed, click yes to confirm and invoice")
# continue # continue
if lot.invoice_line: if lot.invoice_line:
continue continue
lots.append(lot) lots.append(lot)
invoice_line = None
if self.inv.type == 'purchase':
Purchase._process_invoice(purchases, lots, action, self.inv.pp_pur)
for lot in lots:
lot = Lot(lot.id)
invoice_line = lot.invoice_line or lot.invoice_line_prov
if invoice_line:
break
else:
if sales:
Sale._process_invoice(sales, lots, action, self.inv.pp_sale)
for lot in lots:
lot = Lot(lot.id)
invoice_line = lot.sale_invoice_line or lot.sale_invoice_line_prov
if invoice_line:
break
if not invoice_line:
raise UserError("No invoice line was generated from the selected lots.")
self.message.invoice = invoice_line.invoice
return 'message'
if self.inv.type == 'purchase': def default_message(self, fields):
Purchase._process_invoice([purchase],lots,action,self.inv.pp_pur) return {
else: 'message': 'The invoice has been successfully created.',
if sale: }
Sale._process_invoice([sale],lots,action,self.inv.pp_sale)
return 'end' 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): def end(self):
return 'reload' return 'reload'
@@ -2962,25 +3050,26 @@ class LotWeighing(Wizard):
Lot = Pool().get('lot.lot') Lot = Pool().get('lot.lot')
context = Transaction().context context = Transaction().context
ids = context.get('active_ids') ids = context.get('active_ids')
for i in ids: for i in ids:
if i > 10000000: if i > 10000000:
raise UserError("Trying to do weighing on open quantity!") raise UserError("Trying to do weighing on open quantity!")
val = {} val = {}
lot = Lot(i) lot = Lot(i)
val['lot'] = lot.id val['lot'] = lot.id
val['lot_name'] = lot.lot_name val['lot_name'] = lot.lot_name
if lot.lot_shipment_in: if lot.lot_shipment_in:
val['lot_shipment_in'] = lot.lot_shipment_in.id val['lot_shipment_in'] = lot.lot_shipment_in.id
if lot.lot_shipment_internal: if lot.lot_shipment_internal:
val['lot_shipment_internal'] = lot.lot_shipment_internal.id val['lot_shipment_internal'] = lot.lot_shipment_internal.id
if lot.lot_shipment_out: if lot.lot_shipment_out:
val['lot_shipment_out'] = lot.lot_shipment_out.id val['lot_shipment_out'] = lot.lot_shipment_out.id
val['lot_product'] = lot.lot_product.id val['lot_product'] = lot.lot_product.id
val['lot_quantity'] = lot.lot_quantity val['lot_qt'] = lot.lot_qt
val['lot_gross_quantity'] = lot.lot_gross_quantity val['lot_quantity'] = lot.lot_quantity
val['lot_unit'] = lot.lot_unit.id val['lot_gross_quantity'] = lot.lot_gross_quantity
val['lot_unit_line'] = lot.lot_unit_line.id val['lot_unit'] = lot.lot_unit.id
lot_p.append(val) val['lot_unit_line'] = lot.lot_unit_line.id
lot_p.append(val)
return { return {
'lot_p': lot_p, 'lot_p': lot_p,
} }
@@ -2995,17 +3084,18 @@ class LotWeighing(Wizard):
lhs = LotHist.search([('lot',"=",l.lot.id),('quantity_type','=',self.w.lot_state.id)]) lhs = LotHist.search([('lot',"=",l.lot.id),('quantity_type','=',self.w.lot_state.id)])
if lhs: if lhs:
lh = lhs[0] lh = lhs[0]
else: else:
lh = LotHist() lh = LotHist()
lh.lot = l.lot lh.lot = l.lot
lh.quantity_type = self.w.lot_state lh.quantity_type = self.w.lot_state
lh.quantity = round(l.lot_quantity_new,5) lh.quantity = round(l.lot_quantity_new,5)
lh.gross_quantity = round(l.lot_gross_quantity_new,5) lh.gross_quantity = round(l.lot_gross_quantity_new,5)
LotHist.save([lh]) LotHist.save([lh])
l.lot.lot_qt = l.lot_qt
if self.w.lot_update_state :
l.lot.lot_state = self.w.lot_state if self.w.lot_update_state :
Lot.save([l.lot]) l.lot.lot_state = self.w.lot_state
Lot.save([l.lot])
diff = round(Decimal(l.lot.get_current_quantity_converted() - quantity),5) diff = round(Decimal(l.lot.get_current_quantity_converted() - quantity),5)
if diff != 0 : if diff != 0 :
#need to update virtual part #need to update virtual part
@@ -3040,12 +3130,13 @@ class LotWeighingLot(ModelView):
lot_name = fields.Char("Name",readonly=True) lot_name = fields.Char("Name",readonly=True)
lot_shipment_in = fields.Many2One('stock.shipment.in',"Shipment In") lot_shipment_in = fields.Many2One('stock.shipment.in',"Shipment In")
lot_shipment_internal = fields.Many2One('stock.shipment.internal',"Shipment Internal") lot_shipment_internal = fields.Many2One('stock.shipment.internal',"Shipment Internal")
lot_shipment_out = fields.Many2One('stock.shipment.out',"Shipment Out") lot_shipment_out = fields.Many2One('stock.shipment.out',"Shipment Out")
lot_product = fields.Many2One('product.product',"Product",readonly=True) lot_product = fields.Many2One('product.product',"Product",readonly=True)
lot_quantity = fields.Numeric("Net weight",digits=(1,5),readonly=True) lot_qt = fields.Integer("Qt")
lot_gross_quantity = fields.Numeric("Gross weight",digits=(1,5),readonly=True) lot_quantity = fields.Numeric("Net weight",digits=(1,5),readonly=True)
lot_unit = fields.Many2One('product.uom',"Unit",readonly=True) lot_gross_quantity = fields.Numeric("Gross weight",digits=(1,5),readonly=True)
lot_unit_line = fields.Many2One('product.uom',"Unit",readonly=True) lot_unit = fields.Many2One('product.uom',"Unit",readonly=True)
lot_unit_line = fields.Many2One('product.uom',"Unit",readonly=True)
lot_quantity_new = fields.Numeric("New net weight",digits=(1,5)) lot_quantity_new = fields.Numeric("New net weight",digits=(1,5))
lot_gross_quantity_new = fields.Numeric("New gross weight",digits=(1,5)) lot_gross_quantity_new = fields.Numeric("New gross weight",digits=(1,5))
lot_shipment_origin = fields.Function( lot_shipment_origin = fields.Function(
@@ -3089,37 +3180,55 @@ class CreateContracts(Wizard):
def transition_start(self): def transition_start(self):
return 'ct' return 'ct'
def default_ct(self, fields): def default_ct(self, fields):
LotQt = Pool().get('lot.qt') LotQt = Pool().get('lot.qt')
Lot = Pool().get('lot.lot') Lot = Pool().get('lot.lot')
context = Transaction().context context = Transaction().context
ids = context.get('active_ids') ids = context.get('active_ids')
unit = None unit = None
product = None product = None
sh_in = None sh_in = None
sh_int = None sh_int = None
sh_out = None sh_out = None
lot = None lot = None
qt = None qt = Decimal(0)
type = None type = None
for i in ids: shipment_in_values = set()
val = {} shipment_internal_values = set()
if i < 10000000: shipment_out_values = set()
raise UserError("You must create contract from an open quantity !") for i in ids:
l = LotQt(i - 10000000) if i < 10000000:
ll = Lot(l.lot_p if l.lot_p else l.lot_s) raise UserError("You must create contract from an open quantity !")
type = "Sale" if l.lot_p else "Purchase" l = LotQt(i - 10000000)
unit = l.lot_unit.id ll = Lot(l.lot_p if l.lot_p else l.lot_s)
qt = l.lot_quantity current_type = "Sale" if l.lot_p else "Purchase"
product = ll.lot_product.id if type and current_type != type:
sh_in = l.lot_shipment_in.id if l.lot_shipment_in else None raise UserError("You must select open quantities from the same side.")
sh_int = l.lot_shipment_internal.id if l.lot_shipment_internal else None type = current_type
sh_out = l.lot_shipment_out.id if l.lot_shipment_out else None if product and ll.lot_product.id != product:
lot = ll.id raise UserError("You must select open quantities with the same product.")
if unit and l.lot_unit.id != unit:
return { raise UserError("You must select open quantities with the same unit.")
'quantity': qt, unit = l.lot_unit.id
'unit': unit, qt += abs(Decimal(str(l.lot_quantity or 0)))
product = ll.lot_product.id
shipment_in_values.add(l.lot_shipment_in.id if l.lot_shipment_in else None)
shipment_internal_values.add(
l.lot_shipment_internal.id if l.lot_shipment_internal else None)
shipment_out_values.add(l.lot_shipment_out.id if l.lot_shipment_out else None)
if lot is None:
lot = ll.id
if len(shipment_in_values) == 1:
sh_in = next(iter(shipment_in_values))
if len(shipment_internal_values) == 1:
sh_int = next(iter(shipment_internal_values))
if len(shipment_out_values) == 1:
sh_out = next(iter(shipment_out_values))
return {
'quantity': qt,
'unit': unit,
'product': product, 'product': product,
'shipment_in': sh_in, 'shipment_in': sh_in,
'shipment_internal': sh_int, 'shipment_internal': sh_int,
@@ -3129,136 +3238,13 @@ class CreateContracts(Wizard):
} }
def transition_creating(self): def transition_creating(self):
SaleLine = Pool().get('sale.line') ContractFactory.create_contracts(
Sale = Pool().get('sale.sale') self.ct.contracts,
PurchaseLine = Pool().get('purchase.line') type_=self.ct.type,
Purchase = Pool().get('purchase.purchase') ct=self.ct,
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)
return 'end' return 'end'
# def do_matching(self, action):
# return action, {
# 'ids': self.sale_lines,
# 'model': str(self.ct.lot.id),
# }
def end(self): def end(self):
return 'reload' return 'reload'
@@ -3288,7 +3274,6 @@ class ContractsStart(ModelView):
def default_matched(cls): def default_matched(cls):
return True return True
class ContractDetail(ModelView): class ContractDetail(ModelView):
"Contract Detail" "Contract Detail"
@@ -3296,26 +3281,29 @@ class ContractDetail(ModelView):
category = fields.Integer("Category") category = fields.Integer("Category")
cd = fields.Many2One('contracts.start',"Contracts") cd = fields.Many2One('contracts.start',"Contracts")
party = fields.Many2One('party.party',"Party",domain=[('categories.parent', 'child_of', Eval('category'))],depends=['category']) party = fields.Many2One('party.party',"Party", required=True,domain=[('categories.parent', 'child_of', Eval('category'))],depends=['category'])
currency = fields.Many2One('currency.currency',"Currency") currency = fields.Many2One('currency.currency',"Currency", required=True)
incoterm = fields.Many2One('incoterm.incoterm',"Incoterm") incoterm = fields.Many2One('incoterm.incoterm',"Incoterm", required=True)
quantity = fields.Numeric("Quantity",digits=(1,5)) quantity = fields.Numeric("Quantity",digits=(1,5), required=True)
unit = fields.Many2One('product.uom',"Unit") unit = fields.Many2One('product.uom',"Unit", required=True)
qt_unit = fields.Many2One('product.uom',"Unit") qt_unit = fields.Many2One('product.uom',"Unit")
tol_min = fields.Numeric("Tol - in %") tol_min = fields.Numeric("Tol - in %", required=True)
tol_max = fields.Numeric("Tol + in %") tol_max = fields.Numeric("Tol + in %", required=True)
crop = fields.Many2One('purchase.crop',"Crop") crop = fields.Many2One('purchase.crop',"Crop")
del_period = fields.Many2One('product.month',"Delivery Period") del_period = fields.Many2One('product.month',"Delivery Period")
from_del = fields.Date("From") from_del = fields.Date("From")
to_del = fields.Date("To") 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([ price_type = price_type = fields.Selection([
('cash', 'Cash Price'), ('cash', 'Cash Price'),
('priced', 'Priced'), ('priced', 'Priced'),
('basis', 'Basis'), ('basis', 'Basis'),
], 'Price type') ], 'Price type', required=True)
currency_unit = fields.Selection('get_currency_unit',string="Curr/Unit") currency_unit = fields.Selection('get_currency_unit',string="Curr/Unit")
reference = fields.Char("Reference") 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 @classmethod
def default_category(cls): def default_category(cls):
@@ -3372,7 +3360,7 @@ class ContractDetail(ModelView):
if lqt and lqt.lot_p and getattr(lqt.lot_p.line.purchase, 'crop', None): if lqt and lqt.lot_p and getattr(lqt.lot_p.line.purchase, 'crop', None):
return lqt.lot_p.line.purchase.crop.id return lqt.lot_p.line.purchase.crop.id
if lqt and lqt.lot_s and getattr(lqt.lot_s.sale_line.sale, 'crop', None): 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 @classmethod
def default_currency(cls): def default_currency(cls):
@@ -3415,4 +3403,4 @@ class ContractDetail(ModelView):
if self.del_period: if self.del_period:
self.from_del = self.del_period.beg_date self.from_del = self.del_period.beg_date
self.to_del = self.del_period.end_date self.to_del = self.del_period.end_date

View 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}"

View File

@@ -1,17 +1,183 @@
from trytond.model import ModelSQL, ModelView, fields 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.exceptions import UserError
from trytond.modules.purchase_trade.purchase import (TRIGGERS) 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'] class PartyExecution(ModelSQL,ModelView):
__metaclass__ = PoolMeta "Party Execution"
__name__ = 'party.execution'
class Party(metaclass=PoolMeta): party = fields.Many2One('party.party',"Party")
__name__ = 'party.party' area = fields.Many2One('country.region',"Area")
percent = fields.Numeric("% targeted")
achieved_percent = fields.Function(fields.Numeric("% achieved"),'get_percent')
@staticmethod
def _to_decimal(value):
if value is None:
return Decimal('0')
if isinstance(value, Decimal):
return value
return Decimal(str(value))
@classmethod
def _round_percent(cls, value):
return cls._to_decimal(value).quantize(
Decimal('0.01'), rounding=ROUND_HALF_UP)
def matches_country(self, country):
if not self.area or not country or not getattr(country, 'region', None):
return False
region = country.region
while region:
if region.id == self.area.id:
return True
region = getattr(region, 'parent', None)
return False
def matches_shipment(self, shipment):
location = getattr(shipment, 'to_location', None)
country = getattr(location, 'country', None)
return self.matches_country(country)
@classmethod
def compute_achieved_percent_for(cls, party, area):
if not party or not area:
return Decimal('0')
Shipment = Pool().get('stock.shipment.in')
shipments = Shipment.search([
('controller', '!=', None),
])
execution = cls()
execution.area = area
shipments = [
shipment for shipment in shipments
if execution.matches_shipment(shipment)]
total = len(shipments)
if not total:
return Decimal('0')
achieved = sum(
1 for shipment in shipments
if shipment.controller and shipment.controller.id == party.id)
return cls._round_percent(
(Decimal(achieved) * Decimal('100')) / Decimal(total))
def compute_achieved_percent(self):
return self.__class__.compute_achieved_percent_for(
self.party, self.area)
def get_target_gap(self):
return self._to_decimal(self.percent) - self.compute_achieved_percent()
def get_percent(self,name):
return self.compute_achieved_percent()
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'
tol_min = fields.Numeric("Tol - in %") tol_min = fields.Numeric("Tol - in %")
tol_max = fields.Numeric("Tol + in %") tol_max = fields.Numeric("Tol + in %")
wb = fields.Many2One('purchase.weight.basis',"Weight basis") wb = fields.Many2One('purchase.weight.basis',"Weight basis")
association = fields.Many2One('purchase.association',"Association") 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_controller_execution_priority(self, shipment):
best_rule = None
best_gap = None
for execution in self.execution or []:
if not execution.matches_shipment(shipment):
continue
gap = execution.get_target_gap()
if best_gap is None or gap > best_gap:
best_gap = gap
best_rule = execution
return best_gap, best_rule
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

View File

@@ -1,5 +1,29 @@
<record model="ir.ui.view" id="party_view_form"> <tryton>
<field name="model">party.party</field> <data>
<field name="inherit" ref="party.party_view_form"/> <record model="ir.ui.view" id="party_view_form">
<field name="name">party_form</field> <field name="model">party.party</field>
</record> <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>

View File

@@ -21,6 +21,7 @@ class PaymentTermLine(metaclass=PoolMeta):
trigger_event = fields.Selection(TRIGGERS, 'Trigger Event') trigger_event = fields.Selection(TRIGGERS, 'Trigger Event')
term_type = fields.Selection([ term_type = fields.Selection([
(None, ''),
('advance', 'Advance'), ('advance', 'Advance'),
('cad', 'CAD'), ('cad', 'CAD'),
('open', 'Open'), ('open', 'Open'),
@@ -30,18 +31,21 @@ class PaymentTermLine(metaclass=PoolMeta):
trigger_offset = fields.Integer('Trigger Offset') trigger_offset = fields.Integer('Trigger Offset')
offset_unit = fields.Selection([ offset_unit = fields.Selection([
(None, ''),
('calendar', 'Calendar Days'), ('calendar', 'Calendar Days'),
('business', 'Business Days'), ('business', 'Business Days'),
], 'Offset Unit') ], 'Offset Unit')
eom_flag = fields.Boolean('EOM Flag') eom_flag = fields.Boolean('EOM Flag')
eom_mode = fields.Selection([ eom_mode = fields.Selection([
(None, ''),
('standard', 'Standard'), ('standard', 'Standard'),
('before', 'Before EOM'), ('before', 'Before EOM'),
('after', 'After EOM'), ('after', 'After EOM'),
], 'EOM Mode') ], 'EOM Mode')
risk_classification = fields.Selection([ risk_classification = fields.Selection([
(None, ''),
('fully_secured', 'Fully Secured'), ('fully_secured', 'Fully Secured'),
('partially_secured', 'Partially Secured'), ('partially_secured', 'Partially Secured'),
('unsecured', 'Unsecured'), ('unsecured', 'Unsecured'),

View File

@@ -50,37 +50,243 @@ DAYS = [
('sunday', 'Sunday'), ('sunday', 'Sunday'),
] ]
class Estimated(ModelSQL, ModelView): class Estimated(ModelSQL, ModelView):
"Estimated date" "Estimated date"
__name__ = 'pricing.estimated' __name__ = 'pricing.estimated'
trigger = fields.Selection(TRIGGERS,"Trigger") 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 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): class Mtm(ModelSQL, ModelView):
"Mtm" "MtM Component"
__name__ = 'mtm.component' __name__ = 'mtm.component'
fix_type = fields.Many2One('price.fixtype',"Fixation type") strategy = fields.Many2One(
ratio = fields.Numeric("%") 'mtm.strategy', "Strategy",
price_index = fields.Many2One('price.price',"Curve") required=True, ondelete='CASCADE'
currency = fields.Function(fields.Many2One('currency.currency',"Curr."),'get_cur') )
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: if self.price_index:
PI = Pool().get('price.price') return self.price_index.price_currency
pi = PI(self.price_index) if self.price_matrix:
return pi.price_currency 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): class Component(ModelSQL, ModelView):
"Component" "Component"
__name__ = 'pricing.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") 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_index = fields.Many2One('price.price',"Curve")
price_matrix = fields.Many2One('price.matrix', "Price Matrix")
currency = fields.Function(fields.Many2One('currency.currency',"Curr."),'get_cur') currency = fields.Function(fields.Many2One('currency.currency',"Curr."),'get_cur')
auto = fields.Boolean("Auto") auto = fields.Boolean("Auto")
fallback = fields.Boolean("Fallback") fallback = fields.Boolean("Fallback")
@@ -119,20 +325,20 @@ class Component(ModelSQL, ModelView):
super(Component, cls).delete(components) super(Component, cls).delete(components)
class Pricing(ModelSQL,ModelView): class Pricing(ModelSQL,ModelView):
"Pricing" "Pricing"
__name__ = 'pricing.pricing' __name__ = 'pricing.pricing'
pricing_date = fields.Date("Date") pricing_date = fields.Date("Date")
price_component = fields.Many2One('pricing.component', "Component")#, domain=[('id', 'in', Eval('line.price_components'))], ondelete='CASCADE') price_component = fields.Many2One('pricing.component', "Component")#, domain=[('id', 'in', Eval('line.price_components'))], ondelete='CASCADE')
quantity = fields.Numeric("Qt",digits='unit') quantity = fields.Numeric("Qt",digits='unit')
settl_price = fields.Numeric("Settl. price",digits='unit') settl_price = fields.Numeric("Settl. price",digits='unit')
fixed_qt = fields.Numeric("Fixed qt",digits='unit',readonly=True) fixed_qt = fields.Numeric("Fixed qt",digits='unit', readonly=True)
fixed_qt_price = fields.Numeric("Fixed qt price",digits='unit',readonly=True) fixed_qt_price = fields.Numeric("Fixed qt price",digits='unit', readonly=True)
unfixed_qt = fields.Numeric("Unfixed qt",digits='unit',readonly=True) unfixed_qt = fields.Numeric("Unfixed qt",digits='unit', readonly=True)
unfixed_qt_price = fields.Numeric("Unfixed qt price",digits='unit',readonly=True) unfixed_qt_price = fields.Numeric("Unfixed qt price",digits='unit', readonly=True)
eod_price = fields.Numeric("EOD price",digits='unit',readonly=True) eod_price = fields.Numeric("EOD price",digits='unit',readonly=True)
last = fields.Boolean("Last") last = fields.Boolean("Last")
@classmethod @classmethod
def default_fixed_qt(cls): def default_fixed_qt(cls):
@@ -158,24 +364,244 @@ class Pricing(ModelSQL,ModelView):
def default_settl_price(cls): def default_settl_price(cls):
return Decimal(0) return Decimal(0)
@classmethod @classmethod
def default_eod_price(cls): def default_eod_price(cls):
return Decimal(0) return Decimal(0)
def get_fixed_price(self): @staticmethod
price = Decimal(0) def _weighted_average_price(fixed_qt, fixed_price, unfixed_qt, unfixed_price):
Pricing = Pool().get('pricing.pricing') fixed_qt = Decimal(str(fixed_qt or 0))
pricings = Pricing.search(['price_component','=',self.price_component.id],order=[('pricing_date', 'ASC')]) fixed_price = Decimal(str(fixed_price or 0))
if pricings: unfixed_qt = Decimal(str(unfixed_qt or 0))
cumul_qt = Decimal(0) unfixed_price = Decimal(str(unfixed_price or 0))
cumul_qt_price = Decimal(0) total_qty = fixed_qt + unfixed_qt
for pr in pricings: if total_qty == 0:
cumul_qt += pr.quantity return Decimal(0)
cumul_qt_price += pr.quantity * pr.settl_price return round(
if pr.id == self.id: ((fixed_qt * fixed_price) + (unfixed_qt * unfixed_price)) / total_qty,
break 4,
if cumul_qt > 0: )
price = cumul_qt_price / cumul_qt
def compute_eod_price(self):
if getattr(self, 'sale_line', None) and hasattr(self, 'get_eod_price_sale'):
return self.get_eod_price_sale()
if getattr(self, 'line', None) and hasattr(self, 'get_eod_price_purchase'):
return self.get_eod_price_purchase()
return self._weighted_average_price(
self.fixed_qt,
self.fixed_qt_price,
self.unfixed_qt,
self.unfixed_qt_price,
)
@fields.depends('fixed_qt', 'fixed_qt_price', 'unfixed_qt', 'unfixed_qt_price')
def on_change_fixed_qt(self):
self.eod_price = self.compute_eod_price()
@fields.depends('fixed_qt', 'fixed_qt_price', 'unfixed_qt', 'unfixed_qt_price')
def on_change_fixed_qt_price(self):
self.eod_price = self.compute_eod_price()
@fields.depends('fixed_qt', 'fixed_qt_price', 'unfixed_qt', 'unfixed_qt_price')
def on_change_unfixed_qt(self):
self.eod_price = self.compute_eod_price()
@fields.depends('fixed_qt', 'fixed_qt_price', 'unfixed_qt', 'unfixed_qt_price')
def on_change_unfixed_qt_price(self):
self.eod_price = self.compute_eod_price()
@classmethod
def create(cls, vlist):
records = super(Pricing, cls).create(vlist)
cls._sync_manual_values(records)
cls._sync_manual_last(records)
cls._sync_eod_price(records)
return records
@classmethod
def write(cls, *args):
super(Pricing, cls).write(*args)
if (Transaction().context.get('skip_pricing_eod_sync')
or Transaction().context.get('skip_pricing_last_sync')):
return
records = []
actions = iter(args)
for record_set, values in zip(actions, actions):
if values:
records.extend(record_set)
cls._sync_manual_values(records)
cls._sync_manual_last(records)
cls._sync_eod_price(records)
@classmethod
def _sync_eod_price(cls, records):
if not records:
return
with Transaction().set_context(skip_pricing_eod_sync=True):
for record in records:
eod_price = record.compute_eod_price()
if Decimal(str(record.eod_price or 0)) == Decimal(str(eod_price or 0)):
continue
super(Pricing, cls).write([record], {
'eod_price': eod_price,
})
@classmethod
def _is_manual_pricing_record(cls, record):
component = getattr(record, 'price_component', None)
if component is None:
return True
return not bool(getattr(component, 'auto', False))
@classmethod
def _get_pricing_group_domain(cls, record):
component = getattr(record, 'price_component', None)
if getattr(record, 'sale_line', None):
return [
('sale_line', '=', record.sale_line.id),
('price_component', '=',
component.id if getattr(component, 'id', None) else None),
]
if getattr(record, 'line', None):
return [
('line', '=', record.line.id),
('price_component', '=',
component.id if getattr(component, 'id', None) else None),
]
return None
@classmethod
def _get_base_quantity(cls, record):
owner = getattr(record, 'sale_line', None) or getattr(record, 'line', None)
if not owner:
return Decimal(0)
if hasattr(owner, '_get_pricing_base_quantity'):
return Decimal(str(owner._get_pricing_base_quantity() or 0))
quantity = getattr(owner, 'quantity_theorical', None)
if quantity is None:
quantity = getattr(owner, 'quantity', None)
return Decimal(str(quantity or 0))
@classmethod
def _sync_manual_values(cls, records):
if (not records
or Transaction().context.get('skip_pricing_manual_sync')):
return
domains = []
seen = set()
for record in records:
if not cls._is_manual_pricing_record(record):
continue
domain = cls._get_pricing_group_domain(record)
if not domain:
continue
key = tuple(domain)
if key in seen:
continue
seen.add(key)
domains.append(domain)
if not domains:
return
with Transaction().set_context(
skip_pricing_manual_sync=True,
skip_pricing_last_sync=True,
skip_pricing_eod_sync=True):
for domain in domains:
pricings = cls.search(
domain,
order=[('pricing_date', 'ASC'), ('id', 'ASC')])
if not pricings:
continue
base_quantity = cls._get_base_quantity(pricings[0])
cumul_qt = Decimal(0)
cumul_qt_price = Decimal(0)
total = len(pricings)
for index, pricing in enumerate(pricings):
quantity = Decimal(str(pricing.quantity or 0))
settl_price = Decimal(str(pricing.settl_price or 0))
cumul_qt += quantity
cumul_qt_price += quantity * settl_price
fixed_qt = cumul_qt
if fixed_qt > 0:
fixed_qt_price = round(cumul_qt_price / fixed_qt, 4)
else:
fixed_qt_price = Decimal(0)
unfixed_qt = base_quantity - fixed_qt
if unfixed_qt < Decimal('0.001'):
unfixed_qt = Decimal(0)
fixed_qt = base_quantity
values = {
'fixed_qt': fixed_qt,
'fixed_qt_price': fixed_qt_price,
'unfixed_qt': unfixed_qt,
'unfixed_qt_price': settl_price,
'last': index == (total - 1),
}
eod_price = cls._weighted_average_price(
values['fixed_qt'],
values['fixed_qt_price'],
values['unfixed_qt'],
values['unfixed_qt_price'],
)
values['eod_price'] = eod_price
super(Pricing, cls).write([pricing], values)
@classmethod
def _get_manual_last_group_domain(cls, record):
return cls._get_pricing_group_domain(record)
@classmethod
def _sync_manual_last(cls, records):
if not records:
return
domains = []
seen = set()
for record in records:
domain = cls._get_manual_last_group_domain(record)
if not domain:
continue
key = tuple(domain)
if key in seen:
continue
seen.add(key)
domains.append(domain)
if not domains:
return
with Transaction().set_context(
skip_pricing_last_sync=True,
skip_pricing_eod_sync=True):
for domain in domains:
pricings = cls.search(
domain,
order=[('pricing_date', 'ASC'), ('id', 'ASC')])
if not pricings:
continue
last_pricing = pricings[-1]
for pricing in pricings[:-1]:
if pricing.last:
super(Pricing, cls).write([pricing], {'last': False})
if not last_pricing.last:
super(Pricing, cls).write([last_pricing], {'last': True})
def get_fixed_price(self):
price = Decimal(0)
Pricing = Pool().get('pricing.pricing')
domain = self._get_pricing_group_domain(self)
if not domain:
return price
pricings = Pricing.search(domain, order=[('pricing_date', 'ASC'), ('id', 'ASC')])
if pricings:
cumul_qt = Decimal(0)
cumul_qt_price = Decimal(0)
for pr in pricings:
quantity = Decimal(str(pr.quantity or 0))
settl_price = Decimal(str(pr.settl_price or 0))
cumul_qt += quantity
cumul_qt_price += quantity * settl_price
if pr.id == self.id:
break
if cumul_qt > 0:
price = cumul_qt_price / cumul_qt
return round(price,4) return round(price,4)
@@ -194,6 +620,7 @@ class Trigger(ModelSQL,ModelView):
'readonly': Eval('pricing_period') != None, 'readonly': Eval('pricing_period') != None,
}) })
average = fields.Boolean("Avg") average = fields.Boolean("Avg")
last = fields.Boolean("Last")
application_period = fields.Many2One('pricing.period',"Application period") application_period = fields.Many2One('pricing.period',"Application period")
from_a = fields.Date("From", from_a = fields.Date("From",
states={ states={
@@ -217,14 +644,11 @@ class Trigger(ModelSQL,ModelView):
pp = PP(self.application_period) pp = PP(self.application_period)
CO = Pool().get('pricing.component') CO = Pool().get('pricing.component')
co = CO(self.component) co = CO(self.component)
logger.info("DELDATEEST_:%s",co)
if co.line: if co.line:
d = co.getEstimatedTriggerPurchase(pp.trigger) d = co.getEstimatedTriggerPurchase(pp.trigger)
else: else:
d = co.getEstimatedTriggerSale(pp.trigger) d = co.getEstimatedTriggerSale(pp.trigger)
logger.info("DELDATEEST:%s",d)
date_from,date_to,dates = pp.getDates(d) date_from,date_to,dates = pp.getDates(d)
logger.info("DELDATEEST2:%s",dates)
return date_from,date_to,d,pp.include,dates return date_from,date_to,d,pp.include,dates
def getApplicationListDates(self, cal): def getApplicationListDates(self, cal):
@@ -288,7 +712,7 @@ class Trigger(ModelSQL,ModelView):
pi = PI(pc.price_index) pi = PI(pc.price_index)
val = {} val = {}
val['date'] = current_date 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'] = val['price']
val['avg_minus_1'] = val['price'] val['avg_minus_1'] = val['price']
val['isAvg'] = self.average val['isAvg'] = self.average
@@ -330,8 +754,6 @@ class Period(ModelSQL,ModelView):
date_from = None date_from = None
date_to = None date_to = None
dates = [] dates = []
logger.info("GETDATES:%s",t)
logger.info("GETDATES:%s",self.every)
if t: if t:
if self.every: if self.every:
if t: if t:
@@ -348,21 +770,18 @@ class Period(ModelSQL,ModelView):
while current.month == t.month: while current.month == t.month:
dates.append(datetime.datetime(current.year, current.month, current.day)) dates.append(datetime.datetime(current.year, current.month, current.day))
current += datetime.timedelta(days=7) current += datetime.timedelta(days=7)
logger.info("GETDATES:%s",dates)
elif self.nb_quotation > 0: elif self.nb_quotation > 0:
days_to_add = (weekday_target - t.weekday()) % 7 days_to_add = (weekday_target - t.weekday()) % 7
current = t + datetime.timedelta(days=days_to_add) current = t + datetime.timedelta(days=days_to_add)
while len(dates) < self.nb_quotation: while len(dates) < self.nb_quotation:
dates.append(datetime.datetime(current.year, current.month, current.day)) dates.append(datetime.datetime(current.year, current.month, current.day))
current += datetime.timedelta(days=7) current += datetime.timedelta(days=7)
logger.info("GETDATES:%s",dates)
elif self.nb_quotation < 0: elif self.nb_quotation < 0:
days_to_sub = (t.weekday() - weekday_target) % 7 days_to_sub = (t.weekday() - weekday_target) % 7
current = t - datetime.timedelta(days=days_to_sub) current = t - datetime.timedelta(days=days_to_sub)
while len(dates) < -self.nb_quotation: while len(dates) < -self.nb_quotation:
dates.append(datetime.datetime(current.year, current.month, current.day)) dates.append(datetime.datetime(current.year, current.month, current.day))
current -= datetime.timedelta(days=7) current -= datetime.timedelta(days=7)
logger.info("GETDATES:%s",dates)
else: else:
if self.startday == 'before': if self.startday == 'before':

View File

@@ -3,6 +3,11 @@
this repository contains the full copyright notices and license terms. --> this repository contains the full copyright notices and license terms. -->
<tryton> <tryton>
<data> <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"> <record model="ir.ui.view" id="summary_view_tree_sequence">
<field name="model">sale.pricing.summary</field> <field name="model">sale.pricing.summary</field>
<field name="type">tree</field> <field name="type">tree</field>
@@ -104,5 +109,84 @@ this repository contains the full copyright notices and license terms. -->
<field name="type">form</field> <field name="type">form</field>
<field name="name">period_form</field> <field name="name">period_form</field>
</record> </record>
<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" />
</data> </data>
</tryton> </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