lambda-erp 0.1.48__tar.gz → 0.2.0__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.48 → lambda_erp-0.2.0}/PKG-INFO +16 -2
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/README.md +15 -1
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/api/auth.py +31 -20
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/api/chat.py +126 -1
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/api/routers/setup.py +70 -4
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/frontend/src/api/client.ts +5 -2
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/accounting/chart_of_accounts.py +64 -28
- lambda_erp-0.2.0/lambda_erp/accounting/setup/__init__.py +67 -0
- lambda_erp-0.2.0/lambda_erp/accounting/setup/engine.py +240 -0
- lambda_erp-0.2.0/lambda_erp/accounting/setup/pack.py +135 -0
- lambda_erp-0.2.0/lambda_erp/accounting/setup/packs/__init__.py +16 -0
- lambda_erp-0.2.0/lambda_erp/accounting/setup/packs/ch.py +256 -0
- lambda_erp-0.2.0/lambda_erp/accounting/setup/packs/generic.py +55 -0
- lambda_erp-0.2.0/lambda_erp/accounting/setup/profiles.py +290 -0
- lambda_erp-0.2.0/lambda_erp/accounting/setup/spine.py +89 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/database.py +3 -3
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/pyproject.toml +1 -1
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/.gitignore +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/LICENSE +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/api/__init__.py +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/api/attachments.py +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/api/bootstrap.py +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/api/demo_limits.py +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/api/deps.py +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/api/errors.py +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/api/main.py +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/api/oauth.py +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/api/pdf.py +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/api/providers.py +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/api/remarks_md.py +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/api/routers/__init__.py +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/api/routers/accounting.py +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/api/routers/admin.py +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/api/routers/analytics.py +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/api/routers/bank_reconciliation.py +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/api/routers/chat_api.py +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/api/routers/documents.py +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/api/routers/masters.py +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/api/routers/proposals.py +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/api/routers/reports.py +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/api/services.py +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/api/templates/document.html +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/api/templates/proposal.html +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/docs/agents/README.md +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/frontend/README.md +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/__init__.py +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/accounting/__init__.py +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/accounting/bank_transaction.py +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/accounting/budget.py +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/accounting/general_ledger.py +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/accounting/journal_entry.py +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/accounting/payment_entry.py +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/accounting/pos_invoice.py +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/accounting/purchase_invoice.py +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/accounting/revaluation.py +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/accounting/sales_invoice.py +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/accounting/subscription.py +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/buying/__init__.py +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/buying/purchase_order.py +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/controllers/__init__.py +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/controllers/currency.py +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/controllers/defaults.py +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/controllers/pricing_rule.py +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/controllers/taxes_and_totals.py +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/exceptions.py +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/hooks.py +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/model.py +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/selling/__init__.py +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/selling/proposal.py +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/selling/quotation.py +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/selling/sales_order.py +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/simulation.py +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/stock/__init__.py +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/stock/delivery_note.py +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/stock/purchase_receipt.py +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/stock/stock_entry.py +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/stock/stock_ledger.py +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/utils.py +0 -0
- {lambda_erp-0.1.48 → lambda_erp-0.2.0}/terraform/README.md +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lambda-erp
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Core ERP logic - accounting, sales, purchasing, inventory
|
|
5
5
|
Author: TORUS INVESTMENTS AG
|
|
6
6
|
License-Expression: MIT
|
|
@@ -135,8 +135,22 @@ Each of those is hours of skilled work today. With a capable LLM in the loop, th
|
|
|
135
135
|
|
|
136
136
|
---
|
|
137
137
|
|
|
138
|
+
## Set up your books by describing your business
|
|
139
|
+
|
|
140
|
+
Standing up a new company usually means an accountant hand-building a chart of accounts. In Lambda ERP you do it in the chat: tell the assistant what kind of business you run and which country you're in, and it proposes the exact accounts — explaining the structure and the judgment calls, and creating nothing until you approve. It's the "chart-of-accounts design and mapping" work from above, done in seconds and reviewed by you.
|
|
141
|
+
|
|
142
|
+
Two dimensions shape the chart, and they compose:
|
|
143
|
+
|
|
144
|
+
- **Localization packs** bring a country's real chart and tax setup. Today: a **generic / international** chart (also the permanent fallback for anywhere not yet localized) and **Switzerland** — the *Kontenrahmen KMU*, in German, in CHF, with MWST tax templates at the current rates. Each country is a self-contained pack, so more can land without disturbing the ones already there — that's the roadmap, one jurisdiction at a time.
|
|
145
|
+
- **Sector profiles** tailor the chart to how you operate — **services, retail / POS, hospitality, wholesale / distribution, import / export, manufacturing, construction** — adding the accounts that model actually needs (work-in-progress and retention for construction, food-vs-beverage cost lines for hospitality, landed-cost accounts for import/export, and so on). Profiles are country-independent: the same seven apply on top of every localization.
|
|
146
|
+
|
|
147
|
+
The result is booked in one step — chart of accounts, sensible default accounts, tax templates, and a cost center — ready for opening balances. New here? The in-app **Tutorial** has a one-click **Get started** that opens the wizard in chat.
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
138
151
|
## What works today
|
|
139
152
|
|
|
153
|
+
- **Guided company setup through chat** — pick a country (generic or Switzerland, more coming) and business type, preview the exact chart of accounts, confirm, and it's booked
|
|
140
154
|
- Full sales and purchase cycles (Quotation → Sales Order → Delivery Note → Sales Invoice → Payment Entry, and the buying equivalents)
|
|
141
155
|
- Returns / credit notes / debit notes with proper GL and stock reversal
|
|
142
156
|
- Moving-average stock ledger with negative-stock protection
|
|
@@ -176,7 +190,7 @@ Four things had to be true for this to work, and they all became true in the las
|
|
|
176
190
|
|
|
177
191
|
Every company configuring an ERP runs into the same problems: local tax rules, common workflow patterns, industry-specific accounting quirks. Most of that knowledge isn't a competitive advantage — it's the same ground being re-covered separately at every implementation, by every team, in slightly different ways.
|
|
178
192
|
|
|
179
|
-
We want Lambda ERP to be where that knowledge lives in public. The base system is MIT-licensed, and
|
|
193
|
+
We want Lambda ERP to be where that knowledge lives in public. The base system is MIT-licensed, and the repo has real seams for that knowledge to slot into: a new country's chart of accounts and tax rules is a self-contained **localization pack**, and an industry's way of operating is a **sector profile** — both live in `lambda_erp/accounting/setup/` and are documented for contributors in [`docs/agents/company_setup.md`](docs/agents/company_setup.md). A German VAT pack, a U.S. sales-tax-by-state module, a template for professional services — each is an additive contribution that every deployment picks up. The goal is a true community where running an ERP gets cheaper, faster, and more flexible for everyone, not just the implementer.
|
|
180
194
|
|
|
181
195
|
---
|
|
182
196
|
|
|
@@ -108,8 +108,22 @@ Each of those is hours of skilled work today. With a capable LLM in the loop, th
|
|
|
108
108
|
|
|
109
109
|
---
|
|
110
110
|
|
|
111
|
+
## Set up your books by describing your business
|
|
112
|
+
|
|
113
|
+
Standing up a new company usually means an accountant hand-building a chart of accounts. In Lambda ERP you do it in the chat: tell the assistant what kind of business you run and which country you're in, and it proposes the exact accounts — explaining the structure and the judgment calls, and creating nothing until you approve. It's the "chart-of-accounts design and mapping" work from above, done in seconds and reviewed by you.
|
|
114
|
+
|
|
115
|
+
Two dimensions shape the chart, and they compose:
|
|
116
|
+
|
|
117
|
+
- **Localization packs** bring a country's real chart and tax setup. Today: a **generic / international** chart (also the permanent fallback for anywhere not yet localized) and **Switzerland** — the *Kontenrahmen KMU*, in German, in CHF, with MWST tax templates at the current rates. Each country is a self-contained pack, so more can land without disturbing the ones already there — that's the roadmap, one jurisdiction at a time.
|
|
118
|
+
- **Sector profiles** tailor the chart to how you operate — **services, retail / POS, hospitality, wholesale / distribution, import / export, manufacturing, construction** — adding the accounts that model actually needs (work-in-progress and retention for construction, food-vs-beverage cost lines for hospitality, landed-cost accounts for import/export, and so on). Profiles are country-independent: the same seven apply on top of every localization.
|
|
119
|
+
|
|
120
|
+
The result is booked in one step — chart of accounts, sensible default accounts, tax templates, and a cost center — ready for opening balances. New here? The in-app **Tutorial** has a one-click **Get started** that opens the wizard in chat.
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
111
124
|
## What works today
|
|
112
125
|
|
|
126
|
+
- **Guided company setup through chat** — pick a country (generic or Switzerland, more coming) and business type, preview the exact chart of accounts, confirm, and it's booked
|
|
113
127
|
- Full sales and purchase cycles (Quotation → Sales Order → Delivery Note → Sales Invoice → Payment Entry, and the buying equivalents)
|
|
114
128
|
- Returns / credit notes / debit notes with proper GL and stock reversal
|
|
115
129
|
- Moving-average stock ledger with negative-stock protection
|
|
@@ -149,7 +163,7 @@ Four things had to be true for this to work, and they all became true in the las
|
|
|
149
163
|
|
|
150
164
|
Every company configuring an ERP runs into the same problems: local tax rules, common workflow patterns, industry-specific accounting quirks. Most of that knowledge isn't a competitive advantage — it's the same ground being re-covered separately at every implementation, by every team, in slightly different ways.
|
|
151
165
|
|
|
152
|
-
We want Lambda ERP to be where that knowledge lives in public. The base system is MIT-licensed, and
|
|
166
|
+
We want Lambda ERP to be where that knowledge lives in public. The base system is MIT-licensed, and the repo has real seams for that knowledge to slot into: a new country's chart of accounts and tax rules is a self-contained **localization pack**, and an industry's way of operating is a **sector profile** — both live in `lambda_erp/accounting/setup/` and are documented for contributors in [`docs/agents/company_setup.md`](docs/agents/company_setup.md). A German VAT pack, a U.S. sales-tax-by-state module, a template for professional services — each is an additive contribution that every deployment picks up. The goal is a true community where running an ERP gets cheaper, faster, and more flexible for everyone, not just the implementer.
|
|
153
167
|
|
|
154
168
|
---
|
|
155
169
|
|
|
@@ -680,14 +680,14 @@ def get_api_caller(request: Request) -> dict:
|
|
|
680
680
|
raise HTTPException(status_code=401, detail="Empty bearer token")
|
|
681
681
|
|
|
682
682
|
rows = db.sql(
|
|
683
|
-
'SELECT id, name,
|
|
683
|
+
'SELECT id, name, owner, role, session_owner FROM "Api Key" WHERE key_hash = ? AND revoked = 0',
|
|
684
684
|
[hash_api_key(token)],
|
|
685
685
|
)
|
|
686
686
|
if not rows:
|
|
687
687
|
raise HTTPException(status_code=401, detail="Invalid API key")
|
|
688
688
|
key = dict(rows[0])
|
|
689
689
|
|
|
690
|
-
owner = db.get_value("User", key["
|
|
690
|
+
owner = db.get_value("User", key["owner"], ["name", "full_name", "role", "enabled"])
|
|
691
691
|
if not owner or not owner.get("enabled"):
|
|
692
692
|
raise HTTPException(status_code=401, detail="Invalid API key")
|
|
693
693
|
effective_role = (
|
|
@@ -699,10 +699,10 @@ def get_api_caller(request: Request) -> dict:
|
|
|
699
699
|
|
|
700
700
|
return {
|
|
701
701
|
"name": key["session_owner"],
|
|
702
|
-
"full_name": f"{owner.get('full_name') or key['
|
|
702
|
+
"full_name": f"{owner.get('full_name') or key['owner']} (API)",
|
|
703
703
|
"role": effective_role,
|
|
704
704
|
"api_key_id": key["id"],
|
|
705
|
-
"api_key_user": key["
|
|
705
|
+
"api_key_user": key["owner"],
|
|
706
706
|
}
|
|
707
707
|
|
|
708
708
|
|
|
@@ -717,7 +717,11 @@ def _serialize_api_key(row: dict) -> dict:
|
|
|
717
717
|
return {
|
|
718
718
|
"id": row["id"],
|
|
719
719
|
"name": row["name"],
|
|
720
|
-
"user": row.get("
|
|
720
|
+
"user": row.get("owner"),
|
|
721
|
+
# Owner's display identity so the UI can group keys per user with a
|
|
722
|
+
# human name instead of the opaque USR-id (admins see many owners).
|
|
723
|
+
"owner_full_name": row.get("owner_full_name"),
|
|
724
|
+
"owner_email": row.get("owner_email"),
|
|
721
725
|
"role": row["role"],
|
|
722
726
|
"key_prefix": row["key_prefix"],
|
|
723
727
|
"created_at": row.get("created_at"),
|
|
@@ -727,7 +731,7 @@ def _serialize_api_key(row: dict) -> dict:
|
|
|
727
731
|
|
|
728
732
|
|
|
729
733
|
def _get_key_or_404(db, key_id: str) -> dict:
|
|
730
|
-
rows = db.sql('SELECT id,
|
|
734
|
+
rows = db.sql('SELECT id, owner, revoked FROM "Api Key" WHERE id = ?', [key_id])
|
|
731
735
|
if not rows:
|
|
732
736
|
raise HTTPException(status_code=404, detail="API key not found")
|
|
733
737
|
return dict(rows[0])
|
|
@@ -735,7 +739,7 @@ def _get_key_or_404(db, key_id: str) -> dict:
|
|
|
735
739
|
|
|
736
740
|
def _require_key_access(key: dict, user: dict) -> None:
|
|
737
741
|
"""A key is managed by its owner; admins can manage every key."""
|
|
738
|
-
if key["
|
|
742
|
+
if key["owner"] != user["name"] and user["role"] != "admin":
|
|
739
743
|
raise HTTPException(status_code=403, detail="Not your API key")
|
|
740
744
|
|
|
741
745
|
|
|
@@ -743,21 +747,26 @@ def _require_key_access(key: dict, user: dict) -> None:
|
|
|
743
747
|
def list_api_keys(user: dict = Depends(require_viewer)):
|
|
744
748
|
"""List API keys (metadata only — the token is never returned).
|
|
745
749
|
|
|
746
|
-
Users see their own keys; admins see everyone's.
|
|
750
|
+
Users see their own keys; admins see everyone's. Each key carries its
|
|
751
|
+
owner's display name/email and an `is_mine` flag so the UI can group keys
|
|
752
|
+
per user (your own first) and warn before touching someone else's.
|
|
747
753
|
"""
|
|
748
754
|
db = get_db()
|
|
755
|
+
base = (
|
|
756
|
+
'SELECT k.id, k.name, k.owner, k.role, k.key_prefix, k.created_at, '
|
|
757
|
+
'k.last_used_at, k.revoked, u.full_name AS owner_full_name, u.email AS owner_email '
|
|
758
|
+
'FROM "Api Key" k LEFT JOIN "User" u ON u.name = k.owner '
|
|
759
|
+
)
|
|
749
760
|
if user["role"] == "admin":
|
|
750
|
-
rows = db.sql(
|
|
751
|
-
'SELECT id, name, user, role, key_prefix, created_at, last_used_at, revoked '
|
|
752
|
-
'FROM "Api Key" ORDER BY created_at DESC'
|
|
753
|
-
)
|
|
761
|
+
rows = db.sql(base + 'ORDER BY k.created_at DESC')
|
|
754
762
|
else:
|
|
755
|
-
rows = db.sql(
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
763
|
+
rows = db.sql(base + 'WHERE k.owner = ? ORDER BY k.created_at DESC', [user["name"]])
|
|
764
|
+
out = []
|
|
765
|
+
for r in rows:
|
|
766
|
+
d = _serialize_api_key(dict(r))
|
|
767
|
+
d["is_mine"] = d["user"] == user["name"]
|
|
768
|
+
out.append(d)
|
|
769
|
+
return out
|
|
761
770
|
|
|
762
771
|
|
|
763
772
|
@router.post("/api-keys")
|
|
@@ -789,16 +798,18 @@ def create_api_key(data: ApiKeyCreate, user: dict = Depends(require_viewer)):
|
|
|
789
798
|
created_at = now()
|
|
790
799
|
db.sql(
|
|
791
800
|
'INSERT INTO "Api Key" '
|
|
792
|
-
'(id, name,
|
|
801
|
+
'(id, name, owner, key_hash, key_prefix, role, session_owner, created_at, last_used_at, revoked) '
|
|
793
802
|
'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0)',
|
|
794
803
|
[key_id, name, user["name"], key_hash, key_prefix, role, f"api:{user['name']}", created_at, None],
|
|
795
804
|
)
|
|
796
805
|
db.conn.commit()
|
|
797
806
|
row = {
|
|
798
|
-
"id": key_id, "name": name, "
|
|
807
|
+
"id": key_id, "name": name, "owner": user["name"], "role": role, "key_prefix": key_prefix,
|
|
799
808
|
"created_at": created_at, "last_used_at": None, "revoked": 0,
|
|
809
|
+
"owner_full_name": user.get("full_name"), "owner_email": user.get("email"),
|
|
800
810
|
}
|
|
801
811
|
result = _serialize_api_key(row)
|
|
812
|
+
result["is_mine"] = True
|
|
802
813
|
result["token"] = token # shown once
|
|
803
814
|
return result
|
|
804
815
|
|
|
@@ -1067,6 +1067,75 @@ TOOLS = [
|
|
|
1067
1067
|
},
|
|
1068
1068
|
},
|
|
1069
1069
|
},
|
|
1070
|
+
{
|
|
1071
|
+
"type": "function",
|
|
1072
|
+
"function": {
|
|
1073
|
+
"name": "plan_company_setup",
|
|
1074
|
+
"description": (
|
|
1075
|
+
"PREVIEW a new company's chart of accounts — creates NOTHING. Use this "
|
|
1076
|
+
"when the user asks to set up a company / get started / create their books. "
|
|
1077
|
+
"Returns the proposed jurisdiction, currency, the full account tree, the "
|
|
1078
|
+
"sector-specific accounts that would be added, the company default-account "
|
|
1079
|
+
"wiring, and — importantly — `sector.guidance` (why the chart looks this way) "
|
|
1080
|
+
"and `sector.big_decisions` (points you MUST confirm with the user before "
|
|
1081
|
+
"applying). Call this first, walk the user through the plan and the big "
|
|
1082
|
+
"decisions in plain language, and only call `apply_company_setup` once they "
|
|
1083
|
+
"confirm.\n\n"
|
|
1084
|
+
"`sector` is one of: services, retail_pos, hospitality, distribution, "
|
|
1085
|
+
"import_export, manufacturing, construction. If you're unsure which fits, "
|
|
1086
|
+
"ASK the user about their business rather than guessing. `country` selects "
|
|
1087
|
+
"the jurisdiction chart. Available: 'generic' (international, USD) and 'CH' "
|
|
1088
|
+
"(Switzerland — Kontenrahmen KMU, German account names, CHF, with MWST tax "
|
|
1089
|
+
"templates). Any other country falls back to the generic chart — tell the "
|
|
1090
|
+
"user when `jurisdiction.is_fallback` is true."
|
|
1091
|
+
),
|
|
1092
|
+
"parameters": {
|
|
1093
|
+
"type": "object",
|
|
1094
|
+
"properties": {
|
|
1095
|
+
"name": {"type": "string", "description": "The company name"},
|
|
1096
|
+
"sector": {
|
|
1097
|
+
"type": "string",
|
|
1098
|
+
"enum": ["services", "retail_pos", "hospitality", "distribution",
|
|
1099
|
+
"import_export", "manufacturing", "construction"],
|
|
1100
|
+
"description": "Operating-mode profile. Omit if the business is generic/unknown.",
|
|
1101
|
+
},
|
|
1102
|
+
"country": {"type": "string", "description": "Jurisdiction code, e.g. 'CH'. Defaults to generic."},
|
|
1103
|
+
"variant": {"type": "string", "description": "Sub-chart variant, e.g. 'skr03' (rarely needed)."},
|
|
1104
|
+
"currency": {"type": "string", "description": "Base currency, e.g. 'USD', 'CHF'. Defaults to the pack currency."},
|
|
1105
|
+
},
|
|
1106
|
+
"required": ["name"],
|
|
1107
|
+
},
|
|
1108
|
+
},
|
|
1109
|
+
},
|
|
1110
|
+
{
|
|
1111
|
+
"type": "function",
|
|
1112
|
+
"function": {
|
|
1113
|
+
"name": "apply_company_setup",
|
|
1114
|
+
"description": (
|
|
1115
|
+
"CREATE the company, its chart of accounts (base + sector overlay), the "
|
|
1116
|
+
"company default accounts, any jurisdiction tax accounts, and the default "
|
|
1117
|
+
"cost center — in one step. Only call this AFTER `plan_company_setup` and "
|
|
1118
|
+
"AFTER the user has explicitly confirmed the plan and any big decisions. "
|
|
1119
|
+
"Refuses if the company already has accounts (admin-only). Pass the SAME "
|
|
1120
|
+
"arguments you used for the plan the user approved."
|
|
1121
|
+
),
|
|
1122
|
+
"parameters": {
|
|
1123
|
+
"type": "object",
|
|
1124
|
+
"properties": {
|
|
1125
|
+
"name": {"type": "string", "description": "The company name (must match the approved plan)"},
|
|
1126
|
+
"sector": {
|
|
1127
|
+
"type": "string",
|
|
1128
|
+
"enum": ["services", "retail_pos", "hospitality", "distribution",
|
|
1129
|
+
"import_export", "manufacturing", "construction"],
|
|
1130
|
+
},
|
|
1131
|
+
"country": {"type": "string"},
|
|
1132
|
+
"variant": {"type": "string"},
|
|
1133
|
+
"currency": {"type": "string"},
|
|
1134
|
+
},
|
|
1135
|
+
"required": ["name"],
|
|
1136
|
+
},
|
|
1137
|
+
},
|
|
1138
|
+
},
|
|
1070
1139
|
]
|
|
1071
1140
|
|
|
1072
1141
|
# ---------------------------------------------------------------------------
|
|
@@ -1583,6 +1652,34 @@ def _handle_update_custom_analytics_report(args, user_info: dict | None = None,
|
|
|
1583
1652
|
return row
|
|
1584
1653
|
|
|
1585
1654
|
|
|
1655
|
+
def _handle_plan_company_setup(args):
|
|
1656
|
+
from lambda_erp.accounting.setup import plan_company_setup
|
|
1657
|
+
name = (args.get("name") or "").strip()
|
|
1658
|
+
if not name:
|
|
1659
|
+
return {"error": "A company name is required to plan the setup."}
|
|
1660
|
+
return plan_company_setup(
|
|
1661
|
+
name,
|
|
1662
|
+
country=(args.get("country") or None),
|
|
1663
|
+
variant=(args.get("variant") or None),
|
|
1664
|
+
sector=(args.get("sector") or None),
|
|
1665
|
+
currency=(args.get("currency") or None),
|
|
1666
|
+
)
|
|
1667
|
+
|
|
1668
|
+
|
|
1669
|
+
def _handle_apply_company_setup(args):
|
|
1670
|
+
from lambda_erp.accounting.setup import apply_company_setup
|
|
1671
|
+
name = (args.get("name") or "").strip()
|
|
1672
|
+
if not name:
|
|
1673
|
+
return {"error": "A company name is required to apply the setup."}
|
|
1674
|
+
return apply_company_setup(
|
|
1675
|
+
name,
|
|
1676
|
+
country=(args.get("country") or None),
|
|
1677
|
+
variant=(args.get("variant") or None),
|
|
1678
|
+
sector=(args.get("sector") or None),
|
|
1679
|
+
currency=(args.get("currency") or None),
|
|
1680
|
+
)
|
|
1681
|
+
|
|
1682
|
+
|
|
1586
1683
|
TOOL_HANDLERS = {
|
|
1587
1684
|
"list_documents": _handle_list_documents,
|
|
1588
1685
|
"get_document": _handle_get_document,
|
|
@@ -1606,6 +1703,8 @@ TOOL_HANDLERS = {
|
|
|
1606
1703
|
"create_custom_analytics_report": lambda args: _handle_create_custom_analytics_report(args),
|
|
1607
1704
|
"get_custom_analytics_report": lambda args: _handle_get_custom_analytics_report(args),
|
|
1608
1705
|
"update_custom_analytics_report": lambda args: _handle_update_custom_analytics_report(args),
|
|
1706
|
+
"plan_company_setup": _handle_plan_company_setup,
|
|
1707
|
+
"apply_company_setup": _handle_apply_company_setup,
|
|
1609
1708
|
}
|
|
1610
1709
|
|
|
1611
1710
|
# ---------------------------------------------------------------------------
|
|
@@ -1747,6 +1846,21 @@ Always include the view link after creating, submitting, converting, or updating
|
|
|
1747
1846
|
else:
|
|
1748
1847
|
role_desc = "You have **viewer** access — you can view documents, master data, and reports, but you cannot create or modify data. If the user asks you to create or change something, let them know they need a manager or admin to do that."
|
|
1749
1848
|
|
|
1849
|
+
# Only admins can run setup, so only they get the wizard playbook (keeps the
|
|
1850
|
+
# prompt lean for everyone else). Triggered from chat OR the Tutorial page's
|
|
1851
|
+
# "Get started" button, which sends a set-up-my-company message.
|
|
1852
|
+
if user_role == "admin":
|
|
1853
|
+
company_setup_section = """## Guided company setup (chart of accounts)
|
|
1854
|
+
When the user wants to **set up a new company**, get started, or create their books/chart of accounts, run the guided setup — do NOT hand-create accounts one by one:
|
|
1855
|
+
1. **Identify the business type.** Which of these fits: services/consulting, retail/POS, hospitality/F&B, distribution/wholesale, import/export, manufacturing, or construction? If the conversation doesn't make it obvious, ASK — never guess the sector.
|
|
1856
|
+
2. **Preview with `plan_company_setup`** (company name + sector + country/currency if known). This creates NOTHING; it returns the proposed chart.
|
|
1857
|
+
3. **Walk them through the plan** in plain language: the sector-specific accounts (`sector_added_accounts`) and the `sector.guidance` (why the chart is shaped this way). Present every item in `sector.big_decisions` and get an explicit yes/no on each — these are the decisions you must not make for them.
|
|
1858
|
+
4. **Only after they confirm, call `apply_company_setup`** with the SAME arguments to create the company, accounts, defaults, and cost center.
|
|
1859
|
+
If `jurisdiction.is_fallback` is true, tell the user their country isn't localized yet and you're using the generic international chart for now.
|
|
1860
|
+
"""
|
|
1861
|
+
else:
|
|
1862
|
+
company_setup_section = ""
|
|
1863
|
+
|
|
1750
1864
|
return f"""You are an ERP assistant for Lambda ERP. Today's date is {date.today().isoformat()}.
|
|
1751
1865
|
|
|
1752
1866
|
You help users manage their business by creating documents, looking up data, and running reports — all through natural conversation.
|
|
@@ -1768,7 +1882,7 @@ When you do build a custom report, pass a plain-language `intent` to `create_cus
|
|
|
1768
1882
|
## Current User
|
|
1769
1883
|
You are speaking with **{user_name}** (role: **{user_role}**).
|
|
1770
1884
|
{role_desc}
|
|
1771
|
-
|
|
1885
|
+
{company_setup_section}
|
|
1772
1886
|
{company_context}
|
|
1773
1887
|
{analytics_context}
|
|
1774
1888
|
{uom_context}
|
|
@@ -2392,6 +2506,17 @@ async def run_thinking_loop(
|
|
|
2392
2506
|
tool_handlers["update_master"] = denied
|
|
2393
2507
|
tool_handlers["delete_master"] = denied
|
|
2394
2508
|
|
|
2509
|
+
# Company setup (chart of accounts, defaults, cost center) is admin-only,
|
|
2510
|
+
# mirroring POST /api/setup/company. Non-admins can still be *shown* a plan?
|
|
2511
|
+
# No — even the preview reveals structure they can't act on, so deny both.
|
|
2512
|
+
if user_role != "admin":
|
|
2513
|
+
setup_denied = lambda _args: {
|
|
2514
|
+
"error": "Setting up a company is an admin task. Ask an administrator "
|
|
2515
|
+
"to run the guided setup, or open it yourself from the Tutorial."
|
|
2516
|
+
}
|
|
2517
|
+
tool_handlers["plan_company_setup"] = setup_denied
|
|
2518
|
+
tool_handlers["apply_company_setup"] = setup_denied
|
|
2519
|
+
|
|
2395
2520
|
# Scope session-aware tools without mutating globals.
|
|
2396
2521
|
if session_id:
|
|
2397
2522
|
tool_handlers["retrieve_chat_history"] = lambda args: _handle_retrieve_chat_history(args, session_id)
|
|
@@ -7,6 +7,12 @@ from fastapi import APIRouter, Depends
|
|
|
7
7
|
from lambda_erp.database import get_db
|
|
8
8
|
from lambda_erp.utils import _dict, flt, nowdate
|
|
9
9
|
from lambda_erp.accounting.chart_of_accounts import setup_chart_of_accounts, setup_cost_center
|
|
10
|
+
from lambda_erp.accounting.setup import (
|
|
11
|
+
plan_company_setup,
|
|
12
|
+
apply_company_setup,
|
|
13
|
+
list_profiles,
|
|
14
|
+
list_packs,
|
|
15
|
+
)
|
|
10
16
|
from api.auth import require_role
|
|
11
17
|
|
|
12
18
|
# Sample US corporate addresses (streets, cities, states) used to auto-fill
|
|
@@ -94,17 +100,71 @@ def create_company(data: dict, _user: dict = Depends(require_role("admin"))):
|
|
|
94
100
|
**contact,
|
|
95
101
|
))
|
|
96
102
|
|
|
97
|
-
|
|
98
|
-
|
|
103
|
+
# Route the chart through the localization-pack engine. The jurisdiction is
|
|
104
|
+
# taken from the company `country` (falls back to the generic pack for any
|
|
105
|
+
# unlocalized country), and an optional `sector` applies an operating-mode
|
|
106
|
+
# overlay. With neither, this is byte-identical to the legacy
|
|
107
|
+
# setup_chart_of_accounts + setup_cost_center path.
|
|
108
|
+
result = apply_company_setup(
|
|
109
|
+
name,
|
|
110
|
+
country=(contact.get("country") or None),
|
|
111
|
+
variant=(data.get("variant") or None),
|
|
112
|
+
sector=(data.get("sector") or None),
|
|
113
|
+
currency=currency,
|
|
114
|
+
)
|
|
115
|
+
if not result.get("ok"):
|
|
116
|
+
return {"detail": result.get("error", "setup failed")}
|
|
99
117
|
|
|
100
118
|
return {
|
|
101
119
|
"ok": True,
|
|
102
120
|
"company": name,
|
|
103
|
-
"cost_center": cost_center,
|
|
121
|
+
"cost_center": result["cost_center"],
|
|
104
122
|
"currency": currency,
|
|
123
|
+
"jurisdiction": result["jurisdiction"],
|
|
124
|
+
"sector": result["sector"],
|
|
125
|
+
"accounts_created": result["accounts_created"],
|
|
126
|
+
"sector_added_accounts": result["sector_added_accounts"],
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@router.get("/profiles")
|
|
131
|
+
def setup_profiles(_user: dict = Depends(require_role("viewer"))):
|
|
132
|
+
"""List the available sector profiles and jurisdiction packs for setup."""
|
|
133
|
+
return {
|
|
134
|
+
"sectors": [
|
|
135
|
+
{
|
|
136
|
+
"key": p.key,
|
|
137
|
+
"label": p.label,
|
|
138
|
+
"summary": p.summary,
|
|
139
|
+
"big_decisions": p.big_decisions,
|
|
140
|
+
}
|
|
141
|
+
for p in list_profiles()
|
|
142
|
+
],
|
|
143
|
+
"jurisdictions": [
|
|
144
|
+
{"key": p.key, "label": p.label, "currency": p.currency,
|
|
145
|
+
"has_standard_tax": p.setup_tax is not None}
|
|
146
|
+
for p in list_packs()
|
|
147
|
+
],
|
|
105
148
|
}
|
|
106
149
|
|
|
107
150
|
|
|
151
|
+
@router.post("/plan")
|
|
152
|
+
def setup_plan(data: dict, _user: dict = Depends(require_role("admin"))):
|
|
153
|
+
"""Preview a company's chart of accounts without creating anything.
|
|
154
|
+
|
|
155
|
+
Expects: {"name": "...", "country": "CH"?, "variant": "skr03"?,
|
|
156
|
+
"sector": "manufacturing"?, "currency": "USD"?}
|
|
157
|
+
"""
|
|
158
|
+
name = data.get("name") or "New Company"
|
|
159
|
+
return plan_company_setup(
|
|
160
|
+
name,
|
|
161
|
+
country=(data.get("country") or None),
|
|
162
|
+
variant=(data.get("variant") or None),
|
|
163
|
+
sector=(data.get("sector") or None),
|
|
164
|
+
currency=(data.get("currency") or None),
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
108
168
|
@router.post("/seed-demo")
|
|
109
169
|
def seed_demo(_user: dict = Depends(require_role("admin"))):
|
|
110
170
|
"""Seed demo master data (customers, suppliers, items, warehouse).
|
|
@@ -229,7 +289,13 @@ def import_account_balances(data: dict, _user: dict = Depends(require_role("admi
|
|
|
229
289
|
|
|
230
290
|
db = get_db()
|
|
231
291
|
abbr = company[:4].upper()
|
|
232
|
-
|
|
292
|
+
# Read the balancing account from the company default rather than hardcoding
|
|
293
|
+
# the English name — localization packs use their own (e.g. the Swiss pack's
|
|
294
|
+
# "2990 Eröffnungsbilanz"). Mirrors the Opening Stock path in stock_entry.py.
|
|
295
|
+
equity_account = (
|
|
296
|
+
db.get_value("Company", company, "default_opening_balance_equity")
|
|
297
|
+
or f"Opening Balance Equity - {abbr}"
|
|
298
|
+
)
|
|
233
299
|
|
|
234
300
|
accounts = []
|
|
235
301
|
total_debit = 0
|
|
@@ -454,10 +454,13 @@ export const api = {
|
|
|
454
454
|
request<Array<{
|
|
455
455
|
id: string; name: string; user?: string; role: string; key_prefix: string;
|
|
456
456
|
created_at?: string; last_used_at?: string | null; revoked: boolean;
|
|
457
|
+
owner_full_name?: string | null; owner_email?: string | null; is_mine?: boolean;
|
|
457
458
|
}>>("/auth/api-keys"),
|
|
458
459
|
createApiKey: (name: string, role: string) =>
|
|
459
|
-
request<{
|
|
460
|
-
|
|
460
|
+
request<{
|
|
461
|
+
id: string; name: string; user?: string; role: string; key_prefix: string; token: string;
|
|
462
|
+
owner_full_name?: string | null; owner_email?: string | null; is_mine?: boolean;
|
|
463
|
+
}>("/auth/api-keys", { method: "POST", body: JSON.stringify({ name, role }) }),
|
|
461
464
|
revokeApiKey: (id: string) =>
|
|
462
465
|
request<{ id: string; revoked: boolean }>(
|
|
463
466
|
`/auth/api-keys/${encodeURIComponent(id)}/revoke`, { method: "POST" }),
|
|
@@ -111,21 +111,50 @@ STANDARD_CHART = {
|
|
|
111
111
|
}
|
|
112
112
|
|
|
113
113
|
|
|
114
|
-
|
|
115
|
-
|
|
114
|
+
# Company default-account wiring for the standard/generic chart, keyed
|
|
115
|
+
# {company_field: leaf_account_name} (un-suffixed). The company abbreviation
|
|
116
|
+
# suffix (" - ABBR") is appended at write time by apply_company_defaults. The
|
|
117
|
+
# generic localization pack reuses this so the new setup engine and this legacy
|
|
118
|
+
# path stay in lockstep.
|
|
119
|
+
STANDARD_DEFAULTS = {
|
|
120
|
+
"default_receivable_account": "Accounts Receivable",
|
|
121
|
+
"default_payable_account": "Accounts Payable",
|
|
122
|
+
"default_income_account": "Sales Revenue",
|
|
123
|
+
"default_expense_account": "Cost of Goods Sold",
|
|
124
|
+
"round_off_account": "Round Off",
|
|
125
|
+
"stock_in_hand_account": "Stock In Hand",
|
|
126
|
+
"stock_received_but_not_billed": "Stock Received But Not Billed",
|
|
127
|
+
"stock_adjustment_account": "Stock Adjustment",
|
|
128
|
+
"default_opening_balance_equity": "Opening Balance Equity",
|
|
129
|
+
"default_freight_in_account": "Freight In",
|
|
130
|
+
"default_customs_account": "Customs & Duties",
|
|
131
|
+
"default_exchange_gain_loss_account": "Exchange Gain/Loss",
|
|
132
|
+
"default_unrealized_exchange_account": "Unrealized Exchange Gain/Loss",
|
|
133
|
+
}
|
|
116
134
|
|
|
117
|
-
|
|
118
|
-
|
|
135
|
+
|
|
136
|
+
def account_abbr(company_name):
|
|
137
|
+
"""The 4-char company suffix used across the chart (e.g. 'Lambda' -> 'LAMB')."""
|
|
138
|
+
return company_name[:4].upper()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def create_accounts_from_tree(company_name, tree, currency="USD"):
|
|
142
|
+
"""Write an account tree for a company. Shared by the legacy setup and the
|
|
143
|
+
localization-pack engine so there is exactly one account writer.
|
|
144
|
+
|
|
145
|
+
``tree`` is ``{account_name: {root_type?, report_type?, account_type?,
|
|
146
|
+
children?}}``. root_type / report_type inherit from the parent when omitted.
|
|
147
|
+
Does NOT commit — the caller owns the transaction boundary.
|
|
119
148
|
"""
|
|
120
149
|
db = get_db()
|
|
121
150
|
|
|
122
|
-
def
|
|
123
|
-
for account_name, details in
|
|
151
|
+
def _create(subtree, parent=None, root_type=None, report_type=None):
|
|
152
|
+
for account_name, details in subtree.items():
|
|
124
153
|
rt = details.get("root_type", root_type)
|
|
125
154
|
rpt = details.get("report_type", report_type)
|
|
126
155
|
has_children = "children" in details
|
|
127
156
|
|
|
128
|
-
name = f"{account_name} - {company_name
|
|
157
|
+
name = f"{account_name} - {account_abbr(company_name)}"
|
|
129
158
|
|
|
130
159
|
account = _dict(
|
|
131
160
|
name=name,
|
|
@@ -141,28 +170,35 @@ def setup_chart_of_accounts(company_name, currency="USD"):
|
|
|
141
170
|
db.insert("Account", account)
|
|
142
171
|
|
|
143
172
|
if has_children:
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
# Set up company defaults
|
|
149
|
-
abbr = company_name[:4].upper()
|
|
150
|
-
db.set_value("Company", company_name, {
|
|
151
|
-
"default_receivable_account": f"Accounts Receivable - {abbr}",
|
|
152
|
-
"default_payable_account": f"Accounts Payable - {abbr}",
|
|
153
|
-
"default_income_account": f"Sales Revenue - {abbr}",
|
|
154
|
-
"default_expense_account": f"Cost of Goods Sold - {abbr}",
|
|
155
|
-
"round_off_account": f"Round Off - {abbr}",
|
|
156
|
-
"stock_in_hand_account": f"Stock In Hand - {abbr}",
|
|
157
|
-
"stock_received_but_not_billed": f"Stock Received But Not Billed - {abbr}",
|
|
158
|
-
"stock_adjustment_account": f"Stock Adjustment - {abbr}",
|
|
159
|
-
"default_opening_balance_equity": f"Opening Balance Equity - {abbr}",
|
|
160
|
-
"default_freight_in_account": f"Freight In - {abbr}",
|
|
161
|
-
"default_customs_account": f"Customs & Duties - {abbr}",
|
|
162
|
-
"default_exchange_gain_loss_account": f"Exchange Gain/Loss - {abbr}",
|
|
163
|
-
"default_unrealized_exchange_account": f"Unrealized Exchange Gain/Loss - {abbr}",
|
|
164
|
-
})
|
|
173
|
+
_create(details["children"], parent=name, root_type=rt, report_type=rpt)
|
|
174
|
+
|
|
175
|
+
_create(tree)
|
|
176
|
+
|
|
165
177
|
|
|
178
|
+
def apply_company_defaults(company_name, defaults):
|
|
179
|
+
"""Point Company default-account fields at the created leaf accounts.
|
|
180
|
+
|
|
181
|
+
``defaults`` is ``{company_field: leaf_account_name}`` (un-suffixed); the
|
|
182
|
+
company suffix is appended here. Does NOT commit.
|
|
183
|
+
"""
|
|
184
|
+
abbr = account_abbr(company_name)
|
|
185
|
+
resolved = {field: f"{leaf} - {abbr}" for field, leaf in defaults.items()}
|
|
186
|
+
if resolved:
|
|
187
|
+
db = get_db()
|
|
188
|
+
db.set_value("Company", company_name, resolved)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def setup_chart_of_accounts(company_name, currency="USD"):
|
|
192
|
+
"""Create all accounts for a company from the standard chart.
|
|
193
|
+
|
|
194
|
+
This mirrors the reference implementation's setup wizard step that creates the Chart of Accounts
|
|
195
|
+
from a template when a new company is created. It is the jurisdiction-neutral
|
|
196
|
+
legacy path; the localization-pack engine (``lambda_erp.accounting.setup``)
|
|
197
|
+
builds on the same writer to support sector overlays and other jurisdictions.
|
|
198
|
+
"""
|
|
199
|
+
db = get_db()
|
|
200
|
+
create_accounts_from_tree(company_name, STANDARD_CHART, currency)
|
|
201
|
+
apply_company_defaults(company_name, STANDARD_DEFAULTS)
|
|
166
202
|
db.commit()
|
|
167
203
|
return True
|
|
168
204
|
|