lambda-erp 0.1.4__tar.gz → 0.1.5__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.5}/PKG-INFO +3 -1
  2. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/api/bootstrap.py +2 -2
  3. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/api/demo_limits.py +2 -1
  4. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/api/routers/analytics.py +7 -3
  5. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/database.py +170 -16
  6. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/utils.py +11 -1
  7. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/pyproject.toml +7 -1
  8. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/.gitignore +0 -0
  9. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/LICENSE +0 -0
  10. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/README.md +0 -0
  11. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/api/__init__.py +0 -0
  12. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/api/attachments.py +0 -0
  13. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/api/auth.py +0 -0
  14. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/api/chat.py +0 -0
  15. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/api/deps.py +0 -0
  16. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/api/errors.py +0 -0
  17. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/api/main.py +0 -0
  18. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/api/pdf.py +0 -0
  19. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/api/providers.py +0 -0
  20. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/api/routers/__init__.py +0 -0
  21. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/api/routers/accounting.py +0 -0
  22. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/api/routers/admin.py +0 -0
  23. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/api/routers/bank_reconciliation.py +0 -0
  24. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/api/routers/documents.py +0 -0
  25. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/api/routers/masters.py +0 -0
  26. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/api/routers/reports.py +0 -0
  27. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/api/routers/setup.py +0 -0
  28. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/api/services.py +0 -0
  29. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/api/templates/document.html +0 -0
  30. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/docs/agents/README.md +0 -0
  31. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/frontend/README.md +0 -0
  32. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/frontend/src/api/client.ts +0 -0
  33. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/__init__.py +0 -0
  34. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/accounting/__init__.py +0 -0
  35. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/accounting/bank_transaction.py +0 -0
  36. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/accounting/budget.py +0 -0
  37. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/accounting/chart_of_accounts.py +0 -0
  38. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/accounting/general_ledger.py +0 -0
  39. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/accounting/journal_entry.py +0 -0
  40. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/accounting/payment_entry.py +0 -0
  41. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/accounting/pos_invoice.py +0 -0
  42. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/accounting/purchase_invoice.py +0 -0
  43. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/accounting/revaluation.py +0 -0
  44. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/accounting/sales_invoice.py +0 -0
  45. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/accounting/subscription.py +0 -0
  46. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/buying/__init__.py +0 -0
  47. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/buying/purchase_order.py +0 -0
  48. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/controllers/__init__.py +0 -0
  49. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/controllers/currency.py +0 -0
  50. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/controllers/defaults.py +0 -0
  51. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/controllers/pricing_rule.py +0 -0
  52. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/controllers/taxes_and_totals.py +0 -0
  53. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/exceptions.py +0 -0
  54. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/hooks.py +0 -0
  55. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/model.py +0 -0
  56. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/selling/__init__.py +0 -0
  57. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/selling/quotation.py +0 -0
  58. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/selling/sales_order.py +0 -0
  59. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/simulation.py +0 -0
  60. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/stock/__init__.py +0 -0
  61. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/stock/delivery_note.py +0 -0
  62. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/stock/purchase_receipt.py +0 -0
  63. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/stock/stock_entry.py +0 -0
  64. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/stock/stock_ledger.py +0 -0
  65. {lambda_erp-0.1.4 → lambda_erp-0.1.5}/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.5
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
@@ -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
  """,
@@ -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()
@@ -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
 
@@ -37,6 +37,103 @@ 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
+ cur = self._raw.cursor()
109
+ if params:
110
+ # psycopg does %-substitution when params are given, so a literal %
111
+ # must be doubled. No params -> psycopg leaves the SQL untouched, so
112
+ # literal % is already safe and needs no escaping.
113
+ cur.execute(sql.replace("%", "%%").replace("?", "%s"), list(params))
114
+ else:
115
+ cur.execute(sql.replace("?", "%s"))
116
+ return cur
117
+
118
+ def executemany(self, sql, seq_params):
119
+ cur = self._raw.cursor()
120
+ cur.executemany(sql.replace("%", "%%").replace("?", "%s"),
121
+ [list(p) for p in seq_params])
122
+ return cur
123
+
124
+ def commit(self):
125
+ self._raw.commit()
126
+
127
+ def rollback(self):
128
+ self._raw.rollback()
129
+
130
+ def close(self):
131
+ self._raw.close()
132
+
133
+ def cursor(self, *args, **kwargs):
134
+ return self._raw.cursor(*args, **kwargs)
135
+
136
+
40
137
  class _NullLock:
41
138
  """No-op context manager used when SQLite-level locking is sufficient."""
42
139
 
@@ -59,8 +156,11 @@ class Database:
59
156
  # is a separate empty database, so we keep one shared connection
60
157
  # guarded by a Python lock.
61
158
  self.db_path = db_path
159
+ self.dialect = "postgres" if _is_postgres_dsn(db_path) else "sqlite"
160
+ # :memory: is a SQLite-only concept (Postgres is always a server).
62
161
  self._is_memory = (db_path == ":memory:")
63
162
  self._local = threading.local()
163
+ self._col_cache = {} # doctype -> set(columns); invalidated on ALTER
64
164
  if self._is_memory:
65
165
  self._lock = threading.Lock()
66
166
  self._shared_conn = self._open_conn()
@@ -74,12 +174,20 @@ class Database:
74
174
  self._setup_schema()
75
175
 
76
176
  def _open_conn(self):
77
- """Open a new sqlite connection with the same per-conn settings.
177
+ """Open a new connection (per-backend) with the same per-conn settings.
78
178
 
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.
179
+ For file/Postgres DBs the connection is stored on thread-local state so
180
+ the same thread reuses it; for :memory: it is returned and held as the
181
+ shared connection. Returns the object the `conn` property hands out:
182
+ the raw sqlite3 connection, or a _PgConn wrapper for Postgres.
82
183
  """
184
+ conn = self._open_pg_conn() if self.dialect == "postgres" else self._open_sqlite_conn()
185
+ if not self._is_memory:
186
+ self._local.conn = conn
187
+ self._local.in_transaction = False
188
+ return conn
189
+
190
+ def _open_sqlite_conn(self):
83
191
  conn = sqlite3.connect(self.db_path, check_same_thread=False)
84
192
  conn.row_factory = sqlite3.Row
85
193
  conn.execute(f"PRAGMA journal_mode={_journal_mode()}")
@@ -88,11 +196,19 @@ class Database:
88
196
  # another thread is mid-write. Generous for our workload (LLM calls
89
197
  # dwarf any DB write).
90
198
  conn.execute("PRAGMA busy_timeout=5000")
91
- if not self._is_memory:
92
- self._local.conn = conn
93
- self._local.in_transaction = False
94
199
  return conn
95
200
 
201
+ def _open_pg_conn(self):
202
+ # psycopg is only needed for the Postgres backend; import lazily so a
203
+ # SQLite-only install (tests, local dev) doesn't require the driver.
204
+ import psycopg
205
+
206
+ # autocommit off: matches SQLite's implicit transaction so the existing
207
+ # _in_transaction + commit()/rollback() atomicity (GL submit/cancel) is
208
+ # preserved exactly.
209
+ return _PgConn(psycopg.connect(self.db_path, autocommit=False,
210
+ row_factory=_pg_row_factory))
211
+
96
212
  @property
97
213
  def conn(self):
98
214
  if self._is_memory:
@@ -1106,10 +1222,27 @@ class Database:
1106
1222
  ]
1107
1223
 
1108
1224
  for stmt in stmts:
1109
- self.conn.execute(stmt)
1225
+ self.conn.execute(self._ddl(stmt))
1110
1226
  self.conn.commit()
1111
1227
  self._migrate()
1112
1228
 
1229
+ def _ddl(self, stmt: str) -> str:
1230
+ """Translate SQLite DDL to the active dialect. No-op for SQLite.
1231
+
1232
+ For Postgres: SQLite's `REAL` is 8-byte (double); Postgres `REAL` is
1233
+ only 4-byte, so map to DOUBLE PRECISION to keep float arithmetic
1234
+ identical (the validation suite's balances depend on it). And
1235
+ `INTEGER PRIMARY KEY AUTOINCREMENT` becomes BIGSERIAL.
1236
+ """
1237
+ if self.dialect != "postgres":
1238
+ return stmt
1239
+ import re
1240
+ stmt = re.sub(r"\bINTEGER\s+PRIMARY\s+KEY\s+AUTOINCREMENT\b",
1241
+ "BIGSERIAL PRIMARY KEY", stmt, flags=re.IGNORECASE)
1242
+ stmt = re.sub(r"\bAUTOINCREMENT\b", "", stmt, flags=re.IGNORECASE)
1243
+ stmt = re.sub(r"\bREAL\b", "DOUBLE PRECISION", stmt, flags=re.IGNORECASE)
1244
+ return stmt
1245
+
1113
1246
  # -----------------------------------------------------------------
1114
1247
  # Migrations
1115
1248
  #
@@ -1125,9 +1258,9 @@ class Database:
1125
1258
  MIGRATIONS = None # populated just below __init_subclass__ — see end of class
1126
1259
 
1127
1260
  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}')
1261
+ if column not in self._get_table_columns(table):
1262
+ self.conn.execute(self._ddl(f'ALTER TABLE "{table}" ADD COLUMN {column} {definition}'))
1263
+ self._col_cache.pop(table, None)
1131
1264
 
1132
1265
  def _migrate(self):
1133
1266
  """Run each pending migration in order, tracking applied versions."""
@@ -1191,7 +1324,7 @@ class Database:
1191
1324
  fieldname = "name"
1192
1325
 
1193
1326
  fields = fieldname if isinstance(fieldname, (list, tuple)) else [fieldname]
1194
- field_str = ", ".join(f'"{f}"' for f in fields)
1327
+ field_str = self._select_fields(doctype, fields)
1195
1328
 
1196
1329
  if isinstance(name, dict) or filters:
1197
1330
  filt = name if isinstance(name, dict) else (filters or {})
@@ -1224,7 +1357,7 @@ class Database:
1224
1357
  if fields == ["*"]:
1225
1358
  field_str = "*"
1226
1359
  else:
1227
- field_str = ", ".join(f'"{f}"' for f in fields)
1360
+ field_str = self._select_fields(doctype, fields)
1228
1361
  query = f'SELECT {field_str} FROM "{doctype}"'
1229
1362
 
1230
1363
  params = []
@@ -1278,9 +1411,30 @@ class Database:
1278
1411
  return bool(rows)
1279
1412
 
1280
1413
  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()}
1414
+ """Get column names for a table (cached; invalidated on ALTER)."""
1415
+ cached = self._col_cache.get(doctype)
1416
+ if cached is not None:
1417
+ return cached
1418
+ if self.dialect == "postgres":
1419
+ rows = self.conn.execute(
1420
+ "SELECT column_name FROM information_schema.columns WHERE table_name = ?",
1421
+ [doctype],
1422
+ ).fetchall()
1423
+ cols = {row[0] for row in rows}
1424
+ else:
1425
+ cursor = self.conn.execute(f'PRAGMA table_info("{doctype}")')
1426
+ cols = {row[1] for row in cursor.fetchall()}
1427
+ self._col_cache[doctype] = cols
1428
+ return cols
1429
+
1430
+ def _select_fields(self, doctype, fields):
1431
+ """Build the SELECT column list, selecting only columns that exist and
1432
+ padding the rest as NULL. SQLite silently returns an unknown quoted
1433
+ identifier as a string literal; Postgres errors. Padding missing fields
1434
+ as NULL gives portable behaviour (None for absent columns) and supports
1435
+ the cross-doctype field lists the codebase relies on."""
1436
+ valid = self._get_table_columns(doctype)
1437
+ return ", ".join(f'"{f}"' if f in valid else f'NULL AS "{f}"' for f in fields)
1284
1438
 
1285
1439
  def insert(self, doctype, doc):
1286
1440
  """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.5"
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