lambda-erp 1.1.7__tar.gz → 1.1.9__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-1.1.7 → lambda_erp-1.1.9}/PKG-INFO +2 -2
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/README.md +1 -1
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/api/chat.py +31 -19
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/api/erp_context.py +11 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/api/pdf.py +7 -3
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/api/pdf_contract.py +29 -3
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/api/templates/_record_base.html +1 -1
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/api/templates/document.html +2 -1
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/api/templates/proposal.html +3 -2
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/pyproject.toml +1 -1
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/.gitignore +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/LICENSE +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/api/__init__.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/api/attachments.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/api/auth.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/api/bootstrap.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/api/chat_links.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/api/demo_limits.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/api/deps.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/api/errors.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/api/list_values.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/api/main.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/api/mcp_oauth.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/api/oauth.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/api/pdf_exports.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/api/pdf_profiles.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/api/providers.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/api/remarks_md.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/api/routers/__init__.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/api/routers/accounting.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/api/routers/actions.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/api/routers/admin.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/api/routers/analytics.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/api/routers/availability.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/api/routers/bank_reconciliation.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/api/routers/bank_statements.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/api/routers/chat_api.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/api/routers/documents.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/api/routers/masters.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/api/routers/mcp.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/api/routers/proposals.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/api/routers/reports.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/api/routers/setup.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/api/services.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/api/templates/_proposal_totals.html +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/api/templates/record.html +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/api/time_filters.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/api/tool_permissions.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/docs/agents/README.md +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/frontend/LICENSE +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/frontend/README.md +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/frontend/src/api/client.ts +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/__init__.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/accounting/__init__.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/accounting/bank_account.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/accounting/bank_reconciliation.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/accounting/bank_statement_import.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/accounting/bank_transaction.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/accounting/budget.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/accounting/camt.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/accounting/chart_of_accounts.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/accounting/general_ledger.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/accounting/journal_entry.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/accounting/payment_entry.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/accounting/pos_invoice.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/accounting/purchase_invoice.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/accounting/revaluation.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/accounting/sales_invoice.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/accounting/settlement.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/accounting/setup/__init__.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/accounting/setup/engine.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/accounting/setup/pack.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/accounting/setup/packs/ch.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/accounting/setup/packs/generic.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/accounting/setup/profiles.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/accounting/setup/spine.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/accounting/subscription.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/assets/__init__.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/assets/asset.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/assets/lifecycle.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/assets/reservation.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/buying/__init__.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/buying/purchase_order.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/controllers/__init__.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/controllers/currency.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/controllers/defaults.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/controllers/item_price.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/controllers/item_prices.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/controllers/pricing_rule.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/controllers/taxes_and_totals.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/database.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/exceptions.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/hooks.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/master_integrity.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/model.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/selling/__init__.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/selling/proposal.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/selling/quotation.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/selling/sales_order.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/simulation.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/stock/__init__.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/stock/delivery_note.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/stock/purchase_receipt.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/stock/stock_entry.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/stock/stock_ledger.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/timestamps.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/utils.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/validation.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/lambda_erp/workflow.py +0 -0
- {lambda_erp-1.1.7 → lambda_erp-1.1.9}/terraform/README.md +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: lambda-erp
|
|
3
|
-
Version: 1.1.
|
|
3
|
+
Version: 1.1.9
|
|
4
4
|
Summary: Core ERP logic - accounting, sales, purchasing, inventory
|
|
5
5
|
Author: TORUS INVESTMENTS AG
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -261,7 +261,7 @@ Open `http://localhost:5173`. Vite proxies `/api/*` to the backend.
|
|
|
261
261
|
|
|
262
262
|
```
|
|
263
263
|
OPENAI_API_KEY=sk-...
|
|
264
|
-
LAMBDA_ERP_REPORT_MODEL=gpt-
|
|
264
|
+
LAMBDA_ERP_REPORT_MODEL=gpt-6.1-sol # optional specialist override; default shown
|
|
265
265
|
LAMBDA_ERP_ADMIN_EMAIL=admin@example.com # optional, seeds the admin at boot
|
|
266
266
|
LAMBDA_ERP_ADMIN_PASSWORD=... # optional, required with the line above
|
|
267
267
|
LAMBDA_ERP_ADMIN_NAME=Administrator # optional, display name for the seeded admin
|
|
@@ -233,7 +233,7 @@ Open `http://localhost:5173`. Vite proxies `/api/*` to the backend.
|
|
|
233
233
|
|
|
234
234
|
```
|
|
235
235
|
OPENAI_API_KEY=sk-...
|
|
236
|
-
LAMBDA_ERP_REPORT_MODEL=gpt-
|
|
236
|
+
LAMBDA_ERP_REPORT_MODEL=gpt-6.1-sol # optional specialist override; default shown
|
|
237
237
|
LAMBDA_ERP_ADMIN_EMAIL=admin@example.com # optional, seeds the admin at boot
|
|
238
238
|
LAMBDA_ERP_ADMIN_PASSWORD=... # optional, required with the line above
|
|
239
239
|
LAMBDA_ERP_ADMIN_NAME=Administrator # optional, display name for the seeded admin
|
|
@@ -2860,7 +2860,7 @@ Users can attach PDFs and images (receipts, bills, contracts, screenshots) to th
|
|
|
2860
2860
|
# ---------------------------------------------------------------------------
|
|
2861
2861
|
# OpenAI report-spec specialist
|
|
2862
2862
|
#
|
|
2863
|
-
# GPT-6
|
|
2863
|
+
# GPT-6.1 Sol acts as the planner/orchestrator. When it decides a report needs
|
|
2864
2864
|
# a custom analytics view it calls the create/update custom analytics tools.
|
|
2865
2865
|
# Those handlers make a separate, narrowly prompted GPT-6.1 Sol call for the bounded
|
|
2866
2866
|
# declarative report spec. Both roles use the existing OPENAI_API_KEY.
|
|
@@ -3148,7 +3148,7 @@ def _generate_report_spec_via_openai(
|
|
|
3148
3148
|
instructions=_REPORT_CODE_SYSTEM_PROMPT,
|
|
3149
3149
|
input=attempt_input,
|
|
3150
3150
|
max_output_tokens=4096,
|
|
3151
|
-
reasoning={"effort": "
|
|
3151
|
+
reasoning={"effort": "medium"},
|
|
3152
3152
|
)
|
|
3153
3153
|
# Log every call for the admin dashboard. Only public_manager rows
|
|
3154
3154
|
# count against the demo cap — other roles are logged for
|
|
@@ -3275,9 +3275,9 @@ async def generate_title(
|
|
|
3275
3275
|
# Reasoning loop
|
|
3276
3276
|
# ---------------------------------------------------------------------------
|
|
3277
3277
|
|
|
3278
|
-
#
|
|
3279
|
-
#
|
|
3280
|
-
ORCHESTRATOR_MODEL = "gpt-6-
|
|
3278
|
+
# GPT-6.1 Sol requires Responses for function tools and does not support
|
|
3279
|
+
# reasoning effort "none", including tool-free wrap-ups after the step limit.
|
|
3280
|
+
ORCHESTRATOR_MODEL = "gpt-6.1-sol"
|
|
3281
3281
|
|
|
3282
3282
|
|
|
3283
3283
|
# ---------------------------------------------------------------------------
|
|
@@ -3294,6 +3294,9 @@ ORCHESTRATOR_MODEL = "gpt-6-luna"
|
|
|
3294
3294
|
# ---------------------------------------------------------------------------
|
|
3295
3295
|
|
|
3296
3296
|
def _use_responses_api() -> bool:
|
|
3297
|
+
# A legacy deployment flag must not route Sol 6.1 into an unsupported API.
|
|
3298
|
+
if ORCHESTRATOR_MODEL == "gpt-6.1-sol":
|
|
3299
|
+
return True
|
|
3297
3300
|
# Default: the Responses API (needed for Office attachments + native
|
|
3298
3301
|
# reasoning). Set ERP_CHAT_API=chat to fall back to Chat Completions.
|
|
3299
3302
|
return os.environ.get("ERP_CHAT_API", "responses").strip().lower() != "chat"
|
|
@@ -3451,9 +3454,9 @@ def _shim_usage_from_responses(usage):
|
|
|
3451
3454
|
return _ShimUsage(inp, out, cached)
|
|
3452
3455
|
|
|
3453
3456
|
|
|
3454
|
-
def _orchestrator_turn(client, messages, tools, max_tokens):
|
|
3457
|
+
def _orchestrator_turn(client, messages, tools, max_tokens, *, tool_choice="auto"):
|
|
3455
3458
|
"""One orchestrator LLM turn. Returns (message, usage) shaped like Chat
|
|
3456
|
-
Completions
|
|
3459
|
+
Completions. Sol 6.1 always uses Responses; compatible models honor ERP_CHAT_API.
|
|
3457
3460
|
Blocking — call via asyncio.to_thread."""
|
|
3458
3461
|
if _use_responses_api():
|
|
3459
3462
|
instructions, input_items = _responses_request_from_chat(messages)
|
|
@@ -3462,7 +3465,7 @@ def _orchestrator_turn(client, messages, tools, max_tokens):
|
|
|
3462
3465
|
instructions=instructions,
|
|
3463
3466
|
input=input_items,
|
|
3464
3467
|
tools=_responses_tools_from_chat(tools),
|
|
3465
|
-
tool_choice=
|
|
3468
|
+
tool_choice=tool_choice,
|
|
3466
3469
|
max_output_tokens=max_tokens,
|
|
3467
3470
|
reasoning={"effort": _RESPONSES_REASONING_EFFORT},
|
|
3468
3471
|
)
|
|
@@ -3471,7 +3474,7 @@ def _orchestrator_turn(client, messages, tools, max_tokens):
|
|
|
3471
3474
|
model=ORCHESTRATOR_MODEL,
|
|
3472
3475
|
messages=messages,
|
|
3473
3476
|
tools=tools,
|
|
3474
|
-
tool_choice=
|
|
3477
|
+
tool_choice=tool_choice,
|
|
3475
3478
|
max_completion_tokens=max_tokens,
|
|
3476
3479
|
# gpt-6-luna rejects function tools on /v1/chat/completions unless
|
|
3477
3480
|
# reasoning_effort is "none" (400 otherwise).
|
|
@@ -3490,7 +3493,7 @@ async def run_thinking_loop(
|
|
|
3490
3493
|
):
|
|
3491
3494
|
"""Run the agentic reasoning loop.
|
|
3492
3495
|
|
|
3493
|
-
The orchestrator
|
|
3496
|
+
The orchestrator and the report specialist use GPT-6.1 Sol.
|
|
3494
3497
|
When GPT decides to call
|
|
3495
3498
|
`create_custom_analytics_report` or `update_custom_analytics_report`
|
|
3496
3499
|
with an intent/feedback hint, the tool handler itself delegates the
|
|
@@ -3638,8 +3641,8 @@ async def run_thinking_loop(
|
|
|
3638
3641
|
print(f"[chat_llm] provider=openai model={model_name} session_id={session_id or '-'} iter={iteration + 1}", flush=True)
|
|
3639
3642
|
await on_event({"type": "llm_provider", "provider": "openai", "model": model_name})
|
|
3640
3643
|
try:
|
|
3641
|
-
# One orchestrator turn via
|
|
3642
|
-
#
|
|
3644
|
+
# One orchestrator turn via Responses (required for Sol 6.1)
|
|
3645
|
+
# or the legacy API for compatible models. Returns Chat-shaped
|
|
3643
3646
|
# (message, usage) either way, so everything below is unchanged.
|
|
3644
3647
|
message, usage = await asyncio.to_thread(
|
|
3645
3648
|
_orchestrator_turn, openai_client, messages, build_tools(user_info), max_completion,
|
|
@@ -3801,10 +3804,10 @@ async def run_thinking_loop(
|
|
|
3801
3804
|
content = "I've reached the maximum number of reasoning steps. Here's what I've done so far — please check the results above."
|
|
3802
3805
|
if not demo_mode:
|
|
3803
3806
|
try:
|
|
3804
|
-
wrap = await asyncio.to_thread(
|
|
3805
|
-
|
|
3806
|
-
|
|
3807
|
-
messages
|
|
3807
|
+
wrap, usage = await asyncio.to_thread(
|
|
3808
|
+
_orchestrator_turn,
|
|
3809
|
+
openai_client,
|
|
3810
|
+
messages + [{
|
|
3808
3811
|
"role": "user",
|
|
3809
3812
|
"content": (
|
|
3810
3813
|
"You hit the step limit before finishing. In the user's language, state "
|
|
@@ -3814,10 +3817,19 @@ async def run_thinking_loop(
|
|
|
3814
3817
|
"stands alone: do NOT refer to 'the results above' or to earlier messages."
|
|
3815
3818
|
),
|
|
3816
3819
|
}],
|
|
3817
|
-
|
|
3818
|
-
|
|
3820
|
+
build_tools(user_info),
|
|
3821
|
+
2048,
|
|
3822
|
+
tool_choice="none",
|
|
3823
|
+
)
|
|
3824
|
+
demo_limiter.settle(
|
|
3825
|
+
None, actual_cost_usd=cost_of_openai_call(ORCHESTRATOR_MODEL, usage),
|
|
3826
|
+
ip=client_ip or "unknown", role=user_role, provider="openai",
|
|
3827
|
+
model=ORCHESTRATOR_MODEL,
|
|
3828
|
+
prompt_tokens=int(getattr(usage, "prompt_tokens", 0) or 0) if usage else 0,
|
|
3829
|
+
completion_tokens=int(getattr(usage, "completion_tokens", 0) or 0) if usage else 0,
|
|
3830
|
+
session_id=session_id,
|
|
3819
3831
|
)
|
|
3820
|
-
wrap_text = (wrap.
|
|
3832
|
+
wrap_text = (wrap.content or "").strip()
|
|
3821
3833
|
if wrap_text:
|
|
3822
3834
|
content = wrap_text
|
|
3823
3835
|
except Exception:
|
|
@@ -8,6 +8,17 @@ WORKFLOW_GUIDANCE = """Discover record types and inspect get_document_fields/get
|
|
|
8
8
|
writes; use their canonical field names, required fields, link targets and PDF requirements.
|
|
9
9
|
Resolve existing records by their returned name; never invent identifiers or silently choose
|
|
10
10
|
between ambiguous matches. Custom document records use document tools, not master tools.
|
|
11
|
+
Ground every written fact in the user's message or a retrieved record for the specific
|
|
12
|
+
target entity. This applies to free-text notes, descriptions and activity bodies as well
|
|
13
|
+
as structured fields. When the user switches company, lead or document, re-establish the
|
|
14
|
+
target and its relationships; do not carry a person's name, employer, sender identity,
|
|
15
|
+
contact details or other attributes over from the previous topic without explicit evidence.
|
|
16
|
+
A known contact at a company is not proof that they sent an unsigned message. If the source
|
|
17
|
+
only identifies a team/company, attribute it to that team/company, leave the person/contact
|
|
18
|
+
unset and preserve the source wording. Ask only if identifying the person is necessary to
|
|
19
|
+
perform the requested operation; never invent a sender to fill the gap.
|
|
20
|
+
Keep dates, times and time zones consistent between structured fields, notes and the final
|
|
21
|
+
answer. Convert the source time once; if the source zone is ambiguous, ask before writing.
|
|
11
22
|
Create/update, submit, cancel and convert are separate business operations. A successful
|
|
12
23
|
draft creation does not imply submission, stock movement or ledger posting. Check returned
|
|
13
24
|
status and warnings. Correct validation errors using the field metadata and user information.
|
|
@@ -7,7 +7,7 @@ from jinja2 import Environment, FileSystemLoader, ChoiceLoader
|
|
|
7
7
|
from weasyprint import HTML
|
|
8
8
|
from lambda_erp.database import get_db
|
|
9
9
|
from api.services import load_document, get_document_class
|
|
10
|
-
from api.pdf_contract import prepare, validate_rendered_pdf, PDFError
|
|
10
|
+
from api.pdf_contract import prepare, validate_rendered_pdf, PDFError, register_pdf_state_labels # noqa: F401
|
|
11
11
|
from api.remarks_md import render_remarks
|
|
12
12
|
from lambda_erp.controllers.taxes_and_totals import split_by_frequency
|
|
13
13
|
|
|
@@ -229,6 +229,7 @@ def _render_commercial_pdf(doctype_slug, name, doc, contract):
|
|
|
229
229
|
context = dict(
|
|
230
230
|
doc=doc,
|
|
231
231
|
pdf_status=contract["status"],
|
|
232
|
+
pdf_state_notice=contract["notice"],
|
|
232
233
|
pdf_warnings=contract["warnings"],
|
|
233
234
|
title=title,
|
|
234
235
|
company_name=company_name,
|
|
@@ -334,8 +335,8 @@ def _render_proposal_pdf(name, proposal, contract):
|
|
|
334
335
|
if quote.get('company') != proposal.get('company') or quote.get('customer') != proposal.get('customer'):
|
|
335
336
|
raise PDFError('Proposal PDF: quotation company and customer must match')
|
|
336
337
|
quote_contract = prepare('Quotation', quote)
|
|
337
|
-
# Each included offer must be rendered completely.
|
|
338
|
-
#
|
|
338
|
+
# Each included offer must be rendered completely. A non-final state
|
|
339
|
+
# (draft, cancelled, discarded) is printed beside its number.
|
|
339
340
|
contract['tokens'].extend(quote_contract['tokens'])
|
|
340
341
|
contract['warnings'].extend(quote_contract['warnings'])
|
|
341
342
|
items = quote.get("items", []) or []
|
|
@@ -352,6 +353,7 @@ def _render_proposal_pdf(name, proposal, contract):
|
|
|
352
353
|
"letter": chr(ord("A") + i),
|
|
353
354
|
"quotation": qname,
|
|
354
355
|
"pdf_status": quote_contract["status"],
|
|
356
|
+
"pdf_state_notice": quote_contract["notice"],
|
|
355
357
|
"title": row.get("position_title") or default_title or qname,
|
|
356
358
|
"blurb": row.get("position_blurb") or quote.get("remarks") or "",
|
|
357
359
|
"is_recommended": bool(row.get("is_recommended")),
|
|
@@ -373,6 +375,7 @@ def _render_proposal_pdf(name, proposal, contract):
|
|
|
373
375
|
context = dict(
|
|
374
376
|
proposal=proposal,
|
|
375
377
|
pdf_status=contract["status"],
|
|
378
|
+
pdf_state_notice=contract["notice"],
|
|
376
379
|
pdf_warnings=contract["warnings"],
|
|
377
380
|
title=proposal.get("title") or "Offerte",
|
|
378
381
|
company_name=company_name,
|
|
@@ -416,6 +419,7 @@ def _render_record_pdf(doctype, name, doc, contract):
|
|
|
416
419
|
page_size_row = get_db().sql('SELECT value FROM "Settings" WHERE key = ?', ['pdf_page_size'])
|
|
417
420
|
context = dict(
|
|
418
421
|
title=contract['profile'].title, name=name, pdf_status=contract['status'],
|
|
422
|
+
pdf_state_notice=contract['notice'],
|
|
419
423
|
pdf_warnings=contract['warnings'], print_fields=contract['fields'],
|
|
420
424
|
print_tables=contract['tables'], print_labels={}, print_title=contract['profile'].title,
|
|
421
425
|
company_info=company, company_name=company.get('company_name') or doc.get('company') or '',
|
|
@@ -61,6 +61,31 @@ def resolve(table, name):
|
|
|
61
61
|
return (db.get_value(table, name, display) if display in fields else None) or name
|
|
62
62
|
|
|
63
63
|
|
|
64
|
+
# States a reader must not mistake for a final, issued document. Only these are
|
|
65
|
+
# printed as a notice and required in the rendered text; a submitted invoice or
|
|
66
|
+
# an ordinary record status carries no notice.
|
|
67
|
+
NOTICE_STATES = ('Draft', 'Cancelled', 'Discarded')
|
|
68
|
+
_state_labels = {}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def register_pdf_state_labels(labels) -> None:
|
|
72
|
+
"""Override the printed notice for non-final states, e.g. {'Draft': 'Entwurf'}.
|
|
73
|
+
|
|
74
|
+
Keys must be in NOTICE_STATES. The label is what templates print as
|
|
75
|
+
`pdf_state_notice` and what the rendered-text check then requires.
|
|
76
|
+
"""
|
|
77
|
+
unknown = set(labels) - set(NOTICE_STATES)
|
|
78
|
+
if unknown:
|
|
79
|
+
raise ValueError(f'Unknown PDF notice states: {sorted(unknown)}')
|
|
80
|
+
if any(not str(label).strip() for label in labels.values()):
|
|
81
|
+
raise ValueError('PDF notice labels must not be empty')
|
|
82
|
+
_state_labels.update(labels)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def notice_for(state):
|
|
86
|
+
return _state_labels.get(state, state) if state in NOTICE_STATES else ''
|
|
87
|
+
|
|
88
|
+
|
|
64
89
|
def state_for(doctype, doc):
|
|
65
90
|
from api.services import DOCUMENT_CLASSES
|
|
66
91
|
if doc.get('discarded'):
|
|
@@ -87,9 +112,10 @@ def prepare(doctype, doc):
|
|
|
87
112
|
if not any(not missing(doc.get(key)) for key in group):
|
|
88
113
|
raise PDFError('PDF requires at least one of: ' + ', '.join(group), fields=group)
|
|
89
114
|
state = state_for(doctype, doc)
|
|
90
|
-
|
|
115
|
+
notice = notice_for(state)
|
|
116
|
+
tokens = [str(doc['name'])] + ([notice] if notice else [])
|
|
91
117
|
warnings = []
|
|
92
|
-
if
|
|
118
|
+
if notice:
|
|
93
119
|
warnings.append({'code': 'pdf_document_state', 'message': f'{doctype} {doc["name"]}: {state}'})
|
|
94
120
|
company = {}
|
|
95
121
|
if doc.get('company'):
|
|
@@ -213,7 +239,7 @@ def prepare(doctype, doc):
|
|
|
213
239
|
if doctype == 'Subscription':
|
|
214
240
|
party_currency = get_db().get_value(party_type, party_id, 'default_currency') if party_id else None
|
|
215
241
|
fields.append({'key': 'currency', 'value': party_currency or company_currency})
|
|
216
|
-
return {'profile': profile, 'status': state, 'warnings': warnings, 'tokens': tokens,
|
|
242
|
+
return {'profile': profile, 'status': state, 'notice': notice, 'warnings': warnings, 'tokens': tokens,
|
|
217
243
|
'fields': fields, 'tables': tables, 'party_name': party_name,
|
|
218
244
|
'company': company, 'company_currency': company_currency}
|
|
219
245
|
|
|
@@ -16,7 +16,7 @@ tr { break-inside: avoid; } .brand { font-weight: bold; letter-spacing: .8pt; }
|
|
|
16
16
|
{% macro label(key) %}{{ print_labels.get(key, key.replace('_',' ').capitalize()) }}{% endmacro %}
|
|
17
17
|
{% block letterhead %}<div class="header"><div class="brand">{{ company_name }}</div>{% if company_info.get('address') %}<div class="muted">{{ company_info.get('address') }}</div>{% endif %}</div>{% endblock %}
|
|
18
18
|
<h1>{{ print_title }}</h1>
|
|
19
|
-
<div class="state">{{ name }} · {{ pdf_status }}</div>
|
|
19
|
+
<div class="state">{{ name }} · {{ pdf_state_notice or pdf_status }}</div>
|
|
20
20
|
{% for warning in pdf_warnings %}<div class="warning">{{ warning.message }}</div>{% endfor %}
|
|
21
21
|
{% if party_name %}<p><strong>{{ party_name }}</strong></p>{% endif %}
|
|
22
22
|
{% if company_currency %}<div class="muted">{{ label('company_currency') }}: {{ company_currency }}</div>{% endif %}
|
|
@@ -58,10 +58,11 @@
|
|
|
58
58
|
.remarks .rm-hr { border-top: 1px solid #d1d5db; margin: 6px 0; }
|
|
59
59
|
.remarks em { font-style: italic; }
|
|
60
60
|
.remarks strong { font-weight: 700; }
|
|
61
|
+
.state-notice { display: inline-block; margin-bottom: 8pt; padding: 3px 10px; border: 1.5px solid #b91c1c; border-radius: 4px; color: #b91c1c; font-size: 10pt; font-weight: 700; letter-spacing: 1px; text-transform: uppercase; }
|
|
61
62
|
</style>
|
|
62
63
|
</head>
|
|
63
64
|
<body>
|
|
64
|
-
<div
|
|
65
|
+
{% if pdf_state_notice %}<div class="state-notice">{{ pdf_state_notice }}</div>{% endif %}
|
|
65
66
|
|
|
66
67
|
{# ---- Header ---- #}
|
|
67
68
|
<div class="header">
|
|
@@ -34,10 +34,11 @@
|
|
|
34
34
|
table.lines td.desc { color: #333; }
|
|
35
35
|
|
|
36
36
|
.footer { margin-top: 28px; padding-top: 10px; border-top: 1px solid #e5e7eb; font-size: 7.5pt; color: #999; text-align: center; }
|
|
37
|
+
.state-notice { display: inline-block; margin-bottom: 8pt; padding: 3px 10px; border: 1.5px solid #b91c1c; border-radius: 4px; color: #b91c1c; font-size: 10pt; font-weight: 700; letter-spacing: 1px; text-transform: uppercase; }
|
|
37
38
|
</style>
|
|
38
39
|
</head>
|
|
39
40
|
<body>
|
|
40
|
-
<div
|
|
41
|
+
{% if pdf_state_notice %}<div class="state-notice">{{ pdf_state_notice }}</div>{% endif %}
|
|
41
42
|
|
|
42
43
|
<div class="header">
|
|
43
44
|
<div><div class="company">{{ company_name or proposal.company or "" }}</div></div>
|
|
@@ -69,7 +70,7 @@
|
|
|
69
70
|
{% if proposal.cover_letter %}<div class="cover">{{ proposal.cover_letter }}</div>{% endif %}
|
|
70
71
|
|
|
71
72
|
{% for p in positions %}
|
|
72
|
-
<div style="font-size:8pt">{{ p.quotation }} · {{ p.
|
|
73
|
+
{% if p.pdf_state_notice %}<div style="font-size:8pt">{{ p.quotation }} · <span class="state-notice" style="font-size:8pt; padding:1px 6px; margin:0">{{ p.pdf_state_notice }}</span></div>{% endif %}
|
|
73
74
|
<div class="position">
|
|
74
75
|
<div class="pos-head">
|
|
75
76
|
<span class="letter">{{ p.letter }}</span>
|
|
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
|
|
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
|