lambda-erp 0.1.4__tar.gz → 0.1.6__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.
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/PKG-INFO +3 -1
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/api/auth.py +5 -5
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/api/bootstrap.py +3 -3
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/api/chat.py +6 -6
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/api/demo_limits.py +2 -1
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/api/main.py +1 -1
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/api/pdf.py +1 -1
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/api/routers/analytics.py +7 -3
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/api/routers/masters.py +4 -4
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/database.py +188 -16
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/utils.py +11 -1
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/pyproject.toml +7 -1
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/.gitignore +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/LICENSE +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/README.md +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/api/__init__.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/api/attachments.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/api/deps.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/api/errors.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/api/providers.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/api/routers/__init__.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/api/routers/accounting.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/api/routers/admin.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/api/routers/bank_reconciliation.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/api/routers/documents.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/api/routers/reports.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/api/routers/setup.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/api/services.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/api/templates/document.html +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/docs/agents/README.md +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/frontend/README.md +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/frontend/src/api/client.ts +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/__init__.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/accounting/__init__.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/accounting/bank_transaction.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/accounting/budget.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/accounting/chart_of_accounts.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/accounting/general_ledger.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/accounting/journal_entry.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/accounting/payment_entry.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/accounting/pos_invoice.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/accounting/purchase_invoice.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/accounting/revaluation.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/accounting/sales_invoice.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/accounting/subscription.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/buying/__init__.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/buying/purchase_order.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/controllers/__init__.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/controllers/currency.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/controllers/defaults.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/controllers/pricing_rule.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/controllers/taxes_and_totals.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/exceptions.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/hooks.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/model.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/selling/__init__.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/selling/quotation.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/selling/sales_order.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/simulation.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/stock/__init__.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/stock/delivery_note.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/stock/purchase_receipt.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/stock/stock_entry.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/stock/stock_ledger.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.6}/terraform/README.md +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lambda-erp
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.6
|
|
4
4
|
Summary: Core ERP logic - accounting, sales, purchasing, inventory
|
|
5
5
|
Author: TORUS INVESTMENTS AG
|
|
6
6
|
License-Expression: MIT
|
|
@@ -20,6 +20,8 @@ Requires-Dist: python-jose[cryptography]>=3.3
|
|
|
20
20
|
Requires-Dist: python-multipart>=0.0.9
|
|
21
21
|
Requires-Dist: uvicorn[standard]>=0.30
|
|
22
22
|
Requires-Dist: weasyprint>=62.0
|
|
23
|
+
Provides-Extra: postgres
|
|
24
|
+
Requires-Dist: psycopg[binary]>=3.1; extra == 'postgres'
|
|
23
25
|
Description-Content-Type: text/markdown
|
|
24
26
|
|
|
25
27
|
# Lambda ERP
|
|
@@ -196,7 +196,7 @@ def get_current_user(request: Request) -> dict:
|
|
|
196
196
|
|
|
197
197
|
# Fall back to public manager (demo mode)
|
|
198
198
|
db = get_db()
|
|
199
|
-
pub = db.sql('SELECT name, email, full_name, role, enabled FROM "User" WHERE role =
|
|
199
|
+
pub = db.sql('SELECT name, email, full_name, role, enabled FROM "User" WHERE role = \'public_manager\' AND enabled = 1')
|
|
200
200
|
if pub:
|
|
201
201
|
return dict(pub[0])
|
|
202
202
|
|
|
@@ -373,7 +373,7 @@ def change_role(user_name: str, data: ChangeRoleRequest, user: dict = Depends(re
|
|
|
373
373
|
raise HTTPException(status_code=404, detail="User not found")
|
|
374
374
|
|
|
375
375
|
if user_name == user["name"] and data.role != "admin":
|
|
376
|
-
admin_count = db.sql('SELECT COUNT(*) as cnt FROM "User" WHERE role =
|
|
376
|
+
admin_count = db.sql('SELECT COUNT(*) as cnt FROM "User" WHERE role = \'admin\' AND enabled = 1')[0]["cnt"]
|
|
377
377
|
if admin_count <= 1:
|
|
378
378
|
raise HTTPException(status_code=409, detail="Cannot demote the only admin")
|
|
379
379
|
|
|
@@ -414,7 +414,7 @@ def create_public_manager(user: dict = Depends(require_admin)):
|
|
|
414
414
|
turned on.
|
|
415
415
|
"""
|
|
416
416
|
db = get_db()
|
|
417
|
-
existing = db.sql('SELECT name, enabled FROM "User" WHERE role =
|
|
417
|
+
existing = db.sql('SELECT name, enabled FROM "User" WHERE role = \'public_manager\'')
|
|
418
418
|
if existing:
|
|
419
419
|
if not existing[0]["enabled"]:
|
|
420
420
|
db.set_value("User", existing[0]["name"], {"enabled": 1, "modified": now()})
|
|
@@ -453,7 +453,7 @@ def create_public_manager(user: dict = Depends(require_admin)):
|
|
|
453
453
|
def remove_public_manager(user: dict = Depends(require_admin)):
|
|
454
454
|
"""Disable the public manager account."""
|
|
455
455
|
db = get_db()
|
|
456
|
-
existing = db.sql('SELECT name FROM "User" WHERE role =
|
|
456
|
+
existing = db.sql('SELECT name FROM "User" WHERE role = \'public_manager\'')
|
|
457
457
|
if not existing:
|
|
458
458
|
return {"ok": True, "status": "not_found"}
|
|
459
459
|
db.set_value("User", existing[0]["name"], {"enabled": 0, "modified": now()})
|
|
@@ -464,7 +464,7 @@ def remove_public_manager(user: dict = Depends(require_admin)):
|
|
|
464
464
|
def get_public_manager_status():
|
|
465
465
|
"""Public: check if a public manager exists and is enabled."""
|
|
466
466
|
db = get_db()
|
|
467
|
-
existing = db.sql('SELECT name, full_name, role, enabled FROM "User" WHERE role =
|
|
467
|
+
existing = db.sql('SELECT name, full_name, role, enabled FROM "User" WHERE role = \'public_manager\' AND enabled = 1')
|
|
468
468
|
if existing:
|
|
469
469
|
return {"active": True, "user": dict(existing[0])}
|
|
470
470
|
return {"active": False}
|
|
@@ -293,7 +293,7 @@ def _ensure_top_customer_snapshots(db) -> None:
|
|
|
293
293
|
SUM(net_total) AS revenue,
|
|
294
294
|
COUNT(*) AS invoice_count
|
|
295
295
|
FROM "Sales Invoice"
|
|
296
|
-
WHERE docstatus = 1 AND
|
|
296
|
+
WHERE docstatus = 1 AND COALESCE(is_return, 0) = 0
|
|
297
297
|
GROUP BY customer, customer_name
|
|
298
298
|
ORDER BY revenue DESC
|
|
299
299
|
LIMIT 3
|
|
@@ -325,7 +325,7 @@ def _ensure_top_customer_snapshots(db) -> None:
|
|
|
325
325
|
"""
|
|
326
326
|
SELECT name, posting_date
|
|
327
327
|
FROM "Sales Invoice"
|
|
328
|
-
WHERE docstatus = 1 AND
|
|
328
|
+
WHERE docstatus = 1 AND COALESCE(is_return, 0) = 0 AND customer = ?
|
|
329
329
|
ORDER BY posting_date DESC, name DESC
|
|
330
330
|
LIMIT 1
|
|
331
331
|
""",
|
|
@@ -477,7 +477,7 @@ def _format_qty(value) -> str:
|
|
|
477
477
|
|
|
478
478
|
def _public_manager_user(db) -> dict | None:
|
|
479
479
|
rows = db.sql(
|
|
480
|
-
'SELECT name, role FROM "User" WHERE role =
|
|
480
|
+
'SELECT name, role FROM "User" WHERE role = \'public_manager\' LIMIT 1'
|
|
481
481
|
)
|
|
482
482
|
if not rows:
|
|
483
483
|
return None
|
|
@@ -297,7 +297,7 @@ def count_assistant_messages(session_id: str) -> int:
|
|
|
297
297
|
"""Count assistant messages in a session (used to decide when to generate title)."""
|
|
298
298
|
db = get_db()
|
|
299
299
|
rows = db.sql(
|
|
300
|
-
'SELECT COUNT(*) as cnt FROM "Chat Message" WHERE session_id = ? AND role =
|
|
300
|
+
'SELECT COUNT(*) as cnt FROM "Chat Message" WHERE session_id = ? AND role = \'assistant\' AND message_type = \'chat\'',
|
|
301
301
|
[session_id],
|
|
302
302
|
)
|
|
303
303
|
return rows[0]["cnt"] if rows else 0
|
|
@@ -385,7 +385,7 @@ def load_demo_history(session_id: str) -> list[dict]:
|
|
|
385
385
|
db = get_db()
|
|
386
386
|
rows = db.sql(
|
|
387
387
|
'SELECT role, content, created_at FROM "Chat Message" '
|
|
388
|
-
'WHERE session_id = ? AND message_type =
|
|
388
|
+
'WHERE session_id = ? AND message_type = \'demo\' ORDER BY id',
|
|
389
389
|
[session_id],
|
|
390
390
|
)
|
|
391
391
|
return [dict(r) for r in rows]
|
|
@@ -1172,7 +1172,7 @@ def _handle_retrieve_chat_history(args, session_id=None):
|
|
|
1172
1172
|
params.append(date_to)
|
|
1173
1173
|
rows = db.sql(
|
|
1174
1174
|
f'SELECT role, content, created_at FROM "Chat Message" '
|
|
1175
|
-
f'WHERE role IN (
|
|
1175
|
+
f'WHERE role IN (\'user\', \'assistant\') {session_clause} {date_clause} '
|
|
1176
1176
|
f'ORDER BY id ASC LIMIT 50',
|
|
1177
1177
|
params,
|
|
1178
1178
|
)
|
|
@@ -1180,7 +1180,7 @@ def _handle_retrieve_chat_history(args, session_id=None):
|
|
|
1180
1180
|
num = min(int(args.get("num_messages", 20)), 50)
|
|
1181
1181
|
rows = db.sql(
|
|
1182
1182
|
f'SELECT role, content, created_at FROM "Chat Message" '
|
|
1183
|
-
f'WHERE role IN (
|
|
1183
|
+
f'WHERE role IN (\'user\', \'assistant\') {session_clause} '
|
|
1184
1184
|
f'ORDER BY id DESC LIMIT ?',
|
|
1185
1185
|
session_params + [num],
|
|
1186
1186
|
)
|
|
@@ -2624,7 +2624,7 @@ async def chat_websocket(
|
|
|
2624
2624
|
# (non-demo) user/assistant messages.
|
|
2625
2625
|
non_demo_rows = get_db().sql(
|
|
2626
2626
|
'SELECT COUNT(*) as cnt FROM "Chat Message" '
|
|
2627
|
-
'WHERE session_id = ? AND message_type !=
|
|
2627
|
+
'WHERE session_id = ? AND message_type != \'demo\'',
|
|
2628
2628
|
[session_id],
|
|
2629
2629
|
)
|
|
2630
2630
|
if non_demo_rows and non_demo_rows[0]["cnt"] > 0:
|
|
@@ -2643,7 +2643,7 @@ async def chat_websocket(
|
|
|
2643
2643
|
db = get_db()
|
|
2644
2644
|
db.sql(
|
|
2645
2645
|
'DELETE FROM "Chat Message" '
|
|
2646
|
-
'WHERE session_id = ? AND message_type =
|
|
2646
|
+
'WHERE session_id = ? AND message_type = \'demo\'',
|
|
2647
2647
|
[session_id],
|
|
2648
2648
|
)
|
|
2649
2649
|
db.conn.commit()
|
|
@@ -80,7 +80,8 @@ def init_schema() -> None:
|
|
|
80
80
|
if _schema_ready:
|
|
81
81
|
return
|
|
82
82
|
db = get_db()
|
|
83
|
-
|
|
83
|
+
# _ddl() maps the SQLite DDL (AUTOINCREMENT, REAL) to the active dialect.
|
|
84
|
+
db.conn.execute(db._ddl(_SCHEMA_SQL))
|
|
84
85
|
db.conn.execute(_INDEX_TS_SQL)
|
|
85
86
|
db.conn.execute(_INDEX_IP_TS_SQL)
|
|
86
87
|
db.conn.commit()
|
|
@@ -121,7 +121,7 @@ async def ws_chat(websocket: WebSocket):
|
|
|
121
121
|
|
|
122
122
|
# Fall back to public manager
|
|
123
123
|
if not user:
|
|
124
|
-
pub = db.sql('SELECT name, full_name, role, enabled FROM "User" WHERE role =
|
|
124
|
+
pub = db.sql('SELECT name, full_name, role, enabled FROM "User" WHERE role = \'public_manager\' AND enabled = 1')
|
|
125
125
|
user = pub[0] if pub else None
|
|
126
126
|
|
|
127
127
|
if not user:
|
|
@@ -127,7 +127,7 @@ def generate_pdf(doctype_slug: str, name: str) -> bytes:
|
|
|
127
127
|
taxes = doc.get("taxes", [])
|
|
128
128
|
|
|
129
129
|
# Page size setting
|
|
130
|
-
page_size_row = db.sql('SELECT value FROM "Settings" WHERE key =
|
|
130
|
+
page_size_row = db.sql('SELECT value FROM "Settings" WHERE key = \'pdf_page_size\'')
|
|
131
131
|
page_size = page_size_row[0]["value"] if page_size_row else "A4"
|
|
132
132
|
|
|
133
133
|
# Render
|
|
@@ -28,10 +28,14 @@ _viewer = Depends(require_role("viewer"))
|
|
|
28
28
|
# Legacy preset analytics
|
|
29
29
|
# ---------------------------------------------------------------------------
|
|
30
30
|
|
|
31
|
+
# Dates are stored as ISO text ('YYYY-MM-DD'), so substr-based bucketing is both
|
|
32
|
+
# exact and portable across SQLite and Postgres — avoids SQLite-only strftime().
|
|
33
|
+
# (Integer division (month+2)/3 -> quarter is integer in both dialects; the
|
|
34
|
+
# parens guard SQLite's quirk where || binds tighter than /.)
|
|
31
35
|
_TIME_BUCKETS = {
|
|
32
|
-
"month": "
|
|
33
|
-
"quarter": "
|
|
34
|
-
"year": "
|
|
36
|
+
"month": "substr({date_col}, 1, 7)",
|
|
37
|
+
"quarter": "substr({date_col}, 1, 4) || '-Q' || ((CAST(substr({date_col}, 6, 2) AS INTEGER) + 2) / 3)",
|
|
38
|
+
"year": "substr({date_col}, 1, 4)",
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
|
|
@@ -67,15 +67,15 @@ DELETE_REFERENCE_CHECKS = {
|
|
|
67
67
|
('SELECT 1 FROM "Sales Invoice" WHERE customer = ? LIMIT 1', "sales invoice"),
|
|
68
68
|
('SELECT 1 FROM "Delivery Note" WHERE customer = ? LIMIT 1', "delivery note"),
|
|
69
69
|
('SELECT 1 FROM "POS Invoice" WHERE customer = ? LIMIT 1', "POS invoice"),
|
|
70
|
-
('SELECT 1 FROM "Payment Entry" WHERE party_type =
|
|
71
|
-
('SELECT 1 FROM "Subscription" WHERE party_type =
|
|
70
|
+
('SELECT 1 FROM "Payment Entry" WHERE party_type = \'Customer\' AND party = ? LIMIT 1', "payment entry"),
|
|
71
|
+
('SELECT 1 FROM "Subscription" WHERE party_type = \'Customer\' AND party = ? LIMIT 1', "subscription"),
|
|
72
72
|
],
|
|
73
73
|
"supplier": [
|
|
74
74
|
('SELECT 1 FROM "Purchase Order" WHERE supplier = ? LIMIT 1', "purchase order"),
|
|
75
75
|
('SELECT 1 FROM "Purchase Invoice" WHERE supplier = ? LIMIT 1', "purchase invoice"),
|
|
76
76
|
('SELECT 1 FROM "Purchase Receipt" WHERE supplier = ? LIMIT 1', "purchase receipt"),
|
|
77
|
-
('SELECT 1 FROM "Payment Entry" WHERE party_type =
|
|
78
|
-
('SELECT 1 FROM "Subscription" WHERE party_type =
|
|
77
|
+
('SELECT 1 FROM "Payment Entry" WHERE party_type = \'Supplier\' AND party = ? LIMIT 1', "payment entry"),
|
|
78
|
+
('SELECT 1 FROM "Subscription" WHERE party_type = \'Supplier\' AND party = ? LIMIT 1', "subscription"),
|
|
79
79
|
],
|
|
80
80
|
"item": [
|
|
81
81
|
('SELECT 1 FROM "Quotation Item" WHERE item_code = ? LIMIT 1', "quotation item"),
|
|
@@ -37,6 +37,121 @@ def _journal_mode() -> str:
|
|
|
37
37
|
return mode if mode in _VALID_JOURNAL_MODES else "WAL"
|
|
38
38
|
|
|
39
39
|
|
|
40
|
+
def _is_postgres_dsn(db_path) -> bool:
|
|
41
|
+
"""A db_path that looks like a Postgres connection URL selects Postgres;
|
|
42
|
+
anything else (a file path or ':memory:') stays on SQLite. This is how
|
|
43
|
+
LAMBDA_ERP_DB doubles as the backend switch — no separate env var."""
|
|
44
|
+
return isinstance(db_path, str) and db_path.startswith(("postgresql://", "postgres://"))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
# Postgres adapter
|
|
49
|
+
#
|
|
50
|
+
# The whole codebase was written against sqlite3: `?` placeholders,
|
|
51
|
+
# `conn.execute(sql, params) -> cursor`, and `sqlite3.Row` rows that support
|
|
52
|
+
# BOTH positional (row[0]) and mapping (row["x"], dict(row)) access. Rather
|
|
53
|
+
# than rewrite every call site, we make a psycopg connection quack like that:
|
|
54
|
+
# * _PgConn translates `?`->`%s` and forwards execute/executemany/commit/...
|
|
55
|
+
# * _PgRow mirrors sqlite3.Row's dual access so get_value/sql/migrations and
|
|
56
|
+
# the external `db.conn.execute(...)` users keep working untouched.
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
class _PgRow:
|
|
60
|
+
"""Row that mimics sqlite3.Row: positional and by-name access, dict(row),
|
|
61
|
+
and tuple(row) -> values."""
|
|
62
|
+
|
|
63
|
+
__slots__ = ("_cols", "_vals")
|
|
64
|
+
|
|
65
|
+
def __init__(self, cols, vals):
|
|
66
|
+
self._cols = cols
|
|
67
|
+
self._vals = vals
|
|
68
|
+
|
|
69
|
+
def __getitem__(self, key):
|
|
70
|
+
if isinstance(key, int):
|
|
71
|
+
return self._vals[key]
|
|
72
|
+
return self._vals[self._cols.index(key)]
|
|
73
|
+
|
|
74
|
+
def get(self, key, default=None):
|
|
75
|
+
try:
|
|
76
|
+
return self[key]
|
|
77
|
+
except (KeyError, ValueError, IndexError):
|
|
78
|
+
return default
|
|
79
|
+
|
|
80
|
+
def keys(self):
|
|
81
|
+
return list(self._cols)
|
|
82
|
+
|
|
83
|
+
def __iter__(self):
|
|
84
|
+
return iter(self._vals) # tuple(row) -> values, like sqlite3.Row
|
|
85
|
+
|
|
86
|
+
def __len__(self):
|
|
87
|
+
return len(self._vals)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _pg_row_factory(cursor):
|
|
91
|
+
cols = [c.name for c in (cursor.description or [])]
|
|
92
|
+
|
|
93
|
+
def make(values):
|
|
94
|
+
return _PgRow(cols, values)
|
|
95
|
+
|
|
96
|
+
return make
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class _PgConn:
|
|
100
|
+
"""Adapts a psycopg (v3) connection to the sqlite3 connection API the code
|
|
101
|
+
uses. autocommit is off so the existing `_in_transaction` + single commit()
|
|
102
|
+
/ rollback() flow gives the same atomicity as SQLite's implicit transaction."""
|
|
103
|
+
|
|
104
|
+
def __init__(self, raw):
|
|
105
|
+
self._raw = raw
|
|
106
|
+
|
|
107
|
+
def execute(self, sql, params=None):
|
|
108
|
+
try:
|
|
109
|
+
cur = self._raw.cursor()
|
|
110
|
+
if params:
|
|
111
|
+
# psycopg does %-substitution when params are given, so a literal
|
|
112
|
+
# % must be doubled. No params -> psycopg leaves the SQL
|
|
113
|
+
# untouched, so literal % is already safe and needs no escaping.
|
|
114
|
+
cur.execute(sql.replace("%", "%%").replace("?", "%s"), list(params))
|
|
115
|
+
else:
|
|
116
|
+
cur.execute(sql.replace("?", "%s"))
|
|
117
|
+
return cur
|
|
118
|
+
except Exception:
|
|
119
|
+
# A failed statement leaves the connection in an aborted transaction
|
|
120
|
+
# (autocommit=False); every later query on this pooled/thread-local
|
|
121
|
+
# connection would then fail with InFailedSqlTransaction. Roll back
|
|
122
|
+
# so the connection stays usable, then re-raise the real error.
|
|
123
|
+
self._safe_rollback()
|
|
124
|
+
raise
|
|
125
|
+
|
|
126
|
+
def executemany(self, sql, seq_params):
|
|
127
|
+
try:
|
|
128
|
+
cur = self._raw.cursor()
|
|
129
|
+
cur.executemany(sql.replace("%", "%%").replace("?", "%s"),
|
|
130
|
+
[list(p) for p in seq_params])
|
|
131
|
+
return cur
|
|
132
|
+
except Exception:
|
|
133
|
+
self._safe_rollback()
|
|
134
|
+
raise
|
|
135
|
+
|
|
136
|
+
def _safe_rollback(self):
|
|
137
|
+
try:
|
|
138
|
+
self._raw.rollback()
|
|
139
|
+
except Exception:
|
|
140
|
+
pass
|
|
141
|
+
|
|
142
|
+
def commit(self):
|
|
143
|
+
self._raw.commit()
|
|
144
|
+
|
|
145
|
+
def rollback(self):
|
|
146
|
+
self._raw.rollback()
|
|
147
|
+
|
|
148
|
+
def close(self):
|
|
149
|
+
self._raw.close()
|
|
150
|
+
|
|
151
|
+
def cursor(self, *args, **kwargs):
|
|
152
|
+
return self._raw.cursor(*args, **kwargs)
|
|
153
|
+
|
|
154
|
+
|
|
40
155
|
class _NullLock:
|
|
41
156
|
"""No-op context manager used when SQLite-level locking is sufficient."""
|
|
42
157
|
|
|
@@ -59,8 +174,11 @@ class Database:
|
|
|
59
174
|
# is a separate empty database, so we keep one shared connection
|
|
60
175
|
# guarded by a Python lock.
|
|
61
176
|
self.db_path = db_path
|
|
177
|
+
self.dialect = "postgres" if _is_postgres_dsn(db_path) else "sqlite"
|
|
178
|
+
# :memory: is a SQLite-only concept (Postgres is always a server).
|
|
62
179
|
self._is_memory = (db_path == ":memory:")
|
|
63
180
|
self._local = threading.local()
|
|
181
|
+
self._col_cache = {} # doctype -> set(columns); invalidated on ALTER
|
|
64
182
|
if self._is_memory:
|
|
65
183
|
self._lock = threading.Lock()
|
|
66
184
|
self._shared_conn = self._open_conn()
|
|
@@ -74,12 +192,20 @@ class Database:
|
|
|
74
192
|
self._setup_schema()
|
|
75
193
|
|
|
76
194
|
def _open_conn(self):
|
|
77
|
-
"""Open a new
|
|
195
|
+
"""Open a new connection (per-backend) with the same per-conn settings.
|
|
78
196
|
|
|
79
|
-
For file DBs the connection is stored on thread-local state so
|
|
80
|
-
same thread reuses it; for :memory: it is returned and held as the
|
|
81
|
-
shared connection.
|
|
197
|
+
For file/Postgres DBs the connection is stored on thread-local state so
|
|
198
|
+
the same thread reuses it; for :memory: it is returned and held as the
|
|
199
|
+
shared connection. Returns the object the `conn` property hands out:
|
|
200
|
+
the raw sqlite3 connection, or a _PgConn wrapper for Postgres.
|
|
82
201
|
"""
|
|
202
|
+
conn = self._open_pg_conn() if self.dialect == "postgres" else self._open_sqlite_conn()
|
|
203
|
+
if not self._is_memory:
|
|
204
|
+
self._local.conn = conn
|
|
205
|
+
self._local.in_transaction = False
|
|
206
|
+
return conn
|
|
207
|
+
|
|
208
|
+
def _open_sqlite_conn(self):
|
|
83
209
|
conn = sqlite3.connect(self.db_path, check_same_thread=False)
|
|
84
210
|
conn.row_factory = sqlite3.Row
|
|
85
211
|
conn.execute(f"PRAGMA journal_mode={_journal_mode()}")
|
|
@@ -88,11 +214,19 @@ class Database:
|
|
|
88
214
|
# another thread is mid-write. Generous for our workload (LLM calls
|
|
89
215
|
# dwarf any DB write).
|
|
90
216
|
conn.execute("PRAGMA busy_timeout=5000")
|
|
91
|
-
if not self._is_memory:
|
|
92
|
-
self._local.conn = conn
|
|
93
|
-
self._local.in_transaction = False
|
|
94
217
|
return conn
|
|
95
218
|
|
|
219
|
+
def _open_pg_conn(self):
|
|
220
|
+
# psycopg is only needed for the Postgres backend; import lazily so a
|
|
221
|
+
# SQLite-only install (tests, local dev) doesn't require the driver.
|
|
222
|
+
import psycopg
|
|
223
|
+
|
|
224
|
+
# autocommit off: matches SQLite's implicit transaction so the existing
|
|
225
|
+
# _in_transaction + commit()/rollback() atomicity (GL submit/cancel) is
|
|
226
|
+
# preserved exactly.
|
|
227
|
+
return _PgConn(psycopg.connect(self.db_path, autocommit=False,
|
|
228
|
+
row_factory=_pg_row_factory))
|
|
229
|
+
|
|
96
230
|
@property
|
|
97
231
|
def conn(self):
|
|
98
232
|
if self._is_memory:
|
|
@@ -1106,10 +1240,27 @@ class Database:
|
|
|
1106
1240
|
]
|
|
1107
1241
|
|
|
1108
1242
|
for stmt in stmts:
|
|
1109
|
-
self.conn.execute(stmt)
|
|
1243
|
+
self.conn.execute(self._ddl(stmt))
|
|
1110
1244
|
self.conn.commit()
|
|
1111
1245
|
self._migrate()
|
|
1112
1246
|
|
|
1247
|
+
def _ddl(self, stmt: str) -> str:
|
|
1248
|
+
"""Translate SQLite DDL to the active dialect. No-op for SQLite.
|
|
1249
|
+
|
|
1250
|
+
For Postgres: SQLite's `REAL` is 8-byte (double); Postgres `REAL` is
|
|
1251
|
+
only 4-byte, so map to DOUBLE PRECISION to keep float arithmetic
|
|
1252
|
+
identical (the validation suite's balances depend on it). And
|
|
1253
|
+
`INTEGER PRIMARY KEY AUTOINCREMENT` becomes BIGSERIAL.
|
|
1254
|
+
"""
|
|
1255
|
+
if self.dialect != "postgres":
|
|
1256
|
+
return stmt
|
|
1257
|
+
import re
|
|
1258
|
+
stmt = re.sub(r"\bINTEGER\s+PRIMARY\s+KEY\s+AUTOINCREMENT\b",
|
|
1259
|
+
"BIGSERIAL PRIMARY KEY", stmt, flags=re.IGNORECASE)
|
|
1260
|
+
stmt = re.sub(r"\bAUTOINCREMENT\b", "", stmt, flags=re.IGNORECASE)
|
|
1261
|
+
stmt = re.sub(r"\bREAL\b", "DOUBLE PRECISION", stmt, flags=re.IGNORECASE)
|
|
1262
|
+
return stmt
|
|
1263
|
+
|
|
1113
1264
|
# -----------------------------------------------------------------
|
|
1114
1265
|
# Migrations
|
|
1115
1266
|
#
|
|
@@ -1125,9 +1276,9 @@ class Database:
|
|
|
1125
1276
|
MIGRATIONS = None # populated just below __init_subclass__ — see end of class
|
|
1126
1277
|
|
|
1127
1278
|
def _add_column_if_missing(self, table: str, column: str, definition: str) -> None:
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
self.
|
|
1279
|
+
if column not in self._get_table_columns(table):
|
|
1280
|
+
self.conn.execute(self._ddl(f'ALTER TABLE "{table}" ADD COLUMN {column} {definition}'))
|
|
1281
|
+
self._col_cache.pop(table, None)
|
|
1131
1282
|
|
|
1132
1283
|
def _migrate(self):
|
|
1133
1284
|
"""Run each pending migration in order, tracking applied versions."""
|
|
@@ -1191,7 +1342,7 @@ class Database:
|
|
|
1191
1342
|
fieldname = "name"
|
|
1192
1343
|
|
|
1193
1344
|
fields = fieldname if isinstance(fieldname, (list, tuple)) else [fieldname]
|
|
1194
|
-
field_str =
|
|
1345
|
+
field_str = self._select_fields(doctype, fields)
|
|
1195
1346
|
|
|
1196
1347
|
if isinstance(name, dict) or filters:
|
|
1197
1348
|
filt = name if isinstance(name, dict) else (filters or {})
|
|
@@ -1224,7 +1375,7 @@ class Database:
|
|
|
1224
1375
|
if fields == ["*"]:
|
|
1225
1376
|
field_str = "*"
|
|
1226
1377
|
else:
|
|
1227
|
-
field_str =
|
|
1378
|
+
field_str = self._select_fields(doctype, fields)
|
|
1228
1379
|
query = f'SELECT {field_str} FROM "{doctype}"'
|
|
1229
1380
|
|
|
1230
1381
|
params = []
|
|
@@ -1278,9 +1429,30 @@ class Database:
|
|
|
1278
1429
|
return bool(rows)
|
|
1279
1430
|
|
|
1280
1431
|
def _get_table_columns(self, doctype):
|
|
1281
|
-
"""Get column names for a table."""
|
|
1282
|
-
|
|
1283
|
-
|
|
1432
|
+
"""Get column names for a table (cached; invalidated on ALTER)."""
|
|
1433
|
+
cached = self._col_cache.get(doctype)
|
|
1434
|
+
if cached is not None:
|
|
1435
|
+
return cached
|
|
1436
|
+
if self.dialect == "postgres":
|
|
1437
|
+
rows = self.conn.execute(
|
|
1438
|
+
"SELECT column_name FROM information_schema.columns WHERE table_name = ?",
|
|
1439
|
+
[doctype],
|
|
1440
|
+
).fetchall()
|
|
1441
|
+
cols = {row[0] for row in rows}
|
|
1442
|
+
else:
|
|
1443
|
+
cursor = self.conn.execute(f'PRAGMA table_info("{doctype}")')
|
|
1444
|
+
cols = {row[1] for row in cursor.fetchall()}
|
|
1445
|
+
self._col_cache[doctype] = cols
|
|
1446
|
+
return cols
|
|
1447
|
+
|
|
1448
|
+
def _select_fields(self, doctype, fields):
|
|
1449
|
+
"""Build the SELECT column list, selecting only columns that exist and
|
|
1450
|
+
padding the rest as NULL. SQLite silently returns an unknown quoted
|
|
1451
|
+
identifier as a string literal; Postgres errors. Padding missing fields
|
|
1452
|
+
as NULL gives portable behaviour (None for absent columns) and supports
|
|
1453
|
+
the cross-doctype field lists the codebase relies on."""
|
|
1454
|
+
valid = self._get_table_columns(doctype)
|
|
1455
|
+
return ", ".join(f'"{f}"' if f in valid else f'NULL AS "{f}"' for f in fields)
|
|
1284
1456
|
|
|
1285
1457
|
def insert(self, doctype, doc):
|
|
1286
1458
|
"""Insert a record from a dict. Ignores fields not in the table schema."""
|
|
@@ -140,7 +140,17 @@ def new_name(prefix, sequence_store={}):
|
|
|
140
140
|
try:
|
|
141
141
|
db = get_db()
|
|
142
142
|
# Search all tables for names matching this prefix pattern
|
|
143
|
-
|
|
143
|
+
if getattr(db, "dialect", "sqlite") == "postgres":
|
|
144
|
+
# Only probe tables that HAVE a `name` column. On Postgres a
|
|
145
|
+
# failing statement (e.g. SELECT name FROM a table without one)
|
|
146
|
+
# aborts the whole transaction, unlike SQLite which shrugs it
|
|
147
|
+
# off — so we must not issue a query that can error here.
|
|
148
|
+
tables = db.sql(
|
|
149
|
+
"SELECT table_name AS name FROM information_schema.columns "
|
|
150
|
+
"WHERE column_name = 'name' AND table_schema = 'public'"
|
|
151
|
+
)
|
|
152
|
+
else:
|
|
153
|
+
tables = db.sql("SELECT name FROM sqlite_master WHERE type='table'")
|
|
144
154
|
max_num = 0
|
|
145
155
|
for t in tables:
|
|
146
156
|
table_name = t["name"] if isinstance(t, dict) else t[0]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "lambda-erp"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.6"
|
|
4
4
|
description = "Core ERP logic - accounting, sales, purchasing, inventory"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -23,6 +23,12 @@ dependencies = [
|
|
|
23
23
|
"holidays>=0.40",
|
|
24
24
|
]
|
|
25
25
|
|
|
26
|
+
# Postgres backend is opt-in: `pip install lambda-erp[postgres]`. SQLite (the
|
|
27
|
+
# default, used by tests and local dev) needs no extra driver. Select Postgres
|
|
28
|
+
# at runtime by setting LAMBDA_ERP_DB to a postgresql:// connection URL.
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
postgres = ["psycopg[binary]>=3.1"]
|
|
31
|
+
|
|
26
32
|
[build-system]
|
|
27
33
|
requires = ["hatchling"]
|
|
28
34
|
build-backend = "hatchling.build"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|