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.
Files changed (65) hide show
  1. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/PKG-INFO +3 -1
  2. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/api/auth.py +5 -5
  3. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/api/bootstrap.py +3 -3
  4. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/api/chat.py +6 -6
  5. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/api/demo_limits.py +2 -1
  6. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/api/main.py +1 -1
  7. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/api/pdf.py +1 -1
  8. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/api/routers/analytics.py +7 -3
  9. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/api/routers/masters.py +4 -4
  10. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/database.py +188 -16
  11. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/utils.py +11 -1
  12. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/pyproject.toml +7 -1
  13. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/.gitignore +0 -0
  14. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/LICENSE +0 -0
  15. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/README.md +0 -0
  16. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/api/__init__.py +0 -0
  17. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/api/attachments.py +0 -0
  18. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/api/deps.py +0 -0
  19. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/api/errors.py +0 -0
  20. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/api/providers.py +0 -0
  21. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/api/routers/__init__.py +0 -0
  22. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/api/routers/accounting.py +0 -0
  23. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/api/routers/admin.py +0 -0
  24. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/api/routers/bank_reconciliation.py +0 -0
  25. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/api/routers/documents.py +0 -0
  26. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/api/routers/reports.py +0 -0
  27. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/api/routers/setup.py +0 -0
  28. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/api/services.py +0 -0
  29. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/api/templates/document.html +0 -0
  30. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/docs/agents/README.md +0 -0
  31. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/frontend/README.md +0 -0
  32. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/frontend/src/api/client.ts +0 -0
  33. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/__init__.py +0 -0
  34. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/accounting/__init__.py +0 -0
  35. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/accounting/bank_transaction.py +0 -0
  36. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/accounting/budget.py +0 -0
  37. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/accounting/chart_of_accounts.py +0 -0
  38. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/accounting/general_ledger.py +0 -0
  39. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/accounting/journal_entry.py +0 -0
  40. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/accounting/payment_entry.py +0 -0
  41. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/accounting/pos_invoice.py +0 -0
  42. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/accounting/purchase_invoice.py +0 -0
  43. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/accounting/revaluation.py +0 -0
  44. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/accounting/sales_invoice.py +0 -0
  45. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/accounting/subscription.py +0 -0
  46. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/buying/__init__.py +0 -0
  47. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/buying/purchase_order.py +0 -0
  48. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/controllers/__init__.py +0 -0
  49. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/controllers/currency.py +0 -0
  50. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/controllers/defaults.py +0 -0
  51. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/controllers/pricing_rule.py +0 -0
  52. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/controllers/taxes_and_totals.py +0 -0
  53. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/exceptions.py +0 -0
  54. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/hooks.py +0 -0
  55. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/model.py +0 -0
  56. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/selling/__init__.py +0 -0
  57. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/selling/quotation.py +0 -0
  58. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/selling/sales_order.py +0 -0
  59. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/simulation.py +0 -0
  60. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/stock/__init__.py +0 -0
  61. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/stock/delivery_note.py +0 -0
  62. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/stock/purchase_receipt.py +0 -0
  63. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/stock/stock_entry.py +0 -0
  64. {lambda_erp-0.1.4 → lambda_erp-0.1.6}/lambda_erp/stock/stock_ledger.py +0 -0
  65. {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.4
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 = "public_manager" AND enabled = 1')
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 = "admin" AND enabled = 1')[0]["cnt"]
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 = "public_manager"')
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 = "public_manager"')
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 = "public_manager" AND enabled = 1')
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 IFNULL(is_return, 0) = 0
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 IFNULL(is_return, 0) = 0 AND customer = ?
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 = "public_manager" LIMIT 1'
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 = "assistant" AND message_type = "chat"',
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 = "demo" ORDER BY id',
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 ("user", "assistant") {session_clause} {date_clause} '
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 ("user", "assistant") {session_clause} '
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 != "demo"',
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 = "demo"',
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
- db.conn.execute(_SCHEMA_SQL)
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 = "public_manager" AND enabled = 1')
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 = "pdf_page_size"')
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": "strftime('%Y-%m', {date_col})",
33
- "quarter": "strftime('%Y-Q', {date_col}) || ((CAST(strftime('%m', {date_col}) AS INTEGER) + 2) / 3)",
34
- "year": "strftime('%Y', {date_col})",
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 = "Customer" AND party = ? LIMIT 1', "payment entry"),
71
- ('SELECT 1 FROM "Subscription" WHERE party_type = "Customer" AND party = ? LIMIT 1', "subscription"),
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 = "Supplier" AND party = ? LIMIT 1', "payment entry"),
78
- ('SELECT 1 FROM "Subscription" WHERE party_type = "Supplier" AND party = ? LIMIT 1', "subscription"),
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 sqlite connection with the same per-conn settings.
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 the
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
- cols = {row[1] for row in self.conn.execute(f'PRAGMA table_info("{table}")').fetchall()}
1129
- if column not in cols:
1130
- self.conn.execute(f'ALTER TABLE "{table}" ADD COLUMN {column} {definition}')
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 = ", ".join(f'"{f}"' for f in fields)
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 = ", ".join(f'"{f}"' for f in fields)
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
- cursor = self.conn.execute(f'PRAGMA table_info("{doctype}")')
1283
- return {row[1] for row in cursor.fetchall()}
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
- tables = db.sql("SELECT name FROM sqlite_master WHERE type='table'")
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.4"
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