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.
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/PKG-INFO +3 -1
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/api/bootstrap.py +2 -2
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/api/demo_limits.py +2 -1
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/api/routers/analytics.py +7 -3
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/database.py +170 -16
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/utils.py +11 -1
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/pyproject.toml +7 -1
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/.gitignore +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/LICENSE +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/README.md +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/api/__init__.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/api/attachments.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/api/auth.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/api/chat.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/api/deps.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/api/errors.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/api/main.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/api/pdf.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/api/providers.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/api/routers/__init__.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/api/routers/accounting.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/api/routers/admin.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/api/routers/bank_reconciliation.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/api/routers/documents.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/api/routers/masters.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/api/routers/reports.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/api/routers/setup.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/api/services.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/api/templates/document.html +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/docs/agents/README.md +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/frontend/README.md +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/frontend/src/api/client.ts +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/__init__.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/accounting/__init__.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/accounting/bank_transaction.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/accounting/budget.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/accounting/chart_of_accounts.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/accounting/general_ledger.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/accounting/journal_entry.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/accounting/payment_entry.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/accounting/pos_invoice.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/accounting/purchase_invoice.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/accounting/revaluation.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/accounting/sales_invoice.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/accounting/subscription.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/buying/__init__.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/buying/purchase_order.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/controllers/__init__.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/controllers/currency.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/controllers/defaults.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/controllers/pricing_rule.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/controllers/taxes_and_totals.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/exceptions.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/hooks.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/model.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/selling/__init__.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/selling/quotation.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/selling/sales_order.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/simulation.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/stock/__init__.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/stock/delivery_note.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/stock/purchase_receipt.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/stock/stock_entry.py +0 -0
- {lambda_erp-0.1.4 → lambda_erp-0.1.5}/lambda_erp/stock/stock_ledger.py +0 -0
- {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.
|
|
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
|
|
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
|
""",
|
|
@@ -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()
|
|
@@ -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
|
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
1129
|
-
|
|
1130
|
-
self.
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
1283
|
-
|
|
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
|
-
|
|
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.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
|
|
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
|