xync-db 0.5.5.dev31__tar.gz → 0.5.5.dev64__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. {xync_db-0.5.5.dev31 → xync_db-0.5.5.dev64}/PKG-INFO +1 -1
  2. {xync_db-0.5.5.dev31 → xync_db-0.5.5.dev64}/src/xync_db/enums.py +29 -15
  3. {xync_db-0.5.5.dev31 → xync_db-0.5.5.dev64}/src/xync_db/migrations/CLAUDE.md +1 -1
  4. {xync_db-0.5.5.dev31 → xync_db-0.5.5.dev64}/src/xync_db/migrations/models/0001_initial.py +109 -25
  5. {xync_db-0.5.5.dev31 → xync_db-0.5.5.dev64}/src/xync_db/migrations/models/0002_triggers.py +106 -22
  6. {xync_db-0.5.5.dev31 → xync_db-0.5.5.dev64}/src/xync_db/migrations/models/0003_seed.py +1 -1
  7. {xync_db-0.5.5.dev31 → xync_db-0.5.5.dev64}/src/xync_db/migrations/models/seed/a_actor_seed.py +13 -1
  8. {xync_db-0.5.5.dev31 → xync_db-0.5.5.dev64}/src/xync_db/migrations/models/seed/a_tg_seed.py +32 -1
  9. {xync_db-0.5.5.dev31 → xync_db-0.5.5.dev64}/src/xync_db/models/CLAUDE.md +16 -2
  10. {xync_db-0.5.5.dev31 → xync_db-0.5.5.dev64}/src/xync_db/models/__init__.py +190 -47
  11. {xync_db-0.5.5.dev31 → xync_db-0.5.5.dev64}/.gitignore +0 -0
  12. {xync_db-0.5.5.dev31 → xync_db-0.5.5.dev64}/README.md +0 -0
  13. {xync_db-0.5.5.dev31 → xync_db-0.5.5.dev64}/pyproject.toml +0 -0
  14. {xync_db-0.5.5.dev31 → xync_db-0.5.5.dev64}/src/xync_db/__init__.py +0 -0
  15. {xync_db-0.5.5.dev31 → xync_db-0.5.5.dev64}/src/xync_db/config.py +0 -0
  16. {xync_db-0.5.5.dev31 → xync_db-0.5.5.dev64}/src/xync_db/exceptions.py +0 -0
  17. {xync_db-0.5.5.dev31 → xync_db-0.5.5.dev64}/src/xync_db/graph.py +0 -0
  18. {xync_db-0.5.5.dev31 → xync_db-0.5.5.dev64}/src/xync_db/i18n.py +0 -0
  19. {xync_db-0.5.5.dev31 → xync_db-0.5.5.dev64}/src/xync_db/login_store.py +0 -0
  20. {xync_db-0.5.5.dev31 → xync_db-0.5.5.dev64}/src/xync_db/logo.png +0 -0
  21. {xync_db-0.5.5.dev31 → xync_db-0.5.5.dev64}/src/xync_db/migrations/__init__.py +0 -0
  22. {xync_db-0.5.5.dev31 → xync_db-0.5.5.dev64}/src/xync_db/migrations/models/__init__.py +0 -0
  23. {xync_db-0.5.5.dev31 → xync_db-0.5.5.dev64}/src/xync_db/migrations/models/seed/__init__.py +0 -0
  24. {xync_db-0.5.5.dev31 → xync_db-0.5.5.dev64}/src/xync_db/migrations/models/seed/a_bin_seed.py +0 -0
  25. {xync_db-0.5.5.dev31 → xync_db-0.5.5.dev64}/src/xync_db/migrations/models/seed/a_country_seed.py +0 -0
  26. {xync_db-0.5.5.dev31 → xync_db-0.5.5.dev64}/src/xync_db/migrations/models/seed/b_htx_seed.py +0 -0
  27. {xync_db-0.5.5.dev31 → xync_db-0.5.5.dev64}/src/xync_db/migrations/models/seed/c_net_seed.py +0 -0
  28. {xync_db-0.5.5.dev31 → xync_db-0.5.5.dev64}/src/xync_db/migrations/models/seed/countries.json +0 -0
  29. {xync_db-0.5.5.dev31 → xync_db-0.5.5.dev64}/src/xync_db/migrations/models/seed/ranges.csv +0 -0
  30. {xync_db-0.5.5.dev31 → xync_db-0.5.5.dev64}/src/xync_db/migrations/models/seed/xync_files.sql +0 -0
  31. {xync_db-0.5.5.dev31 → xync_db-0.5.5.dev64}/src/xync_db/py.typed +0 -0
  32. {xync_db-0.5.5.dev31 → xync_db-0.5.5.dev64}/src/xync_db/shared.py +0 -0
  33. {xync_db-0.5.5.dev31 → xync_db-0.5.5.dev64}/src/xync_db/typs/__init__.py +0 -0
  34. {xync_db-0.5.5.dev31 → xync_db-0.5.5.dev64}/src/xync_db/typs/db/__init__.py +0 -0
  35. {xync_db-0.5.5.dev31 → xync_db-0.5.5.dev64}/src/xync_db/typs/db/actor.py +0 -0
  36. {xync_db-0.5.5.dev31 → xync_db-0.5.5.dev64}/src/xync_db/typs/db/ad.py +0 -0
  37. {xync_db-0.5.5.dev31 → xync_db-0.5.5.dev64}/src/xync_db/typs/db/common.py +0 -0
  38. {xync_db-0.5.5.dev31 → xync_db-0.5.5.dev64}/src/xync_db/typs/db/cred.py +0 -0
  39. {xync_db-0.5.5.dev31 → xync_db-0.5.5.dev64}/src/xync_db/typs/db/order.py +0 -0
  40. {xync_db-0.5.5.dev31 → xync_db-0.5.5.dev64}/src/xync_db/typs/db/pm.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: xync-db
3
- Version: 0.5.5.dev31
3
+ Version: 0.5.5.dev64
4
4
  Summary: Xync DB schema: Tortoise ORM models, enums, pydantic DTOs.
5
5
  Author-email: Mike Artemiev <mixartemev@gmail.com>
6
6
  License: LicenseRef-EULA
@@ -176,12 +176,9 @@ class AgentStatus(IntEnum):
176
176
 
177
177
 
178
178
  class SynonymType(IntEnum):
179
- name = 1
180
179
  ppo = 2
181
- from_party = 3
182
- to_party = 4
183
- slip_req = 5
184
- slip_send = 6
180
+ from_party = 3 # с чьего лица платит покупатель: sell — какое мейкер требует, buy — с какого шлёт сам
181
+ slip = 5 # sell-объява: какой чек мейкер требует, buy: какой шлёт (бывш. slip_req/slip_send)
185
182
  abuser = 7
186
183
  scale = 8
187
184
  slavic = 9
@@ -203,15 +200,14 @@ class Boundary(IntEnum):
203
200
 
204
201
 
205
202
  class SbpStrict(IntEnum):
206
- no = 0
207
203
  sbp = 1
208
204
  card = 2
209
205
 
210
206
 
211
- class Party(IntEnum):
207
+ class FromParty(IntEnum):
212
208
  fst = 0
213
209
  fam = 1
214
- lk = 2 # lk-photo/video/comment
210
+ lk = 2
215
211
  trd = 3
216
212
 
217
213
 
@@ -222,17 +218,35 @@ class Slip(IntEnum):
222
218
  pdf_mail = 3
223
219
 
224
220
 
225
- class NameType(IntEnum):
226
- no_slavic = 0
227
- slavic = 1
228
- fake = 2
229
-
230
-
231
221
  class AbuserType(IntEnum):
232
- no = 0
233
222
  rating = 1 # рейтинг: жмет "Оплачено" сразу, "Отмена" по апелляции
234
223
  fake = 2 # реклама
235
224
  cash = 3 # типа кэш обменник, но хз
225
+ long = 4 # ждать дольше 15 мин (оплату/отпуск)
226
+ add_vrf = 5 # сверх чека — видео из лк/выписка и т.п.
227
+ fee = 6 # доплата/комиссия сверх суммы ордера
228
+ rate = 7 # цена объявления не совпадает с реальным (сделка будет по другому курсу)
229
+
230
+
231
+ class CredType(
232
+ IntEnum
233
+ ): # требование к реквизитам (искл: foreign — предупреждение мейкера-продавца о своей зарубежной карте)
234
+ no_llc = 1 # не ИП/юрлицо: «с ип не работаю»
235
+ rus_num = 2 # только российские, обычно номер телефона: «только на номера рф»
236
+ foreign = 3 # продавец просит платёж на зарубежную (не российскую) карту/номер: «перевод на зарубежную карту»
237
+
238
+
239
+ class GeminiModel(IntEnum): # модель Gemini (`GeminiReq`); имя в API, порядок силы и квоты — `cond_ai.MODELS`
240
+ lite_3_5 = 5
241
+ lite_3_1 = 6
242
+
243
+
244
+ class GeminiRes(IntEnum): # исход запроса к Gemini (`GeminiReq`)
245
+ ok = 0
246
+ minute = 1 # прочие 429 (…PerMinute…, отказ без квоты): ключ на минуту мимо этой модели
247
+ day = 2 # 429 …PerDay…: ключ выбыл по этой модели до сброса квоты (`GeminiKey.pick`)
248
+ bad_key = 4 # 401/403/API_KEY_INVALID: ключ выбыл по всем моделям на сутки
249
+ fail = 5 # прочие сбои (таймаут; 5xx — перегружена модель: условие — к следующей)
236
250
 
237
251
 
238
252
  class ExStatus(IntEnum):
@@ -93,7 +93,7 @@ Loads PM logo files from `seed/xync_files.sql` if < 100 files exist. These are T
93
93
  ## models/0004_all_seed.py — Full Seed
94
94
 
95
95
  Orchestrates the seed scripts in order:
96
- 1. `a_actor_seed` — Exchanges, currencies (56 base), coins, XyncPay PM, origin user (id=0)
96
+ 1. `a_actor_seed` — Exchanges, currencies (56 base), coins, XyncPay PM, e-wallets yoomoney/cupis/pyypl/alipay (Bank + Pm typ=emoney + RUB pmcur), origin user (id=0)
97
97
  2. `a_tg_seed` — Telegram DCs, app config, PM name mappings (`pm_rep`)
98
98
  3. `a_country_seed` — **all** countries + their currencies from the bundled `seed/countries.json`
99
99
  (generated from mledoze/countries), single source, BEFORE the exchange seeders. Sets `ccn3`
@@ -10,7 +10,7 @@ from tortoise.fields.db_defaults import Now
10
10
  from tortoise.migrations import operations as ops
11
11
  from tortoise.migrations.constraints import CheckConstraint
12
12
  from x_auth.enums import Lang, PeerType, Role
13
- from x_model.field import DatetimeSecField, UInt1Field, UInt2Field, UInt4Field, UInt8Field, UniqBinaryField
13
+ from x_model.field import DatetimeSecField, UInt1Field, UInt2Field, UInt4Field, UInt8Field, UInt16Field, UniqBinaryField
14
14
 
15
15
  from xync_db.enums import (
16
16
  AbuserType,
@@ -19,14 +19,17 @@ from xync_db.enums import (
19
19
  AgentStatus,
20
20
  Boundary,
21
21
  CoinType,
22
+ CredType,
22
23
  DepType,
23
24
  ExAction,
24
25
  ExStatus,
25
26
  ExType,
26
27
  FileType,
28
+ FromParty,
29
+ GeminiModel,
30
+ GeminiRes,
27
31
  HotStatus,
28
32
  OrderStatus,
29
- Party,
30
33
  PersonStatus,
31
34
  PmType,
32
35
  SbpStrict,
@@ -70,12 +73,97 @@ class Migration(migrations.Migration):
70
73
  ("id", fields.IntField(generated=True, primary_key=True, unique=True, db_index=True)),
71
74
  ("created_at", DatetimeSecField(db_default=Now(), auto_now=False, auto_now_add=True)),
72
75
  ("updated_at", DatetimeSecField(db_default=Now(), auto_now=True, auto_now_add=False)),
73
- ("raw_txt", fields.CharField(unique=True, max_length=4095)),
76
+ ("raw_txt", fields.CharField(db_index=True, max_length=4095)),
74
77
  ("last_ver", fields.CharField(null=True, max_length=4095)),
75
78
  ],
76
79
  options={"table": "cond", "app": "models", "pk_attr": "id"},
77
80
  bases=["Model", "TsTrait"],
78
81
  ),
82
+ ops.CreateModel(
83
+ name="CondPrompt",
84
+ fields=[
85
+ ("id", fields.IntField(generated=True, primary_key=True, unique=True, db_index=True)),
86
+ ("txt", fields.TextField(unique=False)),
87
+ ("is_jev", fields.BooleanField(db_default=False)),
88
+ (
89
+ "cond",
90
+ fields.ForeignKeyField(
91
+ "models.Cond",
92
+ source_field="cond_id",
93
+ null=True,
94
+ db_constraint=True,
95
+ to_field="id",
96
+ related_name="prompts",
97
+ on_delete=OnDelete.SET_NULL,
98
+ ),
99
+ ),
100
+ ("remark", fields.TextField(null=True, unique=False)),
101
+ ("note", fields.TextField(null=True, unique=False)),
102
+ ("created_at", DatetimeSecField(db_default=Now(), auto_now=False, auto_now_add=True)),
103
+ ],
104
+ options={
105
+ "table": "cond_prompt",
106
+ "app": "models",
107
+ "pk_attr": "id",
108
+ "table_description": "Версия промпта разбора условий (`xync_service.common.cond_ai`): системного промпта Gemini или вопросов JEV",
109
+ },
110
+ bases=["Model"],
111
+ ),
112
+ ops.CreateModel(
113
+ name="GeminiKey",
114
+ fields=[
115
+ ("id", fields.IntField(generated=True, primary_key=True, unique=True, db_index=True)),
116
+ ("key", fields.CharField(unique=True, max_length=63)),
117
+ ],
118
+ options={
119
+ "table": "gemini_key",
120
+ "app": "models",
121
+ "pk_attr": "id",
122
+ "table_description": "Ключ Gemini API free tier (разбор условий, `xync_service.common.cond_ai`). Квота своя на каждую модель, расход и",
123
+ },
124
+ bases=["Model"],
125
+ ),
126
+ ops.CreateModel(
127
+ name="GeminiReq",
128
+ fields=[
129
+ ("id", fields.IntField(generated=True, primary_key=True, unique=True, db_index=True)),
130
+ (
131
+ "key",
132
+ fields.ForeignKeyField(
133
+ "models.GeminiKey",
134
+ source_field="key_id",
135
+ db_constraint=True,
136
+ to_field="id",
137
+ related_name="reqs",
138
+ on_delete=OnDelete.CASCADE,
139
+ ),
140
+ ),
141
+ (
142
+ "model",
143
+ fields.IntEnumField(
144
+ description="lite_3_5: 5\nlite_3_1: 6",
145
+ enum_type=GeminiModel,
146
+ generated=False,
147
+ ),
148
+ ),
149
+ (
150
+ "res",
151
+ fields.IntEnumField(
152
+ description="ok: 0\nminute: 1\nday: 2\nbad_key: 4\nfail: 5",
153
+ enum_type=GeminiRes,
154
+ generated=False,
155
+ ),
156
+ ),
157
+ ("created_at", DatetimeSecField(db_default=Now(), auto_now=False, auto_now_add=True)),
158
+ ],
159
+ options={
160
+ "table": "gemini_req",
161
+ "app": "models",
162
+ "pk_attr": "id",
163
+ "table_description": "Запрос к Gemini (`cond_ai._generate`) — история за сутки (старше удаляет `CondAi` на входе): по ней",
164
+ },
165
+ bases=["Model"],
166
+ ),
79
167
  ops.CreateModel(
80
168
  name="CondSim",
81
169
  fields=[
@@ -621,27 +709,21 @@ class Migration(migrations.Migration):
621
709
  on_delete=OnDelete.CASCADE,
622
710
  ),
623
711
  ),
624
- (
625
- "to_party",
626
- fields.IntEnumField(
627
- null=True, description="fst: 0\nfam: 1\nlk: 2\ntrd: 3", enum_type=Party, generated=False
628
- ),
629
- ),
630
712
  (
631
713
  "from_party",
632
714
  fields.IntEnumField(
633
- null=True, description="fst: 0\nfam: 1\nlk: 2\ntrd: 3", enum_type=Party, generated=False
715
+ null=True, description="fst: 0\nfam: 1\nlk: 2\ntrd: 3", enum_type=FromParty, generated=False
634
716
  ),
635
717
  ),
636
- ("ppo", UInt1Field(null=True)),
637
718
  (
638
- "slip_req",
719
+ "to_party",
639
720
  fields.IntEnumField(
640
- null=True, description="no: 0\nscreen: 1\npdf: 2\npdf_mail: 3", enum_type=Slip, generated=False
721
+ null=True, description="fst: 0\nfam: 1\nlk: 2\ntrd: 3", enum_type=FromParty, generated=False
641
722
  ),
642
723
  ),
724
+ ("ppo", UInt1Field(null=True)),
643
725
  (
644
- "slip_send",
726
+ "slip",
645
727
  fields.IntEnumField(
646
728
  null=True, description="no: 0\nscreen: 1\npdf: 2\npdf_mail: 3", enum_type=Slip, generated=False
647
729
  ),
@@ -649,27 +731,28 @@ class Migration(migrations.Migration):
649
731
  (
650
732
  "abuser",
651
733
  fields.IntEnumField(
652
- description="no: 0\nrating: 1\nfake: 2\ncash: 3",
653
- db_default=AbuserType.no,
734
+ null=True,
735
+ description="rating: 1\nfake: 2\ncash: 3\nlong: 4\nadd_vrf: 5\nfee: 6\nrate: 7",
654
736
  enum_type=AbuserType,
655
737
  generated=False,
656
738
  ),
657
739
  ),
658
740
  ("slavic", fields.BooleanField(null=True)),
741
+ (
742
+ "cred",
743
+ fields.IntEnumField(
744
+ null=True, description="no_llc: 1\nrus_num: 2\nforeign: 3", enum_type=CredType, generated=False
745
+ ),
746
+ ),
659
747
  ("mtl_like", fields.BooleanField(null=True)),
660
748
  ("scale", fields.SmallIntField(null=True)),
661
749
  ("bank_side", fields.BooleanField(null=True)),
662
750
  (
663
751
  "sbp_strict",
664
- fields.IntEnumField(
665
- description="no: 0\nsbp: 1\ncard: 2",
666
- db_default=SbpStrict.no,
667
- enum_type=SbpStrict,
668
- generated=False,
669
- ),
752
+ fields.IntEnumField(null=True, description="sbp: 1\ncard: 2", enum_type=SbpStrict, generated=False),
670
753
  ),
671
754
  ("contact", fields.CharField(null=True, max_length=127)),
672
- ("done", fields.BooleanField(db_index=True, db_default=False)),
755
+ ("is_intermediary", fields.BooleanField(null=True)),
673
756
  (
674
757
  "banks",
675
758
  fields.ManyToManyField(
@@ -930,7 +1013,7 @@ class Migration(migrations.Migration):
930
1013
  "typ",
931
1014
  fields.IntEnumField(
932
1015
  db_index=True,
933
- description="name: 1\nppo: 2\nfrom_party: 3\nto_party: 4\nslip_req: 5\nslip_send: 6\nabuser: 7\nscale: 8\nslavic: 9\nmtl_like: 10\nbank: 11\nbank_side: 12\nsbp_strict: 13\ncontact: 14",
1016
+ description="ppo: 2\nfrom_party: 3\nslip: 5\nabuser: 7\nscale: 8\nslavic: 9\nmtl_like: 10\nbank: 11\nbank_side: 12\nsbp_strict: 13\ncontact: 14",
934
1017
  enum_type=SynonymType,
935
1018
  generated=False,
936
1019
  ),
@@ -1361,7 +1444,7 @@ class Migration(migrations.Migration):
1361
1444
  name="Actor",
1362
1445
  fields=[
1363
1446
  ("id", fields.IntField(generated=True, primary_key=True, unique=True, db_index=True)),
1364
- ("exid", UInt8Field()),
1447
+ ("exid", UInt16Field()),
1365
1448
  ("name", fields.CharField(max_length=63)),
1366
1449
  (
1367
1450
  "ex",
@@ -1379,6 +1462,7 @@ class Migration(migrations.Migration):
1379
1462
  fields.ForeignKeyField(
1380
1463
  "models.Person",
1381
1464
  source_field="person_id",
1465
+ null=True,
1382
1466
  db_constraint=True,
1383
1467
  to_field="id",
1384
1468
  related_name="actors",
@@ -18,20 +18,15 @@ Channels emitted:
18
18
  w3addr_wanted / w3addr_filled -- web3 address handshake (0009)
19
19
 
20
20
  Guards (RAISE, без NOTIFY): tx_before (баланс/иммутабельность/монотонность), tx_no_delete,
21
- ad_exid_ex_uniq (0008, exid уникален per-exchange). Utility: _user_balance(uid, cid), _tx_pack(...).
22
-
23
- `actor_new` trigger: link the auto-created anonymous person + stamp its `note` (D-035).
24
-
25
- The BEFORE-INSERT `actor_new()` created an anonymous person for a person-less actor but never wrote
26
- `NEW.person_id` back, so such an actor would land with `person_id = NULL` — a latent bug on the
27
- taker/raw-insert path (dormant today because every `Actor.create` site passes a person). We:
28
-
29
- (a) assign the freshly created person back to the actor (`NEW.person_id := p_id`), and
30
- (b) stamp `note = "{ex_id}:{exid}"` — the per-actor identity key that `Ex.person_save` and
31
- `Agent.upsert` resolve bootstrap persons by, and that `0010_person_note_unique` makes UNIQUE.
32
-
33
- The `status = -1` (USER_DEF) branch is unchanged: an agent person pre-created by `Agent.upsert`
34
- triggers the `actor_person_needs_def_user` NOTIFY so an admin is asked whose agent it is.
21
+ ad_exid_ex_uniq (0008, exid уникален per-exchange), ad_cond_side_uniq/cond_txt_side_uniq (unique
22
+ (cond.raw_txt, ad.pair_side.is_sell)). GC: ad_cond_gc/cond_new_gc — условие без объяв удаляется (cond_gc).
23
+ Utility: _user_balance(uid, cid), _tx_pack(...).
24
+
25
+ `actor_new` trigger: only the `status = -1` (USER_DEF) branch — an agent person pre-created by
26
+ `Agent.upsert` triggers the `actor_person_needs_def_user` NOTIFY so an admin is asked whose agent it is.
27
+ Persons of actors are created ONLY in Python (`Ex.person_save` / `Agent.upsert`, which stamp
28
+ `note = "{ex_id}:{exid}"`, D-035); the former auto-person branch was a duplicate path (every
29
+ `Actor.create` site passes a person). A person-less actor is a maker masked by the exchange (D-042).
35
30
  """
36
31
 
37
32
  from typing import ClassVar
@@ -43,17 +38,12 @@ from tortoise.migrations.operations import RunSQL
43
38
  TRIGGERS_SQL = """
44
39
  -- before new Actor
45
40
  CREATE OR REPLACE FUNCTION actor_new() returns trigger as $actor_new_trg$
46
- DECLARE
47
- p_id INT;
48
41
  BEGIN
49
- -- actor без person_id (зашли тейкером либо парсим объявы) → создаём анонимную персону,
50
- -- клеймим её note = "{ex_id}:{exid}" (ключ актора, D-035) и ПРИВЯЗЫВАЕМ её к актору.
51
- IF NEW.person_id IS NULL THEN
52
- INSERT INTO person (status, note) VALUES (0, NEW.ex_id || ':' || NEW.exid) RETURNING id INTO p_id;
53
- NEW.person_id := p_id;
42
+ -- персону актору заводит ТОЛЬКО Python (Ex.person_save / Agent.upsert, note = "{ex_id}:{exid}", D-035);
43
+ -- актор без person_id — замаскированный биржей мейкер (D-042), персону ему не выдумываем.
54
44
  -- агентской персоне уже проставлен status=-1 (USER_DEF) в Agent.upsert → зовём админа
55
45
  -- определить юзера (actor_person_needs_def_user).
56
- ELSIF (SELECT status FROM person WHERE id = NEW.person_id) = -1 THEN
46
+ IF (SELECT status FROM person WHERE id = NEW.person_id) = -1 THEN
57
47
  PERFORM pg_notify('actor_person_needs_def_user', row_to_json(NEW)::varchar);
58
48
  END IF;
59
49
  RETURN NEW;
@@ -369,6 +359,98 @@ $ad_exid_ex_uniq_trg$ LANGUAGE plpgsql;
369
359
  CREATE OR REPLACE TRIGGER ad_exid_ex_uniq BEFORE INSERT OR UPDATE ON "ad" FOR EACH ROW EXECUTE FUNCTION ad_exid_ex_uniq();
370
360
  """
371
361
 
362
+ # Cond: unique (raw_txt, ads.pair_side.is_sell) — разбор условия (from_party/to_party) зависит от стороны объяв, поэтому
363
+ # тот же текст у buy и sell объяв — два условия, а у условия объявы только одной стороны. Tortoise uniq через join не
364
+ # выразит → BEFORE-RAISE на записи объявы (cond_id/сторона) и на смене текста условия (кодом не меняется: сменился
365
+ # текст объявы — она к условию нового текста, `Cond.side_get`; гард — от ручного SQL). Одновременные
366
+ # записи одного текста идут по очереди (advisory-lock на текст до конца транзакции): иначе обе проверки прошли бы.
367
+ COND_SIDE_UNIQ_SQL = """
368
+ CREATE OR REPLACE FUNCTION ad_cond_side_uniq() returns trigger as $ad_cond_side_uniq_trg$
369
+ DECLARE
370
+ txt VARCHAR;
371
+ side BOOL;
372
+ conflict_cond INT;
373
+ conflict_ad INT;
374
+ BEGIN
375
+ IF NEW.cond_id IS NULL THEN
376
+ RETURN NEW;
377
+ END IF;
378
+ SELECT raw_txt INTO txt FROM cond WHERE id = NEW.cond_id;
379
+ PERFORM pg_advisory_xact_lock(hashtext(txt));
380
+ SELECT is_sell INTO side FROM pair_side WHERE id = NEW.pair_side_id;
381
+ -- конфликт: объявы этой стороны — при другом условии этого текста, либо объявы другой стороны — при этом
382
+ SELECT a.cond_id, a.id INTO conflict_cond, conflict_ad
383
+ FROM ad a JOIN pair_side ps ON ps.id = a.pair_side_id JOIN cond c ON c.id = a.cond_id
384
+ WHERE c.raw_txt = txt AND a.id != NEW.id
385
+ AND ((ps.is_sell = side AND a.cond_id != NEW.cond_id) OR (ps.is_sell != side AND a.cond_id = NEW.cond_id))
386
+ LIMIT 1;
387
+ IF conflict_ad IS NOT NULL THEN
388
+ RAISE EXCEPTION 'ad#% (is_sell=%) -> cond#%: (cond.raw_txt, ad.pair_side.is_sell) must be unique, conflicts with ad#% of cond#%',
389
+ NEW.id, side, NEW.cond_id, conflict_ad, conflict_cond;
390
+ END IF;
391
+ RETURN NEW;
392
+ END
393
+ $ad_cond_side_uniq_trg$ LANGUAGE plpgsql;
394
+ CREATE OR REPLACE TRIGGER ad_cond_side_uniq BEFORE INSERT OR UPDATE OF cond_id, pair_side_id ON "ad"
395
+ FOR EACH ROW EXECUTE FUNCTION ad_cond_side_uniq();
396
+
397
+ CREATE OR REPLACE FUNCTION cond_txt_side_uniq() returns trigger as $cond_txt_side_uniq_trg$
398
+ DECLARE
399
+ conflict_cond INT;
400
+ BEGIN
401
+ PERFORM pg_advisory_xact_lock(hashtext(NEW.raw_txt));
402
+ -- конфликт: другое условие нового текста, чьи объявы той же стороны, что и объявы этого
403
+ SELECT o.cond_id INTO conflict_cond
404
+ FROM ad o JOIN pair_side ops ON ops.id = o.pair_side_id JOIN cond c ON c.id = o.cond_id
405
+ WHERE c.raw_txt = NEW.raw_txt AND c.id != NEW.id AND ops.is_sell IN (
406
+ SELECT ps.is_sell FROM ad a JOIN pair_side ps ON ps.id = a.pair_side_id WHERE a.cond_id = NEW.id
407
+ )
408
+ LIMIT 1;
409
+ IF conflict_cond IS NOT NULL THEN
410
+ RAISE EXCEPTION 'cond#% raw_txt: (cond.raw_txt, ad.pair_side.is_sell) must be unique, conflicts with cond#%',
411
+ NEW.id, conflict_cond;
412
+ END IF;
413
+ RETURN NEW;
414
+ END
415
+ $cond_txt_side_uniq_trg$ LANGUAGE plpgsql;
416
+ CREATE OR REPLACE TRIGGER cond_txt_side_uniq BEFORE UPDATE OF raw_txt ON "cond"
417
+ FOR EACH ROW EXECUTE FUNCTION cond_txt_side_uniq();
418
+ """
419
+
420
+ # Cond живёт, пока у него есть объявы. Осиротело (объява перецеплена на другое условие — мейкер сменил текст, — или
421
+ # удалена) — удаляем сразу; новое (`Cond.side_get`), к которому до конца ЕГО транзакции не прицепилась объява, —
422
+ # на коммите (DEFERRED): поэтому условие создаётся только в одной транзакции с записью объявы. Тот же advisory-lock на
423
+ # текст, что у ad_cond_side_uniq: иначе параллельная запись объявы в это условие прошла бы мимо NOT EXISTS, а удаление
424
+ # условия унесло бы её каскадом (ad.cond_id ON DELETE CASCADE).
425
+ COND_GC_SQL = """
426
+ CREATE OR REPLACE FUNCTION cond_gc() returns trigger as $cond_gc_trg$
427
+ DECLARE
428
+ cid INT;
429
+ txt VARCHAR;
430
+ BEGIN
431
+ IF TG_TABLE_NAME = 'cond' THEN
432
+ cid := NEW.id;
433
+ ELSIF TG_OP = 'DELETE' OR OLD.cond_id IS DISTINCT FROM NEW.cond_id THEN
434
+ cid := OLD.cond_id;
435
+ ELSE
436
+ RETURN NULL;
437
+ END IF;
438
+ SELECT raw_txt INTO txt FROM cond WHERE id = cid;
439
+ IF txt IS NULL THEN
440
+ RETURN NULL;
441
+ END IF;
442
+ PERFORM pg_advisory_xact_lock(hashtext(txt));
443
+ DELETE FROM cond c WHERE c.id = cid AND NOT EXISTS (SELECT 1 FROM ad a WHERE a.cond_id = cid);
444
+ RETURN NULL;
445
+ END
446
+ $cond_gc_trg$ LANGUAGE plpgsql;
447
+ CREATE OR REPLACE TRIGGER ad_cond_gc AFTER UPDATE OF cond_id OR DELETE ON "ad"
448
+ FOR EACH ROW WHEN (OLD.cond_id IS NOT NULL) EXECUTE FUNCTION cond_gc();
449
+ DROP TRIGGER IF EXISTS cond_new_gc ON "cond"; -- CONSTRAINT TRIGGER не поддерживает OR REPLACE
450
+ CREATE CONSTRAINT TRIGGER cond_new_gc AFTER INSERT ON "cond" DEFERRABLE INITIALLY DEFERRED
451
+ FOR EACH ROW EXECUTE FUNCTION cond_gc();
452
+ """
453
+
372
454
  # web3-хендшейк адресов (0009): net_addr_ins (вставка пустого val → зовём получателя завести адрес,
373
455
  # канал `w3addr_wanted`) и net_addr_upd (val NULL→real → сообщаем отправителям pending-W3Send, канал
374
456
  # `w3addr_filled`). (`ALTER net_addr.val DROP NOT NULL` уехал в 0001 — теперь val создаётся nullable.)
@@ -411,5 +493,7 @@ class Migration(BaseMigration):
411
493
  RunSQL(CREDEX_ORPHAN_SQL),
412
494
  RunSQL(AGENT_AUTH_SQL),
413
495
  RunSQL(AD_EXID_EX_UNIQ_SQL),
496
+ RunSQL(COND_SIDE_UNIQ_SQL),
497
+ RunSQL(COND_GC_SQL),
414
498
  RunSQL(NET_ADDR_TRIGGERS_SQL),
415
499
  ]
@@ -6,7 +6,7 @@
6
6
 
7
7
  Порядок (зависимости):
8
8
  1. files — PM-логотипы из `seed/xync_files.sql` (если файлов < 100)
9
- 2. a_actor_seed — биржи, 56 базовых валют, монеты, XyncPay PM, origin user (id=0)
9
+ 2. a_actor_seed — биржи, 56 базовых валют, монеты, XyncPay PM, эл. кошельки ЮMoney/ЦУПИС/Pyypl/Alipay (Bank+Pm+RUB), origin user
10
10
  3. a_tg_seed — Telegram DC/App, нормализация имён PM (`pm_rep`)
11
11
  4. a_country_seed — ВСЕ страны + их валюты из mledoze (`seed/countries.json`), ДО биржевых
12
12
  5. a_bin_seed — BIN-диапазоны + банки из binlist/data (`seed/ranges.csv`), ПОСЛЕ стран (линк по alpha-2)
@@ -2,7 +2,7 @@ from tortoise import BaseDBAsyncClient
2
2
  from x_auth.models import Username
3
3
 
4
4
  from xync_db.enums import ExAction, ExStatus, ExType, PmType, UserStatus
5
- from xync_db.models import Coin, CoinEx, Cur, CurEx, Ex, ExStat, Pm, User
5
+ from xync_db.models import Bank, Coin, CoinEx, Cur, CurEx, Ex, ExStat, Pm, PmCur, User
6
6
 
7
7
 
8
8
  async def actor_seed(db: BaseDBAsyncClient) -> str:
@@ -284,6 +284,18 @@ async def actor_seed(db: BaseDBAsyncClient) -> str:
284
284
 
285
285
  xyncpay, _ = await Pm.update_or_create(dict(ok=True, qr=True, fee=0, typ=PmType.emoney), id=0, norm="xyncpay")
286
286
 
287
+ # эл. кошельки: в p2p-условиях RUB их называют наравне с банками → свой Bank (Pm с банком попадает в список
288
+ # банков разбора условий, `CondAi._pms`) + PmCur RUB; ЦУПИС биржи не отдают — Pm заводим сами. Pyypl/Alipay
289
+ # не рублёвые — df_cur им не ставим (Alipay — CNY в `refine_pms`)
290
+ rub = await Cur.get(ticker="RUB")
291
+ wallets = ("yoomoney", None, rub), ("cupis", "цупис", rub), ("pyypl", None, None), ("alipay", None, None)
292
+ for norm, alias, df_cur in wallets:
293
+ bank, _ = await Bank.get_or_create(name=norm)
294
+ pm, _ = await Pm.update_or_create(
295
+ dict(typ=PmType.emoney, bank=bank, df_cur=df_cur, alias=alias), norm=norm, country_id=None
296
+ )
297
+ await PmCur.get_or_create(pm=pm, cur=rub)
298
+
287
299
  # origin user
288
300
  id0, _ = await Username.get_or_create({"phone": 852_6_497_5811}, id=7807393311, username="XyncPay")
289
301
  try:
@@ -51,11 +51,14 @@ async def tg_seed(_: BaseDBAsyncClient) -> str:
51
51
  "ArmBussinessBank": ("ArmBusinessBank", 20),
52
52
  "Asia United Bank (QR) lightnin...": ("Asia United Bank (QR) lightning", 11),
53
53
  "Bank Credit Dnepro": ("Credit Dnipro", 14),
54
+ "Banco de Desarollo Rural": ("Banco de desarrollo Rural", 4),
55
+ "Banco Itau de Chile": ("Banco Itau Chile", 4),
54
56
  "Banistimo Panamá": ("Banistmo Panama", 14),
55
57
  "Banque du Developpement Local BDL": ("Banque de développement local (BDL)", 3),
56
58
  "Banque de développement local (BDL)": ("Banque de développement local (BDL)", None),
57
59
  "BDO": ("Banco De Oro (BDO)", None),
58
60
  "Careen Pay": ("Careem Pay", None),
61
+ "CenterCreditBank": ("CenterCredit Bank", 4),
59
62
  "Cash in Person Transaction": ("Cash in Person", 2),
60
63
  "Cash Plus": ("Cashplus", 12),
61
64
  "Chinabank": ("China Bank", 14),
@@ -66,8 +69,11 @@ async def tg_seed(_: BaseDBAsyncClient) -> str:
66
69
  "Elkart": ("Elcart", 20),
67
70
  "Erste Group Bank": ("Erste Bank", 3),
68
71
  "eZ Cash": ("eZCash", 3),
72
+ "Grab Pay": ("GrabPay", 9),
69
73
  "GTB Bank (Guarantee Trust Bank)": ("GTBank", None),
70
74
  "IMEpay": ("IME Pay", 2),
75
+ "Kapital Bank": ("KapitalBank", 12), # азербайджанский Kapital Bank = «KapitalBank» Bybit/Htx/Mexc
76
+ "Kapital Bank Instant": ("KapitalBank", 12),
71
77
  "Liv KSA": ("Liv.KSA", 14),
72
78
  "Liv.KSA": ("Liv.KSA", 3),
73
79
  "Local Bank (R-Green)": ("Sberbank", None),
@@ -84,23 +90,46 @@ async def tg_seed(_: BaseDBAsyncClient) -> str:
84
90
  "LINE Bank by Hana Bank": ("LINE Pay", 3),
85
91
  "Local Bank (S-Green)": ("Sberbank", 7),
86
92
  "m 10": ("m10", 3),
93
+ "MICB": ("MICB Bank", 9), # «Bank» в цели — флаг банка для set_pms (Pm.bank), norm не меняется
87
94
  "M-PESA kenya(Safaricom)": ("M-PESA kenya (Safaricom)", 11),
88
95
  "Mercado Pago": ("Mercadopago", 11),
96
+ "Metrobank": ("Metropolitan Bank of the Philippines", 12),
97
+ "Millenium": ("Millennium", None),
98
+ "Mpesa Pochi la Biashara": ("M-PESA Pochi la Biashara", None),
89
99
  "NaranjaX": ("Naranja X", 2),
100
+ "National Bank of Egyp (NBE)": ("National Bank of Egypt (NBE)", 12),
101
+ "OptimaBank": ("Optima Bank", 12),
102
+ "Oschabank": ("Oschadbank", 12),
103
+ "Otkritie FC Bank": ("Otkritie Bank", 12),
104
+ "PagoMóvil": ("Pago Movil", None),
105
+ "Palm Pay": ("PalmPay", None),
90
106
  "Philippine National Bank (PNB)": ("Philippines National Bank (PNB)", 12),
91
107
  "Philippine National Bank": ("Philippines National Bank", 3),
108
+ "Pochta Bank": ("Post Bank", 12),
109
+ "PUMB": ("PUMB Bank", None),
110
+ # страна в цели: у Bybit ПриватБанк с Ukraine, у Htx/Mexc без страны — Pm уникален по (norm, country)
111
+ "Privat Bank": ("Privatbank (Ukraine)", None),
112
+ "Privat Bank (Universal Card)": ("Privatbank (Ukraine)", 12),
113
+ "PSB": ("Promsvyazbank", 9),
92
114
  "SBP - Fast Bank Transfer": ("SBP", None),
93
115
  "Sovkombank": ("Sovcombank", 9),
94
116
  "SBP Fast Bank Transfer": ("SBP", 14),
95
117
  "Rabbit LINE Pay": ("LINE Pay", 20),
118
+ "Rabitabank": ("Rabita Bank", 12),
96
119
  "Raiffaisen Bank": ("Raiffeisen Bank", 14),
97
120
  "Raiffaizen": ("Raiffeisen", 14),
121
+ "Raiffeisen Bank": ("Raiffeisenbank", 12),
98
122
  "Russian Standart Bank": ("Russian Standard Bank", 4),
123
+ "Santander México": ("Santander Mexico", 4),
124
+ "Scotiabank Perú": ("Scotiabank Peru", 12),
99
125
  "Sovkm Bank": ("Sovcombank", 3),
126
+ "Sport Bank": ("Sportbank", 12),
127
+ "Superbank": ("Super Bank", 12),
100
128
  "Tinkoff": ("T-Bank", None),
101
129
  "Tinkoff Bank": ("T-Bank", 2),
102
130
  "Transfer with Specific Bank": ("Transfers with specific bank", 2),
103
131
  "TrueMoney": ("True Money", 20),
132
+ "UBRR": ("UBRR Bank", 9),
104
133
  "TrueMoney (Cambodia)": ("True Money (Cambodia)", 2),
105
134
  "TrueMoney (cambodia)": ("True Money (Cambodia)", 14),
106
135
  "United Bank of Africa": ("United Bank for Africa", 4),
@@ -113,7 +142,9 @@ async def tg_seed(_: BaseDBAsyncClient) -> str:
113
142
  "YapıKredi": ("Yapi Kredi", 1), # noqa: RUF001 - Turkish dotless-i is the exchange-side spelling
114
143
  "Zain Cash - Business": ("ZainCash - Business", 14),
115
144
  "Zain Cash": ("ZainCash", None),
116
- "Zalo Pay": ("ZaloPay", 12),
145
+ # страна в цели: у Bybit ZaloPay с Southeast Asia, у Htx/Mexc без страны
146
+ "Zalo Pay": ("ZaloPay (Southeast Asia)", None),
147
+ "ZaloPay": ("ZaloPay (Southeast Asia)", None),
117
148
  "Zirraat": ("Ziraat", 3),
118
149
  "СБП": ("SBP", 2),
119
150
  "ЮMoney": ("YooMoney", None),
@@ -72,6 +72,8 @@ actor.set_user(username_id):
72
72
  - `CredEx` = credential registered on exchange `(ex_id, exid)` unique
73
73
  - `Fiat` = balance tracker (one-to-one with `Cred`): `amount`, `target`, `min_deposit`
74
74
  - `PmAgent` = automation account for a PM `(pm_id, user_id)` unique
75
+ - `Pm` unique `(norm, country)`; дубли сливает ЕДИНСТВЕННЫЙ writer `Pm.merge(keep_id, dup_ids)` (переносит PmEx/PmCur/ad_pm/
76
+ cond_banks/креды/агентов, удаляет дубли) — плюс правило `PmRep` в seed `a_tg_seed.pm_reps`, чтобы `set_pms` не пересоздал дубль
75
77
 
76
78
  ### P2P Trading
77
79
  `Ad` -> `MyAd` -> `Race` -> `RaceStat`, `Order` -> `Msg`/`Transfer`/`Task`, `Hot`
@@ -84,10 +86,22 @@ actor.set_user(username_id):
84
86
  - `Transfer` = order payment record with `amount`, optional `File` (receipt)
85
87
 
86
88
  ### Conditions (Ad parsing)
87
- `Cond` -> `CondParsed`, `Synonym`, `CondSim`
89
+ `Cond` -> `CondParsed`, `Synonym`, `CondSim`, `CondPrompt`
88
90
 
89
91
  - `Cond.raw_txt` = original ad conditions text (4095 chars max, unique)
90
- - `CondParsed` = structured parse result: `to_party`/`from_party` (`Party`), `ppo`, `slip_req`/`slip_send` (`Slip`), abuse flags, linked bank `Pm`s
92
+ - `CondParsed` = structured parse result: `from_party` / `to_party` (`FromParty`: с чьего / на чей счёт идёт платёж
93
+ покупателя), `ppo`, `slip` (`Slip`) — требует/шлёт понятно из направления объявы, abuse flags, `is_intermediary`
94
+ (мейкер-посредник «треугол»), linked bank `Pm`s
95
+ — все поля nullable, дефолт `None` (= «в условии не сказано»; никаких `*.no`-заглушек). Единый writer
96
+ `CondParsed.upsert(cond_id, bank_ids=None, **fields)` (бот-разметка /cond и Gemini, D-043); ответ админа
97
+ на «сохранить разбор?» — эмиттер `CondParsed.answer(cond_id, remark=None)` → pg-канал `cond_ai_answer`
98
+ `[cond_id, remark]` (null — «да», текст — «нет» с замечанием, "" — «нет», замечание пишется, true — «🔄»: объяву
99
+ условия заново с биржи — реальный exid замаскированного мейкера + свежий `raw_txt`, затем вопрос заново); возвращает, слушает
100
+ ли канал хоть один прогон (pg_stat_activity) — иначе ответ пропал бы молча
101
+ - `CondPrompt` = версии промпта разбора: системного промпта Gemini и (`is_jev`) вопросов JEV JSON-ом — у каждого
102
+ действует последняя (`id` max); пишет только `CondAi` (засев исходной + правки Claude по замечаниям админа, `cond` —
103
+ условие правки: его одобренный `CondParsed` — эталон регресс-проверки следующих). FK на `cond` — SET NULL: история
104
+ версий не теряется
91
105
  - `Synonym` = condition term with `typ` (`SynonymType`), optional regex (`is_re`), boundary matching
92
106
 
93
107
  ### Transactions (XyncPay)
@@ -1,15 +1,17 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import json
3
4
  import logging
4
5
  import re
5
6
  import struct
6
7
  import sys
7
8
  from collections.abc import Collection
8
- from datetime import datetime
9
+ from datetime import datetime, timedelta
9
10
  from decimal import Decimal
10
11
  from time import time
11
12
  from typing import ClassVar
12
13
  from uuid import UUID
14
+ from zoneinfo import ZoneInfo
13
15
 
14
16
  from aiohttp import ClientSession
15
17
  from cryptography.exceptions import InvalidSignature
@@ -19,9 +21,10 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey,
19
21
  from pyro_client.models import File # noqa: F401
20
22
  from tortoise import BackwardFKRelation, BackwardOneToOneRelation, BaseDBAsyncClient, connections
21
23
  from tortoise import Model as BaseModel
22
- from tortoise.expressions import Q
24
+ from tortoise.expressions import Q, Subquery
23
25
  from tortoise.fields import (
24
26
  CASCADE,
27
+ SET_NULL,
25
28
  BigIntField,
26
29
  BinaryField,
27
30
  BooleanField,
@@ -40,9 +43,10 @@ from tortoise.fields import (
40
43
  OneToOneNullableRelation,
41
44
  OneToOneRelation,
42
45
  SmallIntField,
46
+ TextField,
43
47
  UUIDField,
44
48
  )
45
- from tortoise.functions import Sum
49
+ from tortoise.functions import Count, Sum
46
50
  from tortoise.migrations.constraints import CheckConstraint
47
51
  from tortoise.signals import pre_save
48
52
  from tortoise.timezone import now
@@ -63,7 +67,7 @@ from x_auth.models import ( # noqa: F401
63
67
  )
64
68
  from x_auth.models import Country as BaseCountry
65
69
  from x_auth.models import User as TgUser
66
- from x_model.field import DatetimeSecField, UInt1Field, UInt2Field, UInt4Field, UInt8Field, UniqBinaryField
70
+ from x_model.field import DatetimeSecField, UInt1Field, UInt2Field, UInt4Field, UInt8Field, UInt16Field, UniqBinaryField
67
71
  from x_model.models import TsTrait
68
72
 
69
73
  from xync_db.enums import (
@@ -73,15 +77,18 @@ from xync_db.enums import (
73
77
  AgentStatus,
74
78
  Boundary,
75
79
  CoinType,
80
+ CredType,
76
81
  DepType,
77
82
  ExAction,
78
83
  ExStatus,
79
84
  ExType,
85
+ FromParty,
86
+ GeminiModel,
87
+ GeminiRes,
80
88
  HotStatus,
81
89
  LinkType,
82
90
  OrderErr,
83
91
  OrderStatus,
84
- Party,
85
92
  PersonStatus,
86
93
  PmType,
87
94
  SbpStrict,
@@ -237,10 +244,11 @@ class Ex(Model):
237
244
  client = sys.modules[module_name].ExClient
238
245
  return client(self, **kwargs)
239
246
 
240
- def etype(self):
241
- module_name = f"xync_client.{self.name}.etype"
242
- __import__(module_name)
243
- return sys.modules[module_name]
247
+ def agent_cls(self) -> type:
248
+ # per-Ex AgentClient: и конструктор живого агента (Agent.client), и classmethod'ы-ссылки, которым сессия
249
+ # не нужна (get_url/order_url) — зовутся по классу из prefetch'нутых данных в любом процессе
250
+ __import__(module := f"xync_client.exs.{self.name}.agent")
251
+ return sys.modules[module].AgentClient
244
252
 
245
253
 
246
254
  class CurEx(BaseModel):
@@ -957,21 +965,9 @@ class User(TgUser, TsTrait):
957
965
  ks = coin_scales.get((mad.ad.maker.ex_id, mad.ad.pair_side.pair.coin_id), 0)
958
966
  return round(mad.ad.quantity * 10**-ks, ks)
959
967
 
960
- # web/mob ссылки на объяву биржеспецифичны (формат/наличие share-hex), поэтому формирует их per-Ex
961
- # AgentClient.get_url. Берём класс нужной биржи тем же __import__, что Agent.client, но дёргаем
962
- # classmethod без живого агент-клиента: ссылку считаем из самой (prefetch'нутой) объявы, сессия не нужна.
963
- url_clss: dict[str, type] = {}
964
-
965
- def ad_url(mad: MyAd) -> tuple[str | None, str | None]:
966
- name = mad.ad.maker.ex.name
967
- if (acl := url_clss.get(name)) is None:
968
- __import__(module := f"xync_client.exs.{name}.agent")
969
- acl = url_clss[name] = sys.modules[module].AgentClient
970
- return acl.get_url(mad)
971
-
972
968
  take_sales, take_buys = [], []
973
969
  for buy_ad in buy_ads:
974
- web_url, mob_url = ad_url(buy_ad)
970
+ web_url, mob_url = buy_ad.urls()
975
971
  cur_scale = cur_scales[(buy_ad.ad.maker.ex_id, buy_ad.ad.pair_side.pair.cur_id)]
976
972
  if mine: # для своих объяв лимит берем из самого объявления, без сверки с балансами
977
973
  max_fiat = buy_ad.ad.max_fiat
@@ -1019,7 +1015,7 @@ class User(TgUser, TsTrait):
1019
1015
  max_fiat = int(min(sell_ad.ad.max_fiat, deposit)) if sell_ad.ad.max_fiat else int(deposit)
1020
1016
  if max_fiat < min_fiat:
1021
1017
  continue # не хватает
1022
- web_url, mob_url = ad_url(sell_ad)
1018
+ web_url, mob_url = sell_ad.urls()
1023
1019
  # есть ли у юзера actor (через person) на бирже этого объявления
1024
1020
  actor_defined = await Actor.filter(person__user_id=self.id, ex_id=sell_ad.ad.maker.ex_id).exists()
1025
1021
  take_buys.append(
@@ -1090,12 +1086,18 @@ class Forum(Model, TsTrait):
1090
1086
 
1091
1087
 
1092
1088
  class Actor(Model):
1093
- exid: int = UInt8Field()
1089
+ # реальные id бирж влезают в uint8; >= MASKED — id, замаскированный биржей (Bybit userMaskId, 128 бит):
1090
+ # публичный листинг объяв прячет мейкера, реальный exid добудем отдельно (D-042)
1091
+ MASKED: ClassVar[int] = 1 << 64
1092
+ exid: int = UInt16Field()
1094
1093
  name: str = CharField(63)
1095
1094
  ex: ForeignKeyRelation[Ex] = ForeignKeyField("models.Ex", "actors", on_update=CASCADE)
1096
1095
  ex_id: int
1097
- person: ForeignKeyRelation[Person] = ForeignKeyField("models.Person", "actors", on_update=CASCADE)
1098
- person_id: int
1096
+ # NULL — у замаскированного актора: персону (Ex.person_save) заводим, когда известен реальный exid
1097
+ person: ForeignKeyNullableRelation[Person] = ForeignKeyField(
1098
+ "models.Person", "actors", on_update=CASCADE, null=True
1099
+ )
1100
+ person_id: int | None
1099
1101
 
1100
1102
  agent: BackwardOneToOneRelation[Agent]
1101
1103
  # conds: BackwardFKRelation[Cond]
@@ -1197,10 +1199,7 @@ class Agent(Model, TsTrait):
1197
1199
  # computed = ["balance"]
1198
1200
 
1199
1201
  def client(self, ex_cl, pm_clients=None, proxy=None):
1200
- module_name = f"xync_client.exs.{self.actor.ex.name}.agent"
1201
- __import__(module_name)
1202
- client = sys.modules[module_name].AgentClient
1203
- return client(
1202
+ return self.actor.ex.agent_cls()(
1204
1203
  self,
1205
1204
  ex_cl,
1206
1205
  pm_clients or {},
@@ -1329,12 +1328,23 @@ class Agent(Model, TsTrait):
1329
1328
 
1330
1329
 
1331
1330
  class Cond(Model, TsTrait):
1332
- raw_txt: str = CharField(4095, unique=True)
1331
+ # unique (raw_txt, ads.pair_side.is_sell): тот же текст у buy и sell объяв — два условия (см. side_get). Tortoise
1332
+ # uniq через join не выразит → триггеры ad_cond_side_uniq / cond_txt_side_uniq (0002_triggers). Условие без объяв
1333
+ # не живёт: осиротевшее (объява перецеплена/удалена) и новое без объявы к коммиту удаляет cond_gc (0002_triggers)
1334
+ raw_txt: str = CharField(4095, db_index=True)
1333
1335
  last_ver: str = CharField(4095, null=True)
1334
1336
  # ads: BackwardFKRelation[Ad]
1335
1337
  # parsed: BackwardOneToOneRelation[CondParsed]
1336
1338
  # sim: BackwardOneToOneRelation[Cond]
1337
1339
 
1340
+ @classmethod
1341
+ async def side_get(cls, txt: str, is_sell: bool) -> Cond:
1342
+ """Условие с текстом `txt` для объявы стороны `is_sell`. Сторона условия — сторона его объяв
1343
+ (ads.pair_side.is_sell), а разбор (from_party/to_party) от неё зависит, поэтому buy и sell объявам с
1344
+ одинаковым текстом — разные условия. Нет условия с объявами этой стороны — новое: звать только в одной
1345
+ транзакции с записью объявы в него, иначе его снесёт на коммите cond_new_gc."""
1346
+ return await cls.filter(raw_txt=txt, ads__pair_side__is_sell=is_sell).first() or await cls.create(raw_txt=txt)
1347
+
1338
1348
  async def sims(self):
1339
1349
  return {self.raw_txt: await self.sim.sims()} if await self.sim else self.raw_txt
1340
1350
 
@@ -1346,14 +1356,14 @@ class Synonym(BaseModel):
1346
1356
  curs: ManyToManyRelation[Cur] = ManyToManyField(
1347
1357
  "models.Cur", "synonym_cur", related_name="synonyms", on_update=CASCADE
1348
1358
  )
1349
- val: int | None = UInt2Field(null=True) # SynonymType dependent (e.g., no-slavic for name, approx for ppo.)
1359
+ val: int | None = UInt2Field(null=True) # SynonymType dependent (e.g., approx for ppo.)
1350
1360
  is_re: bool = BooleanField(db_default=False)
1351
1361
 
1352
1362
 
1353
1363
  class CondSim(BaseModel):
1354
1364
  cond: OneToOneRelation[Cond] = OneToOneField("models.Cond", "sim", primary_key=True, on_update=CASCADE)
1355
1365
  cond_id: int # new
1356
- similarity: int = UInt2Field(db_index=True) # /1000
1366
+ similarity: int = UInt2Field(db_index=True) # (SequenceMatcher.ratio - 0.6) * 10_000 — xync_client.base.Ex.get_sim
1357
1367
  cond_rel: ForeignKeyRelation[Cond] = ForeignKeyField("models.Cond", "sims_rel", on_update=CASCADE)
1358
1368
  cond_rel_id: int # old
1359
1369
 
@@ -1413,6 +1423,36 @@ class Pm(Model):
1413
1423
  table_description = "Payment methods"
1414
1424
  unique_together = (("norm", "country"), ("alias", "country"))
1415
1425
 
1426
+ @classmethod
1427
+ async def merge(cls, keep_id: int, dup_ids: list[int]) -> None:
1428
+ """ЕДИНСТВЕННЫЙ writer слияния дублей Pm в `keep_id`: биржевые строки (PmEx), валюты (PmCur; если у keep
1429
+ такая валюта уже есть — креды/лимиты дубля переезжают на его PmCur), объявы, условия, override-креды, агенты
1430
+ и topupable; при конфликте уникальности остаётся строка keep. Дубли удаляются. Креды, которые столкнутся
1431
+ уникальностью (person, pmcur, …), роняют всю транзакцию — это уже дедуп кредов, не Pm. Чтобы сидер
1432
+ (`set_pms`) не пересоздал дубль, его биржевые имена сводятся к keep правилом `PmRep` (seed `a_tg_seed`)."""
1433
+ async with in_transaction() as db:
1434
+ for dup in dup_ids: # по одному: два дубля с общей валютой, которой нет у keep, не столкнутся
1435
+ for sql in (
1436
+ "UPDATE pmex SET pm_id=$1 WHERE pm_id=$2",
1437
+ "UPDATE cred c SET pmcur_id=k.id FROM pmcur d JOIN pmcur k ON k.cur_id=d.cur_id AND k.pm_id=$1"
1438
+ " WHERE c.pmcur_id=d.id AND d.pm_id=$2",
1439
+ 'UPDATE "limit" l SET pmcur_id=k.id FROM pmcur d JOIN pmcur k ON k.cur_id=d.cur_id AND k.pm_id=$1'
1440
+ " WHERE l.pmcur_id=d.id AND d.pm_id=$2",
1441
+ "DELETE FROM pmcur d USING pmcur k WHERE d.pm_id=$2 AND k.pm_id=$1 AND k.cur_id=d.cur_id",
1442
+ "UPDATE pmcur SET pm_id=$1 WHERE pm_id=$2",
1443
+ "INSERT INTO ad_pm (ad_id, pm_id) SELECT ad_id, $1 FROM ad_pm WHERE pm_id=$2 ON CONFLICT DO NOTHING",
1444
+ "INSERT INTO cond_banks (cond_parsed_id, pm_id) SELECT cond_parsed_id, $1 FROM cond_banks"
1445
+ " WHERE pm_id=$2 ON CONFLICT DO NOTHING",
1446
+ "UPDATE cred SET ovr_pm_id=$1 WHERE ovr_pm_id=$2",
1447
+ "UPDATE pm_agent d SET pm_id=$1 WHERE pm_id=$2"
1448
+ " AND NOT EXISTS (SELECT 1 FROM pm_agent k WHERE k.pm_id=$1 AND k.user_id=d.user_id)",
1449
+ "UPDATE topupable SET pm_id=$1 WHERE pm_id=$2"
1450
+ " AND NOT EXISTS (SELECT 1 FROM topupable WHERE pm_id=$1)",
1451
+ ):
1452
+ await db.execute_query(sql, [keep_id, dup])
1453
+ # остатки (ad_pm/cond_banks/agent/topupable, столкнувшиеся с keep) уходят каскадом
1454
+ await db.execute_query("DELETE FROM pm WHERE id=$1", [dup])
1455
+
1416
1456
  # class PydanticMeta(Model.PydanticMeta):
1417
1457
  # max_recursion = 3
1418
1458
  # backward_relations = True
@@ -1427,24 +1467,125 @@ class Pm(Model):
1427
1467
  class CondParsed(Model, TsTrait):
1428
1468
  cond: OneToOneNullableRelation[Cond] = OneToOneField("models.Cond", "parsed", on_update=CASCADE, null=True)
1429
1469
  cond_id: int | None # new
1430
- to_party: Party = IntEnumField(Party, null=True)
1431
- from_party: Party = IntEnumField(Party, null=True)
1470
+ from_party: FromParty = IntEnumField(FromParty, null=True) # с чьего счёта платят
1471
+ to_party: FromParty | None = IntEnumField(FromParty, null=True) # на чей счёт платят
1432
1472
  ppo: int = UInt1Field(null=True) # Payments per order
1433
- slip_req: Slip = IntEnumField(Slip, null=True)
1434
- slip_send: Slip = IntEnumField(Slip, null=True)
1435
- abuser: AbuserType = IntEnumField(AbuserType, db_default=AbuserType.no)
1473
+ slip: Slip = IntEnumField(Slip, null=True)
1474
+ abuser: AbuserType | None = IntEnumField(AbuserType, null=True)
1436
1475
  slavic: bool = BooleanField(null=True)
1476
+ cred: CredType | None = IntEnumField(
1477
+ CredType, null=True
1478
+ ) # требование к реквизитам тейкера; (foreign — предупреждение мейкера-продавца о своей зарубежной карте)
1437
1479
  mtl_like: bool = BooleanField(null=True)
1438
1480
  scale: int = SmallIntField(null=True)
1439
1481
  bank_side: bool = BooleanField(null=True) # False - except these banks, True - only this banks
1440
- sbp_strict: SbpStrict = IntEnumField(SbpStrict, db_default=SbpStrict.no)
1482
+ sbp_strict: SbpStrict | None = IntEnumField(SbpStrict, null=True)
1441
1483
  contact: str | None = CharField(127, null=True)
1442
- done: bool = BooleanField(db_default=False, db_index=True)
1484
+ is_intermediary: bool | None = BooleanField(null=True) # мейкер-посредник («треугол»): сводит с чужим ордером
1443
1485
  banks: ManyToManyRelation[Pm] = ManyToManyField("models.Pm", "cond_banks", related_name="conds", on_update=CASCADE)
1444
1486
 
1487
+ ANSWER = "cond_ai_answer" # pg-канал ответа админа «сохранить разбор Gemini?» (answer → lens.parse_conds_admin)
1488
+
1445
1489
  class Meta:
1446
1490
  table = "cond_parsed"
1447
1491
 
1492
+ @classmethod
1493
+ async def upsert(cls, cond_id: int, bank_ids: list[int] | None = None, **fields) -> CondParsed:
1494
+ """ЕДИНСТВЕННЫЙ writer разбора условия: бот-разметка /cond (по полю) и Gemini (lens.parse_conds_admin,
1495
+ целиком). bank_ids: None — банки не трогаем, список — заменяем им cond_banks."""
1496
+ async with in_transaction():
1497
+ cp, _ = await cls.update_or_create(fields, cond_id=cond_id)
1498
+ if bank_ids is not None:
1499
+ await cp.banks.clear()
1500
+ await cp.banks.add(*await Pm.filter(id__in=bank_ids))
1501
+ return cp
1502
+
1503
+ @classmethod
1504
+ async def answer(cls, cond_id: int, remark: str | bool | None = None) -> bool:
1505
+ """Ответ админа на вопрос «сохранить разбор Gemini?» (бот, routers/cond): `remark` None — «да», текст —
1506
+ «нет» с замечанием, что не так (по нему Claude правит промпт — `CondAi.fix`), "" — «нет», замечание пишется,
1507
+ True — «🔄»: объяву условия заново с биржи (реальный exid мейкера, свежий текст), затем вопрос заново.
1508
+ Ждёт его прогон lens.parse_conds_admin в ДРУГОМ процессе (скрипт/крон), а ответ ловит бот api — поэтому через
1509
+ pg_notify, как Agent.confirm_trade; на «да» разбор пишет сам ждущий (у него распарсенные поля).
1510
+ → ждёт ли кто-то: без слушателя pg_notify молча пропадает (прогон упал/закончился) — бот скажет это админу.
1511
+ Слушатель виден в pg_stat_activity: его соединение после LISTEN больше ничего не выполняет."""
1512
+ _, [row] = await connections.get("default").execute_query(
1513
+ f"SELECT pg_notify('{cls.ANSWER}', jsonb_build_array($1::int, $2::jsonb)::varchar),"
1514
+ f" EXISTS(SELECT 1 FROM pg_stat_activity WHERE query = 'LISTEN \"{cls.ANSWER}\"') waits",
1515
+ [cond_id, json.dumps(remark)],
1516
+ )
1517
+ return row["waits"]
1518
+
1519
+
1520
+ class CondPrompt(Model):
1521
+ """Версия промпта разбора условий (`xync_service.common.cond_ai`): системного промпта Gemini или вопросов JEV
1522
+ (`is_jev`, JSON) — у каждого действует последняя. Новую пишет Claude по замечанию админа к отклонённому разбору
1523
+ (`CondAi.fix`); `cond` — это условие: когда админ одобрит его разбор, `CondParsed` условия станет эталоном
1524
+ регресс-проверки следующих правок."""
1525
+
1526
+ txt: str = TextField()
1527
+ is_jev: bool = BooleanField(db_default=False) # промпт JEV (её вопросы JSON-ом), иначе — Gemini
1528
+ cond: ForeignKeyNullableRelation[Cond] = ForeignKeyField( # удаление условия версию промпта не сносит
1529
+ "models.Cond", "prompts", on_update=CASCADE, on_delete=SET_NULL, null=True
1530
+ )
1531
+ cond_id: int | None
1532
+ remark: str | None = TextField(null=True) # замечание админа
1533
+ note: str | None = TextField(null=True) # что изменил Claude
1534
+ created_at: datetime = DatetimeSecField(auto_now_add=True, db_default=Now())
1535
+
1536
+ class Meta:
1537
+ table = "cond_prompt"
1538
+
1539
+
1540
+ class GeminiKey(Model):
1541
+ """Ключ Gemini API free tier (разбор условий, `xync_service.common.cond_ai`). Квота своя на каждую модель, расход и
1542
+ выбывание ключа — по истории его запросов (`GeminiReq`)."""
1543
+
1544
+ key: str = CharField(63, unique=True)
1545
+ # reqs: BackwardFKRelation[GeminiReq]
1546
+
1547
+ class Meta:
1548
+ table = "gemini_key"
1549
+
1550
+ @classmethod
1551
+ async def pick(cls, model: GeminiModel, rpm: int, rpd: int) -> GeminiKey | None:
1552
+ """Ключ для запроса к `model` при квоте на ключ `rpm` запросов в минуту и `rpd` в сутки: запросов к ней с
1553
+ последнего сброса квоты (полночь по Тихоокеанскому — у всех ключей разом) меньше `rpd` и за минуту меньше `rpm`
1554
+ — сверх квоты не шлём; без 429 PerDay по ней и bad_key с последнего сброса, без прочих 429 по ней за минуту —
1555
+ отказ Google главнее счётчика; наименее нагруженный ею — ключи идут по кругу. None — ни одного: не ждать, а к
1556
+ следующей модели / громко упасть."""
1557
+ reset = datetime.now(ZoneInfo("America/Los_Angeles")).replace(hour=0, minute=0, second=0, microsecond=0)
1558
+ minute = now() - timedelta(minutes=1)
1559
+ out = GeminiReq.filter(
1560
+ Q(res=GeminiRes.bad_key, created_at__gte=reset)
1561
+ | Q(res=GeminiRes.day, model=model, created_at__gte=reset)
1562
+ | Q(res=GeminiRes.minute, model=model, created_at__gte=minute)
1563
+ )
1564
+ return await (
1565
+ cls.exclude(id__in=Subquery(out.values("key_id")))
1566
+ .annotate(
1567
+ d=Count("reqs", _filter=Q(reqs__model=model, reqs__created_at__gte=reset)),
1568
+ m=Count("reqs", _filter=Q(reqs__model=model, reqs__created_at__gte=minute)),
1569
+ )
1570
+ .filter(d__lt=rpd, m__lt=rpm)
1571
+ .order_by("d", "id")
1572
+ .first()
1573
+ )
1574
+
1575
+
1576
+ class GeminiReq(Model):
1577
+ """Запрос к Gemini (`cond_ai._generate`) — история за сутки (старше удаляет `CondAi` на входе): по ней
1578
+ `GeminiKey.pick` выбирает ключ, по ней же видно расход квот ключей по моделям."""
1579
+
1580
+ key: ForeignKeyRelation[GeminiKey] = ForeignKeyField("models.GeminiKey", "reqs", on_update=CASCADE)
1581
+ key_id: int
1582
+ model: GeminiModel = IntEnumField(GeminiModel)
1583
+ res: GeminiRes = IntEnumField(GeminiRes)
1584
+ created_at: datetime = DatetimeSecField(auto_now_add=True, db_default=Now())
1585
+
1586
+ class Meta:
1587
+ table = "gemini_req"
1588
+
1448
1589
 
1449
1590
  class Ad(Model, TsTrait):
1450
1591
  exid: int = UInt8Field(db_index=True)
@@ -1454,8 +1595,8 @@ class Ad(Model, TsTrait):
1454
1595
  premium: int = SmallIntField(null=True, db_default=None) # *100
1455
1596
  amount: int = UInt4Field(null=True) # /10^cur.scale
1456
1597
  quantity: int = UInt8Field(null=True) # /10^coinex.scale
1457
- min_fiat: int = UInt4Field() # /10^cur.scale
1458
- max_fiat: int | None = UInt4Field(null=True) # /10^cur.scale
1598
+ min_fiat: int = UInt4Field() # целые единицы валюты, БЕЗ scale (ExClient._ad_xe2x: min — ceil, max — floor)
1599
+ max_fiat: int | None = UInt4Field(null=True) # целые единицы валюты, БЕЗ scale
1459
1600
  auto_msg: str | None = CharField(4095, null=True)
1460
1601
  status: AdStatus = IntEnumField(AdStatus, db_default=AdStatus.active)
1461
1602
  filtered: bool = BooleanField(db_default=False)
@@ -1633,6 +1774,11 @@ class MyAd(Model): # Road
1633
1774
  class Meta:
1634
1775
  table = "my_ad"
1635
1776
 
1777
+ def urls(self) -> tuple[str | None, str | None]:
1778
+ # web/mob ссылки на объяву биржеспецифичны (формат/наличие share-hex) — строит per-Ex AgentClient.get_url
1779
+ # из самой объявы, без сессии. Требует ad__maker__ex + ad__pair_side__pair__coin/cur
1780
+ return self.ad.maker.ex.agent_cls().get_url(self)
1781
+
1636
1782
  @classmethod
1637
1783
  # запрос для получения объяв всех агентов этой биржи для разогрева
1638
1784
  def hot_mads_query(cls, ex_ids: list[int], active: bool = True):
@@ -2374,11 +2520,8 @@ class Order(Model, TsTrait):
2374
2520
 
2375
2521
  def ex_url(self) -> str:
2376
2522
  # ссылка на этот ордер на его бирже — биржеспецифична (Bybit/orderList, Htx/tradeInfo…);
2377
- # строит per-Ex AgentClient.order_url. Берём класс нужной биржи тем же __import__, что client(),
2378
- # но дёргаем classmethod из одного exid — живой агент-клиент/сессия не нужны. Требует ad__maker__ex.
2379
- module_name = f"xync_client.exs.{self.ad.maker.ex.name}.agent"
2380
- __import__(module_name)
2381
- return sys.modules[module_name].AgentClient.order_url(self.exid)
2523
+ # строит per-Ex AgentClient.order_url (classmethod из одного exid — сессия не нужна). Требует ad__maker__ex.
2524
+ return self.ad.maker.ex.agent_cls().order_url(self.exid)
2382
2525
 
2383
2526
  async def get_chat(self):
2384
2527
  await self.fetch_related("ad__pair_side", "msgs")
File without changes
File without changes