lambda-erp 0.6.24__tar.gz → 0.7.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.
Files changed (87) hide show
  1. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/PKG-INFO +5 -3
  2. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/README.md +3 -1
  3. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/api/chat.py +17 -0
  4. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/api/routers/masters.py +4 -0
  5. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/api/services.py +7 -0
  6. lambda_erp-0.7.0/lambda_erp/assets/__init__.py +33 -0
  7. lambda_erp-0.7.0/lambda_erp/assets/asset.py +134 -0
  8. lambda_erp-0.7.0/lambda_erp/assets/reservation.py +328 -0
  9. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/lambda_erp/database.py +64 -0
  10. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/pyproject.toml +1 -1
  11. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/.gitignore +0 -0
  12. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/LICENSE +0 -0
  13. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/api/__init__.py +0 -0
  14. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/api/attachments.py +0 -0
  15. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/api/auth.py +0 -0
  16. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/api/bootstrap.py +0 -0
  17. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/api/demo_limits.py +0 -0
  18. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/api/deps.py +0 -0
  19. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/api/errors.py +0 -0
  20. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/api/main.py +0 -0
  21. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/api/oauth.py +0 -0
  22. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/api/pdf.py +0 -0
  23. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/api/providers.py +0 -0
  24. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/api/remarks_md.py +0 -0
  25. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/api/routers/__init__.py +0 -0
  26. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/api/routers/accounting.py +0 -0
  27. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/api/routers/admin.py +0 -0
  28. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/api/routers/analytics.py +0 -0
  29. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/api/routers/bank_reconciliation.py +0 -0
  30. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/api/routers/chat_api.py +0 -0
  31. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/api/routers/documents.py +0 -0
  32. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/api/routers/mcp.py +0 -0
  33. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/api/routers/proposals.py +0 -0
  34. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/api/routers/reports.py +0 -0
  35. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/api/routers/setup.py +0 -0
  36. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/api/templates/document.html +0 -0
  37. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/api/templates/proposal.html +0 -0
  38. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/docs/agents/README.md +0 -0
  39. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/frontend/LICENSE +0 -0
  40. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/frontend/README.md +0 -0
  41. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/frontend/src/api/client.ts +0 -0
  42. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/lambda_erp/__init__.py +0 -0
  43. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/lambda_erp/accounting/__init__.py +0 -0
  44. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/lambda_erp/accounting/bank_transaction.py +0 -0
  45. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/lambda_erp/accounting/budget.py +0 -0
  46. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/lambda_erp/accounting/chart_of_accounts.py +0 -0
  47. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/lambda_erp/accounting/general_ledger.py +0 -0
  48. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/lambda_erp/accounting/journal_entry.py +0 -0
  49. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/lambda_erp/accounting/payment_entry.py +0 -0
  50. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/lambda_erp/accounting/pos_invoice.py +0 -0
  51. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/lambda_erp/accounting/purchase_invoice.py +0 -0
  52. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/lambda_erp/accounting/revaluation.py +0 -0
  53. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/lambda_erp/accounting/sales_invoice.py +0 -0
  54. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/lambda_erp/accounting/setup/__init__.py +0 -0
  55. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/lambda_erp/accounting/setup/engine.py +0 -0
  56. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/lambda_erp/accounting/setup/pack.py +0 -0
  57. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
  58. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/lambda_erp/accounting/setup/packs/ch.py +0 -0
  59. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
  60. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
  61. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
  62. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/lambda_erp/accounting/setup/packs/generic.py +0 -0
  63. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/lambda_erp/accounting/setup/profiles.py +0 -0
  64. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/lambda_erp/accounting/setup/spine.py +0 -0
  65. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/lambda_erp/accounting/subscription.py +0 -0
  66. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/lambda_erp/buying/__init__.py +0 -0
  67. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/lambda_erp/buying/purchase_order.py +0 -0
  68. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/lambda_erp/controllers/__init__.py +0 -0
  69. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/lambda_erp/controllers/currency.py +0 -0
  70. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/lambda_erp/controllers/defaults.py +0 -0
  71. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/lambda_erp/controllers/pricing_rule.py +0 -0
  72. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/lambda_erp/controllers/taxes_and_totals.py +0 -0
  73. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/lambda_erp/exceptions.py +0 -0
  74. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/lambda_erp/hooks.py +0 -0
  75. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/lambda_erp/model.py +0 -0
  76. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/lambda_erp/selling/__init__.py +0 -0
  77. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/lambda_erp/selling/proposal.py +0 -0
  78. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/lambda_erp/selling/quotation.py +0 -0
  79. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/lambda_erp/selling/sales_order.py +0 -0
  80. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/lambda_erp/simulation.py +0 -0
  81. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/lambda_erp/stock/__init__.py +0 -0
  82. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/lambda_erp/stock/delivery_note.py +0 -0
  83. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/lambda_erp/stock/purchase_receipt.py +0 -0
  84. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/lambda_erp/stock/stock_entry.py +0 -0
  85. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/lambda_erp/stock/stock_ledger.py +0 -0
  86. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/lambda_erp/utils.py +0 -0
  87. {lambda_erp-0.6.24 → lambda_erp-0.7.0}/terraform/README.md +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: lambda-erp
3
- Version: 0.6.24
3
+ Version: 0.7.0
4
4
  Summary: Core ERP logic - accounting, sales, purchasing, inventory
5
5
  Author: TORUS INVESTMENTS AG
6
6
  License-Expression: Apache-2.0
@@ -158,6 +158,7 @@ The result is booked in one step — chart of accounts, sensible default account
158
158
  - Moving-average stock ledger with negative-stock protection
159
159
  - Double-entry General Ledger with cancellation reversal
160
160
  - Preset reports: Trial Balance, Profit & Loss, Balance Sheet, General Ledger, AR/AP Aging, Stock Balance
161
+ - **Assets & reservations** — for equipment you own and hire out rather than sell: an `Asset` is one physical unit of an Item (the Item stays the *type* and carries the pricing), and a `Reservation` books a half-open date window against either a specific unit or `qty` units of an item+warehouse pool, refusing double-bookings and answering "what's free between the 14th and the 19th". Tracking is opt-in per Item (`is_asset_tracked`, off by default), and neither posts to the GL or the stock ledger — an owned machine going out on hire is not a sale of inventory. See [`docs/adr-0002-asset-and-reservation.md`](docs/adr-0002-asset-and-reservation.md).
161
162
  - Custom analytics drafts via chat (persisted, shareable, editable)
162
163
  - Server-side aggregation tool for in-chat factual answers across large datasets
163
164
  - PDF / image attachment → add invoices, create quotations, etc. all directly by adding them in the chat
@@ -170,7 +171,8 @@ The result is booked in one step — chart of accounts, sensible default account
170
171
 
171
172
  - Multi-currency beyond the simplified current handling
172
173
  - Workflows / approval chains
173
- - Serial & batch tracking
174
+ - Serial & batch tracking for *sellable stock* (unit identity for owned equipment
175
+ is covered by Assets — see below)
174
176
  - Manufacturing (BOM, work orders)
175
177
  - HR / Payroll beyond the journal-entry workaround
176
178
  - Regional compliance packs (GST, VAT returns, etc.) - see below
@@ -130,6 +130,7 @@ The result is booked in one step — chart of accounts, sensible default account
130
130
  - Moving-average stock ledger with negative-stock protection
131
131
  - Double-entry General Ledger with cancellation reversal
132
132
  - Preset reports: Trial Balance, Profit & Loss, Balance Sheet, General Ledger, AR/AP Aging, Stock Balance
133
+ - **Assets & reservations** — for equipment you own and hire out rather than sell: an `Asset` is one physical unit of an Item (the Item stays the *type* and carries the pricing), and a `Reservation` books a half-open date window against either a specific unit or `qty` units of an item+warehouse pool, refusing double-bookings and answering "what's free between the 14th and the 19th". Tracking is opt-in per Item (`is_asset_tracked`, off by default), and neither posts to the GL or the stock ledger — an owned machine going out on hire is not a sale of inventory. See [`docs/adr-0002-asset-and-reservation.md`](docs/adr-0002-asset-and-reservation.md).
133
134
  - Custom analytics drafts via chat (persisted, shareable, editable)
134
135
  - Server-side aggregation tool for in-chat factual answers across large datasets
135
136
  - PDF / image attachment → add invoices, create quotations, etc. all directly by adding them in the chat
@@ -142,7 +143,8 @@ The result is booked in one step — chart of accounts, sensible default account
142
143
 
143
144
  - Multi-currency beyond the simplified current handling
144
145
  - Workflows / approval chains
145
- - Serial & batch tracking
146
+ - Serial & batch tracking for *sellable stock* (unit identity for owned equipment
147
+ is covered by Assets — see below)
146
148
  - Manufacturing (BOM, work orders)
147
149
  - HR / Payroll beyond the journal-entry workaround
148
150
  - Regional compliance packs (GST, VAT returns, etc.) - see below
@@ -449,6 +449,10 @@ DOCUMENT_SLUGS = [
449
449
  "payment-entry", "journal-entry", "stock-entry",
450
450
  "delivery-note", "purchase-receipt", "pos-invoice",
451
451
  "pricing-rule", "budget", "subscription", "bank-transaction",
452
+ # Owned equipment and its hire calendar. Core doctypes, so they belong in
453
+ # this list rather than surfacing as "deployment-specific extensions"
454
+ # (_extra_document_slugs is defined as everything NOT named here).
455
+ "asset", "reservation",
452
456
  ]
453
457
 
454
458
  MASTER_TYPES = ["customer", "supplier", "item", "warehouse", "account", "company", "cost-center"]
@@ -2135,6 +2139,7 @@ When a user asks you to do something they don't have permission for, explain wha
2135
2139
  - **Buying:** purchase-order, purchase-invoice
2136
2140
  - **Accounting:** payment-entry, journal-entry, budget, subscription, bank-transaction
2137
2141
  - **Stock:** stock-entry, delivery-note, purchase-receipt
2142
+ - **Assets & hire:** asset, reservation
2138
2143
  - **Settings:** pricing-rule{extension_doctypes_line}{chat_doctype_section}
2139
2144
 
2140
2145
  ## Document Workflow & What Each Document Does
@@ -2239,6 +2244,18 @@ For a complete sales return (financial + stock): create and submit both a Credit
2239
2244
  - **Material Issue:** Removes stock for write-offs or internal consumption. Posts Dr Stock Adjustment / Cr Stock In Hand.
2240
2245
  - **Material Transfer:** Moves stock between warehouses. No GL impact.
2241
2246
 
2247
+ ### Assets & Reservations (equipment you OWN and hire out — not stock you sell)
2248
+ Use these only for items flagged `is_asset_tracked` on the Item master. Neither posts GL or stock entries: an owned machine going out on hire is not a sale of inventory, so the books are untouched until you invoice the hire through the normal selling cycle.
2249
+
2250
+ - **Item vs Asset:** the Item is the *type* ("17t Excavator") and carries the rate and pricing rules. An **Asset** (slug `asset`) is ONE physical unit of it. Three identical machines = one Item + three Assets. Create with `item_code` (required) plus `asset_tag` (plate / serial — optional but must be unique), `warehouse` (its home yard), and optionally `purchase_date`, `purchase_value`, `meter_reading`. `status` is Available / On Hire / Maintenance / Retired — the unit's state *today*, not a date range. Retired units leave the pool.
2251
+ - **Opting in:** creating an Asset for an Item that isn't asset-tracked is refused. Fix it by setting `is_asset_tracked` = 1 on the Item via update_master, then retry. Never suggest the user edit the database.
2252
+ - **Reservation** (slug `reservation`) books a time window. Required: `from_datetime` and `to_datetime` (`YYYY-MM-DD` or `YYYY-MM-DD HH:MM:SS`; a bare date means midnight). Windows are **half-open** — a hire ending on the 19th and the next starting on the 19th do NOT clash. Two ways to book:
2253
+ - **pooled** — set `item_code`, `warehouse` and `qty`: "*a* machine of this type from that yard". Use this at quotation/order time when the specific unit doesn't matter yet.
2254
+ - **unit** — set `asset`: "*that* machine". `item_code`, `warehouse` and `qty` are filled in from the Asset automatically.
2255
+ - `status` is Reserved / Out / Returned / Cancelled. **Reserved and Out block the calendar; Returned and Cancelled do not** — so a hire that comes back early frees its slot the moment you set it to Returned. Link a booking to the order that caused it with `voucher_type` + `voucher_no`.
2256
+ - **Double-booking is refused at save time.** If you get "already committed" (that unit is taken) or "free at" (the yard's pool is exhausted for that window), do NOT retry blindly — report the conflict, then offer the alternatives: different dates, a different yard, or a different model.
2257
+ - To answer "what's free between X and Y", list reservations for the item over the window and compare against the Assets that exist; a unit with no overlapping Reserved/Out row is available.
2258
+
2242
2259
  ### Salary / Payroll (manual flow)
2243
2260
  There is no dedicated payroll module. Handle salary payments with two steps:
2244
2261
  1. **Accrue salary:** Create a Journal Entry — Debit "Salary Expense" (the expense), Credit "Salary Payable" (the liability). Add a remark like "April 2026 salaries".
@@ -78,6 +78,8 @@ DELETE_REFERENCE_CHECKS = {
78
78
  ('SELECT 1 FROM "Bin" WHERE item_code = ? LIMIT 1', "bin"),
79
79
  ('SELECT 1 FROM "Pricing Rule" WHERE item_code = ? LIMIT 1', "pricing rule"),
80
80
  ('SELECT 1 FROM "Subscription Plan" WHERE item_code = ? LIMIT 1', "subscription plan"),
81
+ ('SELECT 1 FROM "Asset" WHERE item_code = ? LIMIT 1', "asset"),
82
+ ('SELECT 1 FROM "Reservation" WHERE item_code = ? LIMIT 1', "reservation"),
81
83
  ],
82
84
  "warehouse": [
83
85
  ('SELECT 1 FROM "Item" WHERE default_warehouse = ? LIMIT 1', "item"),
@@ -94,6 +96,8 @@ DELETE_REFERENCE_CHECKS = {
94
96
  ('SELECT 1 FROM "Stock Entry Detail" WHERE s_warehouse = ? OR t_warehouse = ? LIMIT 1', "stock entry item"),
95
97
  ('SELECT 1 FROM "Stock Ledger Entry" WHERE warehouse = ? LIMIT 1', "stock ledger entry"),
96
98
  ('SELECT 1 FROM "Bin" WHERE warehouse = ? LIMIT 1', "bin"),
99
+ ('SELECT 1 FROM "Asset" WHERE warehouse = ? LIMIT 1', "asset"),
100
+ ('SELECT 1 FROM "Reservation" WHERE warehouse = ? LIMIT 1', "reservation"),
97
101
  ],
98
102
  "account": [
99
103
  ('SELECT 1 FROM "Account" WHERE parent_account = ? LIMIT 1', "child account"),
@@ -22,6 +22,8 @@ from lambda_erp.controllers.pricing_rule import PricingRule
22
22
  from lambda_erp.accounting.budget import Budget
23
23
  from lambda_erp.accounting.subscription import Subscription
24
24
  from lambda_erp.accounting.bank_transaction import BankTransaction
25
+ from lambda_erp.assets.asset import Asset
26
+ from lambda_erp.assets.reservation import Reservation
25
27
 
26
28
 
27
29
  # --- Doctype registries ---
@@ -43,6 +45,11 @@ DOCUMENT_CLASSES = {
43
45
  "Budget": Budget,
44
46
  "Subscription": Subscription,
45
47
  "Bank Transaction": BankTransaction,
48
+ # Neither posts to the GL or the Stock Ledger; both stay at docstatus 0 and
49
+ # carry their meaning in `status`. Registered here so the generic document
50
+ # CRUD, the chat tools and MCP drive them like every other doctype.
51
+ "Asset": Asset,
52
+ "Reservation": Reservation,
46
53
  }
47
54
 
48
55
  CONVERTERS = {
@@ -0,0 +1,33 @@
1
+ """Unit-level asset identity and date-ranged reservations.
2
+
3
+ Deliberately NOT part of `lambda_erp/stock/`. Stock models quantities that are
4
+ consumed — you buy 10, sell 3, and the ledger moves the value out as COGS. An
5
+ Asset is an owned unit that goes out and comes back: it is never consumed, so
6
+ nothing here touches the Stock Ledger or the General Ledger.
7
+
8
+ Two pieces:
9
+
10
+ - `asset.py` — the Asset master: one row per physical unit, pointing at the
11
+ Item that describes its *type* (and carries its pricing).
12
+ - `reservation.py` — the Reservation primitive: a half-open time window that
13
+ commits either a specific Asset or `qty` units from an item+warehouse pool,
14
+ plus the availability engine that answers "what's free between X and Y".
15
+
16
+ See docs/adr-0002-asset-and-reservation.md for why it's shaped this way.
17
+ """
18
+
19
+ from lambda_erp.assets.asset import (
20
+ Asset, ASSET_STATUSES, ASSET_RETIRED, usable_assets,
21
+ )
22
+ from lambda_erp.assets.reservation import (
23
+ Reservation, RESERVATION_STATUSES, BLOCKING_STATUSES,
24
+ available_assets, available_qty, committed_qty, overlapping_reservations,
25
+ pool_capacity, release_reservations,
26
+ )
27
+
28
+ __all__ = [
29
+ "Asset", "ASSET_STATUSES", "ASSET_RETIRED", "usable_assets",
30
+ "Reservation", "RESERVATION_STATUSES", "BLOCKING_STATUSES",
31
+ "available_assets", "available_qty", "committed_qty",
32
+ "overlapping_reservations", "pool_capacity", "release_reservations",
33
+ ]
@@ -0,0 +1,134 @@
1
+ """Asset — one row per physical unit of an asset-tracked Item.
2
+
3
+ The Item stays the *type* ("Kubota U 17 E", with its rate and pricing rules);
4
+ the Asset is the *unit* (the three machines you actually own, each with its own
5
+ plate number, home yard, and hour meter). Pricing, quoting and invoicing keep
6
+ keying off the Item, so nothing about the selling flow changes — only the
7
+ question "which one, and is it free?" becomes answerable.
8
+
9
+ Opt-in: an Item is only asset-tracked when `is_asset_tracked = 1`. The flag
10
+ defaults to 0, so every existing deployment behaves exactly as before after the
11
+ version bump — no receipt suddenly demands unit identities. Creating an Asset
12
+ against an Item that hasn't opted in is refused with a message that says how.
13
+
14
+ Nothing here posts to the GL or the Stock Ledger. An Asset is a record of a
15
+ thing you own, not a valuation entry; see docs/adr-0002-asset-and-reservation.md.
16
+ """
17
+
18
+ from lambda_erp.database import get_db
19
+ from lambda_erp.exceptions import ValidationError
20
+ from lambda_erp.model import Document
21
+
22
+ # Current state of the unit. This is the *now* status, deliberately NOT a
23
+ # window: "is it free on the 14th" is answered by the Reservation calendar, not
24
+ # by this field (see reservation.py). Retired units drop out of the pool.
25
+ ASSET_AVAILABLE = "Available"
26
+ ASSET_ON_HIRE = "On Hire"
27
+ ASSET_MAINTENANCE = "Maintenance"
28
+ ASSET_RETIRED = "Retired"
29
+
30
+ ASSET_STATUSES = [ASSET_AVAILABLE, ASSET_ON_HIRE, ASSET_MAINTENANCE, ASSET_RETIRED]
31
+
32
+
33
+ # The "Asset" table itself is declared with every other core table in
34
+ # lambda_erp/database.py — one source of truth for the schema.
35
+
36
+
37
+ def is_asset_tracked(db, item_code: str) -> bool:
38
+ """True when the Item has opted in to unit-level tracking.
39
+
40
+ Reads through get_value (never a hand-written SELECT of a maybe-missing
41
+ column) so it stays correct on both backends — on SQLite an unknown column
42
+ silently returns its own name as a string rather than raising (see
43
+ docs/agents/gotchas.md)."""
44
+ row = db.get_value("Item", item_code, ["is_asset_tracked"])
45
+ if not row:
46
+ return False
47
+ return bool(row.get("is_asset_tracked"))
48
+
49
+
50
+ class Asset(Document):
51
+ DOCTYPE = "Asset"
52
+ CHILD_TABLES = {}
53
+ PREFIX = "ASSET"
54
+ LINK_FIELDS = {
55
+ "item_code": "Item",
56
+ "warehouse": "Warehouse",
57
+ "company": "Company",
58
+ }
59
+
60
+ def validate(self):
61
+ if not self.item_code:
62
+ raise ValidationError("Item Code is required")
63
+
64
+ db = get_db()
65
+ # _validate_links proves the Item exists (it runs after validate), but
66
+ # the opt-in check needs the row now and must give the better message.
67
+ if not db.exists("Item", self.item_code):
68
+ raise ValidationError(
69
+ f"Asset: Item Code '{self.item_code}' does not exist in Item"
70
+ )
71
+ if not is_asset_tracked(db, self.item_code):
72
+ raise ValidationError(
73
+ f"Item '{self.item_code}' is not asset-tracked. Set "
74
+ f"is_asset_tracked = 1 on the Item before creating Assets for it."
75
+ )
76
+
77
+ status = self._data.get("status") or ASSET_AVAILABLE
78
+ if status not in ASSET_STATUSES:
79
+ raise ValidationError(
80
+ f"Invalid asset status '{status}' — must be one of "
81
+ f"{', '.join(ASSET_STATUSES)}"
82
+ )
83
+ self._data["status"] = status
84
+
85
+ # An asset tag is the plate / manufacturer serial. Optional, but when
86
+ # given it identifies exactly one unit — a duplicate means someone
87
+ # entered the same machine twice, which would double the pool capacity.
88
+ tag = (self._data.get("asset_tag") or "").strip()
89
+ if tag:
90
+ self._data["asset_tag"] = tag
91
+ clash = db.sql(
92
+ 'SELECT name FROM "Asset" WHERE asset_tag = ? AND name != ? '
93
+ "AND COALESCE(discarded, 0) = 0 LIMIT 1",
94
+ [tag, self.name],
95
+ )
96
+ if clash:
97
+ raise ValidationError(
98
+ f"Asset tag '{tag}' is already used by {clash[0]['name']}"
99
+ )
100
+
101
+ # Default the display name so lists and link fields read sensibly
102
+ # without forcing the caller to invent one.
103
+ if not self._data.get("asset_name"):
104
+ self._data["asset_name"] = f"{self.item_code} {tag}".strip()
105
+
106
+ # Fall back to the item's default warehouse so a single-yard deployment
107
+ # never has to think about locations.
108
+ if not self._data.get("warehouse"):
109
+ item = db.get_value("Item", self.item_code, ["default_warehouse"])
110
+ if item and item.get("default_warehouse"):
111
+ self._data["warehouse"] = item["default_warehouse"]
112
+
113
+
114
+ def usable_assets(db, item_code: str, warehouse: str | None = None) -> list:
115
+ """Assets of an item that can still be hired out — i.e. the pool.
116
+
117
+ Retired, disabled and discarded units are excluded. `Maintenance` is NOT
118
+ excluded: it describes the unit *today*, and this function answers a
119
+ question about a future window. Blocking a machine for a service slot is a
120
+ Reservation with no party, so that one calendar governs every window.
121
+ """
122
+ # COALESCE rather than a `{"disabled": 0}` filter: `disabled = 0` does not
123
+ # match NULL, and a row written by a plugin or a raw INSERT may leave the
124
+ # flag unset. Portable across SQLite and Postgres.
125
+ sql = (
126
+ 'SELECT name, asset_name, asset_tag, warehouse, status FROM "Asset" '
127
+ "WHERE item_code = ? AND COALESCE(disabled, 0) = 0 "
128
+ "AND COALESCE(discarded, 0) = 0 AND COALESCE(status, '') != ?"
129
+ )
130
+ params = [item_code, ASSET_RETIRED]
131
+ if warehouse:
132
+ sql += " AND warehouse = ?"
133
+ params.append(warehouse)
134
+ return db.sql(sql + " ORDER BY name", params)
@@ -0,0 +1,328 @@
1
+ """Reservation — a date-ranged commitment against an Asset or an item pool.
2
+
3
+ This is the primitive the rest of the system was missing. `Bin.reserved_qty`
4
+ already tracks *how many* units a Sales Order has spoken for, but it carries no
5
+ dates, so it can never answer "is a machine free from the 14th to the 19th".
6
+ Reservation is that answer, and it deliberately sits beside Bin rather than
7
+ replacing it: Bin stays the Sales Order's stock semantic, this is the calendar.
8
+
9
+ Two levels, because that's how hire actually works:
10
+
11
+ - **pooled** — `item_code` + `warehouse` + `qty`: "*a* 1.7 t excavator out of
12
+ St. Gallen, 14.–19.". What you commit at quote and order time, when nobody
13
+ cares which unit.
14
+ - **unit** — `asset` set: "*that* machine". Assigned at dispatch, or earlier
15
+ when the customer insists on a specific one.
16
+
17
+ A unit reservation also consumes one slot of its pool, so the two levels stay
18
+ consistent: booking three machines pooled and pinning a fourth is four of your
19
+ capacity, not one plus three.
20
+
21
+ Windows are **half-open**: `[from, to)`. A hire ending 09:00 and the next
22
+ starting 09:00 do not collide, which is the behaviour a dispatcher expects.
23
+
24
+ Nothing here posts to the GL or the Stock Ledger. A commitment is not a
25
+ transaction — the invoice that eventually bills the hire is, and that flows
26
+ through the ordinary selling cycle untouched.
27
+ """
28
+
29
+ import datetime
30
+
31
+ from lambda_erp.assets.asset import ASSET_RETIRED, is_asset_tracked, usable_assets
32
+ from lambda_erp.database import get_db
33
+ from lambda_erp.exceptions import ValidationError
34
+ from lambda_erp.model import Document
35
+ from lambda_erp.utils import flt
36
+
37
+ RESERVED = "Reserved"
38
+ OUT = "Out"
39
+ RETURNED = "Returned"
40
+ CANCELLED = "Cancelled"
41
+
42
+ RESERVATION_STATUSES = [RESERVED, OUT, RETURNED, CANCELLED]
43
+
44
+ # Only these hold the calendar. Returned/Cancelled rows stay for the audit
45
+ # trail but stop blocking, so a released window is immediately re-bookable.
46
+ BLOCKING_STATUSES = (RESERVED, OUT)
47
+
48
+ _DT_FORMAT = "%Y-%m-%d %H:%M:%S"
49
+
50
+
51
+ # The "Reservation" table is declared with every other core table in
52
+ # lambda_erp/database.py — one source of truth for the schema.
53
+
54
+
55
+ def normalize_datetime(value, field="datetime") -> str:
56
+ """Normalise to a fixed-width `YYYY-MM-DD HH:MM:SS` string.
57
+
58
+ Every stored bound goes through this, which is what lets the overlap test
59
+ be a plain string comparison — fixed-width ISO sorts lexicographically, and
60
+ TEXT comparison behaves identically on SQLite and Postgres. A bare date
61
+ means midnight, so `2026-08-14` and `2026-08-14 00:00:00` are one instant
62
+ rather than two incomparable strings.
63
+
64
+ Any timezone suffix is dropped: a hire window is wall-clock time at the
65
+ yard, not an instant on a global timeline.
66
+ """
67
+ if value is None or value == "":
68
+ raise ValidationError(f"{field} is required")
69
+ if isinstance(value, datetime.datetime):
70
+ return value.strftime(_DT_FORMAT)
71
+ if isinstance(value, datetime.date):
72
+ return datetime.datetime(value.year, value.month, value.day).strftime(_DT_FORMAT)
73
+
74
+ text = str(value).strip().replace("T", " ")
75
+ if text.endswith("Z"):
76
+ text = text[:-1].strip()
77
+ # Strip a trailing +02:00 / -05:00 offset without touching the date's own
78
+ # hyphens (offsets only ever appear after the time part).
79
+ for sep in ("+", "-"):
80
+ idx = text.find(sep, 11)
81
+ if idx > 0:
82
+ text = text[:idx].strip()
83
+ break
84
+ try:
85
+ parsed = datetime.datetime.fromisoformat(text)
86
+ except ValueError:
87
+ raise ValidationError(
88
+ f"Invalid {field} '{value}' — expected YYYY-MM-DD or "
89
+ f"YYYY-MM-DD HH:MM:SS"
90
+ )
91
+ return parsed.strftime(_DT_FORMAT)
92
+
93
+
94
+ class Reservation(Document):
95
+ DOCTYPE = "Reservation"
96
+ CHILD_TABLES = {}
97
+ PREFIX = "RES"
98
+ LINK_FIELDS = {
99
+ "item_code": "Item",
100
+ "warehouse": "Warehouse",
101
+ "asset": "Asset",
102
+ "company": "Company",
103
+ }
104
+ DYNAMIC_LINK_FIELDS = {
105
+ "party": ("party_type", {"Customer": "Customer", "Supplier": "Supplier"}),
106
+ }
107
+
108
+ def validate(self):
109
+ db = get_db()
110
+
111
+ status = self._data.get("status") or RESERVED
112
+ if status not in RESERVATION_STATUSES:
113
+ raise ValidationError(
114
+ f"Invalid reservation status '{status}' — must be one of "
115
+ f"{', '.join(RESERVATION_STATUSES)}"
116
+ )
117
+ self._data["status"] = status
118
+
119
+ from_dt = normalize_datetime(self._data.get("from_datetime"), "From Datetime")
120
+ to_dt = normalize_datetime(self._data.get("to_datetime"), "To Datetime")
121
+ if from_dt >= to_dt:
122
+ raise ValidationError(
123
+ f"To Datetime ({to_dt}) must be after From Datetime ({from_dt})"
124
+ )
125
+ self._data["from_datetime"] = from_dt
126
+ self._data["to_datetime"] = to_dt
127
+
128
+ asset = self._data.get("asset")
129
+ if asset:
130
+ self._resolve_from_asset(db, asset)
131
+ elif not self._data.get("item_code"):
132
+ raise ValidationError("Item Code is required (or set an Asset)")
133
+
134
+ item_code = self._data["item_code"]
135
+ if not db.exists("Item", item_code):
136
+ raise ValidationError(
137
+ f"Reservation: Item Code '{item_code}' does not exist in Item"
138
+ )
139
+ # Capacity is counted from Asset rows, so an item with no units has no
140
+ # pool to reserve against. Pooled reservation of ordinary (untracked)
141
+ # stock is a different feature — see the ADR's scope note.
142
+ if not is_asset_tracked(db, item_code):
143
+ raise ValidationError(
144
+ f"Item '{item_code}' is not asset-tracked. Set is_asset_tracked = 1 "
145
+ f"on the Item before reserving it."
146
+ )
147
+
148
+ if not self._data.get("warehouse"):
149
+ item = db.get_value("Item", item_code, ["default_warehouse"])
150
+ if item and item.get("default_warehouse"):
151
+ self._data["warehouse"] = item["default_warehouse"]
152
+ if not self._data.get("warehouse"):
153
+ raise ValidationError(
154
+ "Warehouse is required — set it on the Reservation, on the Asset, "
155
+ "or as the Item's default warehouse"
156
+ )
157
+
158
+ qty = flt(self._data.get("qty") or (1 if asset else 0))
159
+ if asset:
160
+ # A pinned unit is exactly one machine; anything else would
161
+ # double-count it against the pool.
162
+ qty = 1
163
+ elif qty <= 0:
164
+ raise ValidationError("Qty must be greater than zero")
165
+ self._data["qty"] = qty
166
+
167
+ if status in BLOCKING_STATUSES:
168
+ self._check_availability(db, from_dt, to_dt)
169
+
170
+ def _resolve_from_asset(self, db, asset: str) -> None:
171
+ """Pin the reservation to one unit, inheriting its item and location."""
172
+ row = db.get_value(
173
+ "Asset", asset, ["item_code", "warehouse", "status", "disabled"]
174
+ )
175
+ if not row:
176
+ raise ValidationError(
177
+ f"Reservation: Asset '{asset}' does not exist in Asset"
178
+ )
179
+ if row.get("status") == ASSET_RETIRED or row.get("disabled"):
180
+ raise ValidationError(
181
+ f"Asset {asset} is retired or disabled and cannot be reserved"
182
+ )
183
+ declared = self._data.get("item_code")
184
+ if declared and declared != row.get("item_code"):
185
+ raise ValidationError(
186
+ f"Asset {asset} is a '{row.get('item_code')}', not a '{declared}'"
187
+ )
188
+ self._data["item_code"] = row.get("item_code")
189
+ if not self._data.get("warehouse") and row.get("warehouse"):
190
+ self._data["warehouse"] = row["warehouse"]
191
+
192
+ def _check_availability(self, db, from_dt: str, to_dt: str) -> None:
193
+ """Refuse a double-booking, at whichever level this reservation binds."""
194
+ asset = self._data.get("asset")
195
+ item_code = self._data["item_code"]
196
+ warehouse = self._data["warehouse"]
197
+
198
+ if asset:
199
+ clash = overlapping_reservations(
200
+ db, asset=asset, from_dt=from_dt, to_dt=to_dt, exclude=self.name
201
+ )
202
+ if clash:
203
+ first = clash[0]
204
+ raise ValidationError(
205
+ f"Asset {asset} is already committed from "
206
+ f"{first['from_datetime']} to {first['to_datetime']} "
207
+ f"by {first['name']}"
208
+ )
209
+
210
+ capacity = pool_capacity(db, item_code, warehouse)
211
+ if capacity <= 0:
212
+ raise ValidationError(
213
+ f"No assets available for '{item_code}' at {warehouse} — "
214
+ f"create Asset records for its units first"
215
+ )
216
+ committed = committed_qty(
217
+ db, item_code, warehouse, from_dt, to_dt, exclude=self.name
218
+ )
219
+ wanted = flt(self._data.get("qty") or 1)
220
+ if committed + wanted > capacity:
221
+ raise ValidationError(
222
+ f"Only {max(0.0, capacity - committed):g} of {capacity:g} "
223
+ f"'{item_code}' free at {warehouse} between {from_dt} and "
224
+ f"{to_dt} — {wanted:g} requested"
225
+ )
226
+
227
+
228
+ # --- Availability engine -------------------------------------------------
229
+ # The whole overlap rule, in one place: two half-open windows collide when
230
+ # `existing.from < new.to AND existing.to > new.from`. Touching ends don't.
231
+
232
+
233
+ def overlapping_reservations(db, *, from_dt, to_dt, item_code=None,
234
+ warehouse=None, asset=None, exclude=None,
235
+ statuses=BLOCKING_STATUSES) -> list:
236
+ """Blocking reservations that overlap the window, narrowed by whichever of
237
+ asset / item+warehouse was given. `exclude` skips a row by name so editing
238
+ a reservation never collides with itself."""
239
+ from_dt = normalize_datetime(from_dt, "From Datetime")
240
+ to_dt = normalize_datetime(to_dt, "To Datetime")
241
+
242
+ placeholders = ", ".join(["?"] * len(statuses))
243
+ sql = (
244
+ 'SELECT name, item_code, warehouse, asset, qty, from_datetime, '
245
+ 'to_datetime, status, voucher_type, voucher_no FROM "Reservation" '
246
+ f"WHERE status IN ({placeholders}) AND COALESCE(discarded, 0) = 0 "
247
+ "AND from_datetime < ? AND to_datetime > ?"
248
+ )
249
+ params = list(statuses) + [to_dt, from_dt]
250
+
251
+ if asset:
252
+ sql += " AND asset = ?"
253
+ params.append(asset)
254
+ if item_code:
255
+ sql += " AND item_code = ?"
256
+ params.append(item_code)
257
+ if warehouse:
258
+ sql += " AND warehouse = ?"
259
+ params.append(warehouse)
260
+ if exclude:
261
+ sql += " AND name != ?"
262
+ params.append(exclude)
263
+
264
+ return db.sql(sql + " ORDER BY from_datetime", params)
265
+
266
+
267
+ def pool_capacity(db, item_code: str, warehouse: str | None = None) -> float:
268
+ """How many units of an item could be hired out of a location at all."""
269
+ return float(len(usable_assets(db, item_code, warehouse)))
270
+
271
+
272
+ def committed_qty(db, item_code: str, warehouse: str | None, from_dt, to_dt,
273
+ exclude=None) -> float:
274
+ """Units already spoken for in the window — pooled and pinned together."""
275
+ rows = overlapping_reservations(
276
+ db, item_code=item_code, warehouse=warehouse,
277
+ from_dt=from_dt, to_dt=to_dt, exclude=exclude,
278
+ )
279
+ return sum(flt(r.get("qty") or 1) for r in rows)
280
+
281
+
282
+ def available_qty(db, item_code: str, warehouse: str | None, from_dt, to_dt,
283
+ exclude=None) -> float:
284
+ """Free units of an item at a location across the whole window."""
285
+ capacity = pool_capacity(db, item_code, warehouse)
286
+ committed = committed_qty(db, item_code, warehouse, from_dt, to_dt, exclude)
287
+ return max(0.0, capacity - committed)
288
+
289
+
290
+ def available_assets(db, item_code: str, warehouse: str | None, from_dt, to_dt,
291
+ exclude=None) -> list:
292
+ """The specific units free for the whole window — what dispatch picks from.
293
+
294
+ Pooled reservations reduce the *count* available without naming a unit, so
295
+ this deliberately reports units with no overlapping commitment of their
296
+ own; callers assigning a machine should also respect `available_qty`.
297
+ """
298
+ pinned = {
299
+ r["asset"]
300
+ for r in overlapping_reservations(
301
+ db, item_code=item_code, warehouse=warehouse,
302
+ from_dt=from_dt, to_dt=to_dt, exclude=exclude,
303
+ )
304
+ if r.get("asset")
305
+ }
306
+ return [a for a in usable_assets(db, item_code, warehouse) if a["name"] not in pinned]
307
+
308
+
309
+ def release_reservations(db, voucher_type: str, voucher_no: str,
310
+ status: str = CANCELLED) -> int:
311
+ """Release every blocking reservation held by a document, freeing the window.
312
+
313
+ The hook a cancelled order or a completed return calls. Writes the status
314
+ directly rather than round-tripping save(): re-validating would re-run the
315
+ availability check against the row's own window, and releasing a booking
316
+ should never be refusable.
317
+ """
318
+ if status not in RESERVATION_STATUSES:
319
+ raise ValidationError(f"Invalid reservation status '{status}'")
320
+ placeholders = ", ".join(["?"] * len(BLOCKING_STATUSES))
321
+ rows = db.sql(
322
+ f'SELECT name FROM "Reservation" WHERE voucher_type = ? AND voucher_no = ? '
323
+ f"AND status IN ({placeholders})",
324
+ [voucher_type, voucher_no] + list(BLOCKING_STATUSES),
325
+ )
326
+ for row in rows:
327
+ db.set_value("Reservation", row["name"], {"status": status})
328
+ return len(rows)
@@ -397,6 +397,7 @@ class Database:
397
397
  stock_uom TEXT DEFAULT 'Nos',
398
398
  is_stock_item INTEGER DEFAULT 1,
399
399
  is_fixed_asset INTEGER DEFAULT 0,
400
+ is_asset_tracked INTEGER DEFAULT 0,
400
401
  valuation_method TEXT DEFAULT 'FIFO',
401
402
  default_warehouse TEXT,
402
403
  standard_rate REAL DEFAULT 0,
@@ -1396,6 +1397,60 @@ class Database:
1396
1397
  creation TEXT,
1397
1398
  modified TEXT
1398
1399
  )""",
1400
+
1401
+ # --- Unit-level asset identity + reservations ---
1402
+ # An Asset is one physical unit of an asset-tracked Item (the Item
1403
+ # stays the *type* and carries the pricing). Owned, not consumed:
1404
+ # nothing in lambda_erp/assets/ touches the Stock Ledger or the GL,
1405
+ # so these tables cannot affect an existing deployment's books.
1406
+ # Tracking is opt-in per Item via `is_asset_tracked` (migration 21),
1407
+ # which defaults to 0. See docs/adr-0002-asset-and-reservation.md.
1408
+ """CREATE TABLE IF NOT EXISTS "Asset" (
1409
+ name TEXT PRIMARY KEY,
1410
+ asset_name TEXT,
1411
+ item_code TEXT,
1412
+ asset_tag TEXT,
1413
+ warehouse TEXT,
1414
+ company TEXT,
1415
+ status TEXT DEFAULT 'Available',
1416
+ purchase_date TEXT,
1417
+ purchase_value REAL DEFAULT 0,
1418
+ meter_reading REAL DEFAULT 0,
1419
+ meter_uom TEXT,
1420
+ notes TEXT,
1421
+ disabled INTEGER DEFAULT 0,
1422
+ discarded INTEGER DEFAULT 0,
1423
+ docstatus INTEGER DEFAULT 0,
1424
+ creation TEXT,
1425
+ modified TEXT
1426
+ )""",
1427
+
1428
+ # A half-open [from, to) commitment against either one Asset
1429
+ # (`asset` set) or `qty` units of an item+warehouse pool. Bounds are
1430
+ # normalised to fixed-width 'YYYY-MM-DD HH:MM:SS', which is what
1431
+ # lets the overlap test be a plain TEXT comparison on both backends.
1432
+ """CREATE TABLE IF NOT EXISTS "Reservation" (
1433
+ name TEXT PRIMARY KEY,
1434
+ item_code TEXT,
1435
+ warehouse TEXT,
1436
+ asset TEXT,
1437
+ qty REAL DEFAULT 1,
1438
+ from_datetime TEXT,
1439
+ to_datetime TEXT,
1440
+ status TEXT DEFAULT 'Reserved',
1441
+ party_type TEXT,
1442
+ party TEXT,
1443
+ voucher_type TEXT,
1444
+ voucher_no TEXT,
1445
+ voucher_detail_no TEXT,
1446
+ purpose TEXT,
1447
+ notes TEXT,
1448
+ company TEXT,
1449
+ discarded INTEGER DEFAULT 0,
1450
+ docstatus INTEGER DEFAULT 0,
1451
+ creation TEXT,
1452
+ modified TEXT
1453
+ )""",
1399
1454
  ]
1400
1455
 
1401
1456
  for stmt in stmts:
@@ -2062,6 +2117,14 @@ def _m020_chat_attachment_openai_file(db: "Database") -> None:
2062
2117
  db._add_column_if_missing("Chat Attachment", "openai_file_expires_at", "INTEGER")
2063
2118
 
2064
2119
 
2120
+ def _m021_item_is_asset_tracked(db: "Database") -> None:
2121
+ """Opt an Item in to unit-level Asset tracking. Defaults to 0, so every
2122
+ existing item in every existing deployment keeps behaving exactly as
2123
+ before — no stock movement starts demanding unit identities. Only items a
2124
+ user explicitly flags get Assets and reservations."""
2125
+ db._add_column_if_missing("Item", "is_asset_tracked", "INTEGER DEFAULT 0")
2126
+
2127
+
2065
2128
  Database.MIGRATIONS = [
2066
2129
  (1, "chat_message_session_id", _m001_chat_message_session_id),
2067
2130
  (2, "chat_session_user_id", _m002_chat_session_user_id),
@@ -2083,6 +2146,7 @@ Database.MIGRATIONS = [
2083
2146
  (18, "quotation_item_frequency", _m018_quotation_item_frequency),
2084
2147
  (19, "api_keys_per_user", _m019_api_keys_per_user),
2085
2148
  (20, "chat_attachment_openai_file", _m020_chat_attachment_openai_file),
2149
+ (21, "item_is_asset_tracked", _m021_item_is_asset_tracked),
2086
2150
  ]
2087
2151
 
2088
2152
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lambda-erp"
3
- version = "0.6.24"
3
+ version = "0.7.0"
4
4
  description = "Core ERP logic - accounting, sales, purchasing, inventory"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
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