lambda-erp 0.6.25__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.
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/PKG-INFO +5 -3
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/README.md +3 -1
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/api/chat.py +17 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/api/routers/masters.py +4 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/api/services.py +7 -0
- lambda_erp-0.7.0/lambda_erp/assets/__init__.py +33 -0
- lambda_erp-0.7.0/lambda_erp/assets/asset.py +134 -0
- lambda_erp-0.7.0/lambda_erp/assets/reservation.py +328 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/lambda_erp/database.py +64 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/pyproject.toml +1 -1
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/.gitignore +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/LICENSE +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/api/__init__.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/api/attachments.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/api/auth.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/api/bootstrap.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/api/demo_limits.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/api/deps.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/api/errors.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/api/main.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/api/oauth.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/api/pdf.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/api/providers.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/api/remarks_md.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/api/routers/__init__.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/api/routers/accounting.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/api/routers/admin.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/api/routers/analytics.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/api/routers/bank_reconciliation.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/api/routers/chat_api.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/api/routers/documents.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/api/routers/mcp.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/api/routers/proposals.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/api/routers/reports.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/api/routers/setup.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/api/templates/document.html +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/api/templates/proposal.html +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/docs/agents/README.md +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/frontend/LICENSE +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/frontend/README.md +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/frontend/src/api/client.ts +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/lambda_erp/__init__.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/lambda_erp/accounting/__init__.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/lambda_erp/accounting/bank_transaction.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/lambda_erp/accounting/budget.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/lambda_erp/accounting/chart_of_accounts.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/lambda_erp/accounting/general_ledger.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/lambda_erp/accounting/journal_entry.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/lambda_erp/accounting/payment_entry.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/lambda_erp/accounting/pos_invoice.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/lambda_erp/accounting/purchase_invoice.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/lambda_erp/accounting/revaluation.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/lambda_erp/accounting/sales_invoice.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/lambda_erp/accounting/setup/__init__.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/lambda_erp/accounting/setup/engine.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/lambda_erp/accounting/setup/pack.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/lambda_erp/accounting/setup/packs/ch.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/lambda_erp/accounting/setup/packs/generic.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/lambda_erp/accounting/setup/profiles.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/lambda_erp/accounting/setup/spine.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/lambda_erp/accounting/subscription.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/lambda_erp/buying/__init__.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/lambda_erp/buying/purchase_order.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/lambda_erp/controllers/__init__.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/lambda_erp/controllers/currency.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/lambda_erp/controllers/defaults.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/lambda_erp/controllers/pricing_rule.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/lambda_erp/controllers/taxes_and_totals.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/lambda_erp/exceptions.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/lambda_erp/hooks.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/lambda_erp/model.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/lambda_erp/selling/__init__.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/lambda_erp/selling/proposal.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/lambda_erp/selling/quotation.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/lambda_erp/selling/sales_order.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/lambda_erp/simulation.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/lambda_erp/stock/__init__.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/lambda_erp/stock/delivery_note.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/lambda_erp/stock/purchase_receipt.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/lambda_erp/stock/stock_entry.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/lambda_erp/stock/stock_ledger.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/lambda_erp/utils.py +0 -0
- {lambda_erp-0.6.25 → lambda_erp-0.7.0}/terraform/README.md +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: lambda-erp
|
|
3
|
-
Version: 0.
|
|
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
|
|
|
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
|
|
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
|