Compare commits
6 Commits
accounting
...
19.0
| Author | SHA1 | Date | |
|---|---|---|---|
| d7bc4a4b88 | |||
| ee9b1958f1 | |||
| 48db592326 | |||
| 8f834373b7 | |||
| c6765e04f7 | |||
| fc97d80c2c |
2
addons/laundry_management/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
from . import models
|
||||||
|
from . import wizard
|
||||||
151
addons/laundry_management/__manifest__.py
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
{
|
||||||
|
'name': 'Laundry Management',
|
||||||
|
'version': '19.0.19.0.4',
|
||||||
|
'summary': 'Laundry Management',
|
||||||
|
'description': 'Laundry Management',
|
||||||
|
'author': 'Laundry Management',
|
||||||
|
'category': 'Services',
|
||||||
|
'license': 'LGPL-3',
|
||||||
|
'depends': [
|
||||||
|
'base',
|
||||||
|
'mail',
|
||||||
|
'sale', # sale.order (legacy refs in commission/dashboard)
|
||||||
|
'account', # account.move, account.payment (legacy refs)
|
||||||
|
'product', # product.template, product.product
|
||||||
|
'base_setup', # res.config.settings integration
|
||||||
|
'sales_team', # group_sale_salesman, group_sale_manager
|
||||||
|
'point_of_sale', # pos.order ??? source of truth
|
||||||
|
],
|
||||||
|
'data': [
|
||||||
|
# 1. Security ??? groups must load before ACLs and rules
|
||||||
|
'security/res_groups.xml',
|
||||||
|
'security/ir_rule.xml',
|
||||||
|
'security/ir.model.access.csv',
|
||||||
|
# 2. Sequences
|
||||||
|
'data/sequence.xml',
|
||||||
|
# 3. Master data
|
||||||
|
'data/laundry_data.xml',
|
||||||
|
'data/service_catalog_data.xml',
|
||||||
|
# 4. Reports ??? must load before views that reference report actions
|
||||||
|
'report/laundry_order_report.xml',
|
||||||
|
'report/laundry_thermal_report.xml',
|
||||||
|
'report/laundry_work_order_report.xml',
|
||||||
|
# 5. Action-defining views ??? must load before menus + reporting,
|
||||||
|
# because both reference these actions by xml id.
|
||||||
|
'views/product_template_views.xml',
|
||||||
|
'views/laundry_order_type_views.xml',
|
||||||
|
'views/laundry_order_attribute_views.xml',
|
||||||
|
'views/laundry_order_views.xml',
|
||||||
|
'views/pos_order_views.xml',
|
||||||
|
'views/laundry_commission_views.xml',
|
||||||
|
# 6. Configuration views
|
||||||
|
'views/laundry_payment_method_views.xml',
|
||||||
|
'views/laundry_settings_views.xml',
|
||||||
|
'views/pos_config_views.xml',
|
||||||
|
# 7. Wizards (their actions are referenced from menus / forms)
|
||||||
|
'views/laundry_print_wizard_views.xml',
|
||||||
|
'wizard/laundry_order_unlock_wizard_views.xml',
|
||||||
|
# 8. MENUS ??? must load BEFORE any file that adds a child menu
|
||||||
|
# under menu_laundry_root (e.g. laundry_reporting_views.xml).
|
||||||
|
'views/laundry_menus.xml',
|
||||||
|
# 9. Reporting ??? adds child menus under menu_laundry_root
|
||||||
|
# (defined in the file above) and under core
|
||||||
|
# sale.menu_sale_report / account.menu_finance_reports.
|
||||||
|
'views/laundry_reporting_views.xml',
|
||||||
|
],
|
||||||
|
'assets': {
|
||||||
|
# ????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????
|
||||||
|
# POS asset bundle ??? EXPLICIT FILE LIST (no broad globs).
|
||||||
|
# ????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????
|
||||||
|
# Lists only the production-required POS workflow files.
|
||||||
|
# Suspect / experimental files are commented OUT individually
|
||||||
|
# rather than disabling the whole bundle. Toggle a single line
|
||||||
|
# to add or remove a feature.
|
||||||
|
#
|
||||||
|
# The defensive guard for the `doHaveConflictWith` crash lives
|
||||||
|
# in pos_store_patch.js ??? that crash is fixed without removing
|
||||||
|
# any feature.
|
||||||
|
# ????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????
|
||||||
|
'point_of_sale._assets_pos': [
|
||||||
|
# ?????? Styling (shared with backend kanban) ??????????????????????????????????????????????????????
|
||||||
|
'laundry_management/static/src/scss/laundry_pos.scss',
|
||||||
|
|
||||||
|
# ?????? Reactive store (must load first; other patches use it)
|
||||||
|
'laundry_management/static/src/js/laundry_context_store.js',
|
||||||
|
|
||||||
|
# ?????? Model + screen patches (workflow core) ????????????????????????????????????????????????
|
||||||
|
'laundry_management/static/src/js/pos_order_patch.js',
|
||||||
|
'laundry_management/static/src/js/pos_store_patch.js',
|
||||||
|
'laundry_management/static/src/js/payment_screen_patch.js',
|
||||||
|
'laundry_management/static/src/js/order_payment_validation.js',
|
||||||
|
|
||||||
|
# ?????? Settle-Due (production-required) ??????????????????????????????????????????????????????????????????
|
||||||
|
'laundry_management/static/src/js/settle_dues.js',
|
||||||
|
'laundry_management/static/src/js/settlement_receipt.js',
|
||||||
|
'laundry_management/static/src/js/laundry_settle_banner.js',
|
||||||
|
'laundry_management/static/src/js/closing_popup_patch.js',
|
||||||
|
|
||||||
|
# ?????? Customer Laundry Orders popup (production-required) ??????
|
||||||
|
'laundry_management/static/src/js/view_laundry_orders.js',
|
||||||
|
'laundry_management/static/src/js/quick_create_partner.js',
|
||||||
|
|
||||||
|
# ?????? Legacy order-type / attribute / delivery flow ???????????????????????????
|
||||||
|
'laundry_management/static/src/js/laundry_order_context_panel.js',
|
||||||
|
'laundry_management/static/src/js/popups/laundry_delivery_details_popup.js',
|
||||||
|
'laundry_management/static/src/js/popups/laundry_order_attribute_popup.js',
|
||||||
|
'laundry_management/static/src/js/popups/laundry_order_type_popup.js',
|
||||||
|
|
||||||
|
# ?????? Cashier UI helpers ????????????????????????????????????????????????????????????????????????????????????????????????????????????
|
||||||
|
'laundry_management/static/src/js/control_buttons_patch.js',
|
||||||
|
'laundry_management/static/src/js/navbar_patch.js',
|
||||||
|
'laundry_management/static/src/js/order_summary_patch.js',
|
||||||
|
'laundry_management/static/src/js/order_tabs_patch.js',
|
||||||
|
'laundry_management/static/src/js/ticket_screen_patch.js',
|
||||||
|
'laundry_management/static/src/js/order_receipt_patch.js',
|
||||||
|
'laundry_management/static/src/js/laundry_receipt_details.js',
|
||||||
|
'laundry_management/static/src/js/laundry_pricing_hook.js',
|
||||||
|
|
||||||
|
# ?????? XML templates for the JS files above ??????????????????????????????????????????????????????
|
||||||
|
'laundry_management/static/src/xml/closing_popup_ext.xml',
|
||||||
|
'laundry_management/static/src/xml/control_buttons.xml',
|
||||||
|
'laundry_management/static/src/xml/laundry_order_context_panel.xml',
|
||||||
|
'laundry_management/static/src/xml/laundry_settle_banner.xml',
|
||||||
|
'laundry_management/static/src/xml/order_summary_patch.xml',
|
||||||
|
'laundry_management/static/src/xml/partner_line.xml',
|
||||||
|
'laundry_management/static/src/xml/popups/laundry_delivery_details_popup.xml',
|
||||||
|
'laundry_management/static/src/xml/popups/laundry_order_attribute_popup.xml',
|
||||||
|
'laundry_management/static/src/xml/popups/laundry_order_type_popup.xml',
|
||||||
|
'laundry_management/static/src/xml/quick_create_partner.xml',
|
||||||
|
'laundry_management/static/src/xml/receipt.xml',
|
||||||
|
'laundry_management/static/src/xml/settle_dues.xml',
|
||||||
|
'laundry_management/static/src/xml/settlement_receipt.xml',
|
||||||
|
'laundry_management/static/src/xml/view_laundry_orders.xml',
|
||||||
|
|
||||||
|
# ?????? Improved laundry configurator UX (XML-only) ?????????????????????????????????
|
||||||
|
# Pure XML inheritance on Odoo's ProductConfiguratorPopup
|
||||||
|
# that adds two CSS classes to the Dialog's contentClass so
|
||||||
|
# the existing SCSS in laundry_pos.scss enhances the popup
|
||||||
|
# for laundry-flagged products only. NO JS override, NO
|
||||||
|
# logic change. The defensive doHaveConflictWith guard in
|
||||||
|
# pos_store_patch.js handles the data-shape edge case
|
||||||
|
# independently ??? re-enabling this is safe.
|
||||||
|
'laundry_management/static/src/xml/product_configurator_popup.xml',
|
||||||
|
|
||||||
|
# ?????? STILL EXCLUDED ??? recent / experimental ????????????????????????????????????????????????
|
||||||
|
# Thermal-receipt component is kept off until an explicit
|
||||||
|
# printer-side validation. PDF fallback remains the path.
|
||||||
|
# 'laundry_management/static/src/js/laundry_thermal_receipt.js',
|
||||||
|
# 'laundry_management/static/src/xml/laundry_thermal_receipt.xml',
|
||||||
|
],
|
||||||
|
'web.assets_backend': [
|
||||||
|
# SCSS shared with backend kanban / dashboard styling.
|
||||||
|
'laundry_management/static/src/scss/laundry_pos.scss',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'demo': [
|
||||||
|
'demo/demo.xml',
|
||||||
|
],
|
||||||
|
'installable': True,
|
||||||
|
'application': True,
|
||||||
|
'auto_install': False,
|
||||||
|
}
|
||||||
349
addons/laundry_management/data/laundry_data.xml
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo noupdate="1">
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════
|
||||||
|
SETTLEMENT PRODUCT — used for collecting outstanding laundry dues.
|
||||||
|
Service type, no tax, NOT a laundry service (no laundry.order created).
|
||||||
|
The income account override happens in pos.order.line Python code
|
||||||
|
to use the partner's receivable account → no revenue recognised.
|
||||||
|
═══════════════════════════════════════════════════════════════ -->
|
||||||
|
<record id="product_laundry_settlement" model="product.product">
|
||||||
|
<field name="name">Laundry Settlement</field>
|
||||||
|
<field name="type">service</field>
|
||||||
|
<field name="lst_price">0.00</field>
|
||||||
|
<field name="sale_ok">True</field>
|
||||||
|
<field name="purchase_ok">False</field>
|
||||||
|
<field name="available_in_pos">True</field>
|
||||||
|
<field name="is_laundry_settlement">True</field>
|
||||||
|
<field name="is_laundry_service">False</field>
|
||||||
|
<field name="taxes_id" eval="[(5, 0, 0)]"/>
|
||||||
|
<field name="description_sale">Settlement of outstanding laundry dues.</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════
|
||||||
|
PRODUCT CATEGORIES (Laundry Services tree)
|
||||||
|
═══════════════════════════════════════════════════════════════ -->
|
||||||
|
<record id="product_categ_laundry_root" model="product.category">
|
||||||
|
<field name="name">Laundry Services</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="product_categ_washing" model="product.category">
|
||||||
|
<field name="name">Washing</field>
|
||||||
|
<field name="parent_id" ref="product_categ_laundry_root"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="product_categ_dry_cleaning" model="product.category">
|
||||||
|
<field name="name">Dry Cleaning</field>
|
||||||
|
<field name="parent_id" ref="product_categ_laundry_root"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="product_categ_ironing" model="product.category">
|
||||||
|
<field name="name">Ironing & Pressing</field>
|
||||||
|
<field name="parent_id" ref="product_categ_laundry_root"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="product_categ_special_care" model="product.category">
|
||||||
|
<field name="name">Special Care</field>
|
||||||
|
<field name="parent_id" ref="product_categ_laundry_root"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="product_categ_express" model="product.category">
|
||||||
|
<field name="name">Express Service</field>
|
||||||
|
<field name="parent_id" ref="product_categ_laundry_root"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════
|
||||||
|
LAUNDRY SERVICE PRODUCTS — WASHING
|
||||||
|
Using product.product so XML IDs are directly referenceable.
|
||||||
|
Template fields (name, categ_id, type, lst_price) are set via
|
||||||
|
product.product's delegation inheritance to product.template.
|
||||||
|
═══════════════════════════════════════════════════════════════ -->
|
||||||
|
<record id="svc_wash_shirt" model="product.product">
|
||||||
|
<field name="name">Shirt — Wash & Iron</field>
|
||||||
|
<field name="type">service</field>
|
||||||
|
<field name="lst_price">5.00</field>
|
||||||
|
<field name="categ_id" ref="product_categ_washing"/>
|
||||||
|
<field name="is_laundry_service">True</field>
|
||||||
|
<field name="sale_ok">True</field>
|
||||||
|
<field name="purchase_ok">False</field>
|
||||||
|
<field name="description_sale">Standard wash and press for dress shirts and casual shirts.</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="svc_wash_trousers" model="product.product">
|
||||||
|
<field name="name">Trousers — Wash & Iron</field>
|
||||||
|
<field name="type">service</field>
|
||||||
|
<field name="lst_price">6.00</field>
|
||||||
|
<field name="categ_id" ref="product_categ_washing"/>
|
||||||
|
<field name="is_laundry_service">True</field>
|
||||||
|
<field name="sale_ok">True</field>
|
||||||
|
<field name="purchase_ok">False</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="svc_wash_tshirt" model="product.product">
|
||||||
|
<field name="name">T-Shirt / Polo — Wash</field>
|
||||||
|
<field name="type">service</field>
|
||||||
|
<field name="lst_price">4.00</field>
|
||||||
|
<field name="categ_id" ref="product_categ_washing"/>
|
||||||
|
<field name="is_laundry_service">True</field>
|
||||||
|
<field name="sale_ok">True</field>
|
||||||
|
<field name="purchase_ok">False</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="svc_wash_jeans" model="product.product">
|
||||||
|
<field name="name">Jeans — Wash</field>
|
||||||
|
<field name="type">service</field>
|
||||||
|
<field name="lst_price">7.00</field>
|
||||||
|
<field name="categ_id" ref="product_categ_washing"/>
|
||||||
|
<field name="is_laundry_service">True</field>
|
||||||
|
<field name="sale_ok">True</field>
|
||||||
|
<field name="purchase_ok">False</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="svc_wash_abaya" model="product.product">
|
||||||
|
<field name="name">Abaya — Wash & Press</field>
|
||||||
|
<field name="type">service</field>
|
||||||
|
<field name="lst_price">10.00</field>
|
||||||
|
<field name="categ_id" ref="product_categ_washing"/>
|
||||||
|
<field name="is_laundry_service">True</field>
|
||||||
|
<field name="sale_ok">True</field>
|
||||||
|
<field name="purchase_ok">False</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="svc_wash_thobe" model="product.product">
|
||||||
|
<field name="name">Thobe / Dishdasha — Wash & Iron</field>
|
||||||
|
<field name="type">service</field>
|
||||||
|
<field name="lst_price">9.00</field>
|
||||||
|
<field name="categ_id" ref="product_categ_washing"/>
|
||||||
|
<field name="is_laundry_service">True</field>
|
||||||
|
<field name="sale_ok">True</field>
|
||||||
|
<field name="purchase_ok">False</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="svc_wash_blanket" model="product.product">
|
||||||
|
<field name="name">Blanket / Duvet — Wash</field>
|
||||||
|
<field name="type">service</field>
|
||||||
|
<field name="lst_price">18.00</field>
|
||||||
|
<field name="categ_id" ref="product_categ_washing"/>
|
||||||
|
<field name="is_laundry_service">True</field>
|
||||||
|
<field name="sale_ok">True</field>
|
||||||
|
<field name="purchase_ok">False</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════
|
||||||
|
LAUNDRY SERVICE PRODUCTS — DRY CLEANING
|
||||||
|
═══════════════════════════════════════════════════════════════ -->
|
||||||
|
<record id="svc_dc_suit" model="product.product">
|
||||||
|
<field name="name">Suit (2-Piece) — Dry Clean</field>
|
||||||
|
<field name="type">service</field>
|
||||||
|
<field name="lst_price">25.00</field>
|
||||||
|
<field name="categ_id" ref="product_categ_dry_cleaning"/>
|
||||||
|
<field name="is_laundry_service">True</field>
|
||||||
|
<field name="sale_ok">True</field>
|
||||||
|
<field name="purchase_ok">False</field>
|
||||||
|
<field name="description_sale">Full dry cleaning for 2-piece suits.</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="svc_dc_jacket" model="product.product">
|
||||||
|
<field name="name">Jacket / Blazer — Dry Clean</field>
|
||||||
|
<field name="type">service</field>
|
||||||
|
<field name="lst_price">15.00</field>
|
||||||
|
<field name="categ_id" ref="product_categ_dry_cleaning"/>
|
||||||
|
<field name="is_laundry_service">True</field>
|
||||||
|
<field name="sale_ok">True</field>
|
||||||
|
<field name="purchase_ok">False</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="svc_dc_dress" model="product.product">
|
||||||
|
<field name="name">Dress / Gown — Dry Clean</field>
|
||||||
|
<field name="type">service</field>
|
||||||
|
<field name="lst_price">20.00</field>
|
||||||
|
<field name="categ_id" ref="product_categ_dry_cleaning"/>
|
||||||
|
<field name="is_laundry_service">True</field>
|
||||||
|
<field name="sale_ok">True</field>
|
||||||
|
<field name="purchase_ok">False</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="svc_dc_abaya" model="product.product">
|
||||||
|
<field name="name">Abaya — Dry Clean & Press</field>
|
||||||
|
<field name="type">service</field>
|
||||||
|
<field name="lst_price">18.00</field>
|
||||||
|
<field name="categ_id" ref="product_categ_dry_cleaning"/>
|
||||||
|
<field name="is_laundry_service">True</field>
|
||||||
|
<field name="sale_ok">True</field>
|
||||||
|
<field name="purchase_ok">False</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="svc_dc_thobe" model="product.product">
|
||||||
|
<field name="name">Thobe / Dishdasha — Dry Clean</field>
|
||||||
|
<field name="type">service</field>
|
||||||
|
<field name="lst_price">16.00</field>
|
||||||
|
<field name="categ_id" ref="product_categ_dry_cleaning"/>
|
||||||
|
<field name="is_laundry_service">True</field>
|
||||||
|
<field name="sale_ok">True</field>
|
||||||
|
<field name="purchase_ok">False</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="svc_dc_wedding_dress" model="product.product">
|
||||||
|
<field name="name">Wedding Dress — Dry Clean & Preserve</field>
|
||||||
|
<field name="type">service</field>
|
||||||
|
<field name="lst_price">60.00</field>
|
||||||
|
<field name="categ_id" ref="product_categ_dry_cleaning"/>
|
||||||
|
<field name="is_laundry_service">True</field>
|
||||||
|
<field name="sale_ok">True</field>
|
||||||
|
<field name="purchase_ok">False</field>
|
||||||
|
<field name="description_sale">Premium dry cleaning and preservation for wedding dresses.</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════
|
||||||
|
LAUNDRY SERVICE PRODUCTS — IRONING & PRESSING
|
||||||
|
═══════════════════════════════════════════════════════════════ -->
|
||||||
|
<record id="svc_iron_shirt" model="product.product">
|
||||||
|
<field name="name">Shirt — Iron & Press</field>
|
||||||
|
<field name="type">service</field>
|
||||||
|
<field name="lst_price">3.00</field>
|
||||||
|
<field name="categ_id" ref="product_categ_ironing"/>
|
||||||
|
<field name="is_laundry_service">True</field>
|
||||||
|
<field name="sale_ok">True</field>
|
||||||
|
<field name="purchase_ok">False</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="svc_iron_trousers" model="product.product">
|
||||||
|
<field name="name">Trousers — Iron & Press</field>
|
||||||
|
<field name="type">service</field>
|
||||||
|
<field name="lst_price">4.00</field>
|
||||||
|
<field name="categ_id" ref="product_categ_ironing"/>
|
||||||
|
<field name="is_laundry_service">True</field>
|
||||||
|
<field name="sale_ok">True</field>
|
||||||
|
<field name="purchase_ok">False</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="svc_iron_thobe" model="product.product">
|
||||||
|
<field name="name">Thobe / Dishdasha — Iron & Press</field>
|
||||||
|
<field name="type">service</field>
|
||||||
|
<field name="lst_price">5.00</field>
|
||||||
|
<field name="categ_id" ref="product_categ_ironing"/>
|
||||||
|
<field name="is_laundry_service">True</field>
|
||||||
|
<field name="sale_ok">True</field>
|
||||||
|
<field name="purchase_ok">False</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="svc_iron_suit" model="product.product">
|
||||||
|
<field name="name">Suit — Iron & Press</field>
|
||||||
|
<field name="type">service</field>
|
||||||
|
<field name="lst_price">8.00</field>
|
||||||
|
<field name="categ_id" ref="product_categ_ironing"/>
|
||||||
|
<field name="is_laundry_service">True</field>
|
||||||
|
<field name="sale_ok">True</field>
|
||||||
|
<field name="purchase_ok">False</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="svc_iron_dress" model="product.product">
|
||||||
|
<field name="name">Dress / Gown — Iron & Press</field>
|
||||||
|
<field name="type">service</field>
|
||||||
|
<field name="lst_price">7.00</field>
|
||||||
|
<field name="categ_id" ref="product_categ_ironing"/>
|
||||||
|
<field name="is_laundry_service">True</field>
|
||||||
|
<field name="sale_ok">True</field>
|
||||||
|
<field name="purchase_ok">False</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════
|
||||||
|
LAUNDRY SERVICE PRODUCTS — SPECIAL CARE
|
||||||
|
═══════════════════════════════════════════════════════════════ -->
|
||||||
|
<record id="svc_special_carpet" model="product.product">
|
||||||
|
<field name="name">Carpet / Rug — Deep Clean</field>
|
||||||
|
<field name="type">service</field>
|
||||||
|
<field name="lst_price">35.00</field>
|
||||||
|
<field name="categ_id" ref="product_categ_special_care"/>
|
||||||
|
<field name="is_laundry_service">True</field>
|
||||||
|
<field name="sale_ok">True</field>
|
||||||
|
<field name="purchase_ok">False</field>
|
||||||
|
<field name="description_sale">Deep steam cleaning for carpets and rugs.</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="svc_special_curtain" model="product.product">
|
||||||
|
<field name="name">Curtain — Wash & Press</field>
|
||||||
|
<field name="type">service</field>
|
||||||
|
<field name="lst_price">25.00</field>
|
||||||
|
<field name="categ_id" ref="product_categ_special_care"/>
|
||||||
|
<field name="is_laundry_service">True</field>
|
||||||
|
<field name="sale_ok">True</field>
|
||||||
|
<field name="purchase_ok">False</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="svc_special_leather" model="product.product">
|
||||||
|
<field name="name">Leather Jacket — Clean & Condition</field>
|
||||||
|
<field name="type">service</field>
|
||||||
|
<field name="lst_price">40.00</field>
|
||||||
|
<field name="categ_id" ref="product_categ_special_care"/>
|
||||||
|
<field name="is_laundry_service">True</field>
|
||||||
|
<field name="sale_ok">True</field>
|
||||||
|
<field name="purchase_ok">False</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="svc_special_stain" model="product.product">
|
||||||
|
<field name="name">Stain Treatment (per item)</field>
|
||||||
|
<field name="type">service</field>
|
||||||
|
<field name="lst_price">12.00</field>
|
||||||
|
<field name="categ_id" ref="product_categ_special_care"/>
|
||||||
|
<field name="is_laundry_service">True</field>
|
||||||
|
<field name="sale_ok">True</field>
|
||||||
|
<field name="purchase_ok">False</field>
|
||||||
|
<field name="description_sale">Targeted stain removal treatment applied before washing.</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════
|
||||||
|
LAUNDRY SERVICE PRODUCTS — EXPRESS SERVICE
|
||||||
|
═══════════════════════════════════════════════════════════════ -->
|
||||||
|
<record id="svc_express_4hr" model="product.product">
|
||||||
|
<field name="name">Express Turnaround (4-Hour)</field>
|
||||||
|
<field name="type">service</field>
|
||||||
|
<field name="lst_price">10.00</field>
|
||||||
|
<field name="categ_id" ref="product_categ_express"/>
|
||||||
|
<field name="is_laundry_service">True</field>
|
||||||
|
<field name="sale_ok">True</field>
|
||||||
|
<field name="purchase_ok">False</field>
|
||||||
|
<field name="description_sale">Priority processing — ready within 4 hours.</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="svc_express_sameday" model="product.product">
|
||||||
|
<field name="name">Same-Day Delivery Surcharge</field>
|
||||||
|
<field name="type">service</field>
|
||||||
|
<field name="lst_price">8.00</field>
|
||||||
|
<field name="categ_id" ref="product_categ_express"/>
|
||||||
|
<field name="is_laundry_service">True</field>
|
||||||
|
<field name="sale_ok">True</field>
|
||||||
|
<field name="purchase_ok">False</field>
|
||||||
|
<field name="description_sale">Add-on fee for same-day home delivery.</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════
|
||||||
|
POLISHED EXAMPLE — Service Speed variant.
|
||||||
|
Demonstrates the cleanest workflow: ONE template, two timing
|
||||||
|
variants (Normal / Express). Cashier picks variant in POS.
|
||||||
|
Uses native product.attribute machinery.
|
||||||
|
═══════════════════════════════════════════════════════════════ -->
|
||||||
|
<record id="lm_attr_service_speed" model="product.attribute">
|
||||||
|
<field name="name">Service Speed</field>
|
||||||
|
<field name="display_type">radio</field>
|
||||||
|
<field name="create_variant">always</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="lm_attr_speed_normal" model="product.attribute.value">
|
||||||
|
<field name="name">Normal</field>
|
||||||
|
<field name="attribute_id" ref="lm_attr_service_speed"/>
|
||||||
|
<field name="sequence">1</field>
|
||||||
|
<field name="default_extra_price">0.00</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="lm_attr_speed_express" model="product.attribute.value">
|
||||||
|
<field name="name">Express</field>
|
||||||
|
<field name="attribute_id" ref="lm_attr_service_speed"/>
|
||||||
|
<field name="sequence">2</field>
|
||||||
|
<field name="default_extra_price">3.00</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Demo product removed; canonical catalog lives in
|
||||||
|
data/service_catalog_data.xml (Abaya / Thobe / T-Shirt). -->
|
||||||
|
|
||||||
|
</odoo>
|
||||||
41
addons/laundry_management/data/sequence.xml
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<!-- Laundry Order sequence: LND/2025/04/0001 -->
|
||||||
|
<record id="seq_laundry_order" model="ir.sequence">
|
||||||
|
<field name="name">Laundry Order</field>
|
||||||
|
<field name="code">laundry.order</field>
|
||||||
|
<field name="prefix">LND/%(year)s/%(month)s/</field>
|
||||||
|
<field name="padding">4</field>
|
||||||
|
<field name="number_increment">1</field>
|
||||||
|
<field name="number_next">1</field>
|
||||||
|
<field name="use_date_range" eval="False"/>
|
||||||
|
<field name="company_id" eval="False"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Laundry Session sequence (kept for backward compat) -->
|
||||||
|
<record id="seq_laundry_session" model="ir.sequence">
|
||||||
|
<field name="name">Laundry Session</field>
|
||||||
|
<field name="code">laundry.session</field>
|
||||||
|
<field name="prefix">LS/%(year)s/%(month)s/</field>
|
||||||
|
<field name="padding">4</field>
|
||||||
|
<field name="number_increment">1</field>
|
||||||
|
<field name="number_next">1</field>
|
||||||
|
<field name="use_date_range" eval="False"/>
|
||||||
|
<field name="company_id" eval="False"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Per-item tracking code: LI-000001 (6-digit pad, no date).
|
||||||
|
Used as the scannable barcode on each laundry.order.line. -->
|
||||||
|
<record id="seq_laundry_order_line_tracking" model="ir.sequence">
|
||||||
|
<field name="name">Laundry Item Tracking Code</field>
|
||||||
|
<field name="code">laundry.order.line.tracking</field>
|
||||||
|
<field name="prefix">LI-</field>
|
||||||
|
<field name="padding">6</field>
|
||||||
|
<field name="number_increment">1</field>
|
||||||
|
<field name="number_next">1</field>
|
||||||
|
<field name="use_date_range" eval="False"/>
|
||||||
|
<field name="company_id" eval="False"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
5
addons/laundry_management/data/service_catalog_data.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<!-- Placeholder to satisfy __manifest__.py.
|
||||||
|
Add service catalog seed data here later if needed. -->
|
||||||
|
</odoo>
|
||||||
52
addons/laundry_management/demo/demo.xml
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo noupdate="1">
|
||||||
|
|
||||||
|
<!-- Laundry orders themselves are created automatically from POS
|
||||||
|
(pos_order_id is required), so we don't seed laundry.order rows.
|
||||||
|
Below: a realistic demo service product with Normal / Express
|
||||||
|
variants so cashiers see something cleaner than a blank catalog. -->
|
||||||
|
|
||||||
|
<!-- ── Product attribute: Service Speed ─────────────────────────── -->
|
||||||
|
<record id="demo_attr_service_speed" model="product.attribute">
|
||||||
|
<field name="name">Service Speed</field>
|
||||||
|
<field name="display_type">radio</field>
|
||||||
|
<field name="create_variant">always</field>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="demo_attr_value_normal" model="product.attribute.value">
|
||||||
|
<field name="name">Normal</field>
|
||||||
|
<field name="attribute_id" ref="demo_attr_service_speed"/>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="demo_attr_value_express" model="product.attribute.value">
|
||||||
|
<field name="name">Express</field>
|
||||||
|
<field name="attribute_id" ref="demo_attr_service_speed"/>
|
||||||
|
<field name="default_extra_price">3.0</field>
|
||||||
|
<field name="sequence">20</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ── Demo product: T-Shirt / Polo Wash ───────────────────────── -->
|
||||||
|
<record id="demo_product_tshirt_wash" model="product.template">
|
||||||
|
<field name="name">T-Shirt / Polo Wash</field>
|
||||||
|
<field name="type">service</field>
|
||||||
|
<field name="sale_ok">True</field>
|
||||||
|
<field name="purchase_ok">False</field>
|
||||||
|
<field name="available_in_pos">True</field>
|
||||||
|
<field name="is_laundry_service">True</field>
|
||||||
|
<field name="list_price">8.0</field>
|
||||||
|
<field name="taxes_id" eval="[(5,)]"/>
|
||||||
|
<field name="description_sale">Wash + iron for tops. Express adds 3 SAR.</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="demo_product_tshirt_wash_attr_line" model="product.template.attribute.line">
|
||||||
|
<field name="product_tmpl_id" ref="demo_product_tshirt_wash"/>
|
||||||
|
<field name="attribute_id" ref="demo_attr_service_speed"/>
|
||||||
|
<field name="value_ids" eval="[(6, 0, [
|
||||||
|
ref('demo_attr_value_normal'),
|
||||||
|
ref('demo_attr_value_express'),
|
||||||
|
])]"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
7
addons/laundry_management/doc/RELEASE_NOTES.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
## Module <laundry_management>
|
||||||
|
|
||||||
|
#### 26.01.2026
|
||||||
|
#### Version 19.0.1.0.0
|
||||||
|
#### ADD
|
||||||
|
|
||||||
|
- Initial commit for Laundry Management
|
||||||
413
addons/laundry_management/i18n/ar.po
Normal file
@@ -0,0 +1,413 @@
|
|||||||
|
# Arabic translation for laundry_management
|
||||||
|
# Copyright (C) 2026
|
||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Project-Id-Version: Odoo Server 19.0\n"
|
||||||
|
"Report-Msgid-Bugs-To: \n"
|
||||||
|
"PO-Revision-Date: 2026-04-01 00:00+0000\n"
|
||||||
|
"Language: ar\n"
|
||||||
|
"MIME-Version: 1.0\n"
|
||||||
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Laundry"
|
||||||
|
msgstr "المغسلة"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Laundry Management"
|
||||||
|
msgstr "إدارة المغسلة"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Laundry Order"
|
||||||
|
msgstr "طلب مغسلة"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Laundry Orders"
|
||||||
|
msgstr "طلبات المغسلة"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Orders"
|
||||||
|
msgstr "الطلبات"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "All Orders"
|
||||||
|
msgstr "جميع الطلبات"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Operations Pipeline"
|
||||||
|
msgstr "خط سير العمليات"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Ready for Pickup"
|
||||||
|
msgstr "جاهز للاستلام"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Configuration"
|
||||||
|
msgstr "الإعدادات"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Service Categories"
|
||||||
|
msgstr "تصنيفات الخدمات"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Item Types"
|
||||||
|
msgstr "أنواع القطع"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Order No."
|
||||||
|
msgstr "رقم الطلب"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Customer"
|
||||||
|
msgstr "العميل"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Mobile"
|
||||||
|
msgstr "الجوال"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Order Date"
|
||||||
|
msgstr "تاريخ الطلب"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Expected Delivery"
|
||||||
|
msgstr "موعد التسليم المتوقع"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Order Type"
|
||||||
|
msgstr "نوع الطلب"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Walk-In / Drop-Off"
|
||||||
|
msgstr "مراجعة مباشرة"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "We Pick Up"
|
||||||
|
msgstr "نستلم من عندك"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Home Delivery"
|
||||||
|
msgstr "توصيل للمنزل"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Priority"
|
||||||
|
msgstr "الأولوية"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Normal"
|
||||||
|
msgstr "عادي"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Urgent"
|
||||||
|
msgstr "عاجل"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Express"
|
||||||
|
msgstr "سريع جداً"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Status"
|
||||||
|
msgstr "الحالة"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Draft"
|
||||||
|
msgstr "مسودة"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Received"
|
||||||
|
msgstr "مستلم"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "In Process"
|
||||||
|
msgstr "تحت المعالجة"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Ready"
|
||||||
|
msgstr "جاهز"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Handed Over"
|
||||||
|
msgstr "تم التسليم"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Cancelled"
|
||||||
|
msgstr "ملغي"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Confirm Receipt"
|
||||||
|
msgstr "تأكيد الاستلام"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Start Processing"
|
||||||
|
msgstr "بدء المعالجة"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Ready for Pickup"
|
||||||
|
msgstr "جاهز للاستلام"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Hand Over to Customer"
|
||||||
|
msgstr "تسليم للعميل"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Cancel Order"
|
||||||
|
msgstr "إلغاء الطلب"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Reopen"
|
||||||
|
msgstr "إعادة فتح"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Items & Services"
|
||||||
|
msgstr "القطع والخدمات"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Item"
|
||||||
|
msgstr "القطعة"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Item Type"
|
||||||
|
msgstr "نوع القطعة"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Service"
|
||||||
|
msgstr "الخدمة"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Category"
|
||||||
|
msgstr "التصنيف"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Color / Description"
|
||||||
|
msgstr "اللون / الوصف"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Stain / Damage"
|
||||||
|
msgstr "بقعة / ضرر"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Special Instructions"
|
||||||
|
msgstr "تعليمات خاصة"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Qty"
|
||||||
|
msgstr "الكمية"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Unit Price"
|
||||||
|
msgstr "سعر الوحدة"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Subtotal"
|
||||||
|
msgstr "المجموع الجزئي"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Total"
|
||||||
|
msgstr "الإجمالي"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Total Amount"
|
||||||
|
msgstr "المبلغ الإجمالي"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Items"
|
||||||
|
msgstr "القطع"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Payment"
|
||||||
|
msgstr "الدفع"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Unpaid"
|
||||||
|
msgstr "غير مدفوع"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Partial"
|
||||||
|
msgstr "مدفوع جزئياً"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Paid"
|
||||||
|
msgstr "مدفوع"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Invoice"
|
||||||
|
msgstr "الفاتورة"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Received By"
|
||||||
|
msgstr "استُلم بواسطة"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Handed Over By"
|
||||||
|
msgstr "سُلّم بواسطة"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Customer Notes"
|
||||||
|
msgstr "ملاحظات العميل"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Internal Notes"
|
||||||
|
msgstr "ملاحظات داخلية"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Processing Info"
|
||||||
|
msgstr "معلومات المعالجة"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Notes"
|
||||||
|
msgstr "الملاحظات"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Washing"
|
||||||
|
msgstr "غسيل"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Dry Cleaning"
|
||||||
|
msgstr "تنظيف جاف"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Ironing & Pressing"
|
||||||
|
msgstr "كوي وضغط"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Special Care"
|
||||||
|
msgstr "عناية خاصة"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Express Service"
|
||||||
|
msgstr "خدمة سريعة"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Shirt"
|
||||||
|
msgstr "قميص"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Trousers / Pants"
|
||||||
|
msgstr "بنطلون"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Suit (2-Piece)"
|
||||||
|
msgstr "بدلة (قطعتان)"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Abaya"
|
||||||
|
msgstr "عباية"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Thobe / Dishdasha"
|
||||||
|
msgstr "ثوب / دشداشة"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Dress / Gown"
|
||||||
|
msgstr "فستان / ثوب سهرة"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Jacket / Blazer"
|
||||||
|
msgstr "جاكيت / بليزر"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "T-Shirt / Polo"
|
||||||
|
msgstr "تيشيرت / بولو"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Jeans"
|
||||||
|
msgstr "جينز"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Blanket / Duvet"
|
||||||
|
msgstr "بطانية / لحاف"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Carpet / Rug"
|
||||||
|
msgstr "سجادة / موكيت"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Curtain"
|
||||||
|
msgstr "ستارة"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Uniform / Workwear"
|
||||||
|
msgstr "يونيفورم / ملابس عمل"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Wedding Dress"
|
||||||
|
msgstr "فستان زفاف"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Other / Custom Item"
|
||||||
|
msgstr "أخرى / قطعة مخصصة"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Laundry Order Receipt"
|
||||||
|
msgstr "إيصال طلب المغسلة"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Search Orders"
|
||||||
|
msgstr "البحث في الطلبات"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Today"
|
||||||
|
msgstr "اليوم"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Pending"
|
||||||
|
msgstr "قيد التنفيذ"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Delivered"
|
||||||
|
msgstr "مسلّم"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Laundry Service Category"
|
||||||
|
msgstr "تصنيف خدمة المغسلة"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Laundry Item Type"
|
||||||
|
msgstr "نوع قطعة المغسلة"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Laundry Order Line"
|
||||||
|
msgstr "سطر طلب المغسلة"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Default Service"
|
||||||
|
msgstr "الخدمة الافتراضية"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Default Unit Price"
|
||||||
|
msgstr "السعر الافتراضي للوحدة"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Active"
|
||||||
|
msgstr "نشط"
|
||||||
|
|
||||||
|
# ── Reporting menus integrated into Sales/Accounting dashboards ──────
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Laundry Orders Analysis"
|
||||||
|
msgstr "تحليل طلبات المغسلة"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Laundry Operations (Live)"
|
||||||
|
msgstr "عمليات المغسلة (مباشر)"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Laundry Invoices"
|
||||||
|
msgstr "فواتير المغسلة"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Reports"
|
||||||
|
msgstr "التقارير"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Orders Analysis"
|
||||||
|
msgstr "تحليل الطلبات"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Operations (Live)"
|
||||||
|
msgstr "العمليات (مباشر)"
|
||||||
|
|
||||||
|
#. module: laundry_management
|
||||||
|
msgid "Invoices Analysis"
|
||||||
|
msgstr "تحليل الفواتير"
|
||||||
228
addons/laundry_management/migrations/19.0.11.0.0/pre_migrate.py
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
"""
|
||||||
|
Pre-migration script for laundry_management 19.0.11.0.0
|
||||||
|
|
||||||
|
Architecture change: standalone laundry.order / laundry.order.line / laundry.payment
|
||||||
|
models are replaced by _inherit = 'sale.order' / 'sale.order.line'.
|
||||||
|
|
||||||
|
This script runs BEFORE Odoo's model sync so that:
|
||||||
|
1. FK constraints from old wizard tables referencing laundry_order are dropped
|
||||||
|
2. Old wizard transient records are purged (they reference non-existent rows)
|
||||||
|
3. Stale ir.model.fields records pointing to old models are removed
|
||||||
|
4. Old ir.model entries are deleted (unblocking the ORM delete check)
|
||||||
|
5. Old physical tables are dropped
|
||||||
|
|
||||||
|
Without this, the ORM raises:
|
||||||
|
"Another model is using the record you are trying to delete.
|
||||||
|
Blocking model: Laundry Print Wizard (laundry.print.wizard),
|
||||||
|
Blocking field: order_id"
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# Old standalone models being removed in this version
|
||||||
|
_OLD_MODELS = [
|
||||||
|
'laundry.order',
|
||||||
|
'laundry.order.line',
|
||||||
|
'laundry.payment',
|
||||||
|
'laundry.order.line.addon',
|
||||||
|
'laundry.register.payment.wizard',
|
||||||
|
'laundry.product.wizard',
|
||||||
|
'laundry.product.wizard.line',
|
||||||
|
'laundry.category',
|
||||||
|
'laundry.item.type',
|
||||||
|
]
|
||||||
|
|
||||||
|
# Physical tables that correspond to the old models
|
||||||
|
_OLD_TABLES = [
|
||||||
|
'laundry_order',
|
||||||
|
'laundry_order_line',
|
||||||
|
'laundry_payment',
|
||||||
|
'laundry_order_line_addon',
|
||||||
|
'laundry_register_payment_wizard',
|
||||||
|
'laundry_product_wizard',
|
||||||
|
'laundry_product_wizard_line',
|
||||||
|
'laundry_category',
|
||||||
|
'laundry_item_type',
|
||||||
|
]
|
||||||
|
|
||||||
|
# Transient/wizard tables that may hold rows with FK refs to laundry_order
|
||||||
|
_WIZARD_TABLES = [
|
||||||
|
'laundry_print_wizard',
|
||||||
|
'laundry_session_wizard',
|
||||||
|
'laundry_whatsapp_wizard',
|
||||||
|
'laundry_register_payment_wizard',
|
||||||
|
'laundry_product_wizard',
|
||||||
|
'laundry_product_wizard_line',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _table_exists(cr, table):
|
||||||
|
cr.execute(
|
||||||
|
"SELECT 1 FROM information_schema.tables "
|
||||||
|
"WHERE table_schema = 'public' AND table_name = %s",
|
||||||
|
(table,),
|
||||||
|
)
|
||||||
|
return bool(cr.fetchone())
|
||||||
|
|
||||||
|
|
||||||
|
def _column_exists(cr, table, column):
|
||||||
|
cr.execute(
|
||||||
|
"SELECT 1 FROM information_schema.columns "
|
||||||
|
"WHERE table_schema = 'public' AND table_name = %s AND column_name = %s",
|
||||||
|
(table, column),
|
||||||
|
)
|
||||||
|
return bool(cr.fetchone())
|
||||||
|
|
||||||
|
|
||||||
|
def _drop_fk_constraints_referencing(cr, referenced_table):
|
||||||
|
"""Drop all FK constraints in the DB that point at referenced_table."""
|
||||||
|
cr.execute(
|
||||||
|
"""
|
||||||
|
SELECT tc.table_name, tc.constraint_name
|
||||||
|
FROM information_schema.table_constraints tc
|
||||||
|
JOIN information_schema.referential_constraints rc
|
||||||
|
ON tc.constraint_name = rc.constraint_name
|
||||||
|
AND tc.table_schema = rc.constraint_schema
|
||||||
|
JOIN information_schema.table_constraints tc2
|
||||||
|
ON rc.unique_constraint_name = tc2.constraint_name
|
||||||
|
AND rc.unique_constraint_schema = tc2.table_schema
|
||||||
|
WHERE tc.constraint_type = 'FOREIGN KEY'
|
||||||
|
AND tc2.table_name = %s
|
||||||
|
AND tc.table_schema = 'public'
|
||||||
|
""",
|
||||||
|
(referenced_table,),
|
||||||
|
)
|
||||||
|
rows = cr.fetchall()
|
||||||
|
for src_table, constraint_name in rows:
|
||||||
|
_logger.info(
|
||||||
|
'pre_migrate: dropping FK constraint %s on %s (referenced %s)',
|
||||||
|
constraint_name, src_table, referenced_table,
|
||||||
|
)
|
||||||
|
cr.execute(
|
||||||
|
'ALTER TABLE "%s" DROP CONSTRAINT IF EXISTS "%s"' %
|
||||||
|
(src_table, constraint_name)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(cr, version):
|
||||||
|
if not version:
|
||||||
|
# Fresh install — nothing to clean up
|
||||||
|
return
|
||||||
|
|
||||||
|
_logger.info('pre_migrate laundry_management %s → 19.0.11.0.0 : start', version)
|
||||||
|
|
||||||
|
# ── Step 1: Purge all transient wizard records ────────────────────────────
|
||||||
|
# TransientModel rows expire naturally but DB rows persist during upgrade;
|
||||||
|
# they hold FK refs that block constraint drops and table drops.
|
||||||
|
for tbl in _WIZARD_TABLES:
|
||||||
|
if _table_exists(cr, tbl):
|
||||||
|
_logger.info('pre_migrate: truncating wizard table %s', tbl)
|
||||||
|
cr.execute('TRUNCATE TABLE "%s" CASCADE' % tbl)
|
||||||
|
|
||||||
|
# ── Step 2: Drop FK constraints pointing at old tables ────────────────────
|
||||||
|
for tbl in _OLD_TABLES:
|
||||||
|
if _table_exists(cr, tbl):
|
||||||
|
_drop_fk_constraints_referencing(cr, tbl)
|
||||||
|
|
||||||
|
# Also drop FKs on wizard tables that point at laundry_order (the primary blocker)
|
||||||
|
# Example: laundry_print_wizard.order_id -> laundry_order.id
|
||||||
|
for wizard_tbl in _WIZARD_TABLES:
|
||||||
|
if not _table_exists(cr, wizard_tbl):
|
||||||
|
continue
|
||||||
|
# Find and drop any FK on this wizard table that references an old table
|
||||||
|
cr.execute(
|
||||||
|
"""
|
||||||
|
SELECT kcu.column_name, ccu.table_name AS foreign_table_name, tc.constraint_name
|
||||||
|
FROM information_schema.table_constraints AS tc
|
||||||
|
JOIN information_schema.key_column_usage AS kcu
|
||||||
|
ON tc.constraint_name = kcu.constraint_name
|
||||||
|
AND tc.table_schema = kcu.table_schema
|
||||||
|
JOIN information_schema.constraint_column_usage AS ccu
|
||||||
|
ON ccu.constraint_name = tc.constraint_name
|
||||||
|
AND ccu.table_schema = tc.table_schema
|
||||||
|
WHERE tc.constraint_type = 'FOREIGN KEY'
|
||||||
|
AND tc.table_schema = 'public'
|
||||||
|
AND tc.table_name = %s
|
||||||
|
AND ccu.table_name = ANY(%s)
|
||||||
|
""",
|
||||||
|
(wizard_tbl, _OLD_TABLES),
|
||||||
|
)
|
||||||
|
for col, ref_tbl, constraint_name in cr.fetchall():
|
||||||
|
_logger.info(
|
||||||
|
'pre_migrate: dropping FK %s on %s.%s -> %s',
|
||||||
|
constraint_name, wizard_tbl, col, ref_tbl,
|
||||||
|
)
|
||||||
|
cr.execute(
|
||||||
|
'ALTER TABLE "%s" DROP CONSTRAINT IF EXISTS "%s"' %
|
||||||
|
(wizard_tbl, constraint_name)
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Step 3: Remove stale ir.model.fields that ref old models ─────────────
|
||||||
|
# These are the records that cause "Blocking model: laundry.print.wizard,
|
||||||
|
# Blocking field: order_id" — the field record itself still has
|
||||||
|
# relation = 'laundry.order', which makes the ORM think laundry.print.wizard
|
||||||
|
# still depends on laundry.order.
|
||||||
|
cr.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM ir_model_fields
|
||||||
|
WHERE relation = ANY(%s)
|
||||||
|
""",
|
||||||
|
(_OLD_MODELS,),
|
||||||
|
)
|
||||||
|
deleted = cr.rowcount
|
||||||
|
_logger.info('pre_migrate: deleted %d stale ir.model.fields rows', deleted)
|
||||||
|
|
||||||
|
# Also remove fields whose model itself is one of the old models
|
||||||
|
cr.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM ir_model_fields
|
||||||
|
WHERE model IN %s
|
||||||
|
""",
|
||||||
|
(tuple(_OLD_MODELS),),
|
||||||
|
)
|
||||||
|
_logger.info('pre_migrate: deleted %d ir.model.fields for old models', cr.rowcount)
|
||||||
|
|
||||||
|
# ── Step 4: Remove stale ir.model.fields_by_name cache ───────────────────
|
||||||
|
# In some Odoo versions there is a separate constraint/index table.
|
||||||
|
# Safe to attempt; ignore if table doesn't exist.
|
||||||
|
if _table_exists(cr, 'ir_model_constraint'):
|
||||||
|
cr.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM ir_model_constraint imc
|
||||||
|
USING ir_model im
|
||||||
|
WHERE imc.model = im.id
|
||||||
|
AND im.model = ANY(%s)
|
||||||
|
""",
|
||||||
|
(_OLD_MODELS,),
|
||||||
|
)
|
||||||
|
_logger.info('pre_migrate: removed %d ir.model.constraint rows', cr.rowcount)
|
||||||
|
|
||||||
|
if _table_exists(cr, 'ir_model_relation'):
|
||||||
|
cr.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM ir_model_relation imr
|
||||||
|
USING ir_model im
|
||||||
|
WHERE imr.model = im.id
|
||||||
|
AND im.model = ANY(%s)
|
||||||
|
""",
|
||||||
|
(_OLD_MODELS,),
|
||||||
|
)
|
||||||
|
_logger.info('pre_migrate: removed %d ir.model.relation rows', cr.rowcount)
|
||||||
|
|
||||||
|
# ── Step 5: Remove ir.model entries for the old models ───────────────────
|
||||||
|
cr.execute(
|
||||||
|
"DELETE FROM ir_model WHERE model = ANY(%s)",
|
||||||
|
(_OLD_MODELS,),
|
||||||
|
)
|
||||||
|
_logger.info('pre_migrate: deleted %d ir.model rows', cr.rowcount)
|
||||||
|
|
||||||
|
# ── Step 6: Drop old physical tables ─────────────────────────────────────
|
||||||
|
for tbl in reversed(_OLD_TABLES): # reverse to respect FK order
|
||||||
|
if _table_exists(cr, tbl):
|
||||||
|
_logger.info('pre_migrate: dropping table %s', tbl)
|
||||||
|
cr.execute('DROP TABLE IF EXISTS "%s" CASCADE' % tbl)
|
||||||
|
|
||||||
|
_logger.info('pre_migrate laundry_management: complete')
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""
|
||||||
|
Pre-migration for laundry_management 19.0.12.0.0
|
||||||
|
|
||||||
|
Changes handled:
|
||||||
|
1. Commission states: 'paid' only → now 'pending', 'confirmed', 'paid'
|
||||||
|
Existing 'paid' rows remain 'paid'. Existing 'pending' rows remain 'pending'.
|
||||||
|
'confirmed' is a new state — no existing rows use it, so no data migration needed.
|
||||||
|
|
||||||
|
2. New model: laundry.payment.wizard — just a new table, nothing to clean.
|
||||||
|
|
||||||
|
3. New groups: group_laundry_operator, group_laundry_cashier added to hierarchy.
|
||||||
|
Existing users with group_laundry_user keep all their permissions (implied).
|
||||||
|
|
||||||
|
4. Access control: new CSV entries for payment wizard and new groups.
|
||||||
|
Handled automatically by Odoo on upgrade.
|
||||||
|
|
||||||
|
No destructive operations needed in this migration.
|
||||||
|
The pre_migrate script from 19.0.11.0.0 already cleaned the legacy tables.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(cr, version):
|
||||||
|
if not version:
|
||||||
|
return # Fresh install
|
||||||
|
|
||||||
|
_logger.info('pre_migrate laundry_management 19.0.12.0.0: checking commission states')
|
||||||
|
|
||||||
|
# Ensure commission state column allows new 'confirmed' value.
|
||||||
|
# In Odoo, Selection fields are stored as VARCHAR — no schema change needed.
|
||||||
|
# Just verify the column exists and log the existing state distribution.
|
||||||
|
cr.execute("""
|
||||||
|
SELECT state, COUNT(*) FROM laundry_commission
|
||||||
|
GROUP BY state
|
||||||
|
ORDER BY state
|
||||||
|
""")
|
||||||
|
rows = cr.fetchall()
|
||||||
|
if rows:
|
||||||
|
_logger.info(
|
||||||
|
'pre_migrate: commission state distribution: %s',
|
||||||
|
{state: count for state, count in rows}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_logger.info('pre_migrate: laundry_commission table is empty — fresh data')
|
||||||
|
|
||||||
|
_logger.info('pre_migrate laundry_management 19.0.12.0.0: complete')
|
||||||
103
addons/laundry_management/migrations/19.0.13.0.0/pre_migrate.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
"""Pre-migration: 19.0.12.0.0 → 19.0.13.0.0
|
||||||
|
|
||||||
|
Removes three models that are being retired in this version:
|
||||||
|
- laundry.product.wizard (replaced by native sale.order.line product selection)
|
||||||
|
- laundry.product.wizard.line (child of above)
|
||||||
|
- laundry.whatsapp.wizard (replaced by one-click wa.me URL action)
|
||||||
|
|
||||||
|
If these ir.model records are left in the DB while the Python classes no longer
|
||||||
|
exist, Odoo will log warnings or fail on field-level checks during upgrade.
|
||||||
|
We clean them here, before the ORM loads.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_REMOVED_MODELS = [
|
||||||
|
'laundry.product.wizard',
|
||||||
|
'laundry.product.wizard.line',
|
||||||
|
'laundry.whatsapp.wizard',
|
||||||
|
]
|
||||||
|
|
||||||
|
_REMOVED_TABLES = [
|
||||||
|
'laundry_product_wizard',
|
||||||
|
'laundry_product_wizard_line',
|
||||||
|
'laundry_whatsapp_wizard',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(cr, version):
|
||||||
|
if not version:
|
||||||
|
return
|
||||||
|
|
||||||
|
_logger.info('pre_migrate 19.0.13.0.0: cleaning retired wizard models %s', _REMOVED_MODELS)
|
||||||
|
|
||||||
|
# 1. Drop physical tables (TransientModels do have real tables in Odoo)
|
||||||
|
for tbl in _REMOVED_TABLES:
|
||||||
|
cr.execute(f'DROP TABLE IF EXISTS "{tbl}" CASCADE')
|
||||||
|
_logger.info(' dropped table: %s', tbl)
|
||||||
|
|
||||||
|
# 2. Remove ir.model.access entries for these models
|
||||||
|
cr.execute("""
|
||||||
|
DELETE FROM ir_model_access
|
||||||
|
WHERE model_id IN (
|
||||||
|
SELECT id FROM ir_model WHERE model = ANY(%s)
|
||||||
|
)
|
||||||
|
""", (_REMOVED_MODELS,))
|
||||||
|
_logger.info(' deleted %d ir.model.access rows', cr.rowcount)
|
||||||
|
|
||||||
|
# 3. Remove ir.rule entries
|
||||||
|
cr.execute("""
|
||||||
|
DELETE FROM ir_rule
|
||||||
|
WHERE model_id IN (
|
||||||
|
SELECT id FROM ir_model WHERE model = ANY(%s)
|
||||||
|
)
|
||||||
|
""", (_REMOVED_MODELS,))
|
||||||
|
|
||||||
|
# 4. Remove ir.model.fields for these models
|
||||||
|
cr.execute("""
|
||||||
|
DELETE FROM ir_model_fields
|
||||||
|
WHERE model_id IN (
|
||||||
|
SELECT id FROM ir_model WHERE model = ANY(%s)
|
||||||
|
)
|
||||||
|
""", (_REMOVED_MODELS,))
|
||||||
|
_logger.info(' deleted ir.model.fields rows')
|
||||||
|
|
||||||
|
# 5. Remove ir.model.constraint
|
||||||
|
cr.execute("""
|
||||||
|
DELETE FROM ir_model_constraint
|
||||||
|
WHERE model_id IN (
|
||||||
|
SELECT id FROM ir_model WHERE model = ANY(%s)
|
||||||
|
)
|
||||||
|
""", (_REMOVED_MODELS,))
|
||||||
|
|
||||||
|
# 6. Remove ir.model.relation
|
||||||
|
cr.execute("""
|
||||||
|
DELETE FROM ir_model_relation
|
||||||
|
WHERE model_id IN (
|
||||||
|
SELECT id FROM ir_model WHERE model = ANY(%s)
|
||||||
|
)
|
||||||
|
""", (_REMOVED_MODELS,))
|
||||||
|
|
||||||
|
# 7. Remove ir.model.data (XML IDs) for these models
|
||||||
|
cr.execute("""
|
||||||
|
DELETE FROM ir_model_data
|
||||||
|
WHERE model = 'ir.model' AND name IN (
|
||||||
|
SELECT REPLACE(model, '.', '_') || '_' || id::text
|
||||||
|
FROM ir_model WHERE model = ANY(%s)
|
||||||
|
)
|
||||||
|
""", (_REMOVED_MODELS,))
|
||||||
|
# Also delete by res_id
|
||||||
|
cr.execute("""
|
||||||
|
DELETE FROM ir_model_data
|
||||||
|
WHERE model = 'ir.model'
|
||||||
|
AND res_id IN (SELECT id FROM ir_model WHERE model = ANY(%s))
|
||||||
|
""", (_REMOVED_MODELS,))
|
||||||
|
|
||||||
|
# 8. Finally remove ir.model entries themselves
|
||||||
|
cr.execute("""
|
||||||
|
DELETE FROM ir_model WHERE model = ANY(%s)
|
||||||
|
""", (_REMOVED_MODELS,))
|
||||||
|
_logger.info(' deleted ir.model entries for retired wizards')
|
||||||
|
|
||||||
|
_logger.info('pre_migrate 19.0.13.0.0: complete')
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""Phase 1 financial model migration.
|
||||||
|
|
||||||
|
Before: laundry_order.amount_paid was blindly copied from pos_order.amount_paid,
|
||||||
|
which includes Customer Account / pay-later payments. Every deferred sale
|
||||||
|
appeared fully paid → amount_due was always 0 → Settle Dues was non-functional.
|
||||||
|
|
||||||
|
After: amount_paid_cash + amount_deferred are computed from pos_payment rows,
|
||||||
|
classified by pos_payment_method.split_transactions. amount_due is recomputed
|
||||||
|
as amount_deferred - amount_settled.
|
||||||
|
|
||||||
|
This script rebuilds the split for every existing laundry.order by replaying
|
||||||
|
its linked pos_order's payment history.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(cr, version):
|
||||||
|
if not version:
|
||||||
|
return
|
||||||
|
|
||||||
|
_logger.info('laundry_management: rebuilding financial split for existing orders')
|
||||||
|
|
||||||
|
# Ensure the new columns exist (ORM will have added them, but be defensive)
|
||||||
|
cr.execute("""
|
||||||
|
ALTER TABLE laundry_order
|
||||||
|
ADD COLUMN IF NOT EXISTS amount_paid_cash numeric DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS amount_deferred numeric DEFAULT 0;
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Rebuild amount_paid_cash / amount_deferred per laundry order
|
||||||
|
# from pos_payment rows classified by pos_payment_method.split_transactions.
|
||||||
|
cr.execute("""
|
||||||
|
WITH classified AS (
|
||||||
|
SELECT
|
||||||
|
lo.id AS lo_id,
|
||||||
|
COALESCE(SUM(CASE WHEN pm.split_transactions = FALSE THEN pp.amount ELSE 0 END), 0) AS cash,
|
||||||
|
COALESCE(SUM(CASE WHEN pm.split_transactions = TRUE THEN pp.amount ELSE 0 END), 0) AS deferred
|
||||||
|
FROM laundry_order lo
|
||||||
|
LEFT JOIN pos_payment pp ON pp.pos_order_id = lo.pos_order_id
|
||||||
|
LEFT JOIN pos_payment_method pm ON pm.id = pp.payment_method_id
|
||||||
|
GROUP BY lo.id
|
||||||
|
)
|
||||||
|
UPDATE laundry_order lo
|
||||||
|
SET amount_paid_cash = c.cash,
|
||||||
|
amount_deferred = c.deferred,
|
||||||
|
amount_settled = COALESCE(lo.amount_settled, 0)
|
||||||
|
FROM classified c
|
||||||
|
WHERE c.lo_id = lo.id;
|
||||||
|
""")
|
||||||
|
|
||||||
|
# amount_due is a stored compute (amount_deferred - amount_settled).
|
||||||
|
# Populate it directly here so the values are correct before the ORM
|
||||||
|
# recomputes (ORM recompute on install will overwrite with same result).
|
||||||
|
cr.execute("""
|
||||||
|
UPDATE laundry_order
|
||||||
|
SET amount_due = GREATEST(
|
||||||
|
COALESCE(amount_deferred, 0) - COALESCE(amount_settled, 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
|
||||||
|
cr.execute("""
|
||||||
|
SELECT COUNT(*),
|
||||||
|
SUM(amount_paid_cash),
|
||||||
|
SUM(amount_deferred),
|
||||||
|
SUM(amount_due)
|
||||||
|
FROM laundry_order
|
||||||
|
""")
|
||||||
|
row = cr.fetchone()
|
||||||
|
_logger.info(
|
||||||
|
'laundry_management migration: %s orders — cash=%.2f, deferred=%.2f, due=%.2f',
|
||||||
|
row[0] or 0, row[1] or 0.0, row[2] or 0.0, row[3] or 0.0,
|
||||||
|
)
|
||||||
0
addons/laundry_management/migrations/__init__.py
Normal file
16
addons/laundry_management/models/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
from . import product_template_ext # adds is_laundry_service to product.template
|
||||||
|
from . import laundry_order_type # standalone: laundry.order.type
|
||||||
|
from . import laundry_order_attribute # standalone: laundry.order.attribute
|
||||||
|
from . import laundry_order # standalone: laundry.order
|
||||||
|
from . import laundry_order_line # standalone: laundry.order.line
|
||||||
|
from . import pos_order # extends pos.order with sync_from_ui hook
|
||||||
|
from . import pos_config_ext # extends pos.config with laundry-pos settings
|
||||||
|
from . import res_partner # extends res.partner with laundry unpaid count
|
||||||
|
from . import laundry_order_line_addon # add-on services per order line
|
||||||
|
from . import laundry_commission # standalone: commission tracking
|
||||||
|
# NOTE: laundry_dashboard removed — depends on laundry.session (POS-owned)
|
||||||
|
from . import laundry_payment_method # standalone: configurable payment methods
|
||||||
|
from . import laundry_settings # extends res.config.settings
|
||||||
|
from . import account_payment_ext # stamps pos_session_id on settlement payments
|
||||||
|
from . import pos_session_ext # ships new POS models to the client
|
||||||
|
# NOTE: laundry_session and account_move removed — session/accounting is POS-owned
|
||||||
21
addons/laundry_management/models/account_move.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
from odoo import models, fields, api
|
||||||
|
|
||||||
|
|
||||||
|
class AccountMoveLaundryExt(models.Model):
|
||||||
|
"""Flag invoices originating from laundry orders."""
|
||||||
|
_inherit = 'account.move'
|
||||||
|
|
||||||
|
is_laundry_invoice = fields.Boolean(
|
||||||
|
string='Laundry Invoice',
|
||||||
|
compute='_compute_is_laundry_invoice',
|
||||||
|
store=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.depends('invoice_line_ids.sale_line_ids.order_id.is_laundry_order')
|
||||||
|
def _compute_is_laundry_invoice(self):
|
||||||
|
for move in self:
|
||||||
|
move.is_laundry_invoice = any(
|
||||||
|
sol.order_id.is_laundry_order
|
||||||
|
for line in move.invoice_line_ids
|
||||||
|
for sol in line.sale_line_ids
|
||||||
|
)
|
||||||
22
addons/laundry_management/models/account_payment_ext.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
from odoo import models, fields
|
||||||
|
|
||||||
|
|
||||||
|
class AccountPaymentLaundryExt(models.Model):
|
||||||
|
"""Informational stamp — links settlement payments to the POS session
|
||||||
|
that was open when the cashier collected the money.
|
||||||
|
|
||||||
|
This field is purely for visibility (closing-screen summary). It does
|
||||||
|
NOT inject settlement totals into POS cash-control math.
|
||||||
|
"""
|
||||||
|
_inherit = 'account.payment'
|
||||||
|
|
||||||
|
pos_session_id = fields.Many2one(
|
||||||
|
'pos.session', string='POS Session',
|
||||||
|
readonly=True, copy=False, index=True,
|
||||||
|
help='POS session that was open when this settlement was created.',
|
||||||
|
)
|
||||||
|
settlement_pos_pm_id = fields.Many2one(
|
||||||
|
'pos.payment.method', string='Settlement Payment Method',
|
||||||
|
readonly=True, copy=False, index=True,
|
||||||
|
help='Original POS payment method chosen by the cashier during settlement.',
|
||||||
|
)
|
||||||
125
addons/laundry_management/models/laundry_commission.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
from odoo import models, fields, api
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
|
||||||
|
class LaundryCommission(models.Model):
|
||||||
|
"""Staff commission tracking (PART 3).
|
||||||
|
|
||||||
|
States:
|
||||||
|
pending — auto-created when order progresses; awaiting manager review
|
||||||
|
confirmed — manager has verified and approved the commission
|
||||||
|
paid — commission has been settled/paid to the staff member
|
||||||
|
|
||||||
|
The commission_account_id (from settings) is informational for now.
|
||||||
|
Managers can bulk-confirm and bulk-mark-paid from the list view.
|
||||||
|
"""
|
||||||
|
_name = 'laundry.commission'
|
||||||
|
_description = 'Laundry Staff Commission'
|
||||||
|
_inherit = ['mail.thread']
|
||||||
|
_order = 'date desc, id desc'
|
||||||
|
|
||||||
|
name = fields.Char(
|
||||||
|
string='Reference',
|
||||||
|
compute='_compute_name', store=True, readonly=True,
|
||||||
|
)
|
||||||
|
order_id = fields.Many2one(
|
||||||
|
'sale.order', string='Order',
|
||||||
|
required=True, ondelete='cascade', index=True,
|
||||||
|
)
|
||||||
|
company_id = fields.Many2one(
|
||||||
|
'res.company',
|
||||||
|
related='order_id.company_id', store=True, index=True,
|
||||||
|
)
|
||||||
|
employee_id = fields.Many2one(
|
||||||
|
'res.users', string='Staff Member',
|
||||||
|
required=True, domain=[('share', '=', False)],
|
||||||
|
tracking=True,
|
||||||
|
)
|
||||||
|
role = fields.Selection([
|
||||||
|
('reception', 'Reception / Intake'),
|
||||||
|
('processing', 'Processing / Cleaning'),
|
||||||
|
('delivery', 'Delivery / Handover'),
|
||||||
|
], string='Role', required=True, tracking=True)
|
||||||
|
|
||||||
|
commission_type = fields.Selection([
|
||||||
|
('percentage', 'Percentage (%)'),
|
||||||
|
('fixed', 'Fixed Amount'),
|
||||||
|
], string='Type', required=True, default='percentage')
|
||||||
|
|
||||||
|
rate = fields.Float(string='Rate / Amount', digits=(10, 2))
|
||||||
|
base_amount = fields.Float(string='Order Total', digits=(10, 2))
|
||||||
|
commission_amount = fields.Float(
|
||||||
|
string='Commission',
|
||||||
|
compute='_compute_commission_amount', store=True, digits=(10, 2),
|
||||||
|
)
|
||||||
|
|
||||||
|
date = fields.Date(string='Date', required=True, default=fields.Date.today)
|
||||||
|
|
||||||
|
state = fields.Selection([
|
||||||
|
('pending', 'Pending'),
|
||||||
|
('confirmed', 'Confirmed'),
|
||||||
|
('paid', 'Paid'),
|
||||||
|
], default='pending', required=True, tracking=True, copy=False, index=True)
|
||||||
|
|
||||||
|
notes = fields.Text(string='Notes')
|
||||||
|
|
||||||
|
_ROLE_LABELS = {
|
||||||
|
'reception': 'Reception',
|
||||||
|
'processing': 'Processing',
|
||||||
|
'delivery': 'Delivery',
|
||||||
|
}
|
||||||
|
|
||||||
|
@api.depends('order_id', 'order_id.name', 'role')
|
||||||
|
def _compute_name(self):
|
||||||
|
for rec in self:
|
||||||
|
order = rec.order_id.name or 'NEW'
|
||||||
|
role = self._ROLE_LABELS.get(rec.role, rec.role or '')
|
||||||
|
rec.name = f'COM/{order}/{role}'
|
||||||
|
|
||||||
|
@api.depends('commission_type', 'rate', 'base_amount')
|
||||||
|
def _compute_commission_amount(self):
|
||||||
|
for rec in self:
|
||||||
|
if rec.commission_type == 'percentage':
|
||||||
|
rec.commission_amount = rec.base_amount * rec.rate / 100.0
|
||||||
|
else:
|
||||||
|
rec.commission_amount = rec.rate
|
||||||
|
|
||||||
|
# ── State transitions ─────────────────────────────────────────────
|
||||||
|
def action_confirm(self):
|
||||||
|
"""Manager confirms commission is valid and approved."""
|
||||||
|
for rec in self:
|
||||||
|
if rec.state != 'pending':
|
||||||
|
raise UserError(
|
||||||
|
f'"{rec.name}" cannot be confirmed — current state: {rec.state}.'
|
||||||
|
)
|
||||||
|
rec.write({'state': 'confirmed'})
|
||||||
|
rec.message_post(
|
||||||
|
body=f'Commission confirmed by {self.env.user.name}. '
|
||||||
|
f'Amount: {rec.commission_amount:.2f}'
|
||||||
|
)
|
||||||
|
|
||||||
|
def action_mark_paid(self):
|
||||||
|
"""Mark commission as settled/paid to staff."""
|
||||||
|
for rec in self:
|
||||||
|
if rec.state == 'paid':
|
||||||
|
raise UserError(f'"{rec.name}" is already paid.')
|
||||||
|
if rec.state == 'pending':
|
||||||
|
# Allow paying directly from pending (manager shortcut)
|
||||||
|
rec.write({'state': 'paid'})
|
||||||
|
else:
|
||||||
|
rec.write({'state': 'paid'})
|
||||||
|
rec.message_post(
|
||||||
|
body=f'Marked as paid by {self.env.user.name}.'
|
||||||
|
)
|
||||||
|
|
||||||
|
def action_reset_pending(self):
|
||||||
|
"""Reset commission back to pending (manager only)."""
|
||||||
|
for rec in self:
|
||||||
|
if rec.state == 'paid':
|
||||||
|
raise UserError(
|
||||||
|
f'Cannot reset "{rec.name}" — it has already been paid.'
|
||||||
|
)
|
||||||
|
rec.write({'state': 'pending'})
|
||||||
|
rec.message_post(
|
||||||
|
body=f'Reset to pending by {self.env.user.name}.'
|
||||||
|
)
|
||||||
180
addons/laundry_management/models/laundry_dashboard.py
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
from odoo import models, fields, api
|
||||||
|
|
||||||
|
|
||||||
|
class LaundryDashboard(models.TransientModel):
|
||||||
|
"""Live KPI dashboard — queries sale.order with is_laundry_order = True."""
|
||||||
|
_name = 'laundry.dashboard'
|
||||||
|
_description = 'Laundry Dashboard'
|
||||||
|
|
||||||
|
today_orders = fields.Integer(string="Today's Orders")
|
||||||
|
today_revenue = fields.Monetary(string="Today's Revenue", currency_field='currency_id')
|
||||||
|
today_collected = fields.Monetary(string='Collected Today', currency_field='currency_id')
|
||||||
|
today_outstanding = fields.Monetary(string='Outstanding Today', currency_field='currency_id')
|
||||||
|
|
||||||
|
pending_count = fields.Integer(string='Pending Orders')
|
||||||
|
ready_count = fields.Integer(string='Ready for Pickup')
|
||||||
|
in_progress_count = fields.Integer(string='In Processing')
|
||||||
|
draft_count = fields.Integer(string='Quotes / Draft')
|
||||||
|
|
||||||
|
session_is_open = fields.Boolean(string='Session Open')
|
||||||
|
session_name = fields.Char(string='Session')
|
||||||
|
session_opening_cash = fields.Monetary(string='Opening Float', currency_field='currency_id')
|
||||||
|
session_sales = fields.Monetary(string='Session Sales', currency_field='currency_id')
|
||||||
|
session_cash = fields.Monetary(string='Session Cash', currency_field='currency_id')
|
||||||
|
session_bank = fields.Monetary(string='Session Bank', currency_field='currency_id')
|
||||||
|
session_id = fields.Many2one('laundry.session', string='Session Link')
|
||||||
|
|
||||||
|
month_orders = fields.Integer(string='Orders This Month')
|
||||||
|
month_revenue = fields.Monetary(string='Revenue This Month', currency_field='currency_id')
|
||||||
|
month_paid = fields.Monetary(string='Collected This Month', currency_field='currency_id')
|
||||||
|
|
||||||
|
currency_id = fields.Many2one(
|
||||||
|
'res.currency',
|
||||||
|
default=lambda self: self.env.company.currency_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _build(self):
|
||||||
|
today = fields.Date.today()
|
||||||
|
month_start = today.replace(day=1)
|
||||||
|
company = self.env.company
|
||||||
|
Order = self.env['sale.order']
|
||||||
|
Payment = self.env['account.payment']
|
||||||
|
|
||||||
|
_base_domain = [
|
||||||
|
('is_laundry_order', '=', True),
|
||||||
|
('company_id', '=', company.id),
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── Today ──────────────────────────────────────────────────────
|
||||||
|
today_orders = Order.search(_base_domain + [
|
||||||
|
('date_order', '>=', fields.Datetime.to_datetime(today)),
|
||||||
|
('state', 'not in', ['cancel', 'draft']),
|
||||||
|
])
|
||||||
|
today_invoices = today_orders.mapped('invoice_ids').filtered(
|
||||||
|
lambda i: i.state == 'posted' and i.move_type == 'out_invoice'
|
||||||
|
)
|
||||||
|
today_revenue = sum(today_orders.mapped('amount_total'))
|
||||||
|
today_outstanding = sum(
|
||||||
|
max(i.amount_residual, 0.0) for i in today_invoices
|
||||||
|
)
|
||||||
|
today_collected = today_revenue - today_outstanding
|
||||||
|
|
||||||
|
# ── Pipeline (all active laundry orders) ──────────────────────
|
||||||
|
pipeline = Order.search(_base_domain + [
|
||||||
|
('state', '=', 'sale'),
|
||||||
|
])
|
||||||
|
pending_count = len(pipeline)
|
||||||
|
ready_count = len(pipeline.filtered(lambda o: o.laundry_state == 'ready'))
|
||||||
|
in_progress_count = len(pipeline.filtered(lambda o: o.laundry_state == 'processing'))
|
||||||
|
draft_count = len(Order.search(_base_domain + [('state', '=', 'draft')]))
|
||||||
|
|
||||||
|
# ── Session ────────────────────────────────────────────────────
|
||||||
|
session = self.env['laundry.session'].search([
|
||||||
|
('state', '=', 'opened'),
|
||||||
|
('company_id', '=', company.id),
|
||||||
|
], limit=1)
|
||||||
|
|
||||||
|
# ── Month ──────────────────────────────────────────────────────
|
||||||
|
month_orders = Order.search(_base_domain + [
|
||||||
|
('date_order', '>=', fields.Datetime.to_datetime(month_start)),
|
||||||
|
('state', 'not in', ['cancel', 'draft']),
|
||||||
|
])
|
||||||
|
month_invoices = month_orders.mapped('invoice_ids').filtered(
|
||||||
|
lambda i: i.state == 'posted' and i.move_type == 'out_invoice'
|
||||||
|
)
|
||||||
|
month_revenue = sum(month_orders.mapped('amount_total'))
|
||||||
|
month_outstanding = sum(max(i.amount_residual, 0.0) for i in month_invoices)
|
||||||
|
month_paid = month_revenue - month_outstanding
|
||||||
|
|
||||||
|
return self.create({
|
||||||
|
'today_orders' : len(today_orders),
|
||||||
|
'today_revenue' : today_revenue,
|
||||||
|
'today_collected' : max(today_collected, 0.0),
|
||||||
|
'today_outstanding' : today_outstanding,
|
||||||
|
'pending_count' : pending_count,
|
||||||
|
'ready_count' : ready_count,
|
||||||
|
'in_progress_count' : in_progress_count,
|
||||||
|
'draft_count' : draft_count,
|
||||||
|
'session_is_open' : bool(session),
|
||||||
|
'session_name' : session.name if session else '',
|
||||||
|
'session_opening_cash' : session.opening_cash if session else 0.0,
|
||||||
|
'session_sales' : session.total_sales if session else 0.0,
|
||||||
|
'session_cash' : session.total_cash if session else 0.0,
|
||||||
|
'session_bank' : session.total_bank if session else 0.0,
|
||||||
|
'session_id' : session.id if session else False,
|
||||||
|
'month_orders' : len(month_orders),
|
||||||
|
'month_revenue' : month_revenue,
|
||||||
|
'month_paid' : max(month_paid, 0.0),
|
||||||
|
})
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def action_open_dashboard(self):
|
||||||
|
rec = self._build()
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'name': 'Dashboard',
|
||||||
|
'res_model': 'laundry.dashboard',
|
||||||
|
'res_id': rec.id,
|
||||||
|
'view_mode': 'form',
|
||||||
|
'target': 'main',
|
||||||
|
'flags': {'mode': 'readonly'},
|
||||||
|
}
|
||||||
|
|
||||||
|
def action_refresh(self):
|
||||||
|
return self.action_open_dashboard()
|
||||||
|
|
||||||
|
def action_new_order(self):
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'name': 'New Laundry Order',
|
||||||
|
'res_model': 'sale.order',
|
||||||
|
'view_mode': 'form',
|
||||||
|
'target': 'current',
|
||||||
|
'context': {
|
||||||
|
'default_is_laundry_order': True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def action_open_session(self):
|
||||||
|
if self.session_id:
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'name': 'Session',
|
||||||
|
'res_model': 'laundry.session',
|
||||||
|
'res_id': self.session_id.id,
|
||||||
|
'view_mode': 'form',
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'name': 'Sessions',
|
||||||
|
'res_model': 'laundry.session',
|
||||||
|
'view_mode': 'list,form',
|
||||||
|
}
|
||||||
|
|
||||||
|
def action_new_session(self):
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'name': 'New Session',
|
||||||
|
'res_model': 'laundry.session',
|
||||||
|
'view_mode': 'form',
|
||||||
|
}
|
||||||
|
|
||||||
|
def action_view_ready_orders(self):
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'name': 'Ready for Pickup',
|
||||||
|
'res_model': 'sale.order',
|
||||||
|
'view_mode': 'list,form',
|
||||||
|
'domain': [('is_laundry_order', '=', True), ('laundry_state', '=', 'ready')],
|
||||||
|
}
|
||||||
|
|
||||||
|
def action_view_pending_orders(self):
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'name': 'Pending Orders',
|
||||||
|
'res_model': 'sale.order',
|
||||||
|
'view_mode': 'list,form',
|
||||||
|
'domain': [('is_laundry_order', '=', True), ('state', '=', 'sale'),
|
||||||
|
('laundry_state', 'not in', ['delivered'])],
|
||||||
|
}
|
||||||
765
addons/laundry_management/models/laundry_order.py
Normal file
@@ -0,0 +1,765 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from odoo import models, fields, api, _
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
STATES = [
|
||||||
|
('intake', 'Intake'),
|
||||||
|
('processing', 'Processing'),
|
||||||
|
('ready', 'Ready for Pickup'),
|
||||||
|
('delivered', 'Delivered'),
|
||||||
|
('cancelled', 'Cancelled'),
|
||||||
|
]
|
||||||
|
STATE_KEYS = [s[0] for s in STATES]
|
||||||
|
FINAL_STATES = {'delivered', 'cancelled'}
|
||||||
|
|
||||||
|
SOURCE_TYPES = [
|
||||||
|
('pos', 'Point of Sale'),
|
||||||
|
('manual', 'Manual / Backoffice'),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Header fields the lock protects. NOT included on purpose:
|
||||||
|
# - state (workflow advance is always allowed via dedicated actions)
|
||||||
|
# - amount_settled (settlement engine writes after lock)
|
||||||
|
# - notes (managerial commentary always allowed)
|
||||||
|
# - manager_unlocked_* (the unlock wizard writes these)
|
||||||
|
# - tracking_enabled (Phase-4 prep, manager configuration)
|
||||||
|
LOCKED_HEADER_FIELDS = frozenset({
|
||||||
|
'partner_id',
|
||||||
|
'amount_total', 'amount_paid_cash', 'amount_deferred',
|
||||||
|
'order_type_id', 'attribute_ids',
|
||||||
|
'is_delivery', 'delivery_address', 'delivery_scheduled_at',
|
||||||
|
'priority_level',
|
||||||
|
'pos_order_id', 'pos_reference',
|
||||||
|
'source_type', 'name', 'company_id',
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sentinel context flag set by the POS sync hook (and any other automated
|
||||||
|
# server-side path) to allow the create + write that bring a fresh
|
||||||
|
# laundry.order to life. Without this flag, locked orders refuse mutations.
|
||||||
|
POS_SYNC_CTX = 'laundry_pos_sync'
|
||||||
|
|
||||||
|
|
||||||
|
class LaundryOrder(models.Model):
|
||||||
|
"""Standalone laundry order — created from POS.
|
||||||
|
|
||||||
|
POS owns payments/sessions/accounting. This model handles operational
|
||||||
|
workflow only: intake -> processing -> ready -> delivered.
|
||||||
|
|
||||||
|
Financial model (Phase 1 fix):
|
||||||
|
amount_total = mirror of pos.order.amount_total
|
||||||
|
amount_paid_cash = real money collected at origin (cash/card)
|
||||||
|
amount_deferred = Customer Account / pay-later amount at origin
|
||||||
|
amount_settled = money collected later via settlement engine
|
||||||
|
amount_due = amount_deferred - amount_settled
|
||||||
|
|
||||||
|
`amount_due > 0` means the customer still owes real money.
|
||||||
|
"""
|
||||||
|
_name = 'laundry.order'
|
||||||
|
_description = 'Laundry Order'
|
||||||
|
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||||
|
_order = 'create_date desc, id desc'
|
||||||
|
|
||||||
|
name = fields.Char(
|
||||||
|
string='Order No.',
|
||||||
|
required=True, copy=False, readonly=True,
|
||||||
|
default='New', tracking=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- POS link --
|
||||||
|
# Optional: manual/backoffice orders have no POS origin. The uniqueness
|
||||||
|
# constraint below still enforces "one laundry order per pos.order" for
|
||||||
|
# POS-sourced rows.
|
||||||
|
pos_order_id = fields.Many2one(
|
||||||
|
'pos.order', string='POS Order',
|
||||||
|
index=True, readonly=True, copy=False,
|
||||||
|
ondelete='restrict',
|
||||||
|
)
|
||||||
|
pos_reference = fields.Char(
|
||||||
|
string='POS Reference',
|
||||||
|
readonly=True, copy=False, index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- Customer --
|
||||||
|
partner_id = fields.Many2one(
|
||||||
|
'res.partner', string='Customer',
|
||||||
|
required=True, index=True, tracking=True,
|
||||||
|
)
|
||||||
|
partner_phone = fields.Char(
|
||||||
|
related='partner_id.phone', string='Phone', readonly=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- Company / Currency --
|
||||||
|
company_id = fields.Many2one(
|
||||||
|
'res.company', string='Company',
|
||||||
|
required=True, default=lambda self: self.env.company,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
currency_id = fields.Many2one(
|
||||||
|
related='company_id.currency_id', store=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- Workflow --
|
||||||
|
state = fields.Selection(
|
||||||
|
STATES,
|
||||||
|
string='Status',
|
||||||
|
default='intake',
|
||||||
|
required=True,
|
||||||
|
tracking=True,
|
||||||
|
copy=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- Lines --
|
||||||
|
line_ids = fields.One2many(
|
||||||
|
'laundry.order.line', 'order_id', string='Order Lines',
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- Financial snapshot --
|
||||||
|
amount_total = fields.Monetary(
|
||||||
|
string='Total',
|
||||||
|
currency_field='currency_id',
|
||||||
|
readonly=True, copy=False, store=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
amount_paid_cash = fields.Monetary(
|
||||||
|
string='Paid (Cash/Card)',
|
||||||
|
currency_field='currency_id',
|
||||||
|
readonly=True, copy=False, store=True,
|
||||||
|
default=0.0,
|
||||||
|
help='Amount collected at origin via non-deferred payment methods '
|
||||||
|
'(cash, card — any pos.payment.method with split_transactions=False).',
|
||||||
|
)
|
||||||
|
|
||||||
|
amount_deferred = fields.Monetary(
|
||||||
|
string='Deferred',
|
||||||
|
currency_field='currency_id',
|
||||||
|
readonly=True, copy=False, store=True,
|
||||||
|
default=0.0,
|
||||||
|
help='Amount deferred at origin via Customer Account / pay-later '
|
||||||
|
'(any pos.payment.method with split_transactions=True).',
|
||||||
|
)
|
||||||
|
|
||||||
|
amount_settled = fields.Monetary(
|
||||||
|
string='Settled',
|
||||||
|
currency_field='currency_id',
|
||||||
|
readonly=True, copy=False, store=True,
|
||||||
|
default=0.0,
|
||||||
|
help='Amount collected later via the settlement engine '
|
||||||
|
'(account.payment + reconciliation).',
|
||||||
|
)
|
||||||
|
|
||||||
|
amount_due = fields.Monetary(
|
||||||
|
string='Due',
|
||||||
|
currency_field='currency_id',
|
||||||
|
compute='_compute_amount_due',
|
||||||
|
store=True,
|
||||||
|
help='Remaining balance the customer owes: amount_deferred - amount_settled.',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Back-compat alias — views/reports still reference `amount_paid`.
|
||||||
|
# Computed, non-stored; reflects real cash collected at origin.
|
||||||
|
amount_paid = fields.Monetary(
|
||||||
|
string='Paid',
|
||||||
|
currency_field='currency_id',
|
||||||
|
compute='_compute_amount_paid_alias',
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- Computed --
|
||||||
|
item_count = fields.Integer(
|
||||||
|
string='Items',
|
||||||
|
compute='_compute_item_count',
|
||||||
|
store=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- Order type / attributes / delivery --
|
||||||
|
order_type_id = fields.Many2one(
|
||||||
|
'laundry.order.type', string='Order Type',
|
||||||
|
index=True, tracking=True,
|
||||||
|
)
|
||||||
|
attribute_ids = fields.Many2many(
|
||||||
|
'laundry.order.attribute',
|
||||||
|
'laundry_order_attribute_rel',
|
||||||
|
'order_id', 'attribute_id',
|
||||||
|
string='Attributes',
|
||||||
|
)
|
||||||
|
is_delivery = fields.Boolean(string='Delivery', tracking=True, index=True)
|
||||||
|
delivery_address = fields.Text(string='Delivery Address')
|
||||||
|
delivery_scheduled_at = fields.Datetime(string='Scheduled At')
|
||||||
|
priority_level = fields.Selection([
|
||||||
|
('normal', 'Normal'),
|
||||||
|
('urgent', 'Urgent'),
|
||||||
|
], string='Priority', default='normal', tracking=True, index=True)
|
||||||
|
|
||||||
|
# -- Notes --
|
||||||
|
notes = fields.Text(string='Notes')
|
||||||
|
|
||||||
|
# ── Source / locking (Phase 3) ────────────────────────────────────
|
||||||
|
# source_type is the truth-bearing identity. is_from_pos is a stored
|
||||||
|
# mirror used in domains, list filters, and rule conditions where a
|
||||||
|
# selection field would be awkward.
|
||||||
|
source_type = fields.Selection(
|
||||||
|
SOURCE_TYPES,
|
||||||
|
string='Source',
|
||||||
|
required=True,
|
||||||
|
default='manual',
|
||||||
|
readonly=True,
|
||||||
|
copy=False,
|
||||||
|
index=True,
|
||||||
|
tracking=True,
|
||||||
|
help='POS-sourced orders are hard-locked: lines, prices and the '
|
||||||
|
'customer cannot be edited unless a manager grants a '
|
||||||
|
'temporary unlock window. Manual orders are editable until '
|
||||||
|
'they reach a final state (delivered / cancelled).',
|
||||||
|
)
|
||||||
|
|
||||||
|
is_from_pos = fields.Boolean(
|
||||||
|
string='From POS',
|
||||||
|
compute='_compute_is_from_pos',
|
||||||
|
store=True, index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Phase-4 prep — flag only, no logic wired yet.
|
||||||
|
tracking_enabled = fields.Boolean(
|
||||||
|
string='Per-Item Tracking',
|
||||||
|
default=False,
|
||||||
|
copy=False,
|
||||||
|
help='When enabled, each laundry.order.line will be advanced '
|
||||||
|
'through its own state machine. Phase 4 wires the '
|
||||||
|
'synchronization between order state and item state.',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Computed: True when the order refuses mutation of LOCKED_HEADER_FIELDS
|
||||||
|
# and any line write/create/unlink. Not stored — cheap to recompute and
|
||||||
|
# depends on a Datetime that ages out without a write.
|
||||||
|
locked = fields.Boolean(
|
||||||
|
string='Locked',
|
||||||
|
compute='_compute_locked',
|
||||||
|
help='Order is read-only when True. POS-sourced orders are '
|
||||||
|
'always locked. Final-state orders (delivered, cancelled) '
|
||||||
|
'are always locked. Managers can grant a temporary unlock '
|
||||||
|
'window via the "Unlock for Editing" action.',
|
||||||
|
)
|
||||||
|
|
||||||
|
manager_unlocked_until = fields.Datetime(
|
||||||
|
string='Unlock Window Expires',
|
||||||
|
copy=False, readonly=True,
|
||||||
|
help='When set in the future, the lock guard is suspended. '
|
||||||
|
'Auto-expires; no manual re-lock required.',
|
||||||
|
)
|
||||||
|
|
||||||
|
manager_unlocked_by = fields.Many2one(
|
||||||
|
'res.users', string='Last Unlocked By',
|
||||||
|
copy=False, readonly=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
manager_unlock_reason = fields.Char(
|
||||||
|
string='Last Unlock Reason',
|
||||||
|
copy=False, readonly=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Stamped when the order moves to delivered. Powers the avg-processing
|
||||||
|
# and on-time KPIs on the Operations Dashboard. Outside
|
||||||
|
# LOCKED_HEADER_FIELDS so action_deliver can write it on POS-locked
|
||||||
|
# orders without needing the bypass context.
|
||||||
|
delivered_at = fields.Datetime(
|
||||||
|
string='Delivered At',
|
||||||
|
readonly=True, copy=False, index=True,
|
||||||
|
help='Timestamp set automatically when the order moves to '
|
||||||
|
'Delivered. Used by the analytics dashboard to compute '
|
||||||
|
'processing time and on-time delivery percentage.',
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- Constraints --
|
||||||
|
_pos_order_uniq = models.Constraint(
|
||||||
|
'UNIQUE(pos_order_id)',
|
||||||
|
'A laundry order already exists for this POS order.',
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.constrains('source_type', 'pos_order_id')
|
||||||
|
def _check_source_type_consistency(self):
|
||||||
|
for order in self:
|
||||||
|
if order.source_type == 'pos' and not order.pos_order_id:
|
||||||
|
raise UserError(_(
|
||||||
|
'Order "%s" is marked as POS-sourced but has no '
|
||||||
|
'linked POS order.', order.name or order.id,
|
||||||
|
))
|
||||||
|
if order.source_type == 'manual' and order.pos_order_id:
|
||||||
|
raise UserError(_(
|
||||||
|
'Order "%s" is marked as manual but has a linked '
|
||||||
|
'POS order. Set source_type="pos" to keep them '
|
||||||
|
'consistent.', order.name or order.id,
|
||||||
|
))
|
||||||
|
|
||||||
|
# -- Computed --
|
||||||
|
@api.depends('amount_deferred', 'amount_settled')
|
||||||
|
def _compute_amount_due(self):
|
||||||
|
for order in self:
|
||||||
|
order.amount_due = max(
|
||||||
|
(order.amount_deferred or 0.0) - (order.amount_settled or 0.0),
|
||||||
|
0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.depends('amount_paid_cash')
|
||||||
|
def _compute_amount_paid_alias(self):
|
||||||
|
for order in self:
|
||||||
|
order.amount_paid = order.amount_paid_cash or 0.0
|
||||||
|
|
||||||
|
@api.depends('source_type')
|
||||||
|
def _compute_is_from_pos(self):
|
||||||
|
for order in self:
|
||||||
|
order.is_from_pos = order.source_type == 'pos'
|
||||||
|
|
||||||
|
@api.depends('source_type', 'state', 'manager_unlocked_until')
|
||||||
|
def _compute_locked(self):
|
||||||
|
now = fields.Datetime.now()
|
||||||
|
for order in self:
|
||||||
|
unlock_active = bool(
|
||||||
|
order.manager_unlocked_until
|
||||||
|
and order.manager_unlocked_until > now
|
||||||
|
)
|
||||||
|
base_locked = (
|
||||||
|
order.source_type == 'pos'
|
||||||
|
or order.state in FINAL_STATES
|
||||||
|
)
|
||||||
|
order.locked = base_locked and not unlock_active
|
||||||
|
|
||||||
|
# ── Lock enforcement helpers ──────────────────────────────────────
|
||||||
|
def _is_pos_sync(self):
|
||||||
|
"""True when the call originates from the POS sync hook (or any
|
||||||
|
explicit server path that opts in via the context flag).
|
||||||
|
|
||||||
|
Both create() and write() honour this so the bridge from
|
||||||
|
pos.order can build / refresh the laundry.order without fighting
|
||||||
|
its own lock guard. This is checked BEFORE anything else in
|
||||||
|
write() so indirect POS writes (stored-compute flushes, cascades)
|
||||||
|
can never raise from the guard.
|
||||||
|
"""
|
||||||
|
return bool(self.env.context.get(POS_SYNC_CTX))
|
||||||
|
|
||||||
|
def _check_lock_for_write(self, vals):
|
||||||
|
"""Raise UserError when `vals` would mutate a protected header
|
||||||
|
field on a currently-locked order. Workflow advances (state,
|
||||||
|
amount_settled, notes, manager_unlocked_*) are excluded by
|
||||||
|
whitelist (LOCKED_HEADER_FIELDS).
|
||||||
|
|
||||||
|
Note: the POS-sync bypass is already applied at the top of
|
||||||
|
`write()` — this helper is only invoked for non-bypassed paths.
|
||||||
|
"""
|
||||||
|
protected = LOCKED_HEADER_FIELDS.intersection(vals.keys())
|
||||||
|
if not protected:
|
||||||
|
return
|
||||||
|
for order in self:
|
||||||
|
if order.locked:
|
||||||
|
raise UserError(_(
|
||||||
|
'Order "%(name)s" is locked. Editable fields: state '
|
||||||
|
'transitions, internal notes, settlement amount.\n'
|
||||||
|
'To edit %(fields)s, ask a manager to use '
|
||||||
|
'"Unlock for Editing" first.',
|
||||||
|
name=order.name,
|
||||||
|
fields=', '.join(sorted(protected)),
|
||||||
|
))
|
||||||
|
|
||||||
|
def _check_lock_for_unlink(self):
|
||||||
|
"""POS-sourced and final-state orders cannot be unlinked. The
|
||||||
|
manager unlock wizard is intentionally NOT honored here — deletion
|
||||||
|
requires a stronger affordance (cancellation + audit trail), not
|
||||||
|
a temporary edit window.
|
||||||
|
"""
|
||||||
|
for order in self:
|
||||||
|
if order.source_type == 'pos':
|
||||||
|
raise UserError(_(
|
||||||
|
'Order "%(name)s" was created from POS and cannot '
|
||||||
|
'be deleted. Cancel the underlying POS order instead.',
|
||||||
|
name=order.name,
|
||||||
|
))
|
||||||
|
if order.state in FINAL_STATES:
|
||||||
|
raise UserError(_(
|
||||||
|
'Order "%(name)s" is in a final state (%(state)s) '
|
||||||
|
'and cannot be deleted.',
|
||||||
|
name=order.name,
|
||||||
|
state=dict(STATES).get(order.state, order.state),
|
||||||
|
))
|
||||||
|
|
||||||
|
@api.depends('line_ids.qty')
|
||||||
|
def _compute_item_count(self):
|
||||||
|
for order in self:
|
||||||
|
order.item_count = int(sum(order.line_ids.mapped('qty')))
|
||||||
|
|
||||||
|
# -- ORM --
|
||||||
|
@api.model_create_multi
|
||||||
|
def create(self, vals_list):
|
||||||
|
for vals in vals_list:
|
||||||
|
if vals.get('name', 'New') == 'New':
|
||||||
|
vals['name'] = (
|
||||||
|
self.env['ir.sequence'].next_by_code('laundry.order')
|
||||||
|
or 'New'
|
||||||
|
)
|
||||||
|
self._apply_type_attribute_inference(vals)
|
||||||
|
return super().create(vals_list)
|
||||||
|
|
||||||
|
def write(self, vals):
|
||||||
|
# STEP 1 — POS sync bypass.
|
||||||
|
# Must be the very first thing we do. Any code path that opts
|
||||||
|
# into the context flag (POS create/sync, settlement engine,
|
||||||
|
# stored-compute flushes triggered from inside a bypassed write)
|
||||||
|
# MUST sail through unconditionally. No locking check, no
|
||||||
|
# side-effects, no iteration over self — just delegate to super.
|
||||||
|
# This guarantee is what makes POS payment/settlement flows
|
||||||
|
# immune to this model's lock guard.
|
||||||
|
if self._is_pos_sync():
|
||||||
|
if _logger.isEnabledFor(logging.DEBUG):
|
||||||
|
_logger.debug(
|
||||||
|
'laundry.order.write BYPASS ids=%s keys=%s',
|
||||||
|
self.ids, list(vals.keys()),
|
||||||
|
)
|
||||||
|
return super().write(vals)
|
||||||
|
|
||||||
|
# STEP 2 — Forensic trace (DEBUG-level; off by default in prod,
|
||||||
|
# enable with `--log-level=debug` or `--log-handler=odoo.addons
|
||||||
|
# .laundry_management.models.laundry_order:DEBUG`).
|
||||||
|
if _logger.isEnabledFor(logging.DEBUG):
|
||||||
|
keys = list(vals.keys())
|
||||||
|
for order in self:
|
||||||
|
_logger.debug(
|
||||||
|
'laundry.order.write id=%s source=%s state=%s '
|
||||||
|
'locked=%s ctx_keys=%s vals_keys=%s',
|
||||||
|
order.id, order.source_type, order.state, order.locked,
|
||||||
|
sorted(self.env.context.keys()), keys,
|
||||||
|
)
|
||||||
|
|
||||||
|
# STEP 3 — Lock guard for non-POS callers.
|
||||||
|
self._check_lock_for_write(vals)
|
||||||
|
return super().write(vals)
|
||||||
|
|
||||||
|
def unlink(self):
|
||||||
|
# Explicit: even with POS-sync context, refuse to delete a locked
|
||||||
|
# order. Deletion is not a mutation the sync path ever issues.
|
||||||
|
self._check_lock_for_unlink()
|
||||||
|
return super().unlink()
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _apply_type_attribute_inference(self, vals):
|
||||||
|
"""Fill in priority_level / is_delivery from the selected order
|
||||||
|
type and attributes when the caller did not explicitly set them.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- type.priority='urgent' OR any attribute with
|
||||||
|
is_priority_related=True → priority_level='urgent'
|
||||||
|
- type.is_delivery=True OR any attribute with
|
||||||
|
is_delivery_related=True → is_delivery=True
|
||||||
|
- Do NOT overwrite explicit incoming delivery_address /
|
||||||
|
delivery_scheduled_at with blank values.
|
||||||
|
"""
|
||||||
|
type_id = vals.get('order_type_id')
|
||||||
|
order_type = (
|
||||||
|
self.env['laundry.order.type'].browse(type_id) if type_id else None
|
||||||
|
)
|
||||||
|
|
||||||
|
attribute_ids = []
|
||||||
|
raw_attrs = vals.get('attribute_ids') or []
|
||||||
|
for cmd in raw_attrs:
|
||||||
|
if isinstance(cmd, (list, tuple)) and len(cmd) >= 3:
|
||||||
|
# Odoo x2m commands: (6,0,[ids]), (4,id), etc.
|
||||||
|
if cmd[0] == 6 and isinstance(cmd[2], (list, tuple)):
|
||||||
|
attribute_ids.extend(cmd[2])
|
||||||
|
elif cmd[0] == 4 and cmd[1]:
|
||||||
|
attribute_ids.append(cmd[1])
|
||||||
|
elif isinstance(cmd, int):
|
||||||
|
attribute_ids.append(cmd)
|
||||||
|
attributes = (
|
||||||
|
self.env['laundry.order.attribute'].browse(attribute_ids)
|
||||||
|
if attribute_ids else self.env['laundry.order.attribute']
|
||||||
|
)
|
||||||
|
|
||||||
|
# Priority
|
||||||
|
if 'priority_level' not in vals:
|
||||||
|
urgent = (
|
||||||
|
(order_type and order_type.priority == 'urgent')
|
||||||
|
or any(a.is_priority_related for a in attributes)
|
||||||
|
)
|
||||||
|
vals['priority_level'] = 'urgent' if urgent else 'normal'
|
||||||
|
|
||||||
|
# Delivery
|
||||||
|
if 'is_delivery' not in vals:
|
||||||
|
delivery = (
|
||||||
|
(order_type and order_type.is_delivery)
|
||||||
|
or any(a.is_delivery_related for a in attributes)
|
||||||
|
)
|
||||||
|
vals['is_delivery'] = bool(delivery)
|
||||||
|
|
||||||
|
# -- Workflow actions --
|
||||||
|
def action_process(self):
|
||||||
|
for order in self:
|
||||||
|
if order.state != 'intake':
|
||||||
|
raise UserError(_(
|
||||||
|
'Order "%(name)s" is not in Intake state.',
|
||||||
|
name=order.name,
|
||||||
|
))
|
||||||
|
order.state = 'processing'
|
||||||
|
|
||||||
|
def action_ready(self):
|
||||||
|
for order in self:
|
||||||
|
if order.state != 'processing':
|
||||||
|
raise UserError(_(
|
||||||
|
'Order "%(name)s" is not in Processing state.',
|
||||||
|
name=order.name,
|
||||||
|
))
|
||||||
|
order.state = 'ready'
|
||||||
|
|
||||||
|
def action_deliver(self):
|
||||||
|
"""Guards: must be Ready + fully paid (amount_due == 0).
|
||||||
|
|
||||||
|
Also stamps `delivered_at` so the dashboard KPIs (avg processing
|
||||||
|
time, on-time delivery %) can be computed from real data instead
|
||||||
|
of the heuristic on `write_date`.
|
||||||
|
"""
|
||||||
|
for order in self:
|
||||||
|
if order.state != 'ready':
|
||||||
|
raise UserError(_(
|
||||||
|
'Order "%(name)s" must be Ready before delivery.',
|
||||||
|
name=order.name,
|
||||||
|
))
|
||||||
|
if order.amount_due > 0:
|
||||||
|
raise UserError(_(
|
||||||
|
'Order "%(name)s" has %(due).2f outstanding. '
|
||||||
|
'Collect payment in POS before delivery.',
|
||||||
|
name=order.name,
|
||||||
|
due=order.amount_due,
|
||||||
|
))
|
||||||
|
order.write({
|
||||||
|
'state': 'delivered',
|
||||||
|
'delivered_at': fields.Datetime.now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
def action_cancel(self):
|
||||||
|
"""Cancel an order. Allowed for manual orders only — POS-sourced
|
||||||
|
orders must be voided through the POS workflow to keep the sale
|
||||||
|
and the operational record in sync.
|
||||||
|
"""
|
||||||
|
for order in self:
|
||||||
|
if order.source_type == 'pos':
|
||||||
|
raise UserError(_(
|
||||||
|
'Order "%(name)s" was created from POS and cannot '
|
||||||
|
'be cancelled here. Cancel the underlying POS order '
|
||||||
|
'instead.',
|
||||||
|
name=order.name,
|
||||||
|
))
|
||||||
|
if order.state in FINAL_STATES:
|
||||||
|
raise UserError(_(
|
||||||
|
'Order "%(name)s" is already %(state)s.',
|
||||||
|
name=order.name,
|
||||||
|
state=dict(STATES).get(order.state, order.state),
|
||||||
|
))
|
||||||
|
order.state = 'cancelled'
|
||||||
|
|
||||||
|
# -- Smart button --
|
||||||
|
def action_open_pos_order(self):
|
||||||
|
self.ensure_one()
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'res_model': 'pos.order',
|
||||||
|
'res_id': self.pos_order_id.id,
|
||||||
|
'view_mode': 'form',
|
||||||
|
'target': 'current',
|
||||||
|
}
|
||||||
|
|
||||||
|
# ═════════════════════════════════════════════════════════════════
|
||||||
|
# POS "Laundry Orders" popup — server-side RPCs
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# These methods are the ONLY way the POS popup interacts with the
|
||||||
|
# model. Each action delegates to the corresponding workflow method
|
||||||
|
# (`action_process` / `action_ready` / `action_deliver`) which already
|
||||||
|
# enforces state + amount_due guards server-side. Direct writes to
|
||||||
|
# LOCKED_HEADER_FIELDS are NOT exposed here — the Phase 3 lock
|
||||||
|
# remains the sole authority for business-edit protection.
|
||||||
|
# ═════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def _pos_allowed_actions(self):
|
||||||
|
"""Return the list of action keys the popup may render for this
|
||||||
|
order. Pure function of state + amount_due. Final states only
|
||||||
|
allow printing.
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
actions = ['print_work_order']
|
||||||
|
if self.state in FINAL_STATES:
|
||||||
|
return actions
|
||||||
|
if self.state == 'intake':
|
||||||
|
actions.append('start_processing')
|
||||||
|
elif self.state == 'processing':
|
||||||
|
actions.append('mark_ready')
|
||||||
|
elif self.state == 'ready':
|
||||||
|
if self.amount_due <= 0:
|
||||||
|
actions.append('deliver')
|
||||||
|
else:
|
||||||
|
actions.append('collect_payment')
|
||||||
|
return actions
|
||||||
|
|
||||||
|
def _pos_payment_state(self):
|
||||||
|
self.ensure_one()
|
||||||
|
if self.amount_due > 0:
|
||||||
|
return 'due'
|
||||||
|
if self.amount_deferred > 0 and self.amount_settled >= self.amount_deferred:
|
||||||
|
return 'settled'
|
||||||
|
if self.amount_deferred > 0:
|
||||||
|
return 'deferred'
|
||||||
|
return 'paid'
|
||||||
|
|
||||||
|
def _pos_payload(self):
|
||||||
|
"""Compact, UI-ready dict. Single source of truth for the popup
|
||||||
|
shape — every RPC returns exactly this structure."""
|
||||||
|
self.ensure_one()
|
||||||
|
names = self.line_ids.mapped('product_id.name')
|
||||||
|
if not names:
|
||||||
|
names = self.line_ids.mapped('description')
|
||||||
|
state_selection = dict(self._fields['state'].selection)
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'name': self.name,
|
||||||
|
'state': self.state,
|
||||||
|
'state_label': state_selection.get(self.state) or self.state,
|
||||||
|
'pos_reference': self.pos_reference or '',
|
||||||
|
'is_from_pos': self.is_from_pos,
|
||||||
|
'create_date': fields.Datetime.to_string(self.create_date) if self.create_date else False,
|
||||||
|
'item_count': int(self.item_count or 0),
|
||||||
|
'service_summary': ', '.join(dict.fromkeys(names))[:80],
|
||||||
|
'amount_total': self.amount_total,
|
||||||
|
'amount_paid': self.amount_paid_cash,
|
||||||
|
'amount_deferred': self.amount_deferred,
|
||||||
|
'amount_settled': self.amount_settled,
|
||||||
|
'amount_due': self.amount_due,
|
||||||
|
'payment_state': self._pos_payment_state(),
|
||||||
|
'is_delivery': self.is_delivery,
|
||||||
|
'allowed_actions': self._pos_allowed_actions(),
|
||||||
|
}
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def pos_search_customer_orders(self, partner_id, search_query=False, limit=20):
|
||||||
|
"""Partner-scoped search for the POS popup.
|
||||||
|
|
||||||
|
• Always filters by partner_id (no global search in this phase).
|
||||||
|
• Optional search_query ilike-matches on name / pos_reference /
|
||||||
|
partner_phone.
|
||||||
|
• Hard-capped at 50 rows regardless of the caller's limit.
|
||||||
|
"""
|
||||||
|
if not partner_id:
|
||||||
|
return []
|
||||||
|
limit = max(1, min(int(limit or 20), 50))
|
||||||
|
q = (search_query or '').strip()
|
||||||
|
domain = [('partner_id', '=', partner_id)]
|
||||||
|
if q:
|
||||||
|
domain = [
|
||||||
|
'&',
|
||||||
|
('partner_id', '=', partner_id),
|
||||||
|
'|', '|',
|
||||||
|
('name', 'ilike', q),
|
||||||
|
('pos_reference', 'ilike', q),
|
||||||
|
('partner_phone', 'ilike', q),
|
||||||
|
]
|
||||||
|
orders = self.search(
|
||||||
|
domain, order='create_date desc, id desc', limit=limit,
|
||||||
|
)
|
||||||
|
return [o._pos_payload() for o in orders]
|
||||||
|
|
||||||
|
def pos_action_start_processing(self):
|
||||||
|
"""POS popup: advance intake → processing. Returns refreshed payload."""
|
||||||
|
self.ensure_one()
|
||||||
|
self.action_process()
|
||||||
|
return self._pos_payload()
|
||||||
|
|
||||||
|
def pos_action_mark_ready(self):
|
||||||
|
"""POS popup: advance processing → ready. Returns refreshed payload."""
|
||||||
|
self.ensure_one()
|
||||||
|
self.action_ready()
|
||||||
|
return self._pos_payload()
|
||||||
|
|
||||||
|
def pos_action_deliver(self):
|
||||||
|
"""POS popup: advance ready → delivered. Returns refreshed payload.
|
||||||
|
|
||||||
|
`action_deliver` raises UserError when amount_due > 0 — the popup
|
||||||
|
surfaces that error; no client-side duplication of the rule.
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
self.action_deliver()
|
||||||
|
return self._pos_payload()
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def pos_get_thermal_data(self, order_id):
|
||||||
|
"""Build a self-contained payload for the thermal Work-Order
|
||||||
|
receipt rendered by `laundry_management.LaundryWorkOrderThermal`.
|
||||||
|
|
||||||
|
Independent from `_pos_payload` — that one is for the popup list
|
||||||
|
(compact); this one carries every line + delivery meta the
|
||||||
|
cashier needs on the printed slip.
|
||||||
|
"""
|
||||||
|
order = self.browse(int(order_id))
|
||||||
|
if not order.exists():
|
||||||
|
return False
|
||||||
|
state_label = dict(order._fields['state'].selection).get(
|
||||||
|
order.state, order.state,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
'id': order.id,
|
||||||
|
'name': order.name,
|
||||||
|
'state': order.state,
|
||||||
|
'state_label': state_label,
|
||||||
|
'payment_state': order._pos_payment_state(),
|
||||||
|
'pos_reference': order.pos_reference or '',
|
||||||
|
'partner_name': order.partner_id.name or '',
|
||||||
|
# `mobile` is provided by the optional `phone` add-on; fall
|
||||||
|
# back gracefully when it isn't present in the install.
|
||||||
|
'partner_phone': (
|
||||||
|
order.partner_id.phone
|
||||||
|
or getattr(order.partner_id, 'mobile', '')
|
||||||
|
or ''
|
||||||
|
),
|
||||||
|
'company_name': order.company_id.name or '',
|
||||||
|
'create_date': fields.Datetime.to_string(order.create_date),
|
||||||
|
'lines': [{
|
||||||
|
'qty': line.qty,
|
||||||
|
'description': (
|
||||||
|
line.description
|
||||||
|
or (line.product_id.name if line.product_id else '')
|
||||||
|
),
|
||||||
|
'price_unit': line.price_unit,
|
||||||
|
'subtotal': line.subtotal,
|
||||||
|
'tracking_code': line.tracking_code or '',
|
||||||
|
} for line in order.line_ids],
|
||||||
|
'item_count': int(order.item_count or 0),
|
||||||
|
'amount_total': order.amount_total,
|
||||||
|
'amount_paid': order.amount_paid_cash,
|
||||||
|
'amount_deferred': order.amount_deferred,
|
||||||
|
'amount_settled': order.amount_settled,
|
||||||
|
'amount_due': order.amount_due,
|
||||||
|
'is_delivery': order.is_delivery,
|
||||||
|
'delivery_address': order.delivery_address or '',
|
||||||
|
'delivery_scheduled_at': (
|
||||||
|
fields.Datetime.to_string(order.delivery_scheduled_at)
|
||||||
|
if order.delivery_scheduled_at else ''
|
||||||
|
),
|
||||||
|
'currency_symbol': order.currency_id.symbol or '',
|
||||||
|
'currency_position': order.currency_id.position or 'after',
|
||||||
|
}
|
||||||
|
|
||||||
|
def action_open_unlock_wizard(self):
|
||||||
|
"""Open the manager unlock wizard pre-filled with this order.
|
||||||
|
Access is enforced inside the wizard's action method (group
|
||||||
|
check + reason required), but we also short-circuit here so
|
||||||
|
the button itself is silent for non-managers.
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
if not self.locked:
|
||||||
|
raise UserError(_(
|
||||||
|
'Order "%s" is already editable.', self.name,
|
||||||
|
))
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'res_model': 'laundry.order.unlock.wizard',
|
||||||
|
'view_mode': 'form',
|
||||||
|
'target': 'new',
|
||||||
|
'context': {
|
||||||
|
'default_order_id': self.id,
|
||||||
|
},
|
||||||
|
}
|
||||||
62
addons/laundry_management/models/laundry_order_attribute.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
from odoo import models, fields, api
|
||||||
|
|
||||||
|
|
||||||
|
class LaundryOrderAttribute(models.Model):
|
||||||
|
"""Optional per-order badge (Urgent / Hanger / Fold / Delicate / ...).
|
||||||
|
|
||||||
|
Multi-selectable on a laundry order. Semantic flags drive behavior
|
||||||
|
without name matching, so admins can rename freely.
|
||||||
|
"""
|
||||||
|
_name = 'laundry.order.attribute'
|
||||||
|
_inherit = ['pos.load.mixin']
|
||||||
|
_description = 'Laundry Order Attribute'
|
||||||
|
_order = 'sequence, id'
|
||||||
|
|
||||||
|
name = fields.Char(string='Name', required=True, translate=True)
|
||||||
|
code = fields.Char(string='Code')
|
||||||
|
sequence = fields.Integer(default=10)
|
||||||
|
active = fields.Boolean(default=True)
|
||||||
|
|
||||||
|
color = fields.Char(string='Color')
|
||||||
|
icon_image = fields.Binary(string='Icon')
|
||||||
|
description = fields.Text(string='Description', translate=True)
|
||||||
|
|
||||||
|
extra_price = fields.Float(
|
||||||
|
string='Extra Price',
|
||||||
|
help='Reserved for future pricing rules. Not applied automatically.',
|
||||||
|
)
|
||||||
|
|
||||||
|
pos_available = fields.Boolean(string='Available in POS', default=True)
|
||||||
|
company_id = fields.Many2one(
|
||||||
|
'res.company', string='Company',
|
||||||
|
default=lambda self: self.env.company, index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
is_delivery_related = fields.Boolean(
|
||||||
|
string='Delivery Related',
|
||||||
|
help='Selecting this attribute marks the order as delivery and '
|
||||||
|
'triggers the delivery-details prompt.',
|
||||||
|
)
|
||||||
|
is_priority_related = fields.Boolean(
|
||||||
|
string='Priority Related',
|
||||||
|
help='Selecting this attribute promotes the order to urgent priority.',
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _load_pos_data_domain(self, data, config):
|
||||||
|
return [
|
||||||
|
('pos_available', '=', True),
|
||||||
|
('active', '=', True),
|
||||||
|
'|',
|
||||||
|
('company_id', '=', False),
|
||||||
|
('company_id', 'in', config.company_id.ids),
|
||||||
|
]
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _load_pos_data_fields(self, config):
|
||||||
|
return [
|
||||||
|
'id', 'name', 'code', 'sequence',
|
||||||
|
'color', 'description',
|
||||||
|
'extra_price',
|
||||||
|
'is_delivery_related', 'is_priority_related',
|
||||||
|
]
|
||||||
288
addons/laundry_management/models/laundry_order_line.py
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from odoo import models, fields, api, _
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
from .laundry_order import POS_SYNC_CTX
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
LINE_STATES = [
|
||||||
|
('received', 'Received'),
|
||||||
|
('processing', 'Processing'),
|
||||||
|
('ready', 'Ready'),
|
||||||
|
('delivered', 'Delivered'),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Line fields the lock protects. NOT included on purpose:
|
||||||
|
# - state (per-item workflow advance is always allowed)
|
||||||
|
# - customer_note (operator commentary)
|
||||||
|
# - tracking_code (auto-assigned; cannot change after create either way)
|
||||||
|
LOCKED_LINE_FIELDS = frozenset({
|
||||||
|
'product_id', 'description', 'qty', 'price_unit',
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class LaundryOrderLine(models.Model):
|
||||||
|
"""Line item on a laundry order — maps from pos.order.line.
|
||||||
|
|
||||||
|
Each line carries a unique scannable tracking_code (barcode) and its
|
||||||
|
own per-item workflow state. The order-level state on laundry.order
|
||||||
|
remains the source of truth for financial gates; the per-line state
|
||||||
|
is an operational overlay that supports items moving through the
|
||||||
|
workflow at different speeds.
|
||||||
|
"""
|
||||||
|
_name = 'laundry.order.line'
|
||||||
|
_description = 'Laundry Order Line'
|
||||||
|
_order = 'order_id, id'
|
||||||
|
|
||||||
|
order_id = fields.Many2one(
|
||||||
|
'laundry.order', string='Order',
|
||||||
|
required=True, ondelete='cascade', index=True,
|
||||||
|
)
|
||||||
|
# Mirror order-level partner/state so list + kanban views can filter/group
|
||||||
|
# without costly cross-model joins.
|
||||||
|
order_partner_id = fields.Many2one(
|
||||||
|
related='order_id.partner_id', store=True, index=True, readonly=True,
|
||||||
|
)
|
||||||
|
order_state = fields.Selection(
|
||||||
|
related='order_id.state', store=True, index=True, readonly=True,
|
||||||
|
)
|
||||||
|
product_id = fields.Many2one(
|
||||||
|
'product.product', string='Product',
|
||||||
|
)
|
||||||
|
description = fields.Char(
|
||||||
|
string='Description',
|
||||||
|
)
|
||||||
|
qty = fields.Float(
|
||||||
|
string='Quantity',
|
||||||
|
default=1.0, digits=(10, 2),
|
||||||
|
)
|
||||||
|
price_unit = fields.Float(
|
||||||
|
string='Unit Price',
|
||||||
|
digits=(10, 2), readonly=True,
|
||||||
|
)
|
||||||
|
customer_note = fields.Char(
|
||||||
|
string='Customer Note',
|
||||||
|
)
|
||||||
|
subtotal = fields.Float(
|
||||||
|
string='Subtotal',
|
||||||
|
compute='_compute_subtotal',
|
||||||
|
store=True, digits=(10, 2),
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- Per-item tracking --
|
||||||
|
tracking_code = fields.Char(
|
||||||
|
string='Tracking Code',
|
||||||
|
copy=False, readonly=True, index=True,
|
||||||
|
help='Unique scannable barcode for this item.',
|
||||||
|
)
|
||||||
|
state = fields.Selection(
|
||||||
|
LINE_STATES,
|
||||||
|
string='Item Status',
|
||||||
|
default='received',
|
||||||
|
required=True, copy=False, index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
_tracking_code_uniq = models.Constraint(
|
||||||
|
'UNIQUE(tracking_code)',
|
||||||
|
'Tracking code must be unique across all laundry items.',
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.depends('qty', 'price_unit')
|
||||||
|
def _compute_subtotal(self):
|
||||||
|
for line in self:
|
||||||
|
line.subtotal = line.qty * line.price_unit
|
||||||
|
|
||||||
|
# Sequence code for the auto-generated tracking_code (barcode).
|
||||||
|
_TRACKING_SEQ_CODE = 'laundry.order.line.tracking'
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _next_tracking_code(self):
|
||||||
|
"""Allocate a tracking_code that is GUARANTEED unique across the
|
||||||
|
existing laundry_order_line table.
|
||||||
|
|
||||||
|
Why this exists
|
||||||
|
───────────────
|
||||||
|
Postgres sequences are NON-transactional: a `nextval()` advances
|
||||||
|
the sequence even when the surrounding ORM transaction rolls
|
||||||
|
back. Repeated POS validates that fail (lock, missing partner,
|
||||||
|
anything) eat sequence values without consuming them in real
|
||||||
|
rows. Conversely, a partial reseed / data import that inserts
|
||||||
|
rows with manual tracking_codes leaves the sequence BEHIND the
|
||||||
|
table's MAX. Either way, `next_by_code()` can return a code
|
||||||
|
that already exists → UniqueViolation → POS sale silently
|
||||||
|
misses its laundry-order link (the savepoint in pos_order.py
|
||||||
|
catches the SQL error to protect the POS commit).
|
||||||
|
|
||||||
|
How this fixes it
|
||||||
|
─────────────────
|
||||||
|
On collision, repair the sequence to (max_tracking_num + 1)
|
||||||
|
using a direct SQL nextval-skip, then ask the sequence again.
|
||||||
|
Capped at a few attempts so a real bug (e.g. malformed schema)
|
||||||
|
still surfaces instead of looping forever.
|
||||||
|
"""
|
||||||
|
seq = self.env['ir.sequence']
|
||||||
|
for attempt in range(5):
|
||||||
|
code = seq.next_by_code(self._TRACKING_SEQ_CODE) or False
|
||||||
|
if not code:
|
||||||
|
return False
|
||||||
|
# Cheap collision check; the underlying UNIQUE constraint is
|
||||||
|
# the real safety net — this just avoids paying the round-trip.
|
||||||
|
existing = self.sudo().search_count([('tracking_code', '=', code)])
|
||||||
|
if not existing:
|
||||||
|
return code
|
||||||
|
# Collision — repair sequence past the current MAX, then retry.
|
||||||
|
self._repair_tracking_sequence()
|
||||||
|
_logger.warning(
|
||||||
|
"laundry.order.line tracking sequence collided on %s "
|
||||||
|
"(attempt %d); repaired and retrying.", code, attempt + 1,
|
||||||
|
)
|
||||||
|
# If we still can't get a unique code after 5 tries, surface the
|
||||||
|
# problem instead of writing an empty code.
|
||||||
|
raise UserError(_(
|
||||||
|
'Could not allocate a unique tracking code after 5 attempts. '
|
||||||
|
'Check the laundry_management sequence configuration.'
|
||||||
|
))
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _repair_tracking_sequence(self):
|
||||||
|
"""Advance the tracking-code sequence past the actual MAX in the
|
||||||
|
table. Idempotent — safe to call repeatedly. SQL-level so it
|
||||||
|
works even when the ORM env context is unusual (sudo, sync hook).
|
||||||
|
"""
|
||||||
|
self.env.cr.execute("""
|
||||||
|
SELECT COALESCE(MAX(
|
||||||
|
CAST(NULLIF(REGEXP_REPLACE(tracking_code, '[^0-9]', '', 'g'), '')
|
||||||
|
AS INTEGER)
|
||||||
|
), 0)
|
||||||
|
FROM laundry_order_line
|
||||||
|
WHERE tracking_code IS NOT NULL;
|
||||||
|
""")
|
||||||
|
max_existing = self.env.cr.fetchone()[0] or 0
|
||||||
|
# `ir.sequence` writes update number_next; we use the API for
|
||||||
|
# safety (handles ranges, prefixes, padding).
|
||||||
|
seq = self.env['ir.sequence'].sudo().search(
|
||||||
|
[('code', '=', self._TRACKING_SEQ_CODE)], limit=1,
|
||||||
|
)
|
||||||
|
if seq:
|
||||||
|
seq.write({'number_next': max_existing + 1})
|
||||||
|
|
||||||
|
@api.model_create_multi
|
||||||
|
def create(self, vals_list):
|
||||||
|
# POS-sync bypass FIRST — POS creates the order and its lines in a
|
||||||
|
# single create_vals payload; both must sail through the guard.
|
||||||
|
pos_sync = bool(self.env.context.get(POS_SYNC_CTX))
|
||||||
|
if not pos_sync:
|
||||||
|
order_ids = {v.get('order_id') for v in vals_list if v.get('order_id')}
|
||||||
|
if order_ids:
|
||||||
|
orders = self.env['laundry.order'].browse(list(order_ids))
|
||||||
|
for order in orders:
|
||||||
|
if order.locked:
|
||||||
|
raise UserError(_(
|
||||||
|
'Cannot add a line to locked order "%s".',
|
||||||
|
order.name,
|
||||||
|
))
|
||||||
|
for vals in vals_list:
|
||||||
|
if not vals.get('tracking_code'):
|
||||||
|
vals['tracking_code'] = self._next_tracking_code()
|
||||||
|
if _logger.isEnabledFor(logging.DEBUG):
|
||||||
|
_logger.debug(
|
||||||
|
'laundry.order.line.create pos_sync=%s count=%d',
|
||||||
|
pos_sync, len(vals_list),
|
||||||
|
)
|
||||||
|
return super().create(vals_list)
|
||||||
|
|
||||||
|
def write(self, vals):
|
||||||
|
if self.env.context.get(POS_SYNC_CTX):
|
||||||
|
if _logger.isEnabledFor(logging.DEBUG):
|
||||||
|
_logger.debug(
|
||||||
|
'laundry.order.line.write BYPASS ids=%s keys=%s',
|
||||||
|
self.ids, list(vals.keys()),
|
||||||
|
)
|
||||||
|
return super().write(vals)
|
||||||
|
protected = LOCKED_LINE_FIELDS.intersection(vals.keys())
|
||||||
|
if protected:
|
||||||
|
for line in self:
|
||||||
|
if line.order_id.locked:
|
||||||
|
raise UserError(_(
|
||||||
|
'Line on locked order "%(order)s" cannot edit '
|
||||||
|
'%(fields)s. Ask a manager to use "Unlock for '
|
||||||
|
'Editing" first.',
|
||||||
|
order=line.order_id.name,
|
||||||
|
fields=', '.join(sorted(protected)),
|
||||||
|
))
|
||||||
|
return super().write(vals)
|
||||||
|
|
||||||
|
def unlink(self):
|
||||||
|
# No bypass: deletion is never issued by the POS sync path.
|
||||||
|
for line in self:
|
||||||
|
if line.order_id.locked:
|
||||||
|
raise UserError(_(
|
||||||
|
'Cannot delete a line from locked order "%s".',
|
||||||
|
line.order_id.name,
|
||||||
|
))
|
||||||
|
return super().unlink()
|
||||||
|
|
||||||
|
# -- Per-line workflow actions (1-click) --
|
||||||
|
def action_line_process(self):
|
||||||
|
for line in self:
|
||||||
|
if line.state != 'received':
|
||||||
|
raise UserError(_(
|
||||||
|
'Item %(code)s is not in Received state.',
|
||||||
|
code=line.tracking_code or line.id,
|
||||||
|
))
|
||||||
|
line.state = 'processing'
|
||||||
|
|
||||||
|
def action_line_ready(self):
|
||||||
|
for line in self:
|
||||||
|
if line.state != 'processing':
|
||||||
|
raise UserError(_(
|
||||||
|
'Item %(code)s is not in Processing state.',
|
||||||
|
code=line.tracking_code or line.id,
|
||||||
|
))
|
||||||
|
line.state = 'ready'
|
||||||
|
|
||||||
|
def action_line_deliver(self):
|
||||||
|
for line in self:
|
||||||
|
if line.state != 'ready':
|
||||||
|
raise UserError(_(
|
||||||
|
'Item %(code)s must be Ready before delivery.',
|
||||||
|
code=line.tracking_code or line.id,
|
||||||
|
))
|
||||||
|
line.state = 'delivered'
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def action_scan_advance(self, tracking_code):
|
||||||
|
"""Advance an item one stage by its scanned tracking code.
|
||||||
|
|
||||||
|
Intended for barcode scanner workflow: scanner types the code,
|
||||||
|
this method finds the line and bumps its state to the next stage.
|
||||||
|
Returns the new state or raises UserError if terminal / unknown.
|
||||||
|
"""
|
||||||
|
if not tracking_code:
|
||||||
|
raise UserError(_('Scan a tracking code.'))
|
||||||
|
line = self.search([('tracking_code', '=', tracking_code.strip())], limit=1)
|
||||||
|
if not line:
|
||||||
|
raise UserError(_('No item with tracking code %s.', tracking_code))
|
||||||
|
transitions = {
|
||||||
|
'received': line.action_line_process,
|
||||||
|
'processing': line.action_line_ready,
|
||||||
|
'ready': line.action_line_deliver,
|
||||||
|
}
|
||||||
|
action = transitions.get(line.state)
|
||||||
|
if not action:
|
||||||
|
raise UserError(_(
|
||||||
|
'Item %(code)s is already %(state)s.',
|
||||||
|
code=line.tracking_code, state=line.state,
|
||||||
|
))
|
||||||
|
action()
|
||||||
|
return {
|
||||||
|
'id': line.id,
|
||||||
|
'tracking_code': line.tracking_code,
|
||||||
|
'state': line.state,
|
||||||
|
'order_name': line.order_id.name,
|
||||||
|
'partner_name': line.order_partner_id.name,
|
||||||
|
'product_name': line.product_id.display_name,
|
||||||
|
}
|
||||||
53
addons/laundry_management/models/laundry_order_line_addon.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
from odoo import models, fields, api
|
||||||
|
|
||||||
|
|
||||||
|
class LaundryOrderLineAddon(models.Model):
|
||||||
|
"""Optional add-on service attached to one sale.order.line.
|
||||||
|
|
||||||
|
Examples per item:
|
||||||
|
- Express handling (+10 SAR)
|
||||||
|
- Starch / تقطير (+3 SAR)
|
||||||
|
- Packaging (+2 SAR)
|
||||||
|
- Perfume scent (+5 SAR)
|
||||||
|
|
||||||
|
The parent line's subtotal does NOT automatically include add-on prices
|
||||||
|
(sale.order.line controls its own subtotal). Add-ons are tracked here for
|
||||||
|
printing and reporting purposes; staff can create a separate order line for
|
||||||
|
the add-on product if billing is required.
|
||||||
|
"""
|
||||||
|
_name = 'laundry.order.line.addon'
|
||||||
|
_description = 'Order Line Add-on'
|
||||||
|
_order = 'line_id, id'
|
||||||
|
|
||||||
|
line_id = fields.Many2one(
|
||||||
|
'sale.order.line', string='Order Line',
|
||||||
|
required=True, ondelete='cascade', index=True,
|
||||||
|
)
|
||||||
|
# Denormal for easy reporting — derived from sale.order.line.order_id
|
||||||
|
order_id = fields.Many2one(
|
||||||
|
'sale.order',
|
||||||
|
related='line_id.order_id', store=True, index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
name = fields.Char(
|
||||||
|
string='Add-on / الإضافة',
|
||||||
|
required=True,
|
||||||
|
help='Name of the additional service (e.g. Express, Starch, Packaging).',
|
||||||
|
)
|
||||||
|
price = fields.Float(
|
||||||
|
string='Price / السعر',
|
||||||
|
required=True, digits=(10, 2), default=0.0,
|
||||||
|
)
|
||||||
|
quantity = fields.Float(
|
||||||
|
string='Qty',
|
||||||
|
default=1.0, digits=(10, 2),
|
||||||
|
)
|
||||||
|
subtotal = fields.Float(
|
||||||
|
string='Subtotal',
|
||||||
|
compute='_compute_subtotal', store=True, digits=(10, 2),
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.depends('price', 'quantity')
|
||||||
|
def _compute_subtotal(self):
|
||||||
|
for addon in self:
|
||||||
|
addon.subtotal = addon.price * addon.quantity
|
||||||
75
addons/laundry_management/models/laundry_order_type.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
from odoo import models, fields, api
|
||||||
|
|
||||||
|
|
||||||
|
class LaundryOrderType(models.Model):
|
||||||
|
"""Main intake type for a laundry order (Standard / Express / Delivery / VIP ...).
|
||||||
|
|
||||||
|
Drives workflow hints (priority, is_delivery) and suggests default
|
||||||
|
attributes. Admin-configurable from the backend; exposed to POS via
|
||||||
|
_load_pos_data_* so cashiers see live options in a popup.
|
||||||
|
"""
|
||||||
|
_name = 'laundry.order.type'
|
||||||
|
_inherit = ['pos.load.mixin']
|
||||||
|
_description = 'Laundry Order Type'
|
||||||
|
_order = 'sequence, id'
|
||||||
|
|
||||||
|
name = fields.Char(string='Name', required=True, translate=True)
|
||||||
|
code = fields.Char(string='Code')
|
||||||
|
sequence = fields.Integer(default=10)
|
||||||
|
active = fields.Boolean(default=True)
|
||||||
|
|
||||||
|
priority = fields.Selection([
|
||||||
|
('normal', 'Normal'),
|
||||||
|
('urgent', 'Urgent'),
|
||||||
|
], string='Priority', default='normal', required=True)
|
||||||
|
|
||||||
|
is_delivery = fields.Boolean(string='Is Delivery')
|
||||||
|
requires_address = fields.Boolean(string='Requires Address')
|
||||||
|
requires_scheduled_time = fields.Boolean(string='Requires Scheduled Time')
|
||||||
|
|
||||||
|
color = fields.Char(string='Color', help='Hex color, e.g. #FF8800')
|
||||||
|
icon_image = fields.Binary(string='Icon')
|
||||||
|
description = fields.Text(string='Description', translate=True)
|
||||||
|
|
||||||
|
extra_price = fields.Float(
|
||||||
|
string='Extra Price',
|
||||||
|
help='Reserved for future pricing rules. Not applied automatically.',
|
||||||
|
)
|
||||||
|
|
||||||
|
pos_available = fields.Boolean(
|
||||||
|
string='Available in POS', default=True,
|
||||||
|
help='Uncheck to hide this type from the POS popup without archiving.',
|
||||||
|
)
|
||||||
|
company_id = fields.Many2one(
|
||||||
|
'res.company', string='Company',
|
||||||
|
default=lambda self: self.env.company, index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
attribute_ids = fields.Many2many(
|
||||||
|
'laundry.order.attribute',
|
||||||
|
'laundry_order_type_attribute_rel',
|
||||||
|
'type_id', 'attribute_id',
|
||||||
|
string='Default Attributes',
|
||||||
|
help='Attributes pre-suggested (pre-checked) when this type is chosen.',
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _load_pos_data_domain(self, data, config):
|
||||||
|
return [
|
||||||
|
('pos_available', '=', True),
|
||||||
|
('active', '=', True),
|
||||||
|
'|',
|
||||||
|
('company_id', '=', False),
|
||||||
|
('company_id', 'in', config.company_id.ids),
|
||||||
|
]
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _load_pos_data_fields(self, config):
|
||||||
|
return [
|
||||||
|
'id', 'name', 'code', 'sequence',
|
||||||
|
'priority', 'is_delivery',
|
||||||
|
'requires_address', 'requires_scheduled_time',
|
||||||
|
'color', 'description',
|
||||||
|
'extra_price',
|
||||||
|
'attribute_ids',
|
||||||
|
]
|
||||||
101
addons/laundry_management/models/laundry_payment_method.py
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
from odoo import models, fields, api
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
|
||||||
|
class LaundryPaymentMethod(models.Model):
|
||||||
|
"""Configurable payment method linked to an accounting journal.
|
||||||
|
|
||||||
|
One record per payment option (e.g. Cash, Visa, Bank Transfer, Credit).
|
||||||
|
Each method is typed as cash / bank / credit so that session cash-control
|
||||||
|
and accounting routing work exactly like Odoo POS payment methods.
|
||||||
|
|
||||||
|
cash → counted in the session cash drawer
|
||||||
|
bank → posted to a bank/card journal, not counted in cash
|
||||||
|
credit → deferred / no-posting (customer owes)
|
||||||
|
"""
|
||||||
|
_name = 'laundry.payment.method'
|
||||||
|
_description = 'Laundry Payment Method'
|
||||||
|
_order = 'sequence, id'
|
||||||
|
|
||||||
|
# ── Identity ──────────────────────────────────────────────────────
|
||||||
|
name = fields.Char(
|
||||||
|
string='Method Name',
|
||||||
|
required=True,
|
||||||
|
translate=True,
|
||||||
|
)
|
||||||
|
sequence = fields.Integer(default=10)
|
||||||
|
active = fields.Boolean(default=True)
|
||||||
|
company_id = fields.Many2one(
|
||||||
|
'res.company',
|
||||||
|
default=lambda self: self.env.company,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Type ──────────────────────────────────────────────────────────
|
||||||
|
payment_type = fields.Selection([
|
||||||
|
('cash', 'Cash / نقد'),
|
||||||
|
('bank', 'Bank / Card / بنك'),
|
||||||
|
('credit', 'Credit / Deferred / آجل'),
|
||||||
|
], string='Type', required=True, default='cash',
|
||||||
|
help=(
|
||||||
|
'cash → counts in session cash drawer\n'
|
||||||
|
'bank → posted to bank/card journal\n'
|
||||||
|
'credit → deferred, no immediate accounting entry'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Journal ───────────────────────────────────────────────────────
|
||||||
|
journal_id = fields.Many2one(
|
||||||
|
'account.journal', string='Accounting Journal',
|
||||||
|
domain="[('type', 'in', ['cash', 'bank']), ('company_id', '=', company_id)]",
|
||||||
|
help='Leave blank only for Credit/Deferred methods.',
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── UI ────────────────────────────────────────────────────────────
|
||||||
|
is_default = fields.Boolean(
|
||||||
|
string='Default',
|
||||||
|
help='Pre-selected when staff opens the Register Payment wizard.',
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Constraints ───────────────────────────────────────────────────
|
||||||
|
@api.constrains('is_default', 'company_id')
|
||||||
|
def _check_single_default(self):
|
||||||
|
for rec in self.filtered('is_default'):
|
||||||
|
duplicate = self.search([
|
||||||
|
('is_default', '=', True),
|
||||||
|
('company_id', '=', rec.company_id.id),
|
||||||
|
('id', '!=', rec.id),
|
||||||
|
], limit=1)
|
||||||
|
if duplicate:
|
||||||
|
raise UserError(
|
||||||
|
f'Only one default payment method is allowed per company.\n'
|
||||||
|
f'"{duplicate.name}" is already the default.\n'
|
||||||
|
'Unset it first before marking this one as default.'
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.constrains('payment_type', 'journal_id')
|
||||||
|
def _check_journal_required(self):
|
||||||
|
for rec in self:
|
||||||
|
if rec.payment_type in ('cash', 'bank') and not rec.journal_id:
|
||||||
|
raise UserError(
|
||||||
|
f'Payment method "{rec.name}" is of type "{rec.payment_type}" '
|
||||||
|
'and requires an accounting journal. '
|
||||||
|
'Please select a journal or change the type to Credit/Deferred.'
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Helpers ───────────────────────────────────────────────────────
|
||||||
|
@api.model
|
||||||
|
def get_default_method(self):
|
||||||
|
"""Return the default payment method for the current company."""
|
||||||
|
company = self.env.company
|
||||||
|
return (
|
||||||
|
self.search([
|
||||||
|
('is_default', '=', True),
|
||||||
|
('company_id', '=', company.id),
|
||||||
|
('active', '=', True),
|
||||||
|
], limit=1)
|
||||||
|
or self.search([
|
||||||
|
('payment_type', '=', 'cash'),
|
||||||
|
('company_id', '=', company.id),
|
||||||
|
('active', '=', True),
|
||||||
|
], limit=1)
|
||||||
|
)
|
||||||
259
addons/laundry_management/models/laundry_session.py
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
from odoo import models, fields, api
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
|
||||||
|
class LaundrySession(models.Model):
|
||||||
|
"""Daily operational session — POS-style cash management.
|
||||||
|
|
||||||
|
A session groups all laundry orders placed in a single working shift.
|
||||||
|
Cash totals are derived from standard account.payment records linked
|
||||||
|
to the session's invoices, so figures are always in sync with Accounting.
|
||||||
|
|
||||||
|
Opening balance:
|
||||||
|
- When opening a new session, the system suggests the previous
|
||||||
|
session's actual_closing_cash as the opening float (carry-forward).
|
||||||
|
|
||||||
|
Closing:
|
||||||
|
- Staff enter the actual physical cash count.
|
||||||
|
- If a difference exists, a journal entry is posted (optional).
|
||||||
|
"""
|
||||||
|
_name = 'laundry.session'
|
||||||
|
_description = 'Laundry Daily Session'
|
||||||
|
_inherit = ['mail.thread']
|
||||||
|
_order = 'opening_datetime desc, id desc'
|
||||||
|
|
||||||
|
# ── Identity ──────────────────────────────────────────────────────
|
||||||
|
name = fields.Char(
|
||||||
|
string='Session',
|
||||||
|
required=True, copy=False, readonly=True,
|
||||||
|
default='New', tracking=True,
|
||||||
|
)
|
||||||
|
user_id = fields.Many2one(
|
||||||
|
'res.users', string='Opened By',
|
||||||
|
required=True, default=lambda self: self.env.user,
|
||||||
|
tracking=True,
|
||||||
|
)
|
||||||
|
company_id = fields.Many2one(
|
||||||
|
'res.company',
|
||||||
|
default=lambda self: self.env.company,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── State ─────────────────────────────────────────────────────────
|
||||||
|
state = fields.Selection([
|
||||||
|
('new', 'New'),
|
||||||
|
('opened', 'Open'),
|
||||||
|
('closed', 'Closed'),
|
||||||
|
], default='new', required=True, tracking=True, copy=False)
|
||||||
|
|
||||||
|
# ── Timing ────────────────────────────────────────────────────────
|
||||||
|
opening_datetime = fields.Datetime(string='Opened At', readonly=True)
|
||||||
|
closing_datetime = fields.Datetime(string='Closed At', readonly=True)
|
||||||
|
|
||||||
|
# ── Cash Control ──────────────────────────────────────────────────
|
||||||
|
opening_cash = fields.Float(
|
||||||
|
string='Opening Float',
|
||||||
|
digits=(10, 2),
|
||||||
|
help='Cash in the drawer at the start of the session.',
|
||||||
|
)
|
||||||
|
actual_closing_cash = fields.Float(
|
||||||
|
string='Actual Cash Count',
|
||||||
|
digits=(10, 2),
|
||||||
|
help='Physical cash counted at session close.',
|
||||||
|
)
|
||||||
|
expected_closing_cash = fields.Float(
|
||||||
|
string='Expected Cash',
|
||||||
|
compute='_compute_cash_control', store=True, digits=(10, 2),
|
||||||
|
)
|
||||||
|
cash_difference = fields.Float(
|
||||||
|
string='Difference',
|
||||||
|
compute='_compute_cash_control', store=True, digits=(10, 2),
|
||||||
|
help='Actual − Expected. Negative = short.',
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Orders (sale.order with session_id = self) ────────────────────
|
||||||
|
order_ids = fields.One2many(
|
||||||
|
'sale.order', 'session_id', string='Orders',
|
||||||
|
)
|
||||||
|
order_count = fields.Integer(
|
||||||
|
compute='_compute_session_totals', store=True,
|
||||||
|
)
|
||||||
|
total_sales = fields.Float(
|
||||||
|
string='Total Sales',
|
||||||
|
compute='_compute_session_totals', store=True, digits=(10, 2),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Payment totals (from account.payment via invoices) ────────────
|
||||||
|
total_cash = fields.Float(
|
||||||
|
string='Cash',
|
||||||
|
compute='_compute_session_totals', store=True, digits=(10, 2),
|
||||||
|
)
|
||||||
|
total_bank = fields.Float(
|
||||||
|
string='Bank / Card',
|
||||||
|
compute='_compute_session_totals', store=True, digits=(10, 2),
|
||||||
|
)
|
||||||
|
total_paid = fields.Float(
|
||||||
|
string='Total Collected',
|
||||||
|
compute='_compute_session_totals', store=True, digits=(10, 2),
|
||||||
|
)
|
||||||
|
total_credit = fields.Float(
|
||||||
|
string='Outstanding / Deferred',
|
||||||
|
compute='_compute_session_totals', store=True, digits=(10, 2),
|
||||||
|
help='Total not yet paid (total_sales − total_paid).',
|
||||||
|
)
|
||||||
|
outstanding_amount = fields.Float(
|
||||||
|
string='Outstanding',
|
||||||
|
compute='_compute_session_totals', store=True, digits=(10, 2),
|
||||||
|
)
|
||||||
|
|
||||||
|
notes = fields.Text(string='Notes')
|
||||||
|
|
||||||
|
# ── Difference account ────────────────────────────────────────────
|
||||||
|
difference_account_id = fields.Many2one(
|
||||||
|
'account.account',
|
||||||
|
string='Cash Difference Account',
|
||||||
|
help='Account used to post cash variances at session close.',
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Constraints ───────────────────────────────────────────────────
|
||||||
|
_session_name_uniq = models.Constraint(
|
||||||
|
'UNIQUE(name)',
|
||||||
|
'Session name must be unique.',
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.constrains('state', 'company_id')
|
||||||
|
def _check_single_open_session(self):
|
||||||
|
for session in self:
|
||||||
|
if session.state == 'opened':
|
||||||
|
duplicate = self.search([
|
||||||
|
('state', '=', 'opened'),
|
||||||
|
('company_id', '=', session.company_id.id),
|
||||||
|
('id', '!=', session.id),
|
||||||
|
], limit=1)
|
||||||
|
if duplicate:
|
||||||
|
raise UserError(
|
||||||
|
f'Session "{duplicate.name}" is already open.\n'
|
||||||
|
'Close it before opening a new session.'
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Computed ──────────────────────────────────────────────────────
|
||||||
|
@api.depends(
|
||||||
|
'order_ids.amount_total',
|
||||||
|
'order_ids.state',
|
||||||
|
'order_ids.invoice_ids.state',
|
||||||
|
'order_ids.invoice_ids.payment_state',
|
||||||
|
'order_ids.invoice_ids.amount_residual',
|
||||||
|
'order_ids.is_laundry_order',
|
||||||
|
)
|
||||||
|
def _compute_session_totals(self):
|
||||||
|
Payment = self.env['account.payment']
|
||||||
|
for session in self:
|
||||||
|
active = session.order_ids.filtered(
|
||||||
|
lambda o: o.is_laundry_order and o.state not in ('cancel', 'draft')
|
||||||
|
)
|
||||||
|
session.order_count = len(active)
|
||||||
|
session.total_sales = sum(active.mapped('amount_total'))
|
||||||
|
|
||||||
|
# Get posted customer invoices for active orders
|
||||||
|
invoices = active.mapped('invoice_ids').filtered(
|
||||||
|
lambda inv: inv.state == 'posted' and inv.move_type == 'out_invoice'
|
||||||
|
)
|
||||||
|
|
||||||
|
if not invoices:
|
||||||
|
session.total_cash = 0.0
|
||||||
|
session.total_bank = 0.0
|
||||||
|
session.total_paid = 0.0
|
||||||
|
session.outstanding_amount = session.total_sales
|
||||||
|
session.total_credit = session.total_sales
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Payments reconciled against those invoices
|
||||||
|
# account.payment.reconciled_invoice_ids is a computed M2M in Odoo 16+
|
||||||
|
payments = Payment.search([
|
||||||
|
('reconciled_invoice_ids', 'in', invoices.ids),
|
||||||
|
('state', '=', 'posted'),
|
||||||
|
('payment_type', '=', 'inbound'),
|
||||||
|
])
|
||||||
|
|
||||||
|
cash_pmts = payments.filtered(lambda p: p.journal_id.type == 'cash')
|
||||||
|
bank_pmts = payments.filtered(lambda p: p.journal_id.type == 'bank')
|
||||||
|
|
||||||
|
session.total_cash = sum(cash_pmts.mapped('amount'))
|
||||||
|
session.total_bank = sum(bank_pmts.mapped('amount'))
|
||||||
|
session.total_paid = sum(payments.mapped('amount'))
|
||||||
|
outstanding = max(session.total_sales - session.total_paid, 0.0)
|
||||||
|
session.outstanding_amount = outstanding
|
||||||
|
session.total_credit = outstanding
|
||||||
|
|
||||||
|
@api.depends('opening_cash', 'total_cash', 'actual_closing_cash', 'state')
|
||||||
|
def _compute_cash_control(self):
|
||||||
|
for session in self:
|
||||||
|
session.expected_closing_cash = (
|
||||||
|
session.opening_cash + session.total_cash
|
||||||
|
)
|
||||||
|
session.cash_difference = (
|
||||||
|
session.actual_closing_cash - session.expected_closing_cash
|
||||||
|
if session.state == 'closed' else 0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── ORM ───────────────────────────────────────────────────────────
|
||||||
|
@api.model_create_multi
|
||||||
|
def create(self, vals_list):
|
||||||
|
for vals in vals_list:
|
||||||
|
if vals.get('name', 'New') == 'New':
|
||||||
|
vals['name'] = (
|
||||||
|
self.env['ir.sequence'].next_by_code('laundry.session')
|
||||||
|
or 'New'
|
||||||
|
)
|
||||||
|
return super().create(vals_list)
|
||||||
|
|
||||||
|
# ── Workflow ──────────────────────────────────────────────────────
|
||||||
|
def action_open_session(self):
|
||||||
|
for session in self:
|
||||||
|
if session.state != 'new':
|
||||||
|
raise UserError('This session is already open or closed.')
|
||||||
|
# Carry forward: suggest previous session's actual closing cash
|
||||||
|
if not session.opening_cash:
|
||||||
|
prev = self.search([
|
||||||
|
('state', '=', 'closed'),
|
||||||
|
('company_id', '=', session.company_id.id),
|
||||||
|
], order='closing_datetime desc', limit=1)
|
||||||
|
if prev and prev.actual_closing_cash:
|
||||||
|
session.opening_cash = prev.actual_closing_cash
|
||||||
|
session.write({
|
||||||
|
'state': 'opened',
|
||||||
|
'opening_datetime': fields.Datetime.now(),
|
||||||
|
})
|
||||||
|
session.message_post(
|
||||||
|
body=f'Session opened by {self.env.user.name}. '
|
||||||
|
f'Opening float: {session.opening_cash:.2f}'
|
||||||
|
)
|
||||||
|
|
||||||
|
def action_close_session(self):
|
||||||
|
self.ensure_one()
|
||||||
|
if self.state != 'opened':
|
||||||
|
raise UserError('Only open sessions can be closed.')
|
||||||
|
return {
|
||||||
|
'name': 'Close Session',
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'res_model': 'laundry.session.close.wizard',
|
||||||
|
'view_mode': 'form',
|
||||||
|
'target': 'new',
|
||||||
|
'context': {'default_session_id': self.id},
|
||||||
|
}
|
||||||
|
|
||||||
|
def action_view_orders(self):
|
||||||
|
self.ensure_one()
|
||||||
|
return {
|
||||||
|
'name': f'Orders — {self.name}',
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'res_model': 'sale.order',
|
||||||
|
'view_mode': 'list,form',
|
||||||
|
'domain': [('session_id', '=', self.id), ('is_laundry_order', '=', True)],
|
||||||
|
'context': {'default_session_id': self.id, 'default_is_laundry_order': True},
|
||||||
|
}
|
||||||
|
|
||||||
|
def action_print_session_report(self):
|
||||||
|
self.ensure_one()
|
||||||
|
return self.env.ref(
|
||||||
|
'laundry_management.action_report_laundry_session'
|
||||||
|
).report_action(self)
|
||||||
137
addons/laundry_management/models/laundry_settings.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
from odoo import models, fields, api
|
||||||
|
|
||||||
|
|
||||||
|
class LaundrySettings(models.TransientModel):
|
||||||
|
"""Laundry Management settings panel (PART 9).
|
||||||
|
|
||||||
|
Extends res.config.settings to add laundry-specific configuration:
|
||||||
|
- WhatsApp Business API credentials and behaviour
|
||||||
|
- Commission tracking rates, rules, and default account
|
||||||
|
- Session enforcement and default cash journal
|
||||||
|
- Print format defaults
|
||||||
|
- Cash control: default difference account
|
||||||
|
"""
|
||||||
|
_inherit = 'res.config.settings'
|
||||||
|
|
||||||
|
# ── WhatsApp ──────────────────────────────────────────────────────
|
||||||
|
laundry_wa_token = fields.Char(
|
||||||
|
string='WhatsApp Cloud API Token',
|
||||||
|
config_parameter='laundry.whatsapp_api_token',
|
||||||
|
)
|
||||||
|
laundry_wa_phone_id = fields.Char(
|
||||||
|
string='Phone Number ID',
|
||||||
|
config_parameter='laundry.whatsapp_phone_id',
|
||||||
|
)
|
||||||
|
laundry_wa_store_number = fields.Char(
|
||||||
|
string='Store WhatsApp Number',
|
||||||
|
config_parameter='laundry.whatsapp_store_number',
|
||||||
|
help='E.164 format without + (e.g. 966501234567). Used as fallback number.',
|
||||||
|
)
|
||||||
|
laundry_wa_auto_send = fields.Boolean(
|
||||||
|
string='Auto-send WhatsApp on Order Confirm',
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Commission ────────────────────────────────────────────────────
|
||||||
|
laundry_commission_enabled = fields.Boolean(
|
||||||
|
string='Enable Staff Commission Tracking',
|
||||||
|
)
|
||||||
|
laundry_commission_type = fields.Selection([
|
||||||
|
('percentage', 'Percentage of Order Total (%)'),
|
||||||
|
('fixed', 'Fixed Amount per Order'),
|
||||||
|
], string='Commission Type')
|
||||||
|
laundry_commission_reception_rate = fields.Float(
|
||||||
|
string='Reception Commission', digits=(10, 2),
|
||||||
|
)
|
||||||
|
laundry_commission_processing_rate = fields.Float(
|
||||||
|
string='Processing Commission', digits=(10, 2),
|
||||||
|
)
|
||||||
|
laundry_commission_delivery_rate = fields.Float(
|
||||||
|
string='Delivery Commission', digits=(10, 2),
|
||||||
|
)
|
||||||
|
laundry_commission_account_id = fields.Many2one(
|
||||||
|
'account.account',
|
||||||
|
string='Commission Expense Account',
|
||||||
|
domain="[('account_type', 'in', ['expense', 'expense_direct_cost']), ('company_ids', 'in', [company_id])]",
|
||||||
|
help='Account for booking commission expenses (for future accounting integration).',
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Session / Cash Control ────────────────────────────────────────
|
||||||
|
laundry_require_session = fields.Boolean(
|
||||||
|
string='Require Open Session to Confirm Orders',
|
||||||
|
)
|
||||||
|
laundry_cash_journal_id = fields.Many2one(
|
||||||
|
'account.journal',
|
||||||
|
string='Default Cash Journal',
|
||||||
|
domain="[('type', '=', 'cash'), ('company_id', '=', company_id)]",
|
||||||
|
help='Default cash journal used in the payment wizard.',
|
||||||
|
)
|
||||||
|
laundry_difference_account_id = fields.Many2one(
|
||||||
|
'account.account',
|
||||||
|
string='Cash Difference Account',
|
||||||
|
domain="[('company_ids', 'in', [company_id])]",
|
||||||
|
help='Default account for posting session cash count variances.',
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Print ─────────────────────────────────────────────────────────
|
||||||
|
laundry_default_paper = fields.Selection([
|
||||||
|
('a4', 'A4 Full Receipt'),
|
||||||
|
('thermal', 'Thermal / 80mm Roll'),
|
||||||
|
], string='Default Print Format')
|
||||||
|
|
||||||
|
# company_id helper for domain filtering
|
||||||
|
company_id = fields.Many2one(
|
||||||
|
'res.company', string='Company',
|
||||||
|
default=lambda self: self.env.company,
|
||||||
|
readonly=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Load & Save ───────────────────────────────────────────────────
|
||||||
|
def get_values(self):
|
||||||
|
res = super().get_values()
|
||||||
|
ICP = self.env['ir.config_parameter'].sudo()
|
||||||
|
|
||||||
|
def _bool(key, default='False'):
|
||||||
|
return ICP.get_param(key, default) == 'True'
|
||||||
|
|
||||||
|
def _float(key, default='0.0'):
|
||||||
|
try:
|
||||||
|
return float(ICP.get_param(key, default))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
def _int_record(key):
|
||||||
|
val = ICP.get_param(key, '')
|
||||||
|
try:
|
||||||
|
return int(val) if val else False
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
res.update({
|
||||||
|
'laundry_wa_auto_send': _bool('laundry_management.wa_auto_send'),
|
||||||
|
'laundry_commission_enabled': _bool('laundry_management.commission_enabled'),
|
||||||
|
'laundry_commission_type': ICP.get_param('laundry_management.commission_type', 'percentage'),
|
||||||
|
'laundry_commission_reception_rate': _float('laundry_management.commission_reception_rate'),
|
||||||
|
'laundry_commission_processing_rate': _float('laundry_management.commission_processing_rate'),
|
||||||
|
'laundry_commission_delivery_rate': _float('laundry_management.commission_delivery_rate'),
|
||||||
|
'laundry_commission_account_id': _int_record('laundry_management.commission_account_id'),
|
||||||
|
'laundry_require_session': _bool('laundry_management.require_session'),
|
||||||
|
'laundry_cash_journal_id': _int_record('laundry_management.cash_journal_id'),
|
||||||
|
'laundry_difference_account_id': _int_record('laundry_management.difference_account_id'),
|
||||||
|
'laundry_default_paper': ICP.get_param('laundry_management.default_paper', 'a4'),
|
||||||
|
})
|
||||||
|
return res
|
||||||
|
|
||||||
|
def set_values(self):
|
||||||
|
super().set_values()
|
||||||
|
ICP = self.env['ir.config_parameter'].sudo()
|
||||||
|
ICP.set_param('laundry_management.wa_auto_send', str(self.laundry_wa_auto_send))
|
||||||
|
ICP.set_param('laundry_management.commission_enabled', str(self.laundry_commission_enabled))
|
||||||
|
ICP.set_param('laundry_management.commission_type', self.laundry_commission_type or 'percentage')
|
||||||
|
ICP.set_param('laundry_management.commission_reception_rate', str(self.laundry_commission_reception_rate))
|
||||||
|
ICP.set_param('laundry_management.commission_processing_rate', str(self.laundry_commission_processing_rate))
|
||||||
|
ICP.set_param('laundry_management.commission_delivery_rate', str(self.laundry_commission_delivery_rate))
|
||||||
|
ICP.set_param('laundry_management.commission_account_id', str(self.laundry_commission_account_id.id or ''))
|
||||||
|
ICP.set_param('laundry_management.require_session', str(self.laundry_require_session))
|
||||||
|
ICP.set_param('laundry_management.cash_journal_id', str(self.laundry_cash_journal_id.id or ''))
|
||||||
|
ICP.set_param('laundry_management.difference_account_id', str(self.laundry_difference_account_id.id or ''))
|
||||||
|
ICP.set_param('laundry_management.default_paper', self.laundry_default_paper or 'a4')
|
||||||
54
addons/laundry_management/models/pos_config_ext.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
from odoo import models, fields
|
||||||
|
|
||||||
|
|
||||||
|
class PosConfigLaundryExt(models.Model):
|
||||||
|
"""Laundry POS settings — dedicated section in POS configuration.
|
||||||
|
|
||||||
|
All defaults are intentionally conservative: feature OFF until the
|
||||||
|
operator opts in. When `enable_laundry_order_type` is False the
|
||||||
|
popups are skipped entirely — existing POS flow is unchanged.
|
||||||
|
"""
|
||||||
|
_inherit = 'pos.config'
|
||||||
|
|
||||||
|
# ── Order-type / attribute / delivery flow ────────────────────────
|
||||||
|
enable_laundry_order_type = fields.Boolean(
|
||||||
|
string='Ask Laundry Order Type in POS',
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
require_laundry_order_type = fields.Boolean(
|
||||||
|
string='Order Type Required',
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
ask_laundry_order_type_on_first_line = fields.Boolean(
|
||||||
|
string='Ask on First Laundry Line',
|
||||||
|
default=True,
|
||||||
|
)
|
||||||
|
allow_change_laundry_order_type_before_payment = fields.Boolean(
|
||||||
|
string='Allow Change Before Payment',
|
||||||
|
default=True,
|
||||||
|
)
|
||||||
|
default_laundry_order_type_id = fields.Many2one(
|
||||||
|
'laundry.order.type',
|
||||||
|
string='Default Order Type',
|
||||||
|
)
|
||||||
|
enable_laundry_attributes = fields.Boolean(
|
||||||
|
string='Enable Order Attributes',
|
||||||
|
default=True,
|
||||||
|
)
|
||||||
|
require_delivery_details_if_needed = fields.Boolean(
|
||||||
|
string='Require Delivery Details',
|
||||||
|
default=True,
|
||||||
|
help='Prompt for delivery address / scheduled time when the selected '
|
||||||
|
'type or attributes flag this as a delivery order.',
|
||||||
|
)
|
||||||
|
require_delivery_time = fields.Boolean(
|
||||||
|
string='Require Delivery Time',
|
||||||
|
default=False,
|
||||||
|
help='When enabled, the delivery details popup enforces a scheduled '
|
||||||
|
'time before it can be confirmed. When disabled, cashiers may '
|
||||||
|
'skip the time field and the order is saved without one.',
|
||||||
|
)
|
||||||
|
show_order_type_icons = fields.Boolean(
|
||||||
|
string='Show Icons in POS',
|
||||||
|
default=True,
|
||||||
|
)
|
||||||
411
addons/laundry_management/models/pos_order.py
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
import logging
|
||||||
|
from odoo import models, fields, api
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class PosOrderLaundryExt(models.Model):
|
||||||
|
"""Extend pos.order to:
|
||||||
|
1. Auto-create laundry.order for sales containing laundry products.
|
||||||
|
2. Split POS payments into real cash vs deferred (Customer Account).
|
||||||
|
|
||||||
|
Settlement is NO LONGER a POS order. It is a pure account.payment
|
||||||
|
invoked via res.partner.settle_laundry_dues_rpc — see res_partner.py.
|
||||||
|
|
||||||
|
The `is_laundry_settlement` field and the old settlement-product path
|
||||||
|
are preserved in schema form for historical compatibility with DB rows
|
||||||
|
created before this refactor, but the sync hook no longer creates or
|
||||||
|
processes settlement POS orders.
|
||||||
|
"""
|
||||||
|
_inherit = 'pos.order'
|
||||||
|
|
||||||
|
laundry_order_id = fields.Many2one(
|
||||||
|
'laundry.order', string='Laundry Order',
|
||||||
|
readonly=True, copy=False, index=True,
|
||||||
|
)
|
||||||
|
is_laundry_settlement = fields.Boolean(
|
||||||
|
string='Laundry Settlement (legacy)',
|
||||||
|
readonly=True, copy=False, default=False,
|
||||||
|
help='Legacy flag from the old settlement-product flow. '
|
||||||
|
'New settlements go through account.payment directly and do '
|
||||||
|
'not create POS orders.',
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- Order type / attributes / delivery (set in POS UI) --
|
||||||
|
laundry_order_type_id = fields.Many2one(
|
||||||
|
'laundry.order.type', string='Laundry Order Type',
|
||||||
|
index=True, copy=False,
|
||||||
|
)
|
||||||
|
laundry_order_attribute_ids = fields.Many2many(
|
||||||
|
'laundry.order.attribute',
|
||||||
|
'pos_order_laundry_attribute_rel',
|
||||||
|
'pos_order_id', 'attribute_id',
|
||||||
|
string='Laundry Attributes',
|
||||||
|
copy=False,
|
||||||
|
)
|
||||||
|
laundry_is_delivery = fields.Boolean(
|
||||||
|
string='Laundry Delivery', copy=False,
|
||||||
|
)
|
||||||
|
laundry_delivery_address = fields.Text(
|
||||||
|
string='Laundry Delivery Address', copy=False,
|
||||||
|
)
|
||||||
|
laundry_delivery_scheduled_at = fields.Datetime(
|
||||||
|
string='Laundry Delivery Scheduled At', copy=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# NOTE: pos.order._load_pos_data_fields is intentionally NOT overridden.
|
||||||
|
# Bisection proved that adding ANY custom field to this loader breaks
|
||||||
|
# POS order construction (`lines is undefined` in _computeAllPrices).
|
||||||
|
# Custom values are sent by serializeForORM (see pos_order_patch.js)
|
||||||
|
# and written directly by core's _process_order via create(**order),
|
||||||
|
# since the columns already exist on this model.
|
||||||
|
|
||||||
|
def action_open_laundry_order(self):
|
||||||
|
self.ensure_one()
|
||||||
|
if not self.laundry_order_id:
|
||||||
|
return
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'res_model': 'laundry.order',
|
||||||
|
'res_id': self.laundry_order_id.id,
|
||||||
|
'view_mode': 'form',
|
||||||
|
'target': 'current',
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────
|
||||||
|
# Sync hook
|
||||||
|
# ─────────────────────────────────────────────────────────────────────
|
||||||
|
@api.model
|
||||||
|
def _extract_pos_order_ids(self, sync_result):
|
||||||
|
"""Defensive extraction of pos.order ids from the value returned
|
||||||
|
by `super().sync_from_ui(...)`. Across Odoo 19 patch levels and
|
||||||
|
edge paths this value can be:
|
||||||
|
|
||||||
|
- a dict {'pos.order': [{'id': N, ...}, ...], ...}
|
||||||
|
- a list of integer ids
|
||||||
|
- a recordset of pos.order (if a downstream module already
|
||||||
|
normalized it)
|
||||||
|
- an empty container of any of the above
|
||||||
|
|
||||||
|
Returns a list of integer pos.order ids. Logs unknown shapes
|
||||||
|
instead of silently dropping them.
|
||||||
|
"""
|
||||||
|
if not sync_result:
|
||||||
|
return []
|
||||||
|
# Recordset → use .ids
|
||||||
|
if isinstance(sync_result, models.BaseModel):
|
||||||
|
return list(sync_result.ids)
|
||||||
|
# Dict payload (canonical in mainline Odoo 19)
|
||||||
|
if isinstance(sync_result, dict):
|
||||||
|
payload = sync_result.get('pos.order', [])
|
||||||
|
ids = []
|
||||||
|
for entry in payload or []:
|
||||||
|
if isinstance(entry, dict) and entry.get('id'):
|
||||||
|
ids.append(entry['id'])
|
||||||
|
elif isinstance(entry, int):
|
||||||
|
ids.append(entry)
|
||||||
|
return ids
|
||||||
|
# Plain list of ids (some patch levels / community forks)
|
||||||
|
if isinstance(sync_result, (list, tuple)):
|
||||||
|
ids = []
|
||||||
|
for entry in sync_result:
|
||||||
|
if isinstance(entry, int):
|
||||||
|
ids.append(entry)
|
||||||
|
elif isinstance(entry, dict) and entry.get('id'):
|
||||||
|
ids.append(entry['id'])
|
||||||
|
return ids
|
||||||
|
_logger.warning(
|
||||||
|
"[laundry] unknown sync_from_ui return shape: %s %r",
|
||||||
|
type(sync_result).__name__, sync_result,
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def sync_from_ui(self, orders):
|
||||||
|
# CRITICAL: super() runs the entire POS payment commit. The
|
||||||
|
# laundry hand-off MUST be additive and side-effect-free if it
|
||||||
|
# fails — savepoint per pos.order so a SQL-level error cannot
|
||||||
|
# poison the parent transaction.
|
||||||
|
result = super().sync_from_ui(orders)
|
||||||
|
|
||||||
|
order_ids = self._extract_pos_order_ids(result)
|
||||||
|
_logger.info(
|
||||||
|
"[laundry] sync_from_ui post-process: %d pos.order id(s) extracted",
|
||||||
|
len(order_ids),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not order_ids:
|
||||||
|
return result
|
||||||
|
|
||||||
|
# `.exists()` filters out any id the caller provided that isn't
|
||||||
|
# actually in the DB anymore (deleted in a concurrent flow).
|
||||||
|
# `sudo()` is bounded to this internal hand-off — POS users
|
||||||
|
# may not hold full create/write rights on laundry.order, but
|
||||||
|
# the hand-off itself is a server-controlled, validated path.
|
||||||
|
for order in self.browse(order_ids).exists():
|
||||||
|
try:
|
||||||
|
with self.env.cr.savepoint():
|
||||||
|
order.sudo()._maybe_create_laundry_order()
|
||||||
|
except Exception:
|
||||||
|
_logger.exception(
|
||||||
|
"[laundry] Failed to create/sync laundry.order from "
|
||||||
|
"POS %s (POS sale committed; rolled back laundry-side "
|
||||||
|
"savepoint only)", order.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def write(self, vals):
|
||||||
|
res = super().write(vals)
|
||||||
|
# Same isolation as above. The amount-sync to laundry is a
|
||||||
|
# SECONDARY effect of a POS payment write; it must never be the
|
||||||
|
# reason a payment fails.
|
||||||
|
if 'amount_paid' in vals or 'payment_ids' in vals or 'amount_total' in vals:
|
||||||
|
for order in self:
|
||||||
|
if not order.laundry_order_id:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with self.env.cr.savepoint():
|
||||||
|
order._sync_laundry_amounts()
|
||||||
|
except Exception:
|
||||||
|
_logger.exception(
|
||||||
|
'Failed to sync amounts to laundry order %s '
|
||||||
|
'from POS %s (POS write succeeded; rolled back '
|
||||||
|
'laundry-side savepoint only)',
|
||||||
|
order.laundry_order_id.id, order.id,
|
||||||
|
)
|
||||||
|
return res
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────
|
||||||
|
# Payment classification
|
||||||
|
# ─────────────────────────────────────────────────────────────────────
|
||||||
|
def _classify_pos_payments(self):
|
||||||
|
"""Return (cash_total, deferred_total) by inspecting pos.payment rows.
|
||||||
|
|
||||||
|
cash_total = Σ amount where method.split_transactions is False
|
||||||
|
deferred_total = Σ amount where method.split_transactions is True
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
cash_total = 0.0
|
||||||
|
deferred_total = 0.0
|
||||||
|
for pmt in self.payment_ids:
|
||||||
|
method = pmt.payment_method_id
|
||||||
|
if method and method.split_transactions:
|
||||||
|
deferred_total += pmt.amount
|
||||||
|
else:
|
||||||
|
cash_total += pmt.amount
|
||||||
|
return cash_total, deferred_total
|
||||||
|
|
||||||
|
def _sync_laundry_amounts(self):
|
||||||
|
"""Push current financial split to the linked laundry.order.
|
||||||
|
|
||||||
|
amount_total / amount_paid_cash / amount_deferred are all in
|
||||||
|
LOCKED_HEADER_FIELDS — without the POS-sync context bypass the
|
||||||
|
lock guard would block this write on every payment edit.
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
if not self.laundry_order_id:
|
||||||
|
return
|
||||||
|
cash, deferred = self._classify_pos_payments()
|
||||||
|
self.laundry_order_id.sudo().with_context(
|
||||||
|
laundry_pos_sync=True
|
||||||
|
).write({
|
||||||
|
'amount_total': self.amount_total,
|
||||||
|
'amount_paid_cash': cash,
|
||||||
|
'amount_deferred': deferred,
|
||||||
|
})
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────
|
||||||
|
# Laundry order creation
|
||||||
|
# ─────────────────────────────────────────────────────────────────────
|
||||||
|
def _maybe_create_laundry_order(self):
|
||||||
|
"""Create laundry.order if this POS order contains laundry products."""
|
||||||
|
self.ensure_one()
|
||||||
|
_logger.warning(
|
||||||
|
"[laundry-trace] _maybe_create_laundry_order POS %s "
|
||||||
|
"(ref=%s, lines=%d, existing_link=%s)",
|
||||||
|
self.id, self.pos_reference or '-',
|
||||||
|
len(self.lines), self.laundry_order_id.id or False,
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.laundry_order_id:
|
||||||
|
_logger.warning(
|
||||||
|
"[laundry-trace] POS %s already linked to laundry.order %s "
|
||||||
|
"→ syncing amounts only", self.id, self.laundry_order_id.id,
|
||||||
|
)
|
||||||
|
self._sync_laundry_amounts()
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self.lines:
|
||||||
|
_logger.warning("[laundry-trace] POS %s has no lines → skip", self.id)
|
||||||
|
return
|
||||||
|
|
||||||
|
partner = self.partner_id or self.env.company.partner_id
|
||||||
|
|
||||||
|
laundry_lines = []
|
||||||
|
skipped = []
|
||||||
|
for line in self.lines:
|
||||||
|
tmpl = line.product_id.product_tmpl_id if line.product_id else None
|
||||||
|
if not tmpl:
|
||||||
|
skipped.append((line.id, 'no template'))
|
||||||
|
continue
|
||||||
|
if not tmpl.is_laundry_service:
|
||||||
|
skipped.append((line.id, f'not laundry ({tmpl.name})'))
|
||||||
|
continue
|
||||||
|
laundry_lines.append((0, 0, {
|
||||||
|
'product_id': line.product_id.id,
|
||||||
|
'description': line.full_product_name or line.product_id.name,
|
||||||
|
'qty': line.qty,
|
||||||
|
'price_unit': line.price_unit,
|
||||||
|
'customer_note': line.customer_note or '',
|
||||||
|
}))
|
||||||
|
|
||||||
|
_logger.warning(
|
||||||
|
"[laundry-trace] POS %s line scan: %d laundry, %d skipped %s",
|
||||||
|
self.id, len(laundry_lines), len(skipped), skipped[:5],
|
||||||
|
)
|
||||||
|
|
||||||
|
if not laundry_lines:
|
||||||
|
_logger.warning(
|
||||||
|
"[laundry-trace] POS %s has no laundry-service lines → skip "
|
||||||
|
"(this is the most common cause of 'no laundry order created')",
|
||||||
|
self.id,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
LaundryOrder = self.env['laundry.order']
|
||||||
|
existing = LaundryOrder.search(
|
||||||
|
[('pos_order_id', '=', self.id)], limit=1,
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
self.laundry_order_id = existing.id
|
||||||
|
self._sync_laundry_amounts()
|
||||||
|
return
|
||||||
|
|
||||||
|
cash, deferred = self._classify_pos_payments()
|
||||||
|
create_vals = {
|
||||||
|
'pos_order_id': self.id,
|
||||||
|
'pos_reference': self.pos_reference or '',
|
||||||
|
'partner_id': partner.id,
|
||||||
|
'company_id': self.company_id.id,
|
||||||
|
# Phase 3: every laundry.order born from POS is hard-locked
|
||||||
|
# by `source_type`. Header lock + line lock both kick in
|
||||||
|
# immediately. The POS-sync context bypass below lets the
|
||||||
|
# initial create + line writes go through.
|
||||||
|
'source_type': 'pos',
|
||||||
|
'amount_total': self.amount_total,
|
||||||
|
'amount_paid_cash': cash,
|
||||||
|
'amount_deferred': deferred,
|
||||||
|
'notes': self.general_customer_note or '',
|
||||||
|
'line_ids': laundry_lines,
|
||||||
|
}
|
||||||
|
# Propagate order type / attributes / delivery — inference in
|
||||||
|
# laundry.order.create fills priority_level and is_delivery from
|
||||||
|
# these when not explicitly provided.
|
||||||
|
if self.laundry_order_type_id:
|
||||||
|
create_vals['order_type_id'] = self.laundry_order_type_id.id
|
||||||
|
if self.laundry_order_attribute_ids:
|
||||||
|
create_vals['attribute_ids'] = [(6, 0, self.laundry_order_attribute_ids.ids)]
|
||||||
|
if self.laundry_is_delivery:
|
||||||
|
create_vals['is_delivery'] = True
|
||||||
|
if self.laundry_delivery_address:
|
||||||
|
create_vals['delivery_address'] = self.laundry_delivery_address
|
||||||
|
if self.laundry_delivery_scheduled_at:
|
||||||
|
create_vals['delivery_scheduled_at'] = self.laundry_delivery_scheduled_at
|
||||||
|
|
||||||
|
laundry_order = LaundryOrder.with_context(
|
||||||
|
laundry_pos_sync=True
|
||||||
|
).create(create_vals)
|
||||||
|
self.laundry_order_id = laundry_order.id
|
||||||
|
|
||||||
|
_logger.info(
|
||||||
|
'Created laundry order %s from POS %s: total=%.2f, cash=%.2f, deferred=%.2f',
|
||||||
|
laundry_order.name, self.pos_reference or self.id,
|
||||||
|
self.amount_total, cash, deferred,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────
|
||||||
|
# Integrity audit — Step 4 of the stabilization brief
|
||||||
|
# ─────────────────────────────────────────────────────────────────────
|
||||||
|
@api.model
|
||||||
|
def audit_laundry_links(self, heal=False):
|
||||||
|
"""Report (and optionally heal) POS orders that should have a
|
||||||
|
linked laundry.order but don't.
|
||||||
|
|
||||||
|
Returns a dict:
|
||||||
|
{
|
||||||
|
'checked': int,
|
||||||
|
'missing': [{pos_order_id, pos_reference, partner_name,
|
||||||
|
amount_total}, ...],
|
||||||
|
'duplicates': [pos_order_id, ...], # pos.order pointing to
|
||||||
|
# a laundry.order that no
|
||||||
|
# longer exists
|
||||||
|
'healed': int, # populated only if heal=True
|
||||||
|
}
|
||||||
|
|
||||||
|
A POS order is "should-link" when it has at least one line whose
|
||||||
|
product_template is flagged is_laundry_service AND is NOT the
|
||||||
|
settlement product. This is exactly the same condition
|
||||||
|
_maybe_create_laundry_order applies, so the audit and the live
|
||||||
|
hand-off agree.
|
||||||
|
|
||||||
|
Heal mode re-runs `_maybe_create_laundry_order` for missing
|
||||||
|
rows, each in its own savepoint — same isolation guarantee as
|
||||||
|
the live hand-off. Safe to call repeatedly.
|
||||||
|
"""
|
||||||
|
Order = self.env['pos.order']
|
||||||
|
all_orders = Order.search([])
|
||||||
|
missing = []
|
||||||
|
duplicates = []
|
||||||
|
for o in all_orders:
|
||||||
|
has_laundry_line = any(
|
||||||
|
line.product_id.product_tmpl_id.is_laundry_service
|
||||||
|
and not line.product_id.product_tmpl_id.is_laundry_settlement
|
||||||
|
for line in o.lines
|
||||||
|
if line.product_id and line.product_id.product_tmpl_id
|
||||||
|
)
|
||||||
|
if not has_laundry_line:
|
||||||
|
continue
|
||||||
|
if o.laundry_order_id and not o.laundry_order_id.exists():
|
||||||
|
# Stored fk to a deleted laundry.order — orphan link
|
||||||
|
duplicates.append(o.id)
|
||||||
|
continue
|
||||||
|
if not o.laundry_order_id:
|
||||||
|
missing.append({
|
||||||
|
'pos_order_id': o.id,
|
||||||
|
'pos_reference': o.pos_reference or '',
|
||||||
|
'partner_name': o.partner_id.name if o.partner_id else '',
|
||||||
|
'amount_total': o.amount_total,
|
||||||
|
})
|
||||||
|
|
||||||
|
healed = 0
|
||||||
|
if heal:
|
||||||
|
for entry in missing:
|
||||||
|
pos_order = Order.browse(entry['pos_order_id'])
|
||||||
|
try:
|
||||||
|
with self.env.cr.savepoint():
|
||||||
|
pos_order._maybe_create_laundry_order()
|
||||||
|
if pos_order.laundry_order_id:
|
||||||
|
healed += 1
|
||||||
|
except Exception:
|
||||||
|
_logger.exception(
|
||||||
|
'audit_laundry_links: heal failed for POS %s',
|
||||||
|
pos_order.id,
|
||||||
|
)
|
||||||
|
# Clear orphan fk pointers (the linked laundry.order was deleted)
|
||||||
|
for pos_id in duplicates:
|
||||||
|
try:
|
||||||
|
with self.env.cr.savepoint():
|
||||||
|
Order.browse(pos_id).laundry_order_id = False
|
||||||
|
except Exception:
|
||||||
|
_logger.exception(
|
||||||
|
'audit_laundry_links: orphan-clear failed for POS %s',
|
||||||
|
pos_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'checked': len(all_orders),
|
||||||
|
'missing': missing,
|
||||||
|
'duplicates': duplicates,
|
||||||
|
'healed': healed,
|
||||||
|
}
|
||||||
22
addons/laundry_management/models/pos_session_ext.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
"""POS session extension for laundry.
|
||||||
|
|
||||||
|
Cash settlements use account.bank.statement.line tagged with
|
||||||
|
pos_session_id, so POS's native _compute_cash_balance picks them up
|
||||||
|
automatically — no display/math override is required.
|
||||||
|
|
||||||
|
We only override _load_pos_data_models to ship our two new configuration
|
||||||
|
models (laundry.order.type, laundry.order.attribute) to the POS client.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from odoo import models
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class PosSessionLaundryExt(models.Model):
|
||||||
|
_inherit = 'pos.session'
|
||||||
|
|
||||||
|
def _load_pos_data_models(self, config):
|
||||||
|
models_to_load = super()._load_pos_data_models(config)
|
||||||
|
models_to_load += ['laundry.order.type', 'laundry.order.attribute']
|
||||||
|
return models_to_load
|
||||||
33
addons/laundry_management/models/product_template_ext.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
from odoo import models, fields, api
|
||||||
|
|
||||||
|
|
||||||
|
class ProductTemplateExt(models.Model):
|
||||||
|
"""Extend product.template with the two flags the laundry workflow needs.
|
||||||
|
|
||||||
|
is_laundry_service: marks products as laundry services. POS sales of
|
||||||
|
these products auto-create / link a laundry.order.
|
||||||
|
is_laundry_settlement: internal flag for the dedicated settlement
|
||||||
|
product used to collect outstanding laundry dues without
|
||||||
|
generating revenue lines.
|
||||||
|
"""
|
||||||
|
_inherit = 'product.template'
|
||||||
|
|
||||||
|
is_laundry_service = fields.Boolean(
|
||||||
|
string='Laundry Service',
|
||||||
|
default=False,
|
||||||
|
help='Tick to include this product in the Laundry Service Catalog '
|
||||||
|
'and allow selection on laundry order lines.',
|
||||||
|
)
|
||||||
|
is_laundry_settlement = fields.Boolean(
|
||||||
|
string='Laundry Settlement',
|
||||||
|
default=False,
|
||||||
|
help='Internal flag for the settlement product. '
|
||||||
|
'Do not enable on regular products.',
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _load_pos_data_fields(self, config_id):
|
||||||
|
fields = super()._load_pos_data_fields(config_id)
|
||||||
|
fields.append('is_laundry_service')
|
||||||
|
fields.append('is_laundry_settlement')
|
||||||
|
return fields
|
||||||
513
addons/laundry_management/models/res_partner.py
Normal file
@@ -0,0 +1,513 @@
|
|||||||
|
import logging
|
||||||
|
from odoo import models, fields, api, _
|
||||||
|
from odoo.exceptions import UserError, ValidationError
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ResPartnerLaundry(models.Model):
|
||||||
|
_inherit = 'res.partner'
|
||||||
|
|
||||||
|
laundry_unpaid_count = fields.Integer(
|
||||||
|
string='Unpaid Laundry Orders',
|
||||||
|
compute='_compute_laundry_unpaid_count',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Customer defaults — seed values only; cashier can override in POS.
|
||||||
|
default_laundry_order_type_id = fields.Many2one(
|
||||||
|
'laundry.order.type', string='Default Laundry Order Type',
|
||||||
|
)
|
||||||
|
default_laundry_attribute_ids = fields.Many2many(
|
||||||
|
'laundry.order.attribute',
|
||||||
|
'res_partner_laundry_default_attr_rel',
|
||||||
|
'partner_id', 'attribute_id',
|
||||||
|
string='Default Laundry Attributes',
|
||||||
|
)
|
||||||
|
|
||||||
|
def _compute_laundry_unpaid_count(self):
|
||||||
|
if not self.ids:
|
||||||
|
self.laundry_unpaid_count = 0
|
||||||
|
return
|
||||||
|
self.env.cr.execute("""
|
||||||
|
SELECT partner_id, COUNT(*)
|
||||||
|
FROM laundry_order
|
||||||
|
WHERE partner_id IN %s
|
||||||
|
AND amount_due > 0
|
||||||
|
AND state != 'delivered'
|
||||||
|
GROUP BY partner_id
|
||||||
|
""", [tuple(self.ids)])
|
||||||
|
counts = dict(self.env.cr.fetchall())
|
||||||
|
for partner in self:
|
||||||
|
partner.laundry_unpaid_count = counts.get(partner.id, 0)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _load_pos_data_fields(self, config):
|
||||||
|
fields = super()._load_pos_data_fields(config)
|
||||||
|
fields.append('laundry_unpaid_count')
|
||||||
|
fields.append('default_laundry_order_type_id')
|
||||||
|
fields.append('default_laundry_attribute_ids')
|
||||||
|
return fields
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def laundry_find_by_phone(self, phone):
|
||||||
|
"""Search for existing partner by phone. Returns dict or False."""
|
||||||
|
phone_clean = phone.strip().replace(' ', '')
|
||||||
|
if not phone_clean:
|
||||||
|
return False
|
||||||
|
domain = [('phone', 'ilike', phone_clean)]
|
||||||
|
if 'mobile' in self._fields:
|
||||||
|
domain = ['|', ('phone', 'ilike', phone_clean), ('mobile', 'ilike', phone_clean)]
|
||||||
|
partner = self.search(domain, limit=1)
|
||||||
|
if partner:
|
||||||
|
phone_val = partner.phone or ''
|
||||||
|
if 'mobile' in self._fields and partner.mobile:
|
||||||
|
phone_val = phone_val or partner.mobile
|
||||||
|
return {'id': partner.id, 'name': partner.name, 'phone': phone_val}
|
||||||
|
return False
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def laundry_quick_create(self, vals):
|
||||||
|
"""Create partner from POS quick-create popup. Returns partner id.
|
||||||
|
|
||||||
|
UX contract:
|
||||||
|
- phone is required (the JS popup enforces this; we also guard).
|
||||||
|
- name is optional. If empty, the partner name defaults to the
|
||||||
|
phone number — keeps lists scannable when a cashier doesn't
|
||||||
|
have time to type a name.
|
||||||
|
- mobile (when the field exists in this Odoo install) is mirrored
|
||||||
|
from phone so duplicate-search by phone OR mobile keeps working.
|
||||||
|
"""
|
||||||
|
phone = (vals.get('phone') or '').strip()
|
||||||
|
if not phone:
|
||||||
|
raise UserError(_('Phone is required to create a customer.'))
|
||||||
|
name = (vals.get('name') or '').strip() or phone
|
||||||
|
street = (vals.get('street') or '').strip() or False
|
||||||
|
create_vals = {'name': name, 'phone': phone, 'street': street}
|
||||||
|
# `mobile` is provided by the optional phone add-on. Mirror only
|
||||||
|
# when the field exists, so this stays portable across Odoo
|
||||||
|
# distributions without bringing in extra deps.
|
||||||
|
if 'mobile' in self._fields:
|
||||||
|
create_vals['mobile'] = phone
|
||||||
|
partner = self.create(create_vals)
|
||||||
|
return partner.id
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def get_laundry_dues(self, partner_id):
|
||||||
|
"""Return outstanding laundry dues for a partner (read-only).
|
||||||
|
|
||||||
|
`amount_due` is now `amount_deferred - amount_settled` — the real
|
||||||
|
money still owed — not `total - paid`.
|
||||||
|
"""
|
||||||
|
orders = self.env['laundry.order'].search([
|
||||||
|
('partner_id', '=', partner_id),
|
||||||
|
('amount_due', '>', 0),
|
||||||
|
('state', '!=', 'delivered'),
|
||||||
|
], order='create_date asc, id asc')
|
||||||
|
order_details = []
|
||||||
|
total_due = 0.0
|
||||||
|
for o in orders:
|
||||||
|
total_due += o.amount_due
|
||||||
|
services = o.line_ids.mapped('product_id.name')
|
||||||
|
service_summary = ', '.join(dict.fromkeys(services))[:80]
|
||||||
|
order_details.append({
|
||||||
|
'id': o.id,
|
||||||
|
'name': o.name,
|
||||||
|
'amount_due': o.amount_due,
|
||||||
|
'amount_total': o.amount_total,
|
||||||
|
'amount_paid_cash': o.amount_paid_cash,
|
||||||
|
'amount_deferred': o.amount_deferred,
|
||||||
|
'amount_settled': o.amount_settled,
|
||||||
|
'state': o.state,
|
||||||
|
'intake_date': fields.Datetime.to_string(o.create_date) if o.create_date else False,
|
||||||
|
'item_count': o.item_count,
|
||||||
|
'service_summary': service_summary or '',
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
'total_due': total_due,
|
||||||
|
'order_count': len(orders),
|
||||||
|
'orders': order_details,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────
|
||||||
|
# Settlement engine
|
||||||
|
# CASH → account.bank.statement.line (Dr Cash / Cr Receivable)
|
||||||
|
# + immediate reconcile vs open AR debits (FIFO)
|
||||||
|
# → POS expected cash auto-includes it via statement_line_ids
|
||||||
|
# NON-CASH → account.payment (Dr Bank / Cr Receivable) + reconcile
|
||||||
|
# → drawer untouched, no duplication risk
|
||||||
|
# ─────────────────────────────────────────────────────────────────────
|
||||||
|
@api.model
|
||||||
|
def settle_laundry_dues_rpc(self, partner_id, payment_lines, pos_session_id=None):
|
||||||
|
"""Collect a laundry receivable via one or more journal entries.
|
||||||
|
|
||||||
|
Per-line routing by journal type — see module docstring above.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
partner_id: res.partner id.
|
||||||
|
payment_lines: list of dicts, each with:
|
||||||
|
- pos_payment_method_id: pos.payment.method id
|
||||||
|
- amount: float > 0
|
||||||
|
pos_session_id: optional pos.session id. For CASH it tags the
|
||||||
|
statement line so POS includes the amount in expected cash.
|
||||||
|
For NON-CASH it's stamped on account.payment for audit.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with: settled_total, remaining_due, payments[], settled_orders[].
|
||||||
|
"""
|
||||||
|
partner = self.browse(partner_id)
|
||||||
|
if not partner.exists():
|
||||||
|
raise UserError(_('Customer not found.'))
|
||||||
|
|
||||||
|
if not payment_lines or not isinstance(payment_lines, list):
|
||||||
|
raise UserError(_('At least one payment line is required.'))
|
||||||
|
|
||||||
|
normalized = []
|
||||||
|
total = 0.0
|
||||||
|
for idx, line in enumerate(payment_lines, start=1):
|
||||||
|
if not isinstance(line, dict):
|
||||||
|
raise UserError(_('Payment line %s is malformed.', idx))
|
||||||
|
method_id = line.get('pos_payment_method_id')
|
||||||
|
if not method_id:
|
||||||
|
raise UserError(_('Payment line %s has no payment method.', idx))
|
||||||
|
try:
|
||||||
|
amt = float(line.get('amount') or 0.0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise UserError(_('Payment line %s has an invalid amount.', idx))
|
||||||
|
if amt <= 0:
|
||||||
|
raise UserError(_('Payment line %s must have a positive amount.', idx))
|
||||||
|
journal = self._resolve_settlement_journal(method_id)
|
||||||
|
normalized.append({
|
||||||
|
'method_id': int(method_id),
|
||||||
|
'amount': amt,
|
||||||
|
'journal': journal,
|
||||||
|
})
|
||||||
|
total += amt
|
||||||
|
|
||||||
|
session = None
|
||||||
|
if pos_session_id:
|
||||||
|
session = self.env['pos.session'].browse(int(pos_session_id))
|
||||||
|
if not session.exists():
|
||||||
|
session = None
|
||||||
|
|
||||||
|
receipt_entries = []
|
||||||
|
for line in normalized:
|
||||||
|
journal = line['journal']
|
||||||
|
method = self.env['pos.payment.method'].browse(line['method_id'])
|
||||||
|
if journal.type == 'cash':
|
||||||
|
stmt_line = self._create_cash_settlement_statement_line(
|
||||||
|
partner, journal, line['amount'], session,
|
||||||
|
)
|
||||||
|
self._reconcile_statement_line_to_receivable(stmt_line, partner)
|
||||||
|
receipt_entries.append({
|
||||||
|
'id': stmt_line.id,
|
||||||
|
'name': stmt_line.move_id.name or stmt_line.payment_ref,
|
||||||
|
'state': stmt_line.move_id.state,
|
||||||
|
'amount': stmt_line.amount,
|
||||||
|
'journal_name': journal.name,
|
||||||
|
'journal_type': 'cash',
|
||||||
|
'method_name': method.name or journal.name,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
pmt = self._create_settlement_account_payment(
|
||||||
|
partner, journal, line['amount'], line['method_id'], session,
|
||||||
|
)
|
||||||
|
receipt_entries.append({
|
||||||
|
'id': pmt.id,
|
||||||
|
'name': pmt.name,
|
||||||
|
'state': pmt.state,
|
||||||
|
'amount': pmt.amount,
|
||||||
|
'journal_name': pmt.journal_id.name,
|
||||||
|
'journal_type': pmt.journal_id.type,
|
||||||
|
'method_name': pmt.settlement_pos_pm_id.name or pmt.journal_id.name,
|
||||||
|
})
|
||||||
|
|
||||||
|
settled_orders, remaining_due = self._distribute_settlement_fifo(partner, total)
|
||||||
|
|
||||||
|
_logger.info(
|
||||||
|
'Settlement RPC: partner=%s total=%.2f lines=%d remaining=%.2f',
|
||||||
|
partner.name, total, len(receipt_entries), remaining_due,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'settled_total': total,
|
||||||
|
'remaining_due': remaining_due,
|
||||||
|
'payments': receipt_entries,
|
||||||
|
'settled_orders': settled_orders,
|
||||||
|
}
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _create_cash_settlement_statement_line(self, partner, journal, amount, session):
|
||||||
|
"""Create a POS-tagged cash-in statement line that posts directly
|
||||||
|
Dr Cash / Cr Receivable.
|
||||||
|
|
||||||
|
`counterpart_account_id` is consumed by account.bank.statement.line.create
|
||||||
|
to override the journal's default suspense account — so the credit
|
||||||
|
leg lands on the partner's receivable account, ready for reconcile.
|
||||||
|
The statement line auto-posts its move (Odoo handles action_post).
|
||||||
|
|
||||||
|
Tagging `pos_session_id` makes the amount flow into POS's native
|
||||||
|
expected-cash formula (statement_line_ids sum) → no closing diff.
|
||||||
|
"""
|
||||||
|
receivable = partner.property_account_receivable_id
|
||||||
|
if not receivable:
|
||||||
|
raise UserError(_(
|
||||||
|
'Customer "%s" has no receivable account configured.',
|
||||||
|
partner.name,
|
||||||
|
))
|
||||||
|
vals = {
|
||||||
|
'journal_id': journal.id,
|
||||||
|
'amount': amount,
|
||||||
|
'partner_id': partner.id,
|
||||||
|
'payment_ref': _('Customer Settlement (POS) — %s', partner.name),
|
||||||
|
'counterpart_account_id': receivable.id,
|
||||||
|
'date': fields.Date.context_today(self),
|
||||||
|
}
|
||||||
|
if session:
|
||||||
|
vals['pos_session_id'] = session.id
|
||||||
|
try:
|
||||||
|
return self.env['account.bank.statement.line'].sudo().create(vals)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception(
|
||||||
|
'Failed to create cash settlement statement line for partner %s',
|
||||||
|
partner.id,
|
||||||
|
)
|
||||||
|
raise UserError(_('Failed to record cash settlement: %s', e))
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _reconcile_statement_line_to_receivable(self, stmt_line, partner):
|
||||||
|
"""Reconcile the statement line's receivable credit against the
|
||||||
|
partner's open receivable debits (FIFO).
|
||||||
|
|
||||||
|
Both sides live on the partner's receivable account, so a direct
|
||||||
|
AML.reconcile() fully settles the customer due in one step.
|
||||||
|
"""
|
||||||
|
receivable = partner.property_account_receivable_id
|
||||||
|
if not receivable:
|
||||||
|
return
|
||||||
|
counterpart_credits = stmt_line.move_id.line_ids.filtered(
|
||||||
|
lambda l: l.account_id == receivable
|
||||||
|
and l.credit > 0
|
||||||
|
and not l.reconciled
|
||||||
|
)
|
||||||
|
if not counterpart_credits:
|
||||||
|
return
|
||||||
|
AML = self.env['account.move.line']
|
||||||
|
open_debits = AML.search([
|
||||||
|
('partner_id', '=', partner.id),
|
||||||
|
('account_id', '=', receivable.id),
|
||||||
|
('reconciled', '=', False),
|
||||||
|
('parent_state', '=', 'posted'),
|
||||||
|
('debit', '>', 0),
|
||||||
|
], order='date asc, id asc')
|
||||||
|
if not open_debits:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
(counterpart_credits | open_debits).reconcile()
|
||||||
|
except Exception:
|
||||||
|
_logger.exception(
|
||||||
|
'Reconciliation failed for cash settlement statement line %s',
|
||||||
|
stmt_line.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _create_settlement_account_payment(
|
||||||
|
self, partner, journal, amount, method_id, session,
|
||||||
|
):
|
||||||
|
"""Non-cash settlement: standard account.payment + reconcile."""
|
||||||
|
Payment = self.env['account.payment']
|
||||||
|
pmt_vals = {
|
||||||
|
'payment_type': 'inbound',
|
||||||
|
'partner_type': 'customer',
|
||||||
|
'partner_id': partner.id,
|
||||||
|
'amount': amount,
|
||||||
|
'journal_id': journal.id,
|
||||||
|
'currency_id': (journal.currency_id or self.env.company.currency_id).id,
|
||||||
|
'date': fields.Date.context_today(self),
|
||||||
|
'memo': _('Laundry dues settlement — %s', partner.name),
|
||||||
|
'settlement_pos_pm_id': method_id,
|
||||||
|
}
|
||||||
|
if session:
|
||||||
|
pmt_vals['pos_session_id'] = session.id
|
||||||
|
pmt = Payment.sudo().create(pmt_vals)
|
||||||
|
try:
|
||||||
|
pmt.action_post()
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception(
|
||||||
|
'Failed to post settlement payment for partner %s', partner.id,
|
||||||
|
)
|
||||||
|
raise UserError(_('Failed to post payment: %s', e))
|
||||||
|
self._reconcile_settlement_payment(pmt, partner)
|
||||||
|
return pmt
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _resolve_settlement_journal(self, pos_payment_method_id):
|
||||||
|
"""Pick the account.journal for a settlement payment.
|
||||||
|
|
||||||
|
1. If a pos.payment.method id is given, enforce split_transactions=False
|
||||||
|
and return its journal.
|
||||||
|
2. Else return the company's default cash journal, then bank journal.
|
||||||
|
Raises UserError if no valid journal can be found.
|
||||||
|
"""
|
||||||
|
if pos_payment_method_id:
|
||||||
|
method = self.env['pos.payment.method'].browse(int(pos_payment_method_id))
|
||||||
|
if not method.exists():
|
||||||
|
raise UserError(_('Selected payment method not found.'))
|
||||||
|
if method.split_transactions:
|
||||||
|
raise ValidationError(_(
|
||||||
|
'Customer Account / pay-later methods cannot be used to '
|
||||||
|
'settle laundry dues — they would create new receivable '
|
||||||
|
'instead of reducing it.'
|
||||||
|
))
|
||||||
|
if not method.journal_id:
|
||||||
|
raise UserError(_(
|
||||||
|
'Payment method "%s" has no journal configured.',
|
||||||
|
method.name,
|
||||||
|
))
|
||||||
|
return method.journal_id
|
||||||
|
|
||||||
|
Journal = self.env['account.journal']
|
||||||
|
j = Journal.search([
|
||||||
|
('company_id', '=', self.env.company.id),
|
||||||
|
('type', '=', 'cash'),
|
||||||
|
], limit=1)
|
||||||
|
if not j:
|
||||||
|
j = Journal.search([
|
||||||
|
('company_id', '=', self.env.company.id),
|
||||||
|
('type', '=', 'bank'),
|
||||||
|
], limit=1)
|
||||||
|
if not j:
|
||||||
|
raise UserError(_('No cash or bank journal available for settlement.'))
|
||||||
|
return j
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _reconcile_settlement_payment(self, payment, partner):
|
||||||
|
"""Reconcile the payment's partner-receivable line against open
|
||||||
|
receivable AMLs from prior customer-account POS sales.
|
||||||
|
|
||||||
|
Journals without an outstanding-receipts account post directly to the
|
||||||
|
partner receivable (Dr Bank / Cr Receivable, state=paid). Journals
|
||||||
|
configured with an outstanding-receipts account post to it first
|
||||||
|
(Dr Bank / Cr Outstanding Receipts, state=in_process) — no partner
|
||||||
|
receivable line yet, so there's nothing to reconcile here until the
|
||||||
|
bank statement clears. This method safely no-ops in that case.
|
||||||
|
"""
|
||||||
|
receivable_account = partner.property_account_receivable_id
|
||||||
|
if not receivable_account:
|
||||||
|
_logger.warning('Partner %s has no receivable account', partner.id)
|
||||||
|
return
|
||||||
|
|
||||||
|
AML = self.env['account.move.line']
|
||||||
|
payment_lines = payment.move_id.line_ids.filtered(
|
||||||
|
lambda l: l.account_id == receivable_account and not l.reconciled
|
||||||
|
)
|
||||||
|
if not payment_lines:
|
||||||
|
return # state=in_process — reconciliation happens at bank clearing
|
||||||
|
|
||||||
|
open_debits = AML.search([
|
||||||
|
('partner_id', '=', partner.id),
|
||||||
|
('account_id', '=', receivable_account.id),
|
||||||
|
('reconciled', '=', False),
|
||||||
|
('parent_state', '=', 'posted'),
|
||||||
|
('debit', '>', 0),
|
||||||
|
], order='date asc, id asc')
|
||||||
|
if not open_debits:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
(payment_lines | open_debits).reconcile()
|
||||||
|
except Exception:
|
||||||
|
_logger.exception(
|
||||||
|
'Reconciliation failed for settlement payment %s', payment.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _distribute_settlement_fifo(self, partner, amount):
|
||||||
|
"""Spread an already-collected amount across the partner's open
|
||||||
|
laundry orders, oldest first. Returns (settled_orders, remaining_due).
|
||||||
|
"""
|
||||||
|
LaundryOrder = self.env['laundry.order']
|
||||||
|
open_orders = LaundryOrder.search([
|
||||||
|
('partner_id', '=', partner.id),
|
||||||
|
('amount_due', '>', 0),
|
||||||
|
('state', '!=', 'delivered'),
|
||||||
|
], order='create_date asc, id asc')
|
||||||
|
|
||||||
|
remaining = amount
|
||||||
|
settled_orders = []
|
||||||
|
for lo in open_orders:
|
||||||
|
if remaining <= 0:
|
||||||
|
break
|
||||||
|
apply_amount = min(remaining, lo.amount_due)
|
||||||
|
# Defense-in-depth: `amount_settled` is not in
|
||||||
|
# LOCKED_HEADER_FIELDS so this write already sails through,
|
||||||
|
# but we pass laundry_pos_sync=True to guarantee the bypass
|
||||||
|
# even if the locked-field whitelist changes later.
|
||||||
|
lo.sudo().with_context(laundry_pos_sync=True).write({
|
||||||
|
'amount_settled': (lo.amount_settled or 0.0) + apply_amount,
|
||||||
|
})
|
||||||
|
remaining -= apply_amount
|
||||||
|
settled_orders.append({
|
||||||
|
'id': lo.id,
|
||||||
|
'name': lo.name,
|
||||||
|
'applied': apply_amount,
|
||||||
|
'remaining_on_order': lo.amount_due,
|
||||||
|
})
|
||||||
|
|
||||||
|
total_due = sum(
|
||||||
|
LaundryOrder.search([
|
||||||
|
('partner_id', '=', partner.id),
|
||||||
|
('amount_due', '>', 0),
|
||||||
|
('state', '!=', 'delivered'),
|
||||||
|
]).mapped('amount_due')
|
||||||
|
)
|
||||||
|
return settled_orders, total_due
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def get_session_settlements(self, pos_session_id):
|
||||||
|
"""Return laundry settlement payments stamped on a POS session.
|
||||||
|
|
||||||
|
Used by the closing-screen extension to show a read-only summary
|
||||||
|
of settlement collections made during this session. Does NOT inject
|
||||||
|
into POS cash-control totals.
|
||||||
|
"""
|
||||||
|
Payment = self.env['account.payment']
|
||||||
|
payments = Payment.sudo().search([
|
||||||
|
('pos_session_id', '=', int(pos_session_id)),
|
||||||
|
('payment_type', '=', 'inbound'),
|
||||||
|
('partner_type', '=', 'customer'),
|
||||||
|
], order='date asc, id asc')
|
||||||
|
|
||||||
|
by_journal = {}
|
||||||
|
for p in payments:
|
||||||
|
jname = p.journal_id.name
|
||||||
|
jtype = p.journal_id.type # 'cash' or 'bank'
|
||||||
|
key = jname
|
||||||
|
if key not in by_journal:
|
||||||
|
by_journal[key] = {'name': jname, 'type': jtype, 'total': 0.0}
|
||||||
|
by_journal[key]['total'] += p.amount
|
||||||
|
|
||||||
|
return {
|
||||||
|
'total': sum(p.amount for p in payments),
|
||||||
|
'count': len(payments),
|
||||||
|
'by_journal': list(by_journal.values()),
|
||||||
|
'payments': [{
|
||||||
|
'id': p.id,
|
||||||
|
'name': p.name,
|
||||||
|
'partner_name': p.partner_id.name,
|
||||||
|
'amount': p.amount,
|
||||||
|
'journal_name': p.journal_id.name,
|
||||||
|
'date': fields.Date.to_string(p.date),
|
||||||
|
} for p in payments],
|
||||||
|
}
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def get_laundry_orders_for_pos(self, partner_id, limit=20):
|
||||||
|
"""Legacy shim — delegates to the canonical method on laundry.order.
|
||||||
|
Kept so older POS bundles that still call this RPC keep working.
|
||||||
|
New code should call `laundry.order.pos_search_customer_orders`
|
||||||
|
directly; it supports a `search_query` parameter and returns a
|
||||||
|
richer payload (payment_state, allowed_actions, …).
|
||||||
|
"""
|
||||||
|
return self.env['laundry.order'].pos_search_customer_orders(
|
||||||
|
partner_id=partner_id, limit=limit,
|
||||||
|
)
|
||||||
139
addons/laundry_management/models/sale_advance_payment.py
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
###############################################################################
|
||||||
|
#
|
||||||
|
# Cybrosys Technologies Pvt. Ltd.
|
||||||
|
#
|
||||||
|
# Copyright (C) 2026-TODAY Cybrosys Technologies(<https://www.cybrosys.com>)
|
||||||
|
# Author: Anaswara S (odoo@cybrosys.com)
|
||||||
|
#
|
||||||
|
# You can modify it under the terms of the GNU AFFERO
|
||||||
|
# GENERAL PUBLIC LICENSE (AGPL v3), Version 3.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU AFFERO GENERAL PUBLIC LICENSE (AGPL v3) for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
# (AGPL v3) along with this program.
|
||||||
|
# If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
###############################################################################
|
||||||
|
import time
|
||||||
|
from odoo import models, _
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
|
||||||
|
class SaleAdvancePaymentInv(models.TransientModel):
|
||||||
|
"""Inheriting the model of sale.advance.payment.inv to generate advance
|
||||||
|
payment of invoice"""
|
||||||
|
_inherit = 'sale.advance.payment.inv'
|
||||||
|
|
||||||
|
def create_invoices(self):
|
||||||
|
"""Function for creating invoices for the advance payment."""
|
||||||
|
laundry_sale_id = self._context.get('laundry_sale_id')
|
||||||
|
sale_order = self.env['sale.order']
|
||||||
|
if laundry_sale_id:
|
||||||
|
sale_orders = sale_order.browse(laundry_sale_id)
|
||||||
|
else:
|
||||||
|
sale_orders = sale_order.browse(
|
||||||
|
self._context.get('active_ids', []))
|
||||||
|
if self.advance_payment_method == 'delivered':
|
||||||
|
sale_orders._create_invoices()
|
||||||
|
elif self.advance_payment_method == 'all':
|
||||||
|
sale_orders._create_invoices()(final=True)
|
||||||
|
else:
|
||||||
|
# Create deposit product if necessary
|
||||||
|
if not self.product_id:
|
||||||
|
vals = self._prepare_deposit_product()
|
||||||
|
self.product_id = self.env['product.product'].create(vals)
|
||||||
|
self.env['ir.config_parameter'].sudo().set_param(
|
||||||
|
'sale.default_deposit_product_id', self.product_id.id)
|
||||||
|
for order in sale_orders:
|
||||||
|
if self.advance_payment_method == 'percentage':
|
||||||
|
amount = order.amount_untaxed * self.amount / 100
|
||||||
|
else:
|
||||||
|
amount = self.amount
|
||||||
|
if self.product_id.invoice_policy != 'order':
|
||||||
|
raise UserError(_(
|
||||||
|
'The product used to invoice a down payment should have'
|
||||||
|
' an invoice policy set to "Ordered'
|
||||||
|
' quantities". Please update your deposit product to be'
|
||||||
|
' able to create a deposit invoice.'))
|
||||||
|
if self.product_id.type != 'service':
|
||||||
|
raise UserError(_(
|
||||||
|
"The product used to invoice a down payment should be"
|
||||||
|
" of type 'Service'. Please use another "
|
||||||
|
"product or update this product."))
|
||||||
|
taxes = self.product_id.taxes_id.filtered(
|
||||||
|
lambda
|
||||||
|
r: not order.company_id or r.company_id ==
|
||||||
|
order.company_id)
|
||||||
|
if order.fiscal_position_id and taxes:
|
||||||
|
tax_ids = order.fiscal_position_id.map_tax(taxes).ids
|
||||||
|
else:
|
||||||
|
tax_ids = taxes.ids
|
||||||
|
so_line = self.env['sale.order.line'].create({
|
||||||
|
'name': _('Advance: %s') % (time.strftime('%m %Y'),),
|
||||||
|
'price_unit': amount,
|
||||||
|
'product_uom_qty': 0.0,
|
||||||
|
'order_id': order.id,
|
||||||
|
'discount': 0.0,
|
||||||
|
'product_uom': self.product_id.uom_id.id,
|
||||||
|
'product_id': self.product_id.id,
|
||||||
|
'tax_id': [(6, 0, tax_ids)],
|
||||||
|
})
|
||||||
|
self._create_invoice(order, so_line, amount)
|
||||||
|
if self._context.get('open_invoices', False):
|
||||||
|
return sale_orders.action_view_invoice()
|
||||||
|
return {'type': 'ir.actions.act_window_close'}
|
||||||
|
|
||||||
|
def _create_invoice(self, order, so_line):
|
||||||
|
"""Function for creating invoice"""
|
||||||
|
if (self.advance_payment_method == 'percentage' and
|
||||||
|
self.amount <= 0.00) or (self.advance_payment_method == 'fixed' and
|
||||||
|
self.fixed_amount <= 0.00):
|
||||||
|
raise UserError(
|
||||||
|
_('The value of the down payment amount must be positive.'))
|
||||||
|
if self.advance_payment_method == 'percentage':
|
||||||
|
amount = order.amount_untaxed * self.amount / 100
|
||||||
|
name = _("Down payment of %s%%") % (self.amount,)
|
||||||
|
else:
|
||||||
|
amount = self.fixed_amount
|
||||||
|
name = _('Down Payment')
|
||||||
|
|
||||||
|
invoice_vals = {
|
||||||
|
'move_type': 'out_invoice',
|
||||||
|
'invoice_origin': order.name,
|
||||||
|
'invoice_user_id': order.user_id.id,
|
||||||
|
'narration': order.note,
|
||||||
|
'partner_id': order.partner_invoice_id.id,
|
||||||
|
'fiscal_position_id': order.fiscal_position_id.id or order.
|
||||||
|
partner_id.property_account_position_id.id,
|
||||||
|
'partner_shipping_id': order.partner_shipping_id.id,
|
||||||
|
'currency_id': order.pricelist_id.currency_id.id,
|
||||||
|
'ref': order.client_order_ref,
|
||||||
|
'invoice_payment_term_id': order.payment_term_id.id,
|
||||||
|
'team_id': order.team_id.id,
|
||||||
|
'campaign_id': order.campaign_id.id,
|
||||||
|
'medium_id': order.medium_id.id,
|
||||||
|
'source_id': order.source_id.id,
|
||||||
|
'invoice_line_ids': [(0, 0, {
|
||||||
|
'name': name,
|
||||||
|
'price_unit': amount,
|
||||||
|
'quantity': 1.0,
|
||||||
|
'product_id': self.product_id.id,
|
||||||
|
'product_uom_id': so_line.product_uom.id,
|
||||||
|
'sale_line_ids': [(6, 0, [so_line.id])],
|
||||||
|
'analytic_tag_ids': [(6, 0, so_line.analytic_tag_ids.ids)],
|
||||||
|
'analytic_account_id': order.analytic_account_id.id or False,
|
||||||
|
})],
|
||||||
|
}
|
||||||
|
if order.fiscal_position_id:
|
||||||
|
invoice_vals['fiscal_position_id'] = order.fiscal_position_id.id
|
||||||
|
invoice = self.env['account.move'].create(invoice_vals)
|
||||||
|
invoice.message_post_with_view('mail.message_origin_link',
|
||||||
|
values={'self': invoice,
|
||||||
|
'origin': order},
|
||||||
|
subtype_id=self.env.ref(
|
||||||
|
'mail.mt_note').id)
|
||||||
37
addons/laundry_management/models/washing_type.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
###############################################################################
|
||||||
|
#
|
||||||
|
# Cybrosys Technologies Pvt. Ltd.
|
||||||
|
#
|
||||||
|
# Copyright (C) 2026-TODAY Cybrosys Technologies(<https://www.cybrosys.com>)
|
||||||
|
# Author: Anaswara S (odoo@cybrosys.com)
|
||||||
|
#
|
||||||
|
# You can modify it under the terms of the GNU AFFERO
|
||||||
|
# GENERAL PUBLIC LICENSE (AGPL v3), Version 3.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU AFFERO GENERAL PUBLIC LICENSE (AGPL v3) for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
# (AGPL v3) along with this program.
|
||||||
|
# If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
###############################################################################
|
||||||
|
from odoo import fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class WashingType(models.Model):
|
||||||
|
"""Washing types generating model"""
|
||||||
|
_name = 'washing.type'
|
||||||
|
_description = "Washing TYpe"
|
||||||
|
|
||||||
|
name = fields.Char(string='Name', required=True,
|
||||||
|
help='Name of Washing type.')
|
||||||
|
assigned_person_id = fields.Many2one('res.users',
|
||||||
|
string='Assigned Person',
|
||||||
|
required=True,
|
||||||
|
help="Name of assigned person")
|
||||||
|
amount = fields.Float(string='Service Charge', required=True,
|
||||||
|
help='Service charge of this type')
|
||||||
141
addons/laundry_management/models/washing_washing.py
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
###############################################################################
|
||||||
|
#
|
||||||
|
# Cybrosys Technologies Pvt. Ltd.
|
||||||
|
#
|
||||||
|
# Copyright (C) 2026-TODAY Cybrosys Technologies(<https://www.cybrosys.com>)
|
||||||
|
# Author: Anaswara S (odoo@cybrosys.com)
|
||||||
|
#
|
||||||
|
# You can modify it under the terms of the GNU AFFERO
|
||||||
|
# GENERAL PUBLIC LICENSE (AGPL v3), Version 3.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU AFFERO GENERAL PUBLIC LICENSE (AGPL v3) for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
# (AGPL v3) along with this program.
|
||||||
|
# If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
###############################################################################
|
||||||
|
from datetime import datetime
|
||||||
|
from odoo import api, fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class WashingWashing(models.Model):
|
||||||
|
"""Washing activity generating model"""
|
||||||
|
_name = 'washing.washing'
|
||||||
|
_description = 'Washing Washing'
|
||||||
|
|
||||||
|
name = fields.Char(string='Work', help='Mention the work')
|
||||||
|
laundry_works = fields.Boolean(default=False, help='For set conditions')
|
||||||
|
user_id = fields.Many2one('res.users',
|
||||||
|
string='Assigned Person',
|
||||||
|
help="Name of assigned person")
|
||||||
|
washing_date = fields.Datetime(string='Date', help="Date of washing")
|
||||||
|
description = fields.Text(string='Description',
|
||||||
|
help='Add the description')
|
||||||
|
state = fields.Selection([
|
||||||
|
('draft', 'Draft'),
|
||||||
|
('process', 'Process'),
|
||||||
|
('done', 'Done'),
|
||||||
|
('cancel', 'Cancelled'),
|
||||||
|
], string='Status', readonly=True, copy=False, index=True, default='draft',
|
||||||
|
help='State of wash')
|
||||||
|
laundry_id = fields.Many2one('laundry.order.line')
|
||||||
|
product_line_ids = fields.One2many('wash.order.line', 'wash_id',
|
||||||
|
string='Products', ondelete='cascade',
|
||||||
|
help='Related Products for wash.')
|
||||||
|
total_amount = fields.Float(compute='_compute_total_amount',
|
||||||
|
string='Grand Total')
|
||||||
|
|
||||||
|
def start_wash(self):
|
||||||
|
"""Function for initiating the activity of washing."""
|
||||||
|
if not self.laundry_works:
|
||||||
|
self.laundry_id.state = 'wash'
|
||||||
|
self.laundry_id.laundry_id.state = 'process'
|
||||||
|
for wash in self:
|
||||||
|
for line in wash.product_line_ids:
|
||||||
|
self.env['sale.order.line'].create(
|
||||||
|
{'product_id': line.product_id.id,
|
||||||
|
'name': line.name,
|
||||||
|
'price_unit': line.price_unit,
|
||||||
|
'order_id': wash.laundry_id.laundry_id.sale_id.id,
|
||||||
|
'product_uom_qty': line.quantity,
|
||||||
|
'product_uom_id': line.uom_id.id,
|
||||||
|
})
|
||||||
|
self.state = 'process'
|
||||||
|
|
||||||
|
def action_set_to_done(self):
|
||||||
|
"""Function for set to done."""
|
||||||
|
self.state = 'done'
|
||||||
|
f = 0
|
||||||
|
if not self.laundry_works:
|
||||||
|
if self.laundry_id.extra_work_ids:
|
||||||
|
for each in self.laundry_id.extra_work_ids:
|
||||||
|
self.create({'name': each.name,
|
||||||
|
'user_id': each.assigned_person_id.id,
|
||||||
|
'description': self.laundry_id.description,
|
||||||
|
'laundry_id': self.laundry_id.id,
|
||||||
|
'state': 'draft',
|
||||||
|
'laundry_works': True,
|
||||||
|
'washing_date': datetime.now().strftime(
|
||||||
|
'%Y-%m-%d %H:%M:%S')})
|
||||||
|
self.laundry_id.state = 'extra_work'
|
||||||
|
laundry_id = self.search([('laundry_id.laundry_id', '=',
|
||||||
|
self.laundry_id.laundry_id.id)])
|
||||||
|
for each in laundry_id:
|
||||||
|
if each.state != 'done' or each.state == 'cancel':
|
||||||
|
f = 1
|
||||||
|
break
|
||||||
|
if f == 0:
|
||||||
|
self.laundry_id.laundry_id.state = 'done'
|
||||||
|
laundry = self.search([('laundry_id', '=', self.laundry_id.id)])
|
||||||
|
f1 = 0
|
||||||
|
for each in laundry:
|
||||||
|
if each.state != 'done' or each.state == 'cancel':
|
||||||
|
f1 = 1
|
||||||
|
break
|
||||||
|
if f1 == 0:
|
||||||
|
self.laundry_id.state = 'done'
|
||||||
|
|
||||||
|
@api.depends('product_line_ids')
|
||||||
|
def _compute_total_amount(self):
|
||||||
|
"""Total of the line"""
|
||||||
|
total = 0
|
||||||
|
for obj in self:
|
||||||
|
for each in obj.product_line_ids:
|
||||||
|
total += each.subtotal
|
||||||
|
obj.total_amount = total
|
||||||
|
|
||||||
|
|
||||||
|
class WashOrderLine(models.Model):
|
||||||
|
"""For creating order lines in washing."""
|
||||||
|
_name = 'wash.order.line'
|
||||||
|
_description = 'Wash Order Line'
|
||||||
|
|
||||||
|
wash_id = fields.Many2one('washing.washing', string='Order Reference',
|
||||||
|
help='Order reference from washing',
|
||||||
|
ondelete='cascade')
|
||||||
|
name = fields.Text(string='Description', required=True,
|
||||||
|
help='Add description')
|
||||||
|
uom_id = fields.Many2one('uom.uom', 'Unit of Measure ', required=True)
|
||||||
|
quantity = fields.Integer(string='Quantity',
|
||||||
|
help='Add the required quantity')
|
||||||
|
product_id = fields.Many2one('product.product', string='Product',
|
||||||
|
help='Order line Product')
|
||||||
|
price_unit = fields.Float('Unit Price', default=0.0,
|
||||||
|
related='product_id.list_price',
|
||||||
|
help='Unit price of Product')
|
||||||
|
subtotal = fields.Float(compute='_compute_subtotal', string='Subtotal',
|
||||||
|
readonly=True, store=True,
|
||||||
|
help='Subtotal of the order line')
|
||||||
|
|
||||||
|
@api.depends('price_unit', 'quantity')
|
||||||
|
def _compute_subtotal(self):
|
||||||
|
"""Computing the subtotal"""
|
||||||
|
total = 0
|
||||||
|
for wash in self:
|
||||||
|
total += wash.price_unit * wash.quantity
|
||||||
|
wash.subtotal = total
|
||||||
36
addons/laundry_management/models/washing_work.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
###############################################################################
|
||||||
|
#
|
||||||
|
# Cybrosys Technologies Pvt. Ltd.
|
||||||
|
#
|
||||||
|
# Copyright (C) 2026-TODAY Cybrosys Technologies(<https://www.cybrosys.com>)
|
||||||
|
# Author: Anaswara S (odoo@cybrosys.com)
|
||||||
|
#
|
||||||
|
# You can modify it under the terms of the GNU AFFERO
|
||||||
|
# GENERAL PUBLIC LICENSE (AGPL v3), Version 3.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU AFFERO GENERAL PUBLIC LICENSE (AGPL v3) for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
# (AGPL v3) along with this program.
|
||||||
|
# If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
###############################################################################
|
||||||
|
from odoo import fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class WashingWork(models.Model):
|
||||||
|
"""Model for creating extra work for washing."""
|
||||||
|
_name = 'washing.work'
|
||||||
|
_description = 'Washing Work'
|
||||||
|
|
||||||
|
name = fields.Char(string='Name', required=True)
|
||||||
|
assigned_person_id = fields.Many2one('res.users',
|
||||||
|
string='Assigned Person',
|
||||||
|
required=True,
|
||||||
|
help="Name of assigned person")
|
||||||
|
amount = fields.Float(string='Service Charge', required=True,
|
||||||
|
help='Service charge for the extra work')
|
||||||
333
addons/laundry_management/report/laundry_order_report.xml
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════
|
||||||
|
REPORT ACTION — Order Receipt
|
||||||
|
model: sale.order (extended with is_laundry_order = True)
|
||||||
|
═══════════════════════════════════════════════════════════════ -->
|
||||||
|
<record id="action_report_laundry_order_receipt" model="ir.actions.report">
|
||||||
|
<field name="name">Laundry Receipt / إيصال المغسلة</field>
|
||||||
|
<field name="model">sale.order</field>
|
||||||
|
<field name="report_type">qweb-pdf</field>
|
||||||
|
<field name="report_name">laundry_management.report_laundry_order_receipt</field>
|
||||||
|
<field name="report_file">laundry_management.report_laundry_order_receipt</field>
|
||||||
|
<field name="print_report_name">'Receipt-%s' % (object.name)</field>
|
||||||
|
<field name="binding_model_id" ref="sale.model_sale_order"/>
|
||||||
|
<field name="binding_type">report</field>
|
||||||
|
<field name="paperformat_id" ref="base.paperformat_euro"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════
|
||||||
|
QWEB TEMPLATE — Bilingual Customer Receipt
|
||||||
|
Field mapping (laundry.order → sale.order):
|
||||||
|
customer_id → partner_id
|
||||||
|
order_date → date_order / intake_date
|
||||||
|
delivery_date → laundry_ready_date
|
||||||
|
order_line_ids → order_line
|
||||||
|
total_amount → amount_total
|
||||||
|
amount_paid → amount_total - amount_due (computed inline)
|
||||||
|
payment_status → laundry_payment_status
|
||||||
|
priority → laundry_priority
|
||||||
|
notes → laundry_notes
|
||||||
|
line.quantity → line.product_uom_qty
|
||||||
|
line.unit_price → line.price_unit
|
||||||
|
line.subtotal → line.price_subtotal
|
||||||
|
line.is_urgent → line.is_laundry_urgent
|
||||||
|
═══════════════════════════════════════════════════════════════ -->
|
||||||
|
<template id="report_laundry_order_receipt">
|
||||||
|
<t t-call="web.html_container">
|
||||||
|
<t t-foreach="docs" t-as="o">
|
||||||
|
<t t-call="web.external_layout">
|
||||||
|
<div class="page" style="font-family: Arial, sans-serif;">
|
||||||
|
|
||||||
|
<!-- ── Bilingual Header ── -->
|
||||||
|
<div class="row mb-3 border-bottom pb-3">
|
||||||
|
<div class="col-6">
|
||||||
|
<h3 class="mb-0" style="color:#1a73e8;">
|
||||||
|
Laundry Order Receipt
|
||||||
|
</h3>
|
||||||
|
<p class="text-muted mb-0" style="font-size:0.85em;">
|
||||||
|
Customer Copy — Please retain for collection
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 text-end" dir="rtl">
|
||||||
|
<h3 class="mb-0" style="color:#1a73e8;">
|
||||||
|
إيصال طلب المغسلة
|
||||||
|
</h3>
|
||||||
|
<p class="text-muted mb-0" style="font-size:0.85em;">
|
||||||
|
نسخة العميل — يرجى الاحتفاظ به للاستلام
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Order Meta (bilingual columns) ── -->
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-6">
|
||||||
|
<table class="table table-sm table-borderless mb-0">
|
||||||
|
<tr>
|
||||||
|
<td style="width:45%;color:#666;">Order No.</td>
|
||||||
|
<td><strong><t t-esc="o.name"/></strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="color:#666;">Date</td>
|
||||||
|
<td>
|
||||||
|
<t t-esc="o.intake_date or o.date_order"
|
||||||
|
t-options='{"widget":"date"}'/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<t t-if="o.laundry_ready_date">
|
||||||
|
<tr>
|
||||||
|
<td style="color:#666;">Expected Ready</td>
|
||||||
|
<td>
|
||||||
|
<strong>
|
||||||
|
<t t-esc="o.laundry_ready_date"
|
||||||
|
t-options='{"widget":"date"}'/>
|
||||||
|
</strong>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</t>
|
||||||
|
<tr>
|
||||||
|
<td style="color:#666;">Order Type</td>
|
||||||
|
<td>
|
||||||
|
<t t-esc="dict(o._fields['delivery_type'].selection).get(o.delivery_type, o.delivery_type)"/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<t t-if="o.laundry_priority and o.laundry_priority != 'normal'">
|
||||||
|
<tr>
|
||||||
|
<td style="color:#666;">Priority</td>
|
||||||
|
<td>
|
||||||
|
<span style="color:#d93025;font-weight:bold;">
|
||||||
|
<t t-esc="dict(o._fields['laundry_priority'].selection).get(o.laundry_priority)"/>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</t>
|
||||||
|
<t t-if="o.session_id">
|
||||||
|
<tr>
|
||||||
|
<td style="color:#666;">Session</td>
|
||||||
|
<td><t t-esc="o.session_id.name"/></td>
|
||||||
|
</tr>
|
||||||
|
</t>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="col-6" dir="rtl">
|
||||||
|
<table class="table table-sm table-borderless mb-0">
|
||||||
|
<tr>
|
||||||
|
<td style="width:45%;color:#666;">رقم الطلب</td>
|
||||||
|
<td><strong><t t-esc="o.name"/></strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="color:#666;">تاريخ الطلب</td>
|
||||||
|
<td>
|
||||||
|
<t t-esc="o.intake_date or o.date_order"
|
||||||
|
t-options='{"widget":"date"}'/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<t t-if="o.laundry_ready_date">
|
||||||
|
<tr>
|
||||||
|
<td style="color:#666;">موعد الجاهزية</td>
|
||||||
|
<td>
|
||||||
|
<strong>
|
||||||
|
<t t-esc="o.laundry_ready_date"
|
||||||
|
t-options='{"widget":"date"}'/>
|
||||||
|
</strong>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</t>
|
||||||
|
<tr>
|
||||||
|
<td style="color:#666;">نوع الطلب</td>
|
||||||
|
<td>
|
||||||
|
<t t-if="o.delivery_type == 'dropoff'">مراجعة مباشرة</t>
|
||||||
|
<t t-if="o.delivery_type == 'pickup'">استلام من المنزل</t>
|
||||||
|
<t t-if="o.delivery_type == 'delivery'">توصيل للمنزل</t>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Customer Block ── -->
|
||||||
|
<div class="row mb-3"
|
||||||
|
style="background:#f8f9fa;border-radius:6px;padding:10px;">
|
||||||
|
<div class="col-6">
|
||||||
|
<div style="color:#666;font-size:0.8em;text-transform:uppercase;">Customer</div>
|
||||||
|
<strong style="font-size:1.1em;">
|
||||||
|
<t t-esc="o.partner_id.name"/>
|
||||||
|
</strong>
|
||||||
|
<t t-if="o.mobile">
|
||||||
|
<div><t t-esc="o.mobile"/></div>
|
||||||
|
</t>
|
||||||
|
<t t-elif="o.partner_id.phone">
|
||||||
|
<div><t t-esc="o.partner_id.phone"/></div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 text-end" dir="rtl">
|
||||||
|
<div style="color:#666;font-size:0.8em;">العميل</div>
|
||||||
|
<strong style="font-size:1.1em;">
|
||||||
|
<t t-esc="o.partner_id.name"/>
|
||||||
|
</strong>
|
||||||
|
<t t-if="o.mobile">
|
||||||
|
<div><t t-esc="o.mobile"/></div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Items Table ── -->
|
||||||
|
<h6 style="color:#1a73e8;border-bottom:2px solid #1a73e8;padding-bottom:4px;">
|
||||||
|
Services / الخدمات
|
||||||
|
</h6>
|
||||||
|
<table class="table table-sm table-bordered"
|
||||||
|
style="font-size:0.9em;">
|
||||||
|
<thead style="background:#1a73e8;color:white;">
|
||||||
|
<tr>
|
||||||
|
<th style="width:4%">#</th>
|
||||||
|
<th>Service / الخدمة</th>
|
||||||
|
<th>Color / اللون</th>
|
||||||
|
<th>Notes / ملاحظات</th>
|
||||||
|
<th class="text-end" style="width:6%">Qty</th>
|
||||||
|
<th class="text-end" style="width:10%">Price</th>
|
||||||
|
<th class="text-end" style="width:11%">Subtotal</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<t t-if="o.order_line">
|
||||||
|
<t t-foreach="o.order_line" t-as="line">
|
||||||
|
<tr t-attf-style="#{line.is_laundry_urgent and 'background:#fff3cd;' or ''}">
|
||||||
|
<td><t t-esc="line_index + 1"/></td>
|
||||||
|
<td>
|
||||||
|
<t t-esc="line.product_id.name if line.product_id else ''"/>
|
||||||
|
<t t-if="line.is_laundry_urgent">
|
||||||
|
<span style="color:#d93025;font-size:0.75em;font-weight:bold;"> ⚡URGENT</span>
|
||||||
|
</t>
|
||||||
|
</td>
|
||||||
|
<td><t t-esc="line.color or ''"/></td>
|
||||||
|
<td>
|
||||||
|
<t t-if="line.stain_note">
|
||||||
|
<span style="color:#d93025;">
|
||||||
|
<t t-esc="line.stain_note"/>
|
||||||
|
</span>
|
||||||
|
</t>
|
||||||
|
<t t-elif="line.special_instructions">
|
||||||
|
<t t-esc="line.special_instructions"/>
|
||||||
|
</t>
|
||||||
|
</td>
|
||||||
|
<td class="text-end"><t t-esc="int(line.product_uom_qty)"/></td>
|
||||||
|
<td class="text-end">
|
||||||
|
<t t-esc="line.price_unit"
|
||||||
|
t-options='{"widget":"monetary","display_currency":o.company_id.currency_id}'/>
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<t t-esc="line.price_subtotal"
|
||||||
|
t-options='{"widget":"monetary","display_currency":o.company_id.currency_id}'/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</t>
|
||||||
|
</t>
|
||||||
|
<t t-else="">
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="text-center text-muted">
|
||||||
|
No items on this order.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</t>
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr style="background:#f0f4ff;">
|
||||||
|
<td colspan="6" class="text-end">
|
||||||
|
<strong>Total / الإجمالي</strong>
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<strong style="font-size:1.1em;">
|
||||||
|
<t t-esc="o.amount_total"
|
||||||
|
t-options='{"widget":"monetary","display_currency":o.company_id.currency_id}'/>
|
||||||
|
</strong>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<t t-set="amount_paid" t-value="o.amount_total - o.amount_due"/>
|
||||||
|
<t t-if="amount_paid > 0">
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="text-end text-success">
|
||||||
|
Paid / المدفوع
|
||||||
|
</td>
|
||||||
|
<td class="text-end text-success">
|
||||||
|
<t t-esc="amount_paid"
|
||||||
|
t-options='{"widget":"monetary","display_currency":o.company_id.currency_id}'/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</t>
|
||||||
|
<t t-if="o.amount_due > 0">
|
||||||
|
<tr style="background:#f8d7da;">
|
||||||
|
<td colspan="6" class="text-end text-danger fw-bold">
|
||||||
|
Balance Due / المبلغ المتبقي
|
||||||
|
</td>
|
||||||
|
<td class="text-end text-danger fw-bold">
|
||||||
|
<t t-esc="o.amount_due"
|
||||||
|
t-options='{"widget":"monetary","display_currency":o.company_id.currency_id}'/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</t>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- ── Customer Notes ── -->
|
||||||
|
<t t-if="o.laundry_notes">
|
||||||
|
<div class="mt-3 p-2"
|
||||||
|
style="border-left:3px solid #1a73e8;background:#f8f9fa;">
|
||||||
|
<strong>Notes / ملاحظات:</strong>
|
||||||
|
<span> <t t-esc="o.laundry_notes"/></span>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<!-- ── Status + Payment ── -->
|
||||||
|
<div class="row mt-3">
|
||||||
|
<div class="col-6">
|
||||||
|
<strong>Processing:</strong>
|
||||||
|
<span t-attf-style="margin-left:6px;padding:2px 8px;border-radius:4px;
|
||||||
|
background:#{o.laundry_state == 'delivered' and '#d4edda' or
|
||||||
|
o.laundry_state in ('processing','ready') and '#fff3cd' or '#cfe2ff'};
|
||||||
|
color:#{o.laundry_state == 'delivered' and '#155724' or
|
||||||
|
o.laundry_state in ('processing','ready') and '#856404' or '#084298'};">
|
||||||
|
<t t-esc="dict(o._fields['laundry_state'].selection).get(o.laundry_state, o.laundry_state)"/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 text-end">
|
||||||
|
<strong>Payment / الدفع:</strong>
|
||||||
|
<span t-attf-style="margin-left:6px;padding:2px 8px;border-radius:4px;
|
||||||
|
background:#{o.laundry_payment_status == 'paid' and '#d4edda' or
|
||||||
|
o.laundry_payment_status == 'partial' and '#fff3cd' or '#f8d7da'};
|
||||||
|
color:#{o.laundry_payment_status == 'paid' and '#155724' or
|
||||||
|
o.laundry_payment_status == 'partial' and '#856404' or '#721c24'};">
|
||||||
|
<t t-esc="dict(o._fields['laundry_payment_status'].selection).get(o.laundry_payment_status)"/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Staff Attribution ── -->
|
||||||
|
<t t-if="o.received_by">
|
||||||
|
<div class="text-muted mt-2" style="font-size:0.8em;">
|
||||||
|
Received by: <t t-esc="o.received_by.name"/>
|
||||||
|
<t t-if="o.delivered_by">
|
||||||
|
 | 
|
||||||
|
Handed over by: <t t-esc="o.delivered_by.name"/>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<!-- ── Footer ── -->
|
||||||
|
<div class="text-center mt-4 pt-3 border-top"
|
||||||
|
style="color:#666;font-size:0.8em;">
|
||||||
|
<p class="mb-1">
|
||||||
|
Thank you for choosing our laundry services.
|
||||||
|
</p>
|
||||||
|
<p class="mb-0" dir="rtl">
|
||||||
|
شكراً لاختياركم خدمات مغسلتنا — نتطلع لخدمتكم دائماً
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</t>
|
||||||
|
</t>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
291
addons/laundry_management/report/laundry_session_report.xml
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════
|
||||||
|
REPORT ACTION — Session Summary
|
||||||
|
═══════════════════════════════════════════════════════════════ -->
|
||||||
|
<record id="action_report_laundry_session" model="ir.actions.report">
|
||||||
|
<field name="name">Session Summary / تقرير الجلسة</field>
|
||||||
|
<field name="model">laundry.session</field>
|
||||||
|
<field name="report_type">qweb-pdf</field>
|
||||||
|
<field name="report_name">laundry_management.report_laundry_session</field>
|
||||||
|
<field name="report_file">laundry_management.report_laundry_session</field>
|
||||||
|
<field name="print_report_name">'Session-%s' % (object.name)</field>
|
||||||
|
<field name="binding_model_id" ref="model_laundry_session"/>
|
||||||
|
<field name="binding_type">report</field>
|
||||||
|
<field name="paperformat_id" ref="base.paperformat_euro"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════
|
||||||
|
QWEB TEMPLATE — Session Summary Report
|
||||||
|
═══════════════════════════════════════════════════════════════ -->
|
||||||
|
<template id="report_laundry_session">
|
||||||
|
<t t-call="web.html_container">
|
||||||
|
<t t-foreach="docs" t-as="s">
|
||||||
|
<t t-call="web.external_layout">
|
||||||
|
<div class="page" style="font-family: Arial, sans-serif;">
|
||||||
|
|
||||||
|
<!-- ── Header ── -->
|
||||||
|
<div class="row mb-4 border-bottom pb-3">
|
||||||
|
<div class="col-7">
|
||||||
|
<h3 style="color:#1a73e8;">Session Summary Report</h3>
|
||||||
|
<h4 style="color:#444;"><t t-esc="s.name"/></h4>
|
||||||
|
</div>
|
||||||
|
<div class="col-5 text-end" dir="rtl">
|
||||||
|
<h3 style="color:#1a73e8;">تقرير ملخص الجلسة</h3>
|
||||||
|
<h4 style="color:#444;"><t t-esc="s.name"/></h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Session Info ── -->
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-6">
|
||||||
|
<table class="table table-sm table-borderless">
|
||||||
|
<tr>
|
||||||
|
<td style="color:#666;width:40%">Opened By</td>
|
||||||
|
<td><strong><t t-esc="s.user_id.name"/></strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="color:#666;">Opened At</td>
|
||||||
|
<td>
|
||||||
|
<t t-esc="s.opening_datetime"
|
||||||
|
t-options='{"widget":"datetime"}'/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<t t-if="s.closing_datetime">
|
||||||
|
<tr>
|
||||||
|
<td style="color:#666;">Closed At</td>
|
||||||
|
<td>
|
||||||
|
<t t-esc="s.closing_datetime"
|
||||||
|
t-options='{"widget":"datetime"}'/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</t>
|
||||||
|
<tr>
|
||||||
|
<td style="color:#666;">Status</td>
|
||||||
|
<td>
|
||||||
|
<span t-attf-style="padding:2px 8px;border-radius:4px;
|
||||||
|
background:#{s.state == 'closed' and '#d4edda' or '#fff3cd'};
|
||||||
|
color:#{s.state == 'closed' and '#155724' or '#856404'};">
|
||||||
|
<t t-esc="dict(s._fields['state'].selection).get(s.state)"/>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="color:#666;">Orders Processed</td>
|
||||||
|
<td><strong><t t-esc="s.order_count"/></strong></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<!-- Cash Control Summary -->
|
||||||
|
<div style="background:#f8f9fa;border-radius:6px;padding:12px;">
|
||||||
|
<h6 style="color:#1a73e8;margin-bottom:8px;">
|
||||||
|
Cash Control / ضبط الصندوق
|
||||||
|
</h6>
|
||||||
|
<table class="table table-sm table-borderless mb-0">
|
||||||
|
<tr>
|
||||||
|
<td style="color:#666;">Opening Float</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<t t-esc="s.opening_cash"
|
||||||
|
t-options='{"widget":"monetary","display_currency":s.company_id.currency_id}'/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="color:#666;">+ Cash Received</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<t t-esc="s.total_cash"
|
||||||
|
t-options='{"widget":"monetary","display_currency":s.company_id.currency_id}'/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-top:1px solid #dee2e6;">
|
||||||
|
<td><strong>Expected Cash</strong></td>
|
||||||
|
<td class="text-end fw-bold">
|
||||||
|
<t t-esc="s.expected_closing_cash"
|
||||||
|
t-options='{"widget":"monetary","display_currency":s.company_id.currency_id}'/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<t t-if="s.state == 'closed'">
|
||||||
|
<tr>
|
||||||
|
<td style="color:#666;">Actual Cash Count</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<t t-esc="s.actual_closing_cash"
|
||||||
|
t-options='{"widget":"monetary","display_currency":s.company_id.currency_id}'/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr t-attf-style="background:#{s.cash_difference < 0 and '#f8d7da' or '#d4edda'};">
|
||||||
|
<td t-attf-style="color:#{s.cash_difference < 0 and '#721c24' or '#155724'};">
|
||||||
|
<strong>Difference</strong>
|
||||||
|
</td>
|
||||||
|
<td class="text-end fw-bold"
|
||||||
|
t-attf-style="color:#{s.cash_difference < 0 and '#721c24' or '#155724'};">
|
||||||
|
<t t-esc="s.cash_difference"
|
||||||
|
t-options='{"widget":"monetary","display_currency":s.company_id.currency_id}'/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</t>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Payment Breakdown ── -->
|
||||||
|
<h6 style="color:#1a73e8;border-bottom:2px solid #1a73e8;padding-bottom:4px;">
|
||||||
|
Sales & Payment Summary / ملخص المبيعات والمدفوعات
|
||||||
|
</h6>
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-6">
|
||||||
|
<table class="table table-bordered table-sm">
|
||||||
|
<thead style="background:#1a73e8;color:white;">
|
||||||
|
<tr>
|
||||||
|
<th>Payment Method</th>
|
||||||
|
<th class="text-end">Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Cash</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<t t-esc="s.total_cash"
|
||||||
|
t-options='{"widget":"monetary","display_currency":s.company_id.currency_id}'/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Bank / Card</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<t t-esc="s.total_bank"
|
||||||
|
t-options='{"widget":"monetary","display_currency":s.company_id.currency_id}'/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Credit / Deferred</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<t t-esc="s.total_credit"
|
||||||
|
t-options='{"widget":"monetary","display_currency":s.company_id.currency_id}'/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="background:#f0f4ff;font-weight:bold;">
|
||||||
|
<td>Total Collected</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<t t-esc="s.total_paid"
|
||||||
|
t-options='{"widget":"monetary","display_currency":s.company_id.currency_id}'/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<table class="table table-bordered table-sm">
|
||||||
|
<thead style="background:#1a73e8;color:white;">
|
||||||
|
<tr>
|
||||||
|
<th>Summary</th>
|
||||||
|
<th class="text-end">Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Total Sales (Invoiced)</td>
|
||||||
|
<td class="text-end fw-bold">
|
||||||
|
<t t-esc="s.total_sales"
|
||||||
|
t-options='{"widget":"monetary","display_currency":s.company_id.currency_id}'/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Total Collected</td>
|
||||||
|
<td class="text-end text-success">
|
||||||
|
<t t-esc="s.total_paid"
|
||||||
|
t-options='{"widget":"monetary","display_currency":s.company_id.currency_id}'/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr t-attf-style="background:#{s.outstanding_amount > 0 and '#f8d7da' or '#d4edda'};">
|
||||||
|
<td>Outstanding (Unpaid)</td>
|
||||||
|
<td class="text-end"
|
||||||
|
t-attf-style="color:#{s.outstanding_amount > 0 and '#721c24' or '#155724'};">
|
||||||
|
<t t-esc="s.outstanding_amount"
|
||||||
|
t-options='{"widget":"monetary","display_currency":s.company_id.currency_id}'/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Order Listing ── -->
|
||||||
|
<h6 style="color:#1a73e8;border-bottom:2px solid #1a73e8;padding-bottom:4px;">
|
||||||
|
Orders / الطلبات
|
||||||
|
</h6>
|
||||||
|
<table class="table table-sm table-bordered" style="font-size:0.85em;">
|
||||||
|
<thead style="background:#e8f0fe;">
|
||||||
|
<tr>
|
||||||
|
<th>Order No.</th>
|
||||||
|
<th>Customer</th>
|
||||||
|
<th>Items</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th class="text-end">Total</th>
|
||||||
|
<th class="text-end">Paid</th>
|
||||||
|
<th>Payment</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<t t-foreach="s.order_ids.filtered(lambda o: o.state != 'cancel')" t-as="order">
|
||||||
|
<tr>
|
||||||
|
<td><strong><t t-esc="order.name"/></strong></td>
|
||||||
|
<td><t t-esc="order.partner_id.name"/></td>
|
||||||
|
<td class="text-center"><t t-esc="order.item_count"/></td>
|
||||||
|
<td>
|
||||||
|
<t t-esc="dict(order._fields['laundry_state'].selection).get(order.laundry_state, order.laundry_state)"/>
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<t t-esc="order.amount_total"
|
||||||
|
t-options='{"widget":"monetary","display_currency":s.company_id.currency_id}'/>
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<t t-set="ord_paid" t-value="order.amount_total - order.amount_due"/>
|
||||||
|
<t t-esc="ord_paid"
|
||||||
|
t-options='{"widget":"monetary","display_currency":s.company_id.currency_id}'/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<t t-esc="dict(order._fields['laundry_payment_status'].selection).get(order.laundry_payment_status)"/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</t>
|
||||||
|
</tbody>
|
||||||
|
<tfoot style="background:#f0f4ff;font-weight:bold;">
|
||||||
|
<tr>
|
||||||
|
<td colspan="4">TOTAL / الإجمالي</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<t t-esc="s.total_sales"
|
||||||
|
t-options='{"widget":"monetary","display_currency":s.company_id.currency_id}'/>
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<t t-esc="s.total_paid"
|
||||||
|
t-options='{"widget":"monetary","display_currency":s.company_id.currency_id}'/>
|
||||||
|
</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- ── Notes ── -->
|
||||||
|
<t t-if="s.notes">
|
||||||
|
<div class="mt-3 p-2"
|
||||||
|
style="border-left:3px solid #1a73e8;background:#f8f9fa;">
|
||||||
|
<strong>Notes / ملاحظات:</strong>
|
||||||
|
<span> <t t-esc="s.notes"/></span>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<!-- ── Footer ── -->
|
||||||
|
<div class="text-center mt-4 pt-3 border-top"
|
||||||
|
style="color:#888;font-size:0.75em;">
|
||||||
|
Generated by Laundry Management System  | 
|
||||||
|
<t t-esc="s.company_id.name"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</t>
|
||||||
|
</t>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
296
addons/laundry_management/report/laundry_thermal_report.xml
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════
|
||||||
|
PAPER FORMAT — Thermal 80mm
|
||||||
|
═══════════════════════════════════════════════════════════════ -->
|
||||||
|
<record id="paperformat_thermal_80mm" model="report.paperformat">
|
||||||
|
<field name="name">Thermal 80mm Receipt</field>
|
||||||
|
<field name="default">False</field>
|
||||||
|
<field name="format">custom</field>
|
||||||
|
<field name="page_height">0</field>
|
||||||
|
<field name="page_width">80</field>
|
||||||
|
<field name="orientation">Portrait</field>
|
||||||
|
<field name="margin_top">3</field>
|
||||||
|
<field name="margin_bottom">3</field>
|
||||||
|
<field name="margin_left">3</field>
|
||||||
|
<field name="margin_right">3</field>
|
||||||
|
<field name="header_line">False</field>
|
||||||
|
<field name="header_spacing">3</field>
|
||||||
|
<field name="dpi">96</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════
|
||||||
|
REPORT ACTION — Thermal Receipt
|
||||||
|
model: sale.order (extended with is_laundry_order = True)
|
||||||
|
═══════════════════════════════════════════════════════════════ -->
|
||||||
|
<record id="action_report_laundry_thermal_receipt" model="ir.actions.report">
|
||||||
|
<field name="name">Thermal Receipt (80mm)</field>
|
||||||
|
<field name="model">sale.order</field>
|
||||||
|
<field name="report_type">qweb-pdf</field>
|
||||||
|
<field name="report_name">laundry_management.report_laundry_thermal_receipt</field>
|
||||||
|
<field name="report_file">laundry_management.report_laundry_thermal_receipt</field>
|
||||||
|
<field name="print_report_name">'Thermal-%s' % (object.name)</field>
|
||||||
|
<field name="paperformat_id" ref="paperformat_thermal_80mm"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════
|
||||||
|
REPORT ACTION — Item Tracking Slip
|
||||||
|
model: sale.order (extended with is_laundry_order = True)
|
||||||
|
═══════════════════════════════════════════════════════════════ -->
|
||||||
|
<record id="action_report_laundry_tracking_slip" model="ir.actions.report">
|
||||||
|
<field name="name">Item Tracking Slips</field>
|
||||||
|
<field name="model">sale.order</field>
|
||||||
|
<field name="report_type">qweb-pdf</field>
|
||||||
|
<field name="report_name">laundry_management.report_laundry_tracking_slip</field>
|
||||||
|
<field name="report_file">laundry_management.report_laundry_tracking_slip</field>
|
||||||
|
<field name="print_report_name">'Tracking-%s' % (object.name)</field>
|
||||||
|
<field name="paperformat_id" ref="base.paperformat_euro"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════
|
||||||
|
QWEB — Thermal 80mm Receipt Template
|
||||||
|
═══════════════════════════════════════════════════════════════ -->
|
||||||
|
<template id="report_laundry_thermal_receipt">
|
||||||
|
<t t-call="web.html_container">
|
||||||
|
<t t-foreach="docs" t-as="o">
|
||||||
|
<div style="font-family:monospace;font-size:11px;width:72mm;
|
||||||
|
margin:0 auto;padding:4px;">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div style="text-align:center;border-bottom:1px dashed #000;
|
||||||
|
padding-bottom:6px;margin-bottom:6px;">
|
||||||
|
<div style="font-size:14px;font-weight:bold;">
|
||||||
|
<t t-esc="o.company_id.name"/>
|
||||||
|
</div>
|
||||||
|
<t t-if="o.company_id.phone">
|
||||||
|
<div><t t-esc="o.company_id.phone"/></div>
|
||||||
|
</t>
|
||||||
|
<t t-if="o.company_id.street">
|
||||||
|
<div><t t-esc="o.company_id.street"/></div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Order Info -->
|
||||||
|
<div style="border-bottom:1px dashed #000;padding-bottom:6px;margin-bottom:6px;">
|
||||||
|
<div style="display:flex;justify-content:space-between;">
|
||||||
|
<span>Order:</span>
|
||||||
|
<strong><t t-esc="o.name"/></strong>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;justify-content:space-between;">
|
||||||
|
<span>Date:</span>
|
||||||
|
<span>
|
||||||
|
<t t-esc="o.intake_date or o.date_order"
|
||||||
|
t-options='{"widget":"date"}'/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<t t-if="o.laundry_ready_date">
|
||||||
|
<div style="display:flex;justify-content:space-between;">
|
||||||
|
<span>Pickup by:</span>
|
||||||
|
<strong>
|
||||||
|
<t t-esc="o.laundry_ready_date"
|
||||||
|
t-options='{"widget":"date"}'/>
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
<div style="display:flex;justify-content:space-between;">
|
||||||
|
<span>Customer:</span>
|
||||||
|
<span><t t-esc="o.partner_id.name"/></span>
|
||||||
|
</div>
|
||||||
|
<t t-if="o.mobile">
|
||||||
|
<div style="display:flex;justify-content:space-between;">
|
||||||
|
<span>Tel:</span>
|
||||||
|
<span><t t-esc="o.mobile"/></span>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
<t t-if="o.laundry_priority and o.laundry_priority != 'normal'">
|
||||||
|
<div style="text-align:center;font-weight:bold;
|
||||||
|
border:1px solid #000;margin-top:4px;padding:2px;">
|
||||||
|
*** <t t-esc="o.laundry_priority.upper()"/> ***
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Items -->
|
||||||
|
<div style="border-bottom:1px dashed #000;padding-bottom:6px;margin-bottom:6px;">
|
||||||
|
<t t-foreach="o.order_line" t-as="line">
|
||||||
|
<div style="display:flex;justify-content:space-between;margin-bottom:2px;">
|
||||||
|
<span style="flex:1;">
|
||||||
|
<t t-esc="line_index + 1"/>.
|
||||||
|
<t t-esc="line.product_id.name"/>
|
||||||
|
<t t-if="line.color">
|
||||||
|
(<t t-esc="line.color"/>)
|
||||||
|
</t>
|
||||||
|
<t t-if="line.is_laundry_urgent">
|
||||||
|
[URG]
|
||||||
|
</t>
|
||||||
|
</span>
|
||||||
|
<span>x<t t-esc="int(line.product_uom_qty)"/></span>
|
||||||
|
<span style="text-align:right;min-width:30px;">
|
||||||
|
<t t-esc="line.price_subtotal"
|
||||||
|
t-options='{"widget":"monetary","display_currency":o.company_id.currency_id}'/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<t t-if="line.stain_note">
|
||||||
|
<div style="color:#666;font-size:10px;padding-left:12px;">
|
||||||
|
Note: <t t-esc="line.stain_note"/>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Totals -->
|
||||||
|
<t t-set="amount_paid" t-value="o.amount_total - o.amount_due"/>
|
||||||
|
<div style="border-bottom:1px dashed #000;padding-bottom:6px;margin-bottom:6px;">
|
||||||
|
<div style="display:flex;justify-content:space-between;font-weight:bold;font-size:13px;">
|
||||||
|
<span>TOTAL:</span>
|
||||||
|
<span>
|
||||||
|
<t t-esc="o.amount_total"
|
||||||
|
t-options='{"widget":"monetary","display_currency":o.company_id.currency_id}'/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<t t-if="amount_paid > 0">
|
||||||
|
<div style="display:flex;justify-content:space-between;">
|
||||||
|
<span>Paid:</span>
|
||||||
|
<span>
|
||||||
|
<t t-esc="amount_paid"
|
||||||
|
t-options='{"widget":"monetary","display_currency":o.company_id.currency_id}'/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
<t t-if="o.amount_due > 0">
|
||||||
|
<div style="display:flex;justify-content:space-between;font-weight:bold;">
|
||||||
|
<span>BALANCE:</span>
|
||||||
|
<span>
|
||||||
|
<t t-esc="o.amount_due"
|
||||||
|
t-options='{"widget":"monetary","display_currency":o.company_id.currency_id}'/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div style="text-align:center;font-size:10px;">
|
||||||
|
<t t-if="o.laundry_notes">
|
||||||
|
<div style="margin-bottom:4px;"><t t-esc="o.laundry_notes"/></div>
|
||||||
|
</t>
|
||||||
|
<div>Thank you / شكراً</div>
|
||||||
|
<t t-if="o.received_by">
|
||||||
|
<div style="margin-top:4px;color:#666;">
|
||||||
|
Staff: <t t-esc="o.received_by.name"/>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</t>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════
|
||||||
|
QWEB — Item Tracking Slip Template
|
||||||
|
One slip per order line — suitable for tagging items
|
||||||
|
═══════════════════════════════════════════════════════════════ -->
|
||||||
|
<template id="report_laundry_tracking_slip">
|
||||||
|
<t t-call="web.html_container">
|
||||||
|
<t t-foreach="docs" t-as="o">
|
||||||
|
<t t-foreach="o.order_line" t-as="line">
|
||||||
|
<div style="font-family:Arial,sans-serif;
|
||||||
|
border:2px solid #1a73e8;border-radius:6px;
|
||||||
|
padding:12px;margin:8px;width:140mm;
|
||||||
|
page-break-inside:avoid;">
|
||||||
|
|
||||||
|
<!-- Header row -->
|
||||||
|
<div style="display:flex;justify-content:space-between;
|
||||||
|
align-items:center;margin-bottom:8px;
|
||||||
|
border-bottom:1px solid #1a73e8;padding-bottom:6px;">
|
||||||
|
<div>
|
||||||
|
<div style="font-size:18px;font-weight:bold;
|
||||||
|
color:#1a73e8;">
|
||||||
|
<t t-esc="o.name"/>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:11px;color:#666;">
|
||||||
|
Item <t t-esc="line_index + 1"/>
|
||||||
|
of <t t-esc="len(o.order_line)"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="text-align:right;">
|
||||||
|
<t t-if="line.is_laundry_urgent">
|
||||||
|
<div style="background:#d93025;color:white;
|
||||||
|
padding:3px 8px;border-radius:4px;
|
||||||
|
font-weight:bold;font-size:12px;">
|
||||||
|
URGENT
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
<t t-if="o.laundry_priority and o.laundry_priority != 'normal'">
|
||||||
|
<div style="font-size:11px;color:#666;margin-top:2px;">
|
||||||
|
<t t-esc="o.laundry_priority.title()"/>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Customer + Service -->
|
||||||
|
<div style="margin-bottom:6px;">
|
||||||
|
<div style="font-size:13px;font-weight:bold;">
|
||||||
|
<t t-esc="o.partner_id.name"/>
|
||||||
|
</div>
|
||||||
|
<t t-if="o.mobile">
|
||||||
|
<div style="font-size:11px;color:#444;">
|
||||||
|
<t t-esc="o.mobile"/>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Service details -->
|
||||||
|
<div style="background:#f0f4ff;padding:8px;
|
||||||
|
border-radius:4px;margin-bottom:6px;">
|
||||||
|
<div style="font-size:15px;font-weight:bold;">
|
||||||
|
<t t-esc="line.product_id.name"/>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:12px;color:#555;">
|
||||||
|
Qty: <strong><t t-esc="int(line.product_uom_qty)"/></strong>
|
||||||
|
<t t-if="line.color">
|
||||||
|
 |  Color: <strong><t t-esc="line.color"/></strong>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Damage / Instructions -->
|
||||||
|
<t t-if="line.stain_note">
|
||||||
|
<div style="color:#d93025;font-size:11px;margin-bottom:4px;">
|
||||||
|
⚠ <t t-esc="line.stain_note"/>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
<t t-if="line.special_instructions">
|
||||||
|
<div style="color:#444;font-size:11px;margin-bottom:4px;">
|
||||||
|
Note: <t t-esc="line.special_instructions"/>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div style="display:flex;justify-content:space-between;
|
||||||
|
font-size:11px;color:#888;margin-top:6px;
|
||||||
|
border-top:1px dashed #ccc;padding-top:4px;">
|
||||||
|
<span>
|
||||||
|
In: <t t-esc="o.intake_date or o.date_order"
|
||||||
|
t-options='{"widget":"date"}'/>
|
||||||
|
</span>
|
||||||
|
<t t-if="o.laundry_ready_date">
|
||||||
|
<span>
|
||||||
|
Out: <strong>
|
||||||
|
<t t-esc="o.laundry_ready_date"
|
||||||
|
t-options='{"widget":"date"}'/>
|
||||||
|
</strong>
|
||||||
|
</span>
|
||||||
|
</t>
|
||||||
|
<span><t t-esc="o.company_id.name"/></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</t>
|
||||||
|
</t>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
228
addons/laundry_management/report/laundry_work_order_report.xml
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════
|
||||||
|
REPORT ACTION — Laundry Work Order (laundry.order, A4)
|
||||||
|
Bound to laundry.order so users see it under the Print menu of
|
||||||
|
the form view AND it can be triggered from a header button.
|
||||||
|
═══════════════════════════════════════════════════════════════ -->
|
||||||
|
<record id="action_report_laundry_work_order" model="ir.actions.report">
|
||||||
|
<field name="name">Laundry Work Order</field>
|
||||||
|
<field name="model">laundry.order</field>
|
||||||
|
<field name="report_type">qweb-pdf</field>
|
||||||
|
<field name="report_name">laundry_management.report_laundry_work_order</field>
|
||||||
|
<field name="report_file">laundry_management.report_laundry_work_order</field>
|
||||||
|
<field name="print_report_name">'WO-%s' % (object.name or '').replace('/', '-')</field>
|
||||||
|
<field name="binding_model_id" ref="model_laundry_order"/>
|
||||||
|
<field name="binding_type">report</field>
|
||||||
|
<field name="paperformat_id" ref="base.paperformat_euro"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<template id="report_laundry_work_order">
|
||||||
|
<t t-call="web.html_container">
|
||||||
|
<t t-foreach="docs" t-as="o">
|
||||||
|
<t t-call="web.external_layout">
|
||||||
|
<div class="page" style="font-family: Arial, sans-serif; color:#1f2937;">
|
||||||
|
|
||||||
|
<!-- ── Header ───────────────────────────────────── -->
|
||||||
|
<div class="row mb-3 border-bottom pb-2">
|
||||||
|
<div class="col-7">
|
||||||
|
<h2 class="mb-0" style="color:#1d4ed8; letter-spacing:0.02em;">
|
||||||
|
Laundry Work Order
|
||||||
|
</h2>
|
||||||
|
<div class="text-muted" style="font-size:0.85em;">
|
||||||
|
Operational copy — staff use only
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-5 text-end" dir="rtl">
|
||||||
|
<h2 class="mb-0" style="color:#1d4ed8;">أمر عمل المغسلة</h2>
|
||||||
|
<div class="text-muted" style="font-size:0.85em;">
|
||||||
|
نسخة العمليات — لاستخدام الموظفين
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Order Meta + Status badges ───────────────── -->
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-7">
|
||||||
|
<table class="table table-sm table-borderless mb-0">
|
||||||
|
<tr>
|
||||||
|
<td style="width:35%;color:#6b7280;">Order No.</td>
|
||||||
|
<td><strong style="font-size:1.05em;"><t t-esc="o.name"/></strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="color:#6b7280;">Intake</td>
|
||||||
|
<td>
|
||||||
|
<t t-esc="o.create_date"
|
||||||
|
t-options='{"widget":"datetime"}'/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<t t-if="o.delivery_scheduled_at">
|
||||||
|
<tr>
|
||||||
|
<td style="color:#6b7280;">Scheduled</td>
|
||||||
|
<td>
|
||||||
|
<strong>
|
||||||
|
<t t-esc="o.delivery_scheduled_at"
|
||||||
|
t-options='{"widget":"datetime"}'/>
|
||||||
|
</strong>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</t>
|
||||||
|
<tr>
|
||||||
|
<td style="color:#6b7280;">POS Ref</td>
|
||||||
|
<td><t t-esc="o.pos_reference or ''"/></td>
|
||||||
|
</tr>
|
||||||
|
<t t-if="o.order_type_id">
|
||||||
|
<tr>
|
||||||
|
<td style="color:#6b7280;">Type</td>
|
||||||
|
<td><t t-esc="o.order_type_id.name"/></td>
|
||||||
|
</tr>
|
||||||
|
</t>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="col-5 text-end">
|
||||||
|
<div style="display:inline-block; text-align:left;">
|
||||||
|
<span t-attf-style="display:inline-block;padding:6px 14px;border-radius:999px;
|
||||||
|
background:#{o.priority_level == 'urgent' and '#fee2e2' or '#e5e7eb'};
|
||||||
|
color:#{o.priority_level == 'urgent' and '#b91c1c' or '#374151'};
|
||||||
|
font-weight:700;letter-spacing:0.04em;">
|
||||||
|
<t t-if="o.priority_level == 'urgent'">⚡ URGENT</t>
|
||||||
|
<t t-else="">NORMAL</t>
|
||||||
|
</span>
|
||||||
|
<t t-if="o.is_delivery">
|
||||||
|
<span style="display:inline-block;margin-top:6px;padding:6px 14px;
|
||||||
|
border-radius:999px;background:#ede9fe;color:#5b21b6;
|
||||||
|
font-weight:700;">
|
||||||
|
🚚 DELIVERY
|
||||||
|
</span>
|
||||||
|
</t>
|
||||||
|
<span t-attf-style="display:block;margin-top:6px;padding:4px 10px;
|
||||||
|
border-radius:6px;background:#dbeafe;color:#1e3a8a;
|
||||||
|
font-weight:600;font-size:0.85em;text-align:center;">
|
||||||
|
Status: <t t-esc="dict(o._fields['state'].selection).get(o.state)"/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Customer Block ───────────────────────────── -->
|
||||||
|
<div class="row mb-3" style="background:#f9fafb;border-left:3px solid #1d4ed8;
|
||||||
|
border-radius:4px;padding:10px;">
|
||||||
|
<div class="col-7">
|
||||||
|
<div style="color:#6b7280;font-size:0.78em;text-transform:uppercase;
|
||||||
|
letter-spacing:0.06em;">Customer</div>
|
||||||
|
<strong style="font-size:1.15em;"><t t-esc="o.partner_id.name"/></strong>
|
||||||
|
<t t-if="o.partner_phone">
|
||||||
|
<div><i class="fa fa-phone me-1"/><t t-esc="o.partner_phone"/></div>
|
||||||
|
</t>
|
||||||
|
<t t-if="o.is_delivery and o.delivery_address">
|
||||||
|
<div style="margin-top:6px;font-size:0.9em;">
|
||||||
|
<i class="fa fa-map-marker me-1"/>
|
||||||
|
<t t-esc="o.delivery_address"/>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
<div class="col-5 text-end" dir="rtl">
|
||||||
|
<div style="color:#6b7280;font-size:0.78em;">العميل</div>
|
||||||
|
<strong style="font-size:1.1em;"><t t-esc="o.partner_id.name"/></strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Attributes (chips) ───────────────────────── -->
|
||||||
|
<t t-if="o.attribute_ids">
|
||||||
|
<div class="mb-3">
|
||||||
|
<span style="color:#6b7280;font-size:0.78em;text-transform:uppercase;
|
||||||
|
letter-spacing:0.06em;margin-right:8px;">Attributes:</span>
|
||||||
|
<t t-foreach="o.attribute_ids" t-as="attr">
|
||||||
|
<span style="display:inline-block;padding:3px 9px;border-radius:999px;
|
||||||
|
background:#eef2ff;color:#3730a3;font-size:0.85em;
|
||||||
|
margin-right:4px;font-weight:600;">
|
||||||
|
<t t-esc="attr.name"/>
|
||||||
|
</span>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<!-- ── Items Table (with tracking codes) ────────── -->
|
||||||
|
<h6 style="color:#1d4ed8;border-bottom:2px solid #1d4ed8;
|
||||||
|
padding-bottom:4px;letter-spacing:0.02em;">
|
||||||
|
ITEMS / العناصر
|
||||||
|
</h6>
|
||||||
|
<table class="table table-sm table-bordered" style="font-size:0.9em;">
|
||||||
|
<thead style="background:#1d4ed8;color:#fff;">
|
||||||
|
<tr>
|
||||||
|
<th style="width:6%;">#</th>
|
||||||
|
<th style="width:22%;">Tracking</th>
|
||||||
|
<th>Service / الخدمة</th>
|
||||||
|
<th class="text-end" style="width:7%;">Qty</th>
|
||||||
|
<th>Note</th>
|
||||||
|
<th style="width:14%;">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<t t-if="o.line_ids">
|
||||||
|
<t t-foreach="o.line_ids" t-as="line">
|
||||||
|
<tr>
|
||||||
|
<td><t t-esc="line_index + 1"/></td>
|
||||||
|
<td style="font-family:monospace;font-weight:bold;">
|
||||||
|
<t t-esc="line.tracking_code or ''"/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<t t-esc="line.product_id.display_name if line.product_id else (line.description or '')"/>
|
||||||
|
</td>
|
||||||
|
<td class="text-end"><t t-esc="int(line.qty)"/></td>
|
||||||
|
<td><t t-esc="line.customer_note or ''"/></td>
|
||||||
|
<td>
|
||||||
|
<span t-attf-style="padding:2px 8px;border-radius:4px;
|
||||||
|
background:#{line.state == 'delivered' and '#d1fae5' or
|
||||||
|
line.state == 'ready' and '#dbeafe' or
|
||||||
|
line.state == 'processing' and '#fef3c7' or '#e5e7eb'};
|
||||||
|
color:#{line.state == 'delivered' and '#065f46' or
|
||||||
|
line.state == 'ready' and '#1e40af' or
|
||||||
|
line.state == 'processing' and '#92400e' or '#374151'};
|
||||||
|
font-weight:600;font-size:0.82em;">
|
||||||
|
<t t-esc="dict(line._fields['state'].selection).get(line.state)"/>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</t>
|
||||||
|
</t>
|
||||||
|
<t t-else="">
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="text-center text-muted">No items.</td>
|
||||||
|
</tr>
|
||||||
|
</t>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- ── Notes ────────────────────────────────────── -->
|
||||||
|
<t t-if="o.notes">
|
||||||
|
<div class="mt-2 p-2"
|
||||||
|
style="border-left:3px solid #f59e0b;background:#fffbeb;font-size:0.9em;">
|
||||||
|
<strong>Notes:</strong> <t t-esc="o.notes"/>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<!-- ── Footer signatures ────────────────────────── -->
|
||||||
|
<div class="row mt-4 pt-3" style="border-top:1px dashed #d1d5db;">
|
||||||
|
<div class="col-6">
|
||||||
|
<div style="color:#6b7280;font-size:0.78em;text-transform:uppercase;">
|
||||||
|
Received by
|
||||||
|
</div>
|
||||||
|
<div style="border-bottom:1px solid #9ca3af;height:32px;"/>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<div style="color:#6b7280;font-size:0.78em;text-transform:uppercase;">
|
||||||
|
Customer signature
|
||||||
|
</div>
|
||||||
|
<div style="border-bottom:1px solid #9ca3af;height:32px;"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</t>
|
||||||
|
</t>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
22
addons/laundry_management/reports/__init__.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
###############################################################################
|
||||||
|
#
|
||||||
|
# Cybrosys Technologies Pvt. Ltd.
|
||||||
|
#
|
||||||
|
# Copyright (C) 2026-TODAY Cybrosys Technologies(<https://www.cybrosys.com>)
|
||||||
|
# Author: Anaswara S (odoo@cybrosys.com)
|
||||||
|
#
|
||||||
|
# You can modify it under the terms of the GNU AFFERO
|
||||||
|
# GENERAL PUBLIC LICENSE (AGPL v3), Version 3.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU AFFERO GENERAL PUBLIC LICENSE (AGPL v3) for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
# (AGPL v3) along with this program.
|
||||||
|
# If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
###############################################################################
|
||||||
|
from . import report_laundry_order
|
||||||
105
addons/laundry_management/reports/report_laundry_order.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
###############################################################################
|
||||||
|
#
|
||||||
|
# Cybrosys Technologies Pvt. Ltd.
|
||||||
|
#
|
||||||
|
# Copyright (C) 2026-TODAY Cybrosys Technologies(<https://www.cybrosys.com>)
|
||||||
|
# Author: Anaswara S (odoo@cybrosys.com)
|
||||||
|
#
|
||||||
|
# You can modify it under the terms of the GNU AFFERO
|
||||||
|
# GENERAL PUBLIC LICENSE (AGPL v3), Version 3.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU AFFERO GENERAL PUBLIC LICENSE (AGPL v3) for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
# (AGPL v3) along with this program.
|
||||||
|
# If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
###############################################################################
|
||||||
|
from odoo import fields, models, tools
|
||||||
|
|
||||||
|
|
||||||
|
class ReportLaundryOrder(models.Model):
|
||||||
|
"""Model for checking the history of all laundry orders."""
|
||||||
|
_name = "report.laundry.order"
|
||||||
|
_description = "Report Laundry Order"
|
||||||
|
_order = 'name desc'
|
||||||
|
_auto = False
|
||||||
|
|
||||||
|
name = fields.Char(string="Label")
|
||||||
|
invoice_status = fields.Selection([
|
||||||
|
('upselling', 'Upselling Opportunity'),
|
||||||
|
('invoiced', 'Fully Invoiced'),
|
||||||
|
('to invoice', 'To Invoice'),
|
||||||
|
('no', 'Nothing to Invoice')
|
||||||
|
], string='Invoice Status', store=True, help="status of invoice")
|
||||||
|
partner_id = fields.Many2one('res.partner', string='Customer',
|
||||||
|
help="Name of the customer")
|
||||||
|
partner_invoice_id = fields.Many2one('res.partner',
|
||||||
|
string='Invoice Address',
|
||||||
|
help="Invoice address of Customer")
|
||||||
|
partner_shipping_id = fields.Many2one('res.partner',
|
||||||
|
string='Delivery Address',
|
||||||
|
help="Delivery address of customer")
|
||||||
|
order_date = fields.Datetime(string="Date", help="Date of order")
|
||||||
|
laundry_person_id = fields.Many2one('res.users',
|
||||||
|
string='Laundry Person',
|
||||||
|
help="Name of laundry person")
|
||||||
|
total_amount = fields.Float(string='Total')
|
||||||
|
currency_id = fields.Many2one("res.currency",
|
||||||
|
string="Currency", help="Name of currency")
|
||||||
|
state = fields.Selection([
|
||||||
|
('draft', 'Draft'),
|
||||||
|
('order', 'Laundry Order'),
|
||||||
|
('process', 'Processing'),
|
||||||
|
('done', 'Done'),
|
||||||
|
('return', 'Returned'),
|
||||||
|
('cancel', 'Cancelled'),
|
||||||
|
], string='Status')
|
||||||
|
|
||||||
|
def _select(self):
|
||||||
|
select_str = """
|
||||||
|
SELECT
|
||||||
|
(select 1 ) AS nbr,
|
||||||
|
t.id as id,
|
||||||
|
t.name as name,
|
||||||
|
t.invoice_status as invoice_status,
|
||||||
|
t.partner_id as partner_id,
|
||||||
|
t.partner_invoice_id as partner_invoice_id,
|
||||||
|
t.partner_shipping_id as partner_shipping_id,
|
||||||
|
t.order_date as order_date,
|
||||||
|
t.laundry_person_id as laundry_person_id,
|
||||||
|
t.total_amount as total_amount,
|
||||||
|
t.currency_id as currency_id,
|
||||||
|
t.state as state
|
||||||
|
"""
|
||||||
|
return select_str
|
||||||
|
|
||||||
|
def _group_by(self):
|
||||||
|
group_by_str = """
|
||||||
|
GROUP BY
|
||||||
|
t.id,
|
||||||
|
name,
|
||||||
|
invoice_status,
|
||||||
|
partner_id,
|
||||||
|
partner_invoice_id,
|
||||||
|
partner_shipping_id,
|
||||||
|
order_date,
|
||||||
|
laundry_person_id,
|
||||||
|
total_amount,
|
||||||
|
currency_id,
|
||||||
|
state
|
||||||
|
"""
|
||||||
|
return group_by_str
|
||||||
|
|
||||||
|
def init(self):
|
||||||
|
tools.sql.drop_view_if_exists(self._cr, 'report_laundry_order')
|
||||||
|
self._cr.execute("""
|
||||||
|
CREATE view report_laundry_order as
|
||||||
|
%s
|
||||||
|
FROM laundry_order t
|
||||||
|
%s
|
||||||
|
""" % (self._select(), self._group_by()))
|
||||||
22
addons/laundry_management/security/ir.model.access.csv
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||||
|
access_laundry_order_operator,laundry.order operator,model_laundry_order,laundry_management.group_laundry_operator,1,0,0,0
|
||||||
|
access_laundry_order_cashier,laundry.order cashier,model_laundry_order,laundry_management.group_laundry_cashier,1,1,1,0
|
||||||
|
access_laundry_order_manager,laundry.order manager,model_laundry_order,laundry_management.group_laundry_manager,1,1,1,1
|
||||||
|
access_laundry_order_line_operator,laundry.order.line operator,model_laundry_order_line,laundry_management.group_laundry_operator,1,0,0,0
|
||||||
|
access_laundry_order_line_cashier,laundry.order.line cashier,model_laundry_order_line,laundry_management.group_laundry_cashier,1,1,1,0
|
||||||
|
access_laundry_order_line_manager,laundry.order.line manager,model_laundry_order_line,laundry_management.group_laundry_manager,1,1,1,1
|
||||||
|
access_laundry_commission_operator,laundry.commission operator,model_laundry_commission,laundry_management.group_laundry_operator,1,0,0,0
|
||||||
|
access_laundry_commission_cashier,laundry.commission cashier,model_laundry_commission,laundry_management.group_laundry_cashier,1,0,0,0
|
||||||
|
access_laundry_commission_manager,laundry.commission manager,model_laundry_commission,laundry_management.group_laundry_manager,1,1,1,1
|
||||||
|
access_laundry_payment_method_user,laundry.payment.method user,model_laundry_payment_method,laundry_management.group_laundry_cashier,1,0,0,0
|
||||||
|
access_laundry_payment_method_manager,laundry.payment.method manager,model_laundry_payment_method,laundry_management.group_laundry_manager,1,1,1,1
|
||||||
|
access_laundry_print_wizard_user,laundry.print.wizard user,model_laundry_print_wizard,laundry_management.group_laundry_operator,1,1,1,1
|
||||||
|
access_laundry_order_line_addon_user,laundry.order.line.addon user,model_laundry_order_line_addon,laundry_management.group_laundry_operator,1,1,1,0
|
||||||
|
access_laundry_order_line_addon_manager,laundry.order.line.addon manager,model_laundry_order_line_addon,laundry_management.group_laundry_manager,1,1,1,1
|
||||||
|
access_laundry_order_type_operator,laundry.order.type operator,model_laundry_order_type,laundry_management.group_laundry_operator,1,0,0,0
|
||||||
|
access_laundry_order_type_cashier,laundry.order.type cashier,model_laundry_order_type,laundry_management.group_laundry_cashier,1,0,0,0
|
||||||
|
access_laundry_order_type_manager,laundry.order.type manager,model_laundry_order_type,laundry_management.group_laundry_manager,1,1,1,1
|
||||||
|
access_laundry_order_attribute_operator,laundry.order.attribute operator,model_laundry_order_attribute,laundry_management.group_laundry_operator,1,0,0,0
|
||||||
|
access_laundry_order_attribute_cashier,laundry.order.attribute cashier,model_laundry_order_attribute,laundry_management.group_laundry_cashier,1,0,0,0
|
||||||
|
access_laundry_order_attribute_manager,laundry.order.attribute manager,model_laundry_order_attribute,laundry_management.group_laundry_manager,1,1,1,1
|
||||||
|
access_laundry_order_unlock_wizard_override,laundry.order.unlock.wizard manager,model_laundry_order_unlock_wizard,laundry_management.group_laundry_manager_override,1,1,1,1
|
||||||
|
47
addons/laundry_management/security/ir_rule.xml
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<!-- ══════════════════════════════════════════════════════════════
|
||||||
|
RECORD RULES — Laundry Orders (laundry.order)
|
||||||
|
|
||||||
|
Company-restricted access per role.
|
||||||
|
Admin / superuser bypasses all ir.rule by design.
|
||||||
|
══════════════════════════════════════════════════════════════ -->
|
||||||
|
|
||||||
|
<!-- Operator: read-only access to laundry orders in own company -->
|
||||||
|
<record id="rule_laundry_order_operator" model="ir.rule">
|
||||||
|
<field name="name">Laundry Order: Operator read own company</field>
|
||||||
|
<field name="model_id" ref="model_laundry_order"/>
|
||||||
|
<field name="domain_force">[('company_id', 'in', company_ids)]</field>
|
||||||
|
<field name="groups" eval="[(4, ref('group_laundry_operator'))]"/>
|
||||||
|
<field name="perm_read" eval="True"/>
|
||||||
|
<field name="perm_write" eval="False"/>
|
||||||
|
<field name="perm_create" eval="False"/>
|
||||||
|
<field name="perm_unlink" eval="False"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Cashier: read / write / create (no delete) -->
|
||||||
|
<record id="rule_laundry_order_cashier" model="ir.rule">
|
||||||
|
<field name="name">Laundry Order: Cashier read/write/create own company</field>
|
||||||
|
<field name="model_id" ref="model_laundry_order"/>
|
||||||
|
<field name="domain_force">[('company_id', 'in', company_ids)]</field>
|
||||||
|
<field name="groups" eval="[(4, ref('group_laundry_cashier'))]"/>
|
||||||
|
<field name="perm_read" eval="True"/>
|
||||||
|
<field name="perm_write" eval="True"/>
|
||||||
|
<field name="perm_create" eval="True"/>
|
||||||
|
<field name="perm_unlink" eval="False"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Manager: full CRUD -->
|
||||||
|
<record id="rule_laundry_order_manager" model="ir.rule">
|
||||||
|
<field name="name">Laundry Order: Manager full access own company</field>
|
||||||
|
<field name="model_id" ref="model_laundry_order"/>
|
||||||
|
<field name="domain_force">[('company_id', 'in', company_ids)]</field>
|
||||||
|
<field name="groups" eval="[(4, ref('group_laundry_manager'))]"/>
|
||||||
|
<field name="perm_read" eval="True"/>
|
||||||
|
<field name="perm_write" eval="True"/>
|
||||||
|
<field name="perm_create" eval="True"/>
|
||||||
|
<field name="perm_unlink" eval="True"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?xml version="1.0" ?>
|
||||||
|
<odoo>
|
||||||
|
<!-- Defined Groups and Record rules-->
|
||||||
|
<record id="module_category_laundry" model="ir.module.category">
|
||||||
|
<field name="name">Laundry</field>
|
||||||
|
<field name="sequence">18</field>
|
||||||
|
</record>
|
||||||
|
<record model="res.groups.privilege" id="res_groups_privilege_laundry">
|
||||||
|
<field name="name">Laundry</field>
|
||||||
|
<field name="category_id" ref="module_category_laundry"/>
|
||||||
|
<field name="sequence">7</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="group_laundry_user" model="res.groups">
|
||||||
|
<field name="name">Laundry User</field>
|
||||||
|
<field name="privilege_id" ref="res_groups_privilege_laundry"/>
|
||||||
|
<field name="user_ids" eval="[(4, ref('base.group_user'))]"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="group_laundry_manager" model="res.groups">
|
||||||
|
<field name="name">Laundry Manager</field>
|
||||||
|
<field name="implied_ids" eval="[(4, ref('group_laundry_user'))]"/>
|
||||||
|
<field name="privilege_id" ref="res_groups_privilege_laundry"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="group_laundry_admin" model="res.groups">
|
||||||
|
<field name="name">Laundry Admin</field>
|
||||||
|
<field name="implied_ids" eval="[(4, ref('group_laundry_manager'))]"/>
|
||||||
|
<field name="privilege_id" ref="res_groups_privilege_laundry"/>
|
||||||
|
<field name="user_ids" eval="[(4, ref('base.user_admin'))]"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="laundry_order_admin_rule" model="ir.rule">
|
||||||
|
<field name="name">Laundry Manager: Full access</field>
|
||||||
|
<field name="model_id" ref="model_laundry_order"/>
|
||||||
|
<field name="domain_force">[(1,'=',1)]</field>
|
||||||
|
<field name="groups" eval="[(4,ref('group_laundry_manager'))]"/>
|
||||||
|
</record>
|
||||||
|
<record id="laundry_order_user_rule" model="ir.rule">
|
||||||
|
<field name="name">User: own document only</field>
|
||||||
|
<field name="model_id" ref="model_laundry_order"/>
|
||||||
|
<field name="domain_force">[('laundry_person_id','=',user.id)]
|
||||||
|
</field>
|
||||||
|
<field name="groups" eval="[(4,ref('group_laundry_user'))]"/>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
71
addons/laundry_management/security/res_groups.xml
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<!-- ══════════════════════════════════════════════════════════════
|
||||||
|
LAUNDRY PERMISSION GROUPS (Odoo 19 — privilege-based)
|
||||||
|
The privilege groups all our res.groups under a single
|
||||||
|
"Laundry" section on the user form (Settings → Users).
|
||||||
|
══════════════════════════════════════════════════════════════ -->
|
||||||
|
|
||||||
|
<record id="module_category_laundry" model="ir.module.category">
|
||||||
|
<field name="name">Laundry</field>
|
||||||
|
<field name="description">Laundry operations and POS settlement permissions.</field>
|
||||||
|
<field name="sequence">30</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="res_groups_privilege_laundry" model="res.groups.privilege">
|
||||||
|
<field name="name">Laundry</field>
|
||||||
|
<field name="sequence">30</field>
|
||||||
|
<field name="category_id" ref="module_category_laundry"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="group_laundry_operator" model="res.groups">
|
||||||
|
<field name="name">Operator</field>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
<field name="privilege_id" ref="res_groups_privilege_laundry"/>
|
||||||
|
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="group_laundry_cashier" model="res.groups">
|
||||||
|
<field name="name">Cashier</field>
|
||||||
|
<field name="sequence">20</field>
|
||||||
|
<field name="privilege_id" ref="res_groups_privilege_laundry"/>
|
||||||
|
<field name="implied_ids" eval="[
|
||||||
|
(4, ref('group_laundry_operator')),
|
||||||
|
(4, ref('sales_team.group_sale_salesman')),
|
||||||
|
(4, ref('account.group_account_invoice')),
|
||||||
|
]"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Kept for backward compatibility with any prior ir.model.access.csv
|
||||||
|
references to group_laundry_user. Not exposed as a separate row
|
||||||
|
on the user form — it merely chains into Cashier. -->
|
||||||
|
<record id="group_laundry_user" model="res.groups">
|
||||||
|
<field name="name">Laundry / User</field>
|
||||||
|
<field name="implied_ids" eval="[(4, ref('group_laundry_cashier'))]"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="group_laundry_manager" model="res.groups">
|
||||||
|
<field name="name">Manager</field>
|
||||||
|
<field name="sequence">30</field>
|
||||||
|
<field name="privilege_id" ref="res_groups_privilege_laundry"/>
|
||||||
|
<field name="implied_ids" eval="[
|
||||||
|
(4, ref('group_laundry_user')),
|
||||||
|
(4, ref('sales_team.group_sale_manager')),
|
||||||
|
(4, ref('account.group_account_manager')),
|
||||||
|
]"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Phase 3 — explicit override privilege.
|
||||||
|
Held in addition to Manager. Required to use the
|
||||||
|
"Unlock for Editing" wizard on a locked laundry.order.
|
||||||
|
Kept narrow so the standard Manager role can still be
|
||||||
|
assigned without granting unlock-the-world rights. -->
|
||||||
|
<record id="group_laundry_manager_override" model="res.groups">
|
||||||
|
<field name="name">Manager Override (Unlock Locked Orders)</field>
|
||||||
|
<field name="sequence">40</field>
|
||||||
|
<field name="privilege_id" ref="res_groups_privilege_laundry"/>
|
||||||
|
<field name="implied_ids" eval="[(4, ref('group_laundry_manager'))]"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 28 KiB |
BIN
addons/laundry_management/static/description/assets/h2.png
Normal file
|
After Width: | Height: | Size: 628 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
<svg width="34" height="34" viewBox="0 0 34 34" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_48_838)">
|
||||||
|
<path d="M0.000110011 16.2916C0.000110011 9.65172 5.40186 4.24997 12.0418 4.24997H26.9168V1.89405C26.9168 0.631803 28.4425 -3.05772e-05 29.335 0.892469L33.585 5.37339C34.1389 5.9273 34.1375 6.82405 33.585 7.37655L29.335 11.8575C28.4425 12.75 26.9168 12.1181 26.9168 10.8559V8.49997H12.0418C7.74503 8.49997 4.25011 11.9963 4.25011 16.2916C4.25011 17.4646 3.29953 18.4166 2.12511 18.4166C0.950693 18.4166 0.000110011 17.4646 0.000110011 16.2916ZM31.8751 15.5833C30.7007 15.5833 29.7501 16.5353 29.7501 17.7083C29.7501 22.0036 26.2552 25.5 21.9584 25.5H7.08344V23.1441C7.08344 21.8818 5.55769 21.25 4.66519 22.1425L0.415193 26.6234C-0.138723 27.1773 -0.138723 28.0741 0.415193 28.6266L4.66519 33.1075C5.55769 34 7.08344 33.3681 7.08344 32.1059V29.75H21.9584C28.5984 29.75 34.0001 24.3482 34.0001 17.7083C34.0001 16.5353 33.0495 15.5833 31.8751 15.5833Z" fill="white"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_48_838">
|
||||||
|
<rect width="34" height="34" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 209 KiB |
|
After Width: | Height: | Size: 109 KiB |
@@ -0,0 +1,9 @@
|
|||||||
|
<svg width="1228" height="268" viewBox="0 0 1228 268" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M0 0H1228V252C1228 260.837 1220.84 268 1212 268H16C7.16347 268 0 260.837 0 252V0Z" fill="url(#paint0_linear_15_2077)"/>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="paint0_linear_15_2077" x1="613.495" y1="-14" x2="613.495" y2="268" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#D7BFFF" stop-opacity="0"/>
|
||||||
|
<stop offset="1" stop-color="#D3D0DA" stop-opacity="0.5"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 495 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg width="24" height="25" viewBox="0 0 24 25" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="Group">
|
||||||
|
<path id="Vector" d="M21 16.46V19.996C21.0001 20.2492 20.9042 20.493 20.7316 20.6782C20.559 20.8635 20.3226 20.9763 20.07 20.994C19.633 21.024 19.276 21.04 19 21.04C10.163 21.04 3 13.877 3 5.04004C3 4.76404 3.015 4.40704 3.046 3.97004C3.06372 3.71748 3.17658 3.48105 3.3618 3.30845C3.54703 3.13585 3.79082 3.03992 4.044 3.04004H7.58C7.70404 3.03991 7.8237 3.0859 7.91573 3.16906C8.00776 3.25222 8.0656 3.36662 8.078 3.49004C8.101 3.72004 8.122 3.90304 8.142 4.04204C8.34073 5.42896 8.748 6.77787 9.35 8.04304C9.445 8.24304 9.383 8.48204 9.203 8.61004L7.045 10.152C8.36445 13.2265 10.8145 15.6766 13.889 16.996L15.429 14.842C15.4919 14.754 15.5838 14.6909 15.6885 14.6637C15.7932 14.6365 15.9042 14.6468 16.002 14.693C17.267 15.2939 18.6156 15.7002 20.002 15.898C20.141 15.918 20.324 15.94 20.552 15.962C20.6752 15.9747 20.7894 16.0326 20.8724 16.1246C20.9553 16.2166 21.0012 16.3362 21.001 16.46H21Z" fill="white"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg width="24" height="22" viewBox="0 0 24 22" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="Group">
|
||||||
|
<path id="Vector" d="M3 2.75H21C21.2652 2.75 21.5196 2.84658 21.7071 3.01849C21.8946 3.19039 22 3.42355 22 3.66667V18.3333C22 18.5764 21.8946 18.8096 21.7071 18.9815C21.5196 19.1534 21.2652 19.25 21 19.25H3C2.73478 19.25 2.48043 19.1534 2.29289 18.9815C2.10536 18.8096 2 18.5764 2 18.3333V3.66667C2 3.42355 2.10536 3.19039 2.29289 3.01849C2.48043 2.84658 2.73478 2.75 3 2.75ZM12.06 10.7094L5.648 5.71817L4.353 7.11517L12.073 13.1239L19.654 7.11058L18.346 5.72367L12.061 10.7094H12.06Z" fill="white"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 624 B |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 214 KiB |
@@ -0,0 +1,41 @@
|
|||||||
|
<svg width="722" height="91" viewBox="0 0 722 91" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_76_492)">
|
||||||
|
<path d="M3.96206 36.4143H16.4878C17.0972 33.1867 18.1886 30.0692 19.7253 27.1662C22.0512 22.5767 25.4478 18.6146 29.628 15.6148C30.3275 15.1152 31.0219 14.6705 31.7114 14.2658V8.67495C31.7121 8.15446 31.6101 7.63895 31.4112 7.15796C31.2123 6.67696 30.9205 6.23993 30.5524 5.87189C30.1844 5.50384 29.7474 5.21203 29.2664 5.01315C28.7854 4.81427 28.2699 4.71224 27.7494 4.71289H3.96206C2.91126 4.71289 1.90349 5.13032 1.16046 5.87335C0.417433 6.61638 3.15053e-06 7.62415 3.15053e-06 8.67495V32.4473C-0.000653502 32.968 0.101344 33.4838 0.300161 33.965C0.498979 34.4463 0.790718 34.8837 1.15869 35.2521C1.52667 35.6206 1.96367 35.9128 2.44469 36.1123C2.92572 36.3117 3.44133 36.4143 3.96206 36.4143Z" fill="#231F20"/>
|
||||||
|
<path d="M26.2803 36.4143H27.7492C28.2699 36.4143 28.7855 36.3116 29.2666 36.1122C29.7476 35.9128 30.1846 35.6205 30.5525 35.252C30.9205 34.8836 31.2123 34.4462 31.4111 33.965C31.6099 33.4837 31.7119 32.9679 31.7112 32.4472V21.4854C30.4259 23.5406 29.3433 25.7159 28.4786 27.9805C27.3964 30.6889 26.6579 33.5222 26.2803 36.4143Z" fill="#231F20"/>
|
||||||
|
<path d="M15.9182 42.4053C15.9165 41.0255 16.0033 39.647 16.178 38.2783H3.96205C2.91079 38.2796 1.90304 38.6982 1.16016 39.442C0.417269 40.1858 -8.33757e-07 41.1941 0 42.2454L0 67.8913C-8.33757e-07 68.9426 0.417269 69.9508 1.16016 70.6947C1.90304 71.4385 2.91079 71.857 3.96205 71.8584H29.613C30.6627 71.8505 31.6672 71.43 32.4095 70.6878C33.1517 69.9455 33.5722 68.941 33.58 67.8913V60.6867C28.5838 60.4868 24.4435 58.7847 21.1593 55.5805C17.6619 52.1697 15.9148 47.7779 15.9182 42.4053Z" fill="#B22126"/>
|
||||||
|
<path d="M63.1879 40.1416H39.4055C38.8844 40.1409 38.3682 40.2431 37.8866 40.4422C37.405 40.6414 36.9674 40.9336 36.5989 41.3021C36.2304 41.6706 35.9382 42.1082 35.7391 42.5898C35.54 43.0714 35.4378 43.5875 35.4385 44.1087V55.2004C37.0405 55.7764 38.7324 56.0624 40.4348 56.0448C42.9361 56.0853 45.4022 55.4519 47.5745 54.2111C50.0647 52.6651 52.2871 50.7246 54.1546 48.4654V52.4275C48.9551 57.7568 42.7164 60.5098 35.4385 60.6863V67.886C35.4463 68.9357 35.8668 69.9402 36.6091 70.6824C37.3513 71.4247 38.3558 71.8452 39.4055 71.853H63.1879C64.2376 71.8452 65.242 71.4247 65.9843 70.6824C66.7266 69.9402 67.1471 68.9357 67.1549 67.886V44.1087C67.1549 43.0565 66.737 42.0475 65.993 41.3035C65.249 40.5596 64.24 40.1416 63.1879 40.1416Z" fill="#231F20"/>
|
||||||
|
<path d="M63.1879 4.71297H37.5419C36.4915 4.71429 35.4845 5.13215 34.7418 5.87489C33.9991 6.61763 33.5812 7.62463 33.5799 8.67502V13.2566C36.09 11.9862 38.8559 11.3028 41.6689 11.2581C44.9697 11.2581 47.6627 12.249 49.7479 14.2309C51.833 16.2128 52.8739 18.7808 52.8705 21.9352C52.9064 24.6318 52.2802 27.2961 51.0469 29.6944C50.9919 29.8143 50.932 29.9342 50.862 30.0541C50.862 30.0841 50.8271 30.1241 50.8071 30.1541C50.3864 30.8401 49.7531 31.3699 49.0036 31.6629C48.2541 31.9559 47.4294 31.996 46.655 31.7771C45.8806 31.5581 45.199 31.0922 44.7138 30.4502C44.2286 29.8082 43.9664 29.0252 43.9672 28.2205C43.9601 27.5105 44.1654 26.8145 44.5567 26.222C45.5827 24.3205 46.1152 22.1921 46.1056 20.0316C46.1056 18.133 45.6692 16.6241 44.7965 15.5049C44.3888 14.9675 43.8589 14.5348 43.2507 14.2429C42.6425 13.951 41.9735 13.8081 41.2991 13.8262C39.1807 13.8262 36.9174 15.1452 34.5092 17.7833C34.1994 18.123 33.8896 18.4827 33.5949 18.8425V34.311C33.5955 34.8321 33.4934 35.3483 33.2942 35.8299C33.0951 36.3115 32.8029 36.7491 32.4344 37.1176C32.0659 37.4861 31.6283 37.7783 31.1467 37.9774C30.6651 38.1765 30.149 38.2787 29.6278 38.278H26.1304C26.1004 38.7777 26.0854 39.2773 26.0854 39.7769C26.0854 44.5334 27.4344 48.4338 30.1324 51.4782C31.1249 52.6139 32.2982 53.5778 33.6049 54.3311V42.2451C33.6049 41.1938 34.0221 40.1855 34.765 39.4417C35.5079 38.6979 36.5157 38.2793 37.5669 38.278H63.2129C64.265 38.278 65.274 37.8601 66.018 37.1161C66.762 36.3721 67.1799 35.3631 67.1799 34.311V8.67502C67.1793 8.15238 67.0753 7.63501 66.8741 7.15264C66.6729 6.67028 66.3784 6.23243 66.0074 5.86426C65.6364 5.49609 65.1964 5.20485 64.7125 5.00727C64.2286 4.80969 63.7105 4.70968 63.1879 4.71297Z" fill="#B22126"/>
|
||||||
|
<path d="M119.037 6.17721V17.5987H118.087C117.371 14.6209 116.039 12.336 114.09 10.7438C112.24 9.18871 109.897 8.34376 107.48 8.36059C105.453 8.35176 103.473 8.97333 101.814 10.1393C100.128 11.2806 98.8172 12.8956 98.0473 14.7808C97.0001 17.3752 96.4804 20.1522 96.5184 22.9497C96.4851 25.6754 96.8623 28.3905 97.6376 31.0038C98.3837 33.4286 99.5495 35.2606 101.135 36.4997C102.72 37.7388 104.779 38.3583 107.31 38.3583C109.293 38.379 111.251 37.9124 113.011 36.9993C114.733 36.09 116.552 34.5261 118.467 32.3078V35.1507C116.842 36.9203 114.878 38.3458 112.691 39.3426C110.473 40.2503 108.093 40.6925 105.697 40.6416C102.448 40.7028 99.2319 39.9778 96.3236 38.5282C93.6588 37.1874 91.456 35.0802 89.9983 32.4777C88.5371 29.9073 87.7708 27.0006 87.7749 24.0439C87.7777 20.8934 88.6304 17.802 90.2431 15.0956C91.8236 12.3238 94.1364 10.0405 96.9281 8.49549C99.6453 6.95916 102.715 6.15514 105.837 6.16222C108.357 6.20019 110.848 6.70854 113.181 7.66111C114.67 8.24734 115.618 8.53879 116.024 8.53546C116.276 8.5371 116.525 8.48745 116.757 8.38953C116.989 8.2916 117.199 8.14745 117.373 7.96588C117.807 7.458 118.066 6.82385 118.112 6.15723L119.037 6.17721Z" fill="black"/>
|
||||||
|
<path d="M157.148 6.93164V7.82598C156.244 7.90593 155.379 8.23447 154.65 8.77527C153.841 9.44144 152.562 11.2967 150.813 14.3411L143.658 26.2773V34.2713C143.658 35.9701 143.748 37.0276 143.928 37.444C144.156 37.9107 144.524 38.2936 144.982 38.5381C145.626 38.8905 146.353 39.0631 147.086 39.0378H148.934V39.9371H130.448V38.9728H132.172C132.972 39.0166 133.77 38.8441 134.48 38.4732C134.936 38.2101 135.288 37.7995 135.479 37.3091C135.656 36.9194 135.744 35.8984 135.744 34.2463V27.6412L127.98 13.5167C126.441 10.7288 125.355 9.08837 124.722 8.5954C123.987 8.06809 123.099 7.79767 122.194 7.82598V6.93164H137.998V7.82598H137.293C136.601 7.7672 135.906 7.91129 135.295 8.24067C135.136 8.32984 135.002 8.45588 134.903 8.6081C134.804 8.76032 134.743 8.93421 134.725 9.11502C134.725 9.70125 135.373 11.1685 136.669 13.5167L142.624 24.4286L148.545 14.491C150.02 12.0595 150.758 10.4457 150.758 9.64962C150.754 9.42828 150.694 9.21158 150.583 9.02008C150.472 8.82858 150.314 8.66864 150.124 8.55543C149.574 8.15073 148.56 7.91091 147.086 7.82598V6.93164H157.148Z" fill="black"/>
|
||||||
|
<path d="M179.866 22.4151C182.994 23.1446 185.191 24.0273 186.456 25.0632C187.327 25.7396 188.027 26.611 188.5 27.6071C188.973 28.6033 189.206 29.6965 189.179 30.7989C189.195 31.9871 188.932 33.1624 188.412 34.2306C187.891 35.2987 187.127 36.2299 186.182 36.9493C183.74 38.9079 180.184 39.8888 175.515 39.8921H158.752V38.9728C160.277 38.9728 161.312 38.8296 161.855 38.5431C162.341 38.3207 162.741 37.9452 162.994 37.4739C163.214 37.0193 163.324 35.9051 163.324 34.1214V12.7123C163.324 10.9337 163.214 9.8095 162.994 9.34984C162.75 8.85687 162.342 8.46452 161.84 8.24067C161.29 7.96421 160.261 7.82598 158.752 7.82598V6.93164H174.555C178.332 6.93164 181.005 7.26473 182.574 7.9309C184.08 8.54684 185.374 9.58864 186.296 10.9287C187.174 12.1792 187.648 13.668 187.655 15.1955C187.656 16.7787 187.038 18.2994 185.932 19.4324C184.776 20.6914 182.754 21.6857 179.866 22.4151ZM171.018 23.6542V34.2264V35.4405C170.969 35.7998 171.004 36.1656 171.12 36.5092C171.236 36.8528 171.43 37.1651 171.686 37.4218C171.942 37.6785 172.254 37.8726 172.597 37.9891C172.941 38.1056 173.306 38.1413 173.666 38.0935C174.914 38.104 176.146 37.8072 177.253 37.2291C178.322 36.6711 179.193 35.7993 179.751 34.731C180.343 33.6039 180.644 32.3466 180.626 31.0737C180.65 29.6203 180.28 28.1875 179.556 26.9268C178.904 25.7558 177.863 24.8493 176.614 24.3637C175.37 23.8691 173.486 23.6392 171.003 23.6542H171.018ZM171.018 21.7556C173.323 21.7556 175.022 21.4975 176.114 20.9812C177.142 20.5106 178.01 19.75 178.612 18.7928C179.235 17.6992 179.538 16.4528 179.487 15.1955C179.539 13.9431 179.241 12.701 178.627 11.6082C178.036 10.671 177.176 9.93434 176.159 9.49474C175.09 9.01176 173.371 8.77694 171.003 8.79026L171.018 21.7556Z" fill="black"/>
|
||||||
|
<path d="M204.138 24.8683V34.1064C204.138 35.8901 204.248 37.0093 204.463 37.4739C204.711 37.9623 205.118 38.3517 205.617 38.5781C206.172 38.8529 207.196 38.9928 208.705 38.9928V39.8921H191.857V38.9728C193.356 38.9728 194.415 38.8279 194.955 38.5431C195.435 38.3174 195.83 37.9423 196.079 37.4739C196.299 37.0193 196.409 35.9051 196.409 34.1214V12.7123C196.409 10.9337 196.299 9.8095 196.079 9.34984C195.833 8.85885 195.426 8.4673 194.925 8.24067C194.376 7.96587 193.341 7.82598 191.837 7.82598V6.93164H207.151C211.148 6.93164 214.064 7.20644 215.9 7.75603C217.691 8.2681 219.274 9.33516 220.421 10.8038C221.591 12.2877 222.21 14.1308 222.175 16.0199C222.202 17.1558 221.981 18.284 221.527 19.3256C221.073 20.3672 220.397 21.2971 219.547 22.0504C218.185 23.1754 216.57 23.9507 214.84 24.3087L222.779 35.4754C223.421 36.4497 224.163 37.354 224.993 38.1734C225.663 38.6676 226.468 38.9463 227.301 38.9728V39.8722H216.924L206.302 24.8683H204.138ZM204.138 8.70532V23.1446H205.522C207.776 23.1446 209.459 22.9347 210.578 22.5251C211.695 22.1139 212.629 21.3209 213.217 20.2867C213.908 18.9924 214.239 17.536 214.176 16.0698C214.176 13.5551 213.588 11.6998 212.412 10.504C211.236 9.30821 209.344 8.70865 206.736 8.70532H204.138Z" fill="black"/>
|
||||||
|
<path d="M246.092 6.46642C251.342 6.27323 255.607 7.80542 258.888 11.063C262.169 14.3206 263.807 18.3959 263.804 23.2889C263.869 27.2766 262.574 31.1675 260.132 34.3207C256.888 38.5376 252.309 40.6443 246.397 40.641C240.485 40.6377 235.901 38.6292 232.647 34.6155C230.09 31.4121 228.738 27.4121 228.83 23.3139C228.83 18.4175 230.495 14.3372 233.826 11.073C237.157 7.80875 241.246 6.27323 246.092 6.46642ZM246.382 8.02026C243.384 8.02026 241.091 9.56079 239.502 12.6418C238.223 15.1699 237.584 18.7989 237.584 23.5287C237.584 29.1546 238.571 33.3181 240.546 36.0195C241.202 36.9265 242.072 37.6568 243.079 38.1451C244.086 38.6334 245.199 38.8643 246.317 38.8174C247.803 38.8704 249.268 38.4448 250.494 37.6033C251.923 36.5674 253.034 34.9019 253.826 32.607C254.619 30.312 255.016 27.3426 255.016 23.6986C255.016 19.3418 254.611 16.0809 253.801 13.9159C252.992 11.7508 251.96 10.2236 250.704 9.33429C249.437 8.45253 247.925 7.99271 246.382 8.02026Z" fill="black"/>
|
||||||
|
<path d="M288.97 6.17699L289.235 17.1688H288.236C287.885 14.6146 286.668 12.2581 284.788 10.4938C283.187 8.91862 281.039 8.02351 278.793 7.99564C277.364 7.91684 275.959 8.39094 274.871 9.31966C274.423 9.69261 274.062 10.1582 273.811 10.6844C273.561 11.2106 273.428 11.7848 273.422 12.3674C273.406 13.0489 273.579 13.7215 273.921 14.3109C274.52 15.2067 275.289 15.9757 276.185 16.5743C277.884 17.6257 279.644 18.5751 281.456 19.4172C285.343 21.3191 287.952 23.1111 289.285 24.7932C290.599 26.433 291.305 28.4774 291.283 30.5789C291.29 31.9257 291.006 33.2581 290.451 34.4851C289.896 35.7121 289.082 36.8048 288.066 37.6886C285.917 39.6871 283.189 40.6863 279.882 40.6863C278.894 40.6914 277.908 40.5858 276.944 40.3716C275.748 40.0718 274.578 39.6741 273.447 39.1825C272.844 38.8962 272.193 38.7266 271.528 38.6828C271.041 38.7082 270.574 38.8826 270.189 39.1825C269.675 39.5638 269.28 40.0837 269.05 40.6813H268.146V28.2006H269.05C269.759 31.698 271.132 34.3627 273.167 36.1947C275.202 38.0267 277.399 38.9526 279.757 38.9726C281.342 39.0506 282.895 38.5136 284.094 37.4737C284.597 37.0496 285.003 36.5212 285.282 35.9252C285.561 35.3292 285.708 34.6796 285.712 34.0213C285.709 33.227 285.495 32.4478 285.093 31.763C284.608 30.9527 283.968 30.2463 283.209 29.6845C281.792 28.6929 280.287 27.8333 278.713 27.1164C275.715 25.6442 273.56 24.3885 272.247 23.3492C271.012 22.4007 269.981 21.2108 269.22 19.8518C268.511 18.5549 268.146 17.0979 268.16 15.62C268.144 14.361 268.393 13.1126 268.891 11.9564C269.39 10.8002 270.126 9.76213 271.053 8.90996C272.026 7.98966 273.172 7.27121 274.424 6.79612C275.676 6.32104 277.009 6.09872 278.348 6.14202C279.396 6.14028 280.44 6.26952 281.456 6.52673C282.434 6.80729 283.383 7.1821 284.289 7.6459C284.998 8.04833 285.775 8.31598 286.582 8.43531C286.766 8.44492 286.951 8.41514 287.123 8.34795C287.296 8.28076 287.452 8.1777 287.581 8.0456C287.944 7.48124 288.176 6.84276 288.261 6.17699H288.97Z" fill="black"/>
|
||||||
|
<path d="M328.666 6.93164V7.82598C327.761 7.90412 326.896 8.23285 326.168 8.77527C325.355 9.44144 324.074 11.2967 322.326 14.3411L315.171 26.2773V34.2713C315.171 35.9701 315.261 37.0276 315.441 37.444C315.669 37.9118 316.04 38.295 316.5 38.5381C317.143 38.8916 317.87 39.0643 318.603 39.0378H320.447V39.9371H301.961V38.9728H303.685C304.486 39.0158 305.285 38.8433 305.998 38.4732C306.453 38.2092 306.805 37.7989 306.997 37.3091C307.177 36.9194 307.267 35.8984 307.267 34.2463V27.6412L299.498 13.5167C297.955 10.7288 296.87 9.08837 296.24 8.5954C295.504 8.06943 294.616 7.79916 293.712 7.82598V6.93164H309.51V7.82598H308.806C308.113 7.76807 307.419 7.91211 306.807 8.24067C306.649 8.32984 306.514 8.45588 306.415 8.6081C306.316 8.76032 306.255 8.93421 306.238 9.11502C306.238 9.70125 306.885 11.1685 308.181 13.5167L314.137 24.4286L320.057 14.491C321.526 12.0595 322.261 10.4457 322.261 9.64962C322.257 9.42886 322.197 9.21265 322.087 9.02127C321.977 8.82989 321.82 8.66961 321.631 8.55543C321.077 8.15073 320.062 7.91091 318.593 7.82598V6.93164H328.666Z" fill="black"/>
|
||||||
|
<path d="M352.608 6.17706L352.873 17.1689H351.874C351.52 14.6133 350.3 12.2567 348.416 10.4938C346.571 8.82842 344.572 7.99571 342.421 7.99571C340.992 7.9169 339.587 8.39101 338.499 9.31972C338.049 9.69134 337.687 10.1567 337.437 10.6832C337.186 11.2097 337.054 11.7845 337.05 12.3675C337.034 13.049 337.207 13.7216 337.549 14.311C338.147 15.2055 338.914 15.9744 339.808 16.5743C341.509 17.6243 343.271 18.5736 345.084 19.4172C348.941 21.3158 351.542 23.1061 352.888 24.7882C354.202 26.4281 354.908 28.4724 354.886 30.5739C354.893 31.9212 354.609 33.2541 354.053 34.4813C353.496 35.7084 352.682 36.8008 351.664 37.6836C349.522 39.6822 346.796 40.6814 343.485 40.6814C342.495 40.6867 341.508 40.5811 340.542 40.3666C339.354 40.0649 338.193 39.6672 337.07 39.1775C336.467 38.8913 335.816 38.7217 335.151 38.6779C334.664 38.7032 334.197 38.8776 333.812 39.1775C333.298 39.56 332.901 40.0794 332.668 40.6764H331.769V28.2007H332.668C333.384 31.6981 334.758 34.3628 336.79 36.1947C338.822 38.0267 341.023 38.9527 343.395 38.9727C344.983 39.0533 346.541 38.5161 347.742 37.4738C348.56 36.7963 349.111 35.8492 349.294 34.8026C349.477 33.7559 349.281 32.6782 348.741 31.763C348.255 30.9538 347.615 30.2477 346.857 29.6846C345.44 28.6924 343.935 27.8327 342.361 27.1165C339.363 25.6443 337.209 24.3885 335.9 23.3493C334.665 22.4008 333.634 21.2109 332.873 19.8519C332.164 18.555 331.799 17.098 331.814 15.62C331.797 14.3611 332.046 13.1127 332.544 11.9565C333.043 10.8003 333.779 9.76219 334.706 8.91003C335.679 7.98949 336.824 7.2709 338.077 6.7958C339.329 6.3207 340.662 6.09851 342.001 6.14208C343.049 6.14035 344.093 6.26959 345.109 6.5268C346.069 6.79899 347 7.16203 347.892 7.61099C348.602 8.01369 349.382 8.28134 350.19 8.40041C350.375 8.41132 350.56 8.38215 350.732 8.31488C350.905 8.24762 351.06 8.14385 351.189 8.0107C351.557 7.44809 351.791 6.80896 351.874 6.14208L352.608 6.17706Z" fill="black"/>
|
||||||
|
<path d="M101.65 56.0352L101.82 59.6425H101.385C101.348 59.1737 101.233 58.7143 101.045 58.2835C100.84 57.8725 100.514 57.5343 100.111 57.3142C99.6313 57.0849 99.1029 56.9769 98.5719 56.9994H96.4285V68.7507C96.4285 69.7 96.5284 70.2896 96.7333 70.5244C96.9082 70.6936 97.1173 70.8235 97.3465 70.9053C97.5757 70.9872 97.8197 71.0191 98.0623 70.999H98.5919V71.4187H92.0967V70.999H92.6413C92.903 71.025 93.167 70.9846 93.4089 70.8816C93.6508 70.7785 93.8628 70.6162 94.0253 70.4095C94.2419 69.885 94.3276 69.3157 94.2751 68.7507V56.9994H92.4265C91.9101 56.9763 91.3931 57.0302 90.8926 57.1593C90.4809 57.3244 90.1314 57.6144 89.8934 57.9887C89.5861 58.4872 89.4139 59.0572 89.3937 59.6425H88.9341L89.1139 56.0352H101.65Z" fill="black"/>
|
||||||
|
<path d="M117.318 64.9529C117.237 66.2587 117.637 67.549 118.442 68.5802C118.759 68.987 119.163 69.317 119.625 69.5454C120.087 69.7738 120.595 69.8948 121.11 69.8992C121.747 69.9116 122.371 69.7131 122.884 69.3346C123.478 68.825 123.916 68.1583 124.148 67.4111L124.498 67.6409C124.329 68.7006 123.847 69.6856 123.114 70.4688C122.759 70.8797 122.318 71.2073 121.823 71.4284C121.327 71.6495 120.789 71.7585 120.246 71.7479C119.638 71.751 119.037 71.6222 118.484 71.3703C117.931 71.1184 117.44 70.7494 117.043 70.2889C116.112 69.2009 115.635 67.7969 115.709 66.3668C115.709 64.5932 116.164 63.2042 117.078 62.2099C117.506 61.7261 118.035 61.3414 118.627 61.0825C119.219 60.8237 119.86 60.6969 120.506 60.7111C121.038 60.6936 121.568 60.7867 122.063 60.9845C122.557 61.1823 123.005 61.4805 123.379 61.8602C123.761 62.2734 124.057 62.759 124.25 63.2884C124.442 63.8179 124.526 64.3804 124.498 64.9429L117.318 64.9529ZM117.318 64.2934H122.129C122.124 63.8141 122.043 63.3386 121.89 62.8844C121.715 62.474 121.421 62.1258 121.045 61.8852C120.699 61.6497 120.29 61.5227 119.871 61.5204C119.556 61.5225 119.244 61.5882 118.955 61.7135C118.666 61.8389 118.405 62.0213 118.187 62.2499C117.665 62.8028 117.357 63.5237 117.318 64.2834V64.2934Z" fill="black"/>
|
||||||
|
<path d="M147.58 67.4509C147.389 68.6537 146.812 69.7617 145.937 70.6086C145.207 71.3115 144.236 71.7084 143.224 71.7178C142.63 71.7112 142.046 71.5734 141.512 71.3142C140.978 71.0551 140.508 70.681 140.136 70.2189C139.217 69.0956 138.749 67.671 138.822 66.2218C138.789 65.4923 138.902 64.7636 139.152 64.0776C139.402 63.3916 139.786 62.7619 140.281 62.2248C140.723 61.7376 141.263 61.3495 141.866 61.086C142.469 60.8225 143.12 60.6896 143.778 60.696C144.68 60.6627 145.565 60.9494 146.276 61.5054C146.559 61.6933 146.796 61.9428 146.969 62.2352C147.142 62.5277 147.247 62.8555 147.276 63.1941C147.28 63.323 147.258 63.4515 147.21 63.5711C147.161 63.6908 147.088 63.7989 146.996 63.8886C146.891 63.9817 146.768 64.053 146.635 64.0984C146.502 64.1439 146.361 64.1625 146.221 64.1534C146.033 64.1648 145.844 64.1319 145.671 64.0573C145.497 63.9826 145.343 63.8684 145.222 63.7237C145.061 63.4474 144.973 63.1343 144.967 62.8144C144.946 62.4351 144.782 62.0779 144.508 61.8151C144.194 61.5732 143.804 61.4525 143.408 61.4754C143.06 61.464 142.714 61.534 142.397 61.6797C142.081 61.8254 141.803 62.0428 141.585 62.3148C140.939 63.1538 140.613 64.1952 140.665 65.2526C140.643 66.4126 140.961 67.5537 141.58 68.5351C141.825 68.9714 142.183 69.3338 142.616 69.5843C143.05 69.8348 143.542 69.9642 144.043 69.9591C144.78 69.963 145.493 69.6923 146.041 69.1996C146.581 68.6659 146.996 68.0192 147.256 67.306L147.58 67.4509Z" fill="black"/>
|
||||||
|
<path d="M164.987 55.29V62.8894C165.558 62.1977 166.233 61.5978 166.986 61.1107C167.437 60.8606 167.944 60.7267 168.46 60.721C169.001 60.7158 169.529 60.8916 169.959 61.2206C170.429 61.6155 170.758 62.1518 170.898 62.7495C171.081 63.6213 171.155 64.5125 171.118 65.4025V69.0798C171.089 69.5351 171.142 69.9918 171.273 70.4288C171.35 70.6108 171.485 70.762 171.657 70.8585C171.973 70.9913 172.315 71.0462 172.657 71.0183V71.428H167.575V71.0183H167.81C168.158 71.0505 168.507 70.9737 168.809 70.7985C169.011 70.6323 169.152 70.4038 169.209 70.149C169.24 69.7935 169.252 69.4366 169.244 69.0798V65.4025C169.282 64.6534 169.221 63.9026 169.064 63.1692C168.972 62.8478 168.775 62.5664 168.505 62.3698C168.23 62.1859 167.905 62.0933 167.575 62.105C167.171 62.1114 166.774 62.2123 166.416 62.3998C165.873 62.7119 165.386 63.114 164.977 63.5889V69.0848C164.947 69.5313 164.988 69.9798 165.097 70.4138C165.194 70.6015 165.348 70.7537 165.537 70.8485C165.892 70.9917 166.275 71.0515 166.656 71.0233V71.433H161.515V71.0233C161.89 71.042 162.264 70.9682 162.604 70.8085C162.773 70.7133 162.902 70.5609 162.969 70.3788C163.084 69.9577 163.13 69.5206 163.104 69.0848V59.6668C163.126 58.9362 163.097 58.2051 163.019 57.4784C162.995 57.27 162.904 57.0751 162.759 56.9238C162.626 56.8189 162.459 56.7657 162.289 56.7739C162.024 56.7967 161.763 56.8572 161.515 56.9538L161.355 56.5591L164.468 55.29H164.987Z" fill="black"/>
|
||||||
|
<path d="M189.449 62.92C190.668 61.4544 191.834 60.7217 192.947 60.7217C193.467 60.7073 193.979 60.8557 194.411 61.1463C194.872 61.5013 195.212 61.9899 195.385 62.5453C195.583 63.2229 195.667 63.9286 195.635 64.6338V69.0705C195.607 69.5228 195.661 69.9764 195.795 70.4095C195.878 70.5964 196.02 70.7508 196.199 70.8491C196.516 70.9791 196.858 71.0338 197.199 71.009V71.4187H192.072V71.009H192.287C192.635 71.0402 192.984 70.9634 193.286 70.7892C193.491 70.6247 193.634 70.3957 193.691 70.1397C193.735 69.785 193.75 69.4275 193.736 69.0705V64.8186C193.784 64.1125 193.657 63.4056 193.366 62.7601C193.241 62.5463 193.058 62.3723 192.837 62.2582C192.617 62.1442 192.369 62.0947 192.122 62.1156C191.223 62.1156 190.329 62.6153 189.434 63.6145V69.1104C189.401 69.549 189.443 69.9901 189.559 70.4145C189.649 70.6235 189.803 70.7986 189.999 70.9141C190.361 71.0422 190.745 71.0933 191.128 71.064V71.4737H186.002V71.064H186.232C186.429 71.0888 186.629 71.0654 186.816 70.9958C187.002 70.9262 187.168 70.8124 187.301 70.6643C187.529 70.1852 187.624 69.6537 187.576 69.1254V65.2683C187.599 64.5079 187.572 63.7469 187.496 62.99C187.469 62.7801 187.376 62.5842 187.231 62.4304C187.096 62.3281 186.93 62.2767 186.761 62.2855C186.5 62.2955 186.243 62.3531 186.002 62.4554L185.832 62.0457L188.965 60.7766H189.464L189.449 62.92Z" fill="black"/>
|
||||||
|
<path d="M215.989 60.7212C216.718 60.699 217.441 60.8501 218.099 61.1619C218.757 61.4737 219.332 61.9374 219.777 62.5148C220.574 63.5052 221.003 64.7411 220.991 66.0122C220.978 66.991 220.75 67.955 220.321 68.8352C219.948 69.7034 219.325 70.441 218.531 70.9543C217.738 71.4676 216.809 71.7335 215.864 71.718C215.136 71.7387 214.414 71.5784 213.762 71.2514C213.111 70.9244 212.551 70.441 212.132 69.8444C211.363 68.8264 210.952 67.5828 210.963 66.307C210.971 65.3166 211.214 64.3422 211.673 63.4641C212.051 62.6376 212.659 61.9375 213.424 61.4475C214.19 60.9576 215.08 60.6985 215.989 60.7012V60.7212ZM215.64 61.4606C215.212 61.466 214.794 61.5887 214.431 61.8154C213.969 62.1122 213.618 62.553 213.431 63.0694C213.16 63.8061 213.033 64.588 213.057 65.3727C213.022 66.7323 213.333 68.0785 213.961 69.2848C214.56 70.384 215.355 70.9286 216.339 70.9286C216.692 70.9328 217.04 70.8529 217.355 70.6956C217.671 70.5384 217.944 70.3082 218.153 70.0243C218.627 69.4147 218.867 68.3805 218.867 66.9016C218.867 65.0563 218.469 63.604 217.673 62.5448C217.45 62.2098 217.148 61.9351 216.793 61.7451C216.438 61.5551 216.042 61.4557 215.64 61.4556V61.4606Z" fill="black"/>
|
||||||
|
<path d="M238.967 55.29V69.0698C238.939 69.506 238.986 69.9438 239.107 70.3638C239.194 70.5747 239.349 70.7505 239.547 70.8635C239.896 71.0003 240.272 71.0566 240.646 71.0283V71.438H235.55V71.0283C235.89 71.0556 236.231 71.006 236.549 70.8834C236.735 70.766 236.878 70.5904 236.954 70.3838C237.081 69.9614 237.131 69.5199 237.104 69.0798V59.6318C237.126 58.9097 237.099 58.1869 237.024 57.4684C237.002 57.2636 236.912 57.0719 236.769 56.9238C236.643 56.8199 236.482 56.7664 236.319 56.7739C236.061 56.7884 235.807 56.8494 235.57 56.9538L235.38 56.5591L238.478 55.29H238.967Z" fill="black"/>
|
||||||
|
<path d="M259.807 60.7212C260.535 60.6999 261.258 60.8513 261.916 61.163C262.574 61.4748 263.149 61.9381 263.594 62.5149C264.391 63.5052 264.82 64.7411 264.808 66.0123C264.796 66.9911 264.567 67.955 264.139 68.8352C263.765 69.7034 263.142 70.441 262.349 70.9543C261.555 71.4677 260.627 71.7335 259.682 71.718C258.953 71.7401 258.23 71.5803 257.579 71.2532C256.927 70.9261 256.367 70.442 255.95 69.8444C255.181 68.8265 254.77 67.5828 254.78 66.3071C254.788 65.3166 255.032 64.3422 255.49 63.4642C255.867 62.6369 256.475 61.9361 257.241 61.446C258.007 60.9558 258.898 60.6972 259.807 60.7012V60.7212ZM259.452 61.4607C259.026 61.4662 258.609 61.5889 258.248 61.8154C257.786 62.1123 257.435 62.553 257.249 63.0695C256.975 63.8055 256.848 64.5879 256.874 65.3727C256.839 66.7323 257.15 68.0785 257.778 69.2848C258.378 70.384 259.172 70.9286 260.151 70.9286C260.505 70.9332 260.854 70.8535 261.17 70.6963C261.486 70.539 261.761 70.3086 261.97 70.0243C262.445 69.4147 262.685 68.3805 262.685 66.9016C262.685 65.0563 262.287 63.6041 261.49 62.5448C261.266 62.2088 260.962 61.9334 260.606 61.7434C260.249 61.5533 259.851 61.4545 259.447 61.4557L259.452 61.4607Z" fill="black"/>
|
||||||
|
<path d="M281.985 67.6312C281.381 67.3504 280.872 66.8988 280.521 66.3321C280.187 65.7759 280.014 65.1375 280.022 64.4885C280.019 63.9886 280.12 63.4935 280.321 63.0356C280.522 62.5776 280.816 62.167 281.186 61.8305C281.587 61.4565 282.059 61.1659 282.574 60.9755C283.088 60.7851 283.636 60.6987 284.184 60.7213C285.096 60.6979 285.995 60.9504 286.762 61.4458H288.96C289.151 61.4363 289.341 61.4463 289.53 61.4757C289.578 61.4916 289.617 61.5257 289.64 61.5707C289.695 61.6838 289.719 61.8098 289.71 61.9354C289.718 62.0742 289.699 62.2133 289.655 62.3451C289.623 62.3851 289.582 62.4161 289.535 62.435C289.345 62.467 289.152 62.4788 288.96 62.47H287.611C288.095 63.1637 288.31 64.009 288.218 64.8495C288.127 65.69 287.733 66.4687 287.112 67.0416C286.283 67.7591 285.209 68.128 284.114 68.0708C283.581 68.0683 283.052 67.9926 282.54 67.846C282.272 68.0556 282.044 68.311 281.865 68.6004C281.762 68.7662 281.702 68.9551 281.691 69.15C281.693 69.2221 281.71 69.293 281.742 69.3577C281.774 69.4223 281.819 69.4793 281.875 69.5248C282.089 69.678 282.342 69.7681 282.605 69.7846C282.82 69.8145 283.349 69.8395 284.194 69.8645C285.753 69.9045 286.762 69.9544 287.221 70.0244C287.856 70.0882 288.45 70.3627 288.91 70.8038C289.116 71.0092 289.278 71.2541 289.386 71.5237C289.494 71.7934 289.546 72.0822 289.54 72.3726C289.52 72.8354 289.402 73.2888 289.195 73.7029C288.987 74.1171 288.694 74.4827 288.336 74.7759C287.072 75.9092 285.415 76.5028 283.719 76.4296C282.415 76.4696 281.128 76.1218 280.022 75.4304C279.452 75.0507 279.167 74.6609 279.167 74.2512C279.17 74.0623 279.214 73.8764 279.297 73.7067C279.515 73.288 279.78 72.8957 280.087 72.5375C280.142 72.4676 280.526 72.0379 281.246 71.3134C280.939 71.145 280.658 70.9329 280.412 70.6839C280.255 70.5117 280.167 70.2873 280.167 70.0544C280.19 69.7212 280.302 69.4003 280.492 69.1251C280.916 68.5586 281.419 68.0555 281.985 67.6312ZM281.87 71.4184C281.559 71.7435 281.289 72.106 281.066 72.4976C280.897 72.7755 280.803 73.092 280.791 73.4169C280.807 73.6194 280.876 73.8141 280.992 73.9809C281.108 74.1478 281.266 74.2807 281.451 74.3662C282.459 74.8951 283.592 75.1386 284.728 75.0706C286.087 75.0706 287.097 74.8308 287.726 74.3512C287.998 74.1896 288.227 73.9659 288.396 73.6984C288.564 73.4309 288.667 73.1272 288.695 72.8123C288.701 72.6288 288.648 72.4484 288.544 72.2973C288.439 72.1463 288.289 72.0326 288.116 71.9729C287.358 71.7547 286.571 71.6552 285.783 71.6782C284.475 71.6609 283.169 71.5742 281.87 71.4184ZM283.959 61.2659C283.689 61.2635 283.421 61.3225 283.177 61.4385C282.933 61.5544 282.719 61.7244 282.55 61.9354C282.128 62.5331 281.928 63.2595 281.985 63.9889C281.924 64.9748 282.195 65.9529 282.755 66.7668C282.927 67.0025 283.152 67.1935 283.413 67.3239C283.674 67.4543 283.962 67.5203 284.254 67.5163C284.523 67.5228 284.79 67.4682 285.035 67.3566C285.28 67.2449 285.496 67.0791 285.668 66.8717C286.089 66.2815 286.287 65.5609 286.227 64.8382C286.288 63.8361 286.012 62.8424 285.443 62.0153C285.274 61.7799 285.051 61.5888 284.793 61.4583C284.535 61.3278 284.248 61.2618 283.959 61.2659Z" fill="black"/>
|
||||||
|
<path d="M307.272 60.7212V69.07C307.244 69.5078 307.291 69.9472 307.412 70.369C307.492 70.5778 307.639 70.7537 307.831 70.8686C308.147 70.9993 308.49 71.054 308.831 71.0285V71.4382H303.784V71.0285C304.124 71.0512 304.464 71.0018 304.784 70.8836C304.974 70.7679 305.12 70.5921 305.198 70.384C305.33 69.9626 305.383 69.5205 305.353 69.08V65.0829C305.376 64.3519 305.341 63.6201 305.248 62.8946C305.222 62.7063 305.136 62.5314 305.003 62.3949C304.867 62.2963 304.702 62.2469 304.534 62.255C304.269 62.2654 304.009 62.3229 303.764 62.4249L303.604 62.0152L306.737 60.7462L307.272 60.7212ZM306.327 55.2902C306.479 55.2882 306.628 55.3164 306.768 55.3731C306.908 55.4299 307.036 55.514 307.143 55.6206C307.25 55.7271 307.334 55.854 307.392 55.9938C307.449 56.1335 307.478 56.2833 307.477 56.4344C307.478 56.5856 307.449 56.7355 307.392 56.8754C307.334 57.0152 307.249 57.1422 307.142 57.2488C306.98 57.4095 306.775 57.5189 306.552 57.5631C306.328 57.6074 306.097 57.5845 305.886 57.4974C305.676 57.4104 305.496 57.263 305.369 57.0738C305.242 56.8846 305.174 56.6621 305.173 56.4344C305.172 56.2829 305.201 56.1327 305.259 55.9926C305.316 55.8525 305.401 55.7254 305.509 55.6187C305.617 55.5121 305.744 55.4281 305.885 55.3717C306.026 55.3152 306.176 55.2875 306.327 55.2902Z" fill="black"/>
|
||||||
|
<path d="M324.759 64.9529C324.673 66.2584 325.072 67.5496 325.878 68.5802C326.195 68.9862 326.599 69.3156 327.061 69.5439C327.523 69.7722 328.031 69.8937 328.546 69.8992C329.185 69.9115 329.81 69.7131 330.324 69.3346C330.917 68.8238 331.355 68.1575 331.588 67.4111L331.938 67.6409C331.768 68.6999 331.286 69.6843 330.554 70.4688C330.199 70.8798 329.757 71.2074 329.26 71.4285C328.764 71.6495 328.225 71.7586 327.681 71.7478C327.074 71.7507 326.473 71.6217 325.92 71.3698C325.367 71.1179 324.875 70.7492 324.479 70.2889C323.548 69.2009 323.07 67.7969 323.145 66.3668C323.145 64.5931 323.599 63.2042 324.514 62.2099C324.943 61.7257 325.472 61.3408 326.065 61.0819C326.657 60.8231 327.3 60.6965 327.946 60.711C328.478 60.6937 329.007 60.7868 329.501 60.9846C329.994 61.1825 330.442 61.4806 330.814 61.8602C331.198 62.273 331.495 62.7584 331.688 63.2878C331.881 63.8173 331.966 64.38 331.938 64.9429L324.759 64.9529ZM324.759 64.2934H329.565C329.563 63.8141 329.484 63.3384 329.33 62.8844C329.152 62.4761 328.859 62.1288 328.486 61.8852C328.139 61.651 327.73 61.5242 327.312 61.5204C326.996 61.523 326.683 61.5888 326.393 61.7141C326.103 61.8394 325.841 62.0216 325.623 62.2499C325.1 62.8022 324.794 63.5239 324.759 64.2834V64.2934Z" fill="black"/>
|
||||||
|
<path d="M352.913 60.7215V64.2589H352.538C352.387 63.4142 352.002 62.6286 351.429 61.9906C350.953 61.5828 350.341 61.3689 349.715 61.391C349.25 61.3692 348.793 61.5185 348.431 61.8107C348.283 61.9183 348.162 62.0576 348.075 62.2185C347.989 62.3793 347.94 62.5576 347.931 62.74C347.922 63.1345 348.051 63.5197 348.296 63.8292C348.694 64.2619 349.182 64.6029 349.725 64.8284L351.384 65.6328C352.919 66.3823 353.685 67.3682 353.682 68.5906C353.696 69.0292 353.606 69.4648 353.42 69.8623C353.235 70.2598 352.958 70.6081 352.613 70.8789C351.941 71.4429 351.092 71.7509 350.215 71.7483C349.477 71.7321 348.744 71.6178 348.036 71.4086C347.838 71.3395 347.631 71.3007 347.422 71.2936C347.332 71.2965 347.246 71.3261 347.174 71.3787C347.102 71.4312 347.047 71.5043 347.017 71.5884H346.642V67.8762H347.017C347.149 68.7843 347.573 69.6247 348.226 70.2694C348.765 70.7836 349.48 71.0731 350.225 71.0788C350.694 71.0977 351.153 70.9328 351.504 70.6191C351.662 70.4809 351.788 70.3103 351.874 70.1189C351.96 69.9275 352.004 69.7199 352.003 69.51C352.008 69.2643 351.962 69.0203 351.866 68.7938C351.77 68.5674 351.628 68.3637 351.449 68.1959C350.782 67.6487 350.044 67.1949 349.255 66.8469C348.444 66.4971 347.712 65.9873 347.102 65.3481C346.752 64.8774 346.575 64.3002 346.602 63.7143C346.592 63.3155 346.664 62.9189 346.816 62.5498C346.967 62.1808 347.194 61.8473 347.482 61.5709C347.783 61.2811 348.139 61.0552 348.53 60.9067C348.921 60.7581 349.337 60.6901 349.755 60.7065C350.264 60.7205 350.769 60.808 351.254 60.9663C351.504 61.0529 351.764 61.1099 352.028 61.1362C352.128 61.1433 352.227 61.115 352.308 61.0562C352.412 60.9582 352.494 60.8389 352.548 60.7065L352.913 60.7215Z" fill="black"/>
|
||||||
|
<path d="M359.183 8.9948V2.82439H356.885V2H362.421V2.82439H360.107V8.9948H359.183Z" fill="#231F20"/>
|
||||||
|
<path d="M363.34 8.9948V2H364.729L366.383 6.94133C366.532 7.40598 366.642 7.74573 366.712 7.97556C366.792 7.72075 366.917 7.35102 367.087 6.85639L368.756 2H370V8.9948H369.111V3.13915L367.082 8.9948H366.228L364.229 3.03923V8.9948H363.34Z" fill="#231F20"/>
|
||||||
|
</g>
|
||||||
|
<line x1="418.5" y1="91" x2="418.5" y2="1" stroke="#E7E7E7"/>
|
||||||
|
<path d="M689.289 19.2031C682.98 19.2031 676.812 21.0739 671.567 24.579C666.321 28.084 662.233 33.0658 659.818 38.8945C657.404 44.7232 656.772 51.1369 658.003 57.3245C659.234 63.5122 662.272 69.1959 666.733 73.657C671.194 78.1181 676.878 81.1561 683.065 82.3869C689.253 83.6177 695.667 82.986 701.495 80.5717C707.324 78.1574 712.306 74.0689 715.811 68.8232C719.316 63.5776 721.187 57.4104 721.187 51.1015C721.187 42.6415 717.826 34.528 711.844 28.5459C705.862 22.5638 697.748 19.2031 689.289 19.2031ZM689.289 70.2405C685.503 70.2405 681.803 69.118 678.655 67.015C675.508 64.912 673.055 61.9228 671.606 58.4256C670.158 54.9285 669.779 51.0802 670.517 47.3676C671.256 43.655 673.079 40.2448 675.755 37.5681C678.432 34.8915 681.842 33.0687 685.555 32.3302C689.267 31.5917 693.115 31.9707 696.613 33.4193C700.11 34.8679 703.099 37.321 705.202 40.4684C707.305 43.6158 708.428 47.3161 708.428 51.1015C708.428 56.1774 706.411 61.0455 702.822 64.6348C699.233 68.2241 694.364 70.2405 689.289 70.2405Z" fill="#8F8F8F"/>
|
||||||
|
<path d="M622.492 19.2031C616.183 19.2031 610.016 21.0739 604.77 24.579C599.524 28.084 595.436 33.0658 593.021 38.8945C590.607 44.7232 589.975 51.1369 591.206 57.3245C592.437 63.5122 595.475 69.1959 599.936 73.657C604.397 78.1181 610.081 81.1561 616.269 82.3869C622.456 83.6177 628.87 82.986 634.699 80.5717C640.527 78.1574 645.509 74.0689 649.014 68.8232C652.519 63.5776 654.39 57.4104 654.39 51.1015C654.39 46.9125 653.565 42.7646 651.962 38.8945C650.359 35.0244 648.009 31.508 645.047 28.5459C642.085 25.5839 638.569 23.2343 634.699 21.6312C630.829 20.0282 626.681 19.2031 622.492 19.2031ZM622.492 70.2405C618.706 70.2405 615.006 69.118 611.859 67.015C608.711 64.912 606.258 61.9228 604.809 58.4256C603.361 54.9285 602.982 51.0802 603.72 47.3676C604.459 43.655 606.282 40.2448 608.958 37.5681C611.635 34.8915 615.045 33.0687 618.758 32.3302C622.47 31.5917 626.319 31.9707 629.816 33.4193C633.313 34.8679 636.302 37.321 638.405 40.4684C640.508 43.6158 641.631 47.3161 641.631 51.1015C641.631 56.1774 639.614 61.0455 636.025 64.6348C632.436 68.2241 627.568 70.2405 622.492 70.2405Z" fill="#8F8F8F"/>
|
||||||
|
<path d="M488.898 19.2031C482.589 19.2031 476.422 21.0739 471.177 24.579C465.931 28.084 461.842 33.0658 459.428 38.8945C457.014 44.7232 456.382 51.1369 457.613 57.3245C458.844 63.5122 461.882 69.1959 466.343 73.657C470.804 78.1181 476.488 81.1561 482.675 82.3869C488.863 83.6177 495.277 82.986 501.105 80.5717C506.934 78.1574 511.916 74.0689 515.421 68.8232C518.926 63.5776 520.797 57.4104 520.797 51.1015C520.797 42.6415 517.436 34.528 511.454 28.5459C505.472 22.5638 497.358 19.2031 488.898 19.2031ZM488.898 70.2405C485.113 70.2405 481.413 69.118 478.265 67.015C475.118 64.912 472.665 61.9228 471.216 58.4256C469.768 54.9285 469.389 51.0802 470.127 47.3676C470.866 43.655 472.688 40.2448 475.365 37.5681C478.042 34.8915 481.452 33.0687 485.165 32.3302C488.877 31.5917 492.725 31.9707 496.223 33.4193C499.72 34.8679 502.709 37.321 504.812 40.4684C506.915 43.6158 508.037 47.3161 508.037 51.1015C508.037 56.1774 506.021 61.0455 502.432 64.6348C498.842 68.2241 493.974 70.2405 488.898 70.2405Z" fill="#714B67"/>
|
||||||
|
<path d="M581.214 0C579.522 0 577.899 0.672142 576.703 1.86856C575.506 3.06498 574.834 4.68768 574.834 6.37967V25.5187C569.088 21.2089 562.055 18.9648 554.874 19.1496C547.694 19.3345 540.786 21.9375 535.269 26.5372C529.752 31.137 525.949 37.4642 524.475 44.4944C523.002 51.5247 523.944 58.8465 527.15 65.2743C530.356 71.7022 535.638 76.8599 542.14 79.9123C548.642 82.9648 555.984 83.7333 562.977 82.0934C569.971 80.4536 576.206 76.5014 580.673 70.8767C585.141 65.252 587.579 58.2842 587.594 51.1012V6.37967C587.594 4.68768 586.921 3.06498 585.725 1.86856C584.529 0.672142 582.906 0 581.214 0ZM555.695 70.1764C551.91 70.1764 548.21 69.0539 545.062 66.9509C541.915 64.8479 539.462 61.8588 538.013 58.3616C536.564 54.8644 536.185 51.0161 536.924 47.3035C537.662 43.5909 539.485 40.1807 542.162 37.504C544.839 34.8274 548.249 33.0046 551.961 32.2661C555.674 31.5276 559.522 31.9066 563.019 33.3552C566.517 34.8038 569.506 37.2569 571.609 40.4043C573.712 43.5517 574.834 47.252 574.834 51.0374C574.834 56.1134 572.818 60.9814 569.229 64.5707C565.639 68.16 560.771 70.1764 555.695 70.1764Z" fill="#8F8F8F"/>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_76_492">
|
||||||
|
<rect width="370" height="74.4297" fill="white" transform="translate(0 2)"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 310 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="Group">
|
||||||
|
<path id="Vector" d="M14 25.6666C7.55651 25.6666 2.33334 20.4434 2.33334 13.9999C2.33334 7.55642 7.55651 2.33325 14 2.33325C20.4435 2.33325 25.6667 7.55642 25.6667 13.9999C25.6667 20.4434 20.4435 25.6666 14 25.6666ZM14 23.3333C16.4754 23.3333 18.8493 22.3499 20.5997 20.5996C22.35 18.8492 23.3333 16.4753 23.3333 13.9999C23.3333 11.5246 22.35 9.1506 20.5997 7.40026C18.8493 5.64992 16.4754 4.66659 14 4.66659C11.5247 4.66659 9.15069 5.64992 7.40035 7.40026C5.65001 9.1506 4.66668 11.5246 4.66668 13.9999C4.66668 16.4753 5.65001 18.8492 7.40035 20.5996C9.15069 22.3499 11.5247 23.3333 14 23.3333ZM14 12.3503L17.2993 9.04975L18.9502 10.7006L15.6497 13.9999L18.9502 17.2993L17.2993 18.9501L14 15.6496L10.7007 18.9501L9.04984 17.2993L12.3503 13.9999L9.04984 10.7006L10.7007 9.04975L14 12.3503Z" fill="white"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 929 B |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.66675 6.66667C3.66675 5.74619 4.41294 5 5.33341 5H13.3334C14.2539 5 15.0001 5.74619 15.0001 6.66667V10.6667C15.0001 11.219 15.4478 11.6667 16.0001 11.6667C16.5524 11.6667 17.0001 11.219 17.0001 10.6667V6.66667C17.0001 4.64162 15.3585 3 13.3334 3H5.33341C3.30837 3 1.66675 4.64162 1.66675 6.66667V25.3333C1.66675 27.3584 3.30837 29 5.33341 29H13.3334C15.3585 29 17.0001 27.3584 17.0001 25.3333V21.3333C17.0001 20.781 16.5524 20.3333 16.0001 20.3333C15.4478 20.3333 15.0001 20.781 15.0001 21.3333V25.3333C15.0001 26.2538 14.2539 27 13.3334 27H5.33341C4.41294 27 3.66675 26.2538 3.66675 25.3333V6.66667ZM5.66675 9.33333C5.66675 8.04467 6.71142 7 8.00008 7H10.6667C11.9554 7 13.0001 8.04467 13.0001 9.33333V12V16V18.6667C13.0001 19.9553 11.9554 21 10.6667 21H8.00008C6.71142 21 5.66675 19.9553 5.66675 18.6667V16V12V9.33333ZM11.0001 9.33333V11H7.66675V9.33333C7.66675 9.14924 7.81599 9 8.00008 9H10.6667C10.8508 9 11.0001 9.14924 11.0001 9.33333ZM11.0001 13H7.66675V15H11.0001V13ZM7.66675 17V18.6667C7.66675 18.8508 7.81599 19 8.00008 19H10.6667C10.8508 19 11.0001 18.8508 11.0001 18.6667V17H7.66675ZM25.6667 9.33333C25.6667 8.04467 26.7114 7 28.0001 7H29.3334C29.8857 7 30.3334 7.44772 30.3334 8C30.3334 8.55228 29.8857 9 29.3334 9H28.0001C27.816 9 27.6667 9.14924 27.6667 9.33333V11H29.3334C29.8857 11 30.3334 11.4477 30.3334 12C30.3334 12.5523 29.8857 13 29.3334 13H27.6667V15H29.3334C29.8857 15 30.3334 15.4477 30.3334 16C30.3334 16.5523 29.8857 17 29.3334 17H27.6667V18.6667C27.6667 18.8508 27.816 19 28.0001 19H29.3334C29.8857 19 30.3334 19.4477 30.3334 20C30.3334 20.5523 29.8857 21 29.3334 21H28.0001C26.7114 21 25.6667 19.9553 25.6667 18.6667V16V12V9.33333ZM28.2996 23.2862C28.6902 22.8957 29.3233 22.8957 29.7139 23.2862L29.7205 23.2929C30.111 23.6834 30.111 24.3166 29.7205 24.7071L29.7139 24.7138C29.3233 25.1043 28.6902 25.1043 28.2996 24.7138L28.293 24.7071C27.9025 24.3166 27.9025 23.6834 28.293 23.2929L28.2996 23.2862ZM9.71385 23.2862C9.32333 22.8957 8.69017 22.8957 8.29964 23.2862L8.29297 23.2929C7.90245 23.6834 7.90245 24.3166 8.29297 24.7071L8.29964 24.7138C8.69017 25.1043 9.32333 25.1043 9.71385 24.7138L9.72052 24.7071C10.111 24.3166 10.111 23.6834 9.72052 23.2929L9.71385 23.2862ZM15.0001 16C15.0001 15.4477 15.4478 15 16.0001 15H20.2525L19.293 14.0404C18.9025 13.6499 18.9025 13.0168 19.293 12.6262C19.6835 12.2357 20.3167 12.2357 20.7072 12.6262L23.3739 15.2929C23.7644 15.6834 23.7644 16.3166 23.3739 16.7071L20.7072 19.3738C20.3167 19.7643 19.6835 19.7643 19.293 19.3738C18.9025 18.9832 18.9025 18.3501 19.293 17.9596L20.2525 17H16.0001C15.4478 17 15.0001 16.5523 15.0001 16ZM25.3334 5C24.4129 5 23.6667 5.74619 23.6667 6.66667V9.33333C23.6667 9.88562 23.219 10.3333 22.6667 10.3333C22.1145 10.3333 21.6667 9.88562 21.6667 9.33333V6.66667C21.6667 4.64162 23.3084 3 25.3334 3H29.3334C29.8857 3 30.3334 3.44772 30.3334 4C30.3334 4.55228 29.8857 5 29.3334 5H25.3334ZM23.6667 22.6667C23.6667 22.1144 23.219 21.6667 22.6667 21.6667C22.1145 21.6667 21.6667 22.1144 21.6667 22.6667V25.3333C21.6667 27.3584 23.3084 29 25.3334 29H29.3334C29.8857 29 30.3334 28.5523 30.3334 28C30.3334 27.4477 29.8857 27 29.3334 27H25.3334C24.4129 27 23.6667 26.2538 23.6667 25.3333V22.6667Z" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.com/svgjs" width="512" height="512" x="0" y="0" viewBox="0 0 24 24" style="enable-background:new 0 0 512 512" xml:space="preserve" class=""><g><g id="Layer_2" data-name="Layer 2"><path d="m12 1a11 11 0 1 0 11 11 11.013 11.013 0 0 0 -11-11zm5.707 9.707-5 5a1 1 0 0 1 -1.414 0l-5-5a1 1 0 0 1 1.414-1.414l4.293 4.293 4.293-4.293a1 1 0 0 1 1.414 1.414z" fill="#781d96" data-original="#000000" class=""></path></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 542 B |
|
After Width: | Height: | Size: 576 B |
|
After Width: | Height: | Size: 733 B |
|
After Width: | Height: | Size: 4.3 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_63_485)">
|
||||||
|
<path d="M201.497 512C199.505 512 197.489 511.602 195.563 510.771C192.299 509.363 189.641 506.842 188.063 503.657C186.485 500.473 186.089 496.831 186.946 493.381L237.67 289.204H101.001C98.5637 289.204 96.1635 288.61 94.0076 287.474C91.8517 286.338 90.005 284.694 88.6274 282.684C87.2497 280.674 86.3825 278.358 86.1008 275.938C85.8191 273.517 86.1314 271.065 87.0106 268.792L187.272 9.58896C188.364 6.76572 190.285 4.33889 192.781 2.62746C195.278 0.916019 198.234 7.05455e-05 201.261 0L339.222 0C344.291 0 349.017 2.55999 351.787 6.80597C353.149 8.89601 353.969 11.2929 354.172 13.7797C354.375 16.2665 353.954 18.7646 352.949 21.0479L293.58 155.807H410.999C413.693 155.807 416.336 156.533 418.652 157.907C420.969 159.281 422.872 161.253 424.163 163.617C425.454 165.98 426.085 168.648 425.99 171.34C425.894 174.031 425.076 176.647 423.62 178.913L214.125 505.104C212.767 507.22 210.898 508.96 208.692 510.165C206.485 511.37 204.011 512.001 201.497 512Z" fill="white"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_63_485">
|
||||||
|
<rect width="512" height="512" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_48_635)">
|
||||||
|
<path d="M15.5549 16.005L18.4799 17.685L20.7299 13.785L17.8199 12.105C17.9399 11.58 17.9999 11.04 17.9999 10.485C17.9999 9.93 17.9249 9.39 17.8199 8.865L20.7299 7.185L18.4799 3.285L15.5549 4.965C14.7599 4.23 13.7999 3.69 12.7499 3.36V0H8.24991V3.39C7.19991 3.72 6.23991 4.275 5.44491 4.995L2.51991 3.315L0.284912 7.215L3.19491 8.895C3.07491 9.42 3.01491 9.96 3.01491 10.515C3.01491 11.07 3.08991 11.61 3.19491 12.135L0.284912 13.785L2.53491 17.685L5.45991 16.005C6.25491 16.74 7.21491 17.28 8.26491 17.61V21H12.7649V17.61C13.8149 17.28 14.7749 16.725 15.5699 16.005H15.5549ZM10.4999 7.5C12.1499 7.5 13.4999 8.85 13.4999 10.5C13.4999 12.15 12.1499 13.5 10.4999 13.5C8.84991 13.5 7.49991 12.15 7.49991 10.5C7.49991 8.85 8.84991 7.5 10.4999 7.5ZM32.9999 25.5C32.9999 24.945 32.9249 24.405 32.8199 23.88L35.7299 22.2L33.4799 18.3L30.5549 19.98C29.7599 19.245 28.7999 18.705 27.7499 18.375V14.985H23.2499V18.375C22.1999 18.705 21.2399 19.26 20.4449 19.98L17.5199 18.3L15.2699 22.2L18.1799 23.88C18.0599 24.405 17.9999 24.945 17.9999 25.5C17.9999 26.055 18.0749 26.595 18.1799 27.12L15.2699 28.8L17.5199 32.7L20.4449 31.02C21.2399 31.755 22.1999 32.295 23.2499 32.625V36.015H27.7499V32.625C28.7999 32.295 29.7599 31.74 30.5549 31.02L33.4799 32.7L35.7299 28.8L32.8199 27.12C32.9399 26.595 32.9999 26.055 32.9999 25.5ZM25.4999 28.5C23.8499 28.5 22.4999 27.15 22.4999 25.5C22.4999 23.85 23.8499 22.5 25.4999 22.5C27.1499 22.5 28.4999 23.85 28.4999 25.5C28.4999 27.15 27.1499 28.5 25.4999 28.5Z" fill="white"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_48_635">
|
||||||
|
<rect width="36" height="36" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 738 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_63_514)">
|
||||||
|
<path d="M35.5528 30.1866L35.4888 30.1001C34.1698 28.5729 32.5491 27.4542 30.9818 26.3735C30.342 25.932 29.205 25.2204 28.4006 24.648C30.2674 22.0121 31.2673 18.8605 31.2616 15.6305C31.2616 6.9982 24.2634 0 15.6305 0C6.9975 0 0 6.9982 0 15.6305C0 24.2627 6.9982 31.2616 15.6305 31.2616C18.8564 31.2665 22.0042 30.2687 24.6382 28.4062C25.2113 29.2099 25.9221 30.3448 26.3644 30.9846C27.4479 32.5547 28.568 34.1719 30.0938 35.4895L30.1802 35.5528C30.6668 35.8503 31.2271 36.0052 31.7974 36C32.6595 35.9973 33.49 35.6752 34.1283 35.0958L35.0719 34.1543L35.0944 34.1311C36.0991 33.0314 36.2918 31.3727 35.5528 30.1866ZM15.6305 27.1835C12.9572 27.1838 10.3666 26.2572 8.29999 24.5615C6.23339 22.8658 4.81869 20.5059 4.29696 17.8841C3.77523 15.2623 4.17875 12.5406 5.43876 10.183C6.69877 7.82531 8.7373 5.97748 11.207 4.95436C13.6767 3.93123 16.4248 3.79611 18.9829 4.57203C21.5411 5.34794 23.7511 6.98687 25.2363 9.20956C26.7215 11.4323 27.3901 14.1012 27.128 16.7615C26.866 19.4219 25.6897 21.9091 23.7994 23.7994C22.7292 24.8756 21.4561 25.7288 20.0539 26.3097C18.6517 26.8906 17.1482 27.1876 15.6305 27.1835Z" fill="white"/>
|
||||||
|
<path d="M15.6304 17.0723C18.1642 17.0723 20.2183 15.0182 20.2183 12.4844C20.2183 9.95055 18.1642 7.89648 15.6304 7.89648C13.0965 7.89648 11.0425 9.95055 11.0425 12.4844C11.0425 15.0182 13.0965 17.0723 15.6304 17.0723Z" fill="white"/>
|
||||||
|
<path d="M23.1884 20.8558C23.1196 20.2123 22.8316 19.6121 22.3728 19.1557C21.9122 18.694 21.3081 18.4029 20.66 18.3302C19.7115 18.2296 17.3553 18.1748 15.8879 18.1748C14.4205 18.1748 11.5327 18.2296 10.5835 18.3302C9.93556 18.4029 9.33159 18.6938 8.87069 19.155C8.4121 19.6114 8.12438 20.2117 8.05577 20.8551V20.8748C8.03327 21.0928 8.01991 21.2819 8.01147 21.4436C8.27125 21.7834 8.55311 22.1056 8.85522 22.4083C10.6527 24.2055 13.0905 25.2152 15.6323 25.2152C18.1741 25.2152 20.6119 24.2055 22.4094 22.4083C22.7068 22.1104 22.9846 21.7935 23.2412 21.4598C23.2306 21.2946 23.2151 21.1005 23.1919 20.8755L23.1884 20.8558Z" fill="white"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_63_514">
|
||||||
|
<rect width="36" height="36" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 911 B |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,13 @@
|
|||||||
|
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_32_1689)">
|
||||||
|
<path d="M10.4999 17.9999C10.5002 16.5303 10.9349 15.0936 11.7494 13.8704L4.26741 6.38843C1.51255 9.63015 0 13.7458 0 17.9999C0 22.2541 1.51255 26.3697 4.26741 29.6114L11.7494 22.1294C10.9349 20.9062 10.5002 19.4695 10.4999 17.9999Z" fill="white"/>
|
||||||
|
<path d="M31.7325 6.38843L24.2505 13.8704C25.0652 15.0935 25.4999 16.5303 25.4999 17.9999C25.4999 19.4695 25.0652 20.9063 24.2505 22.1294L31.7325 29.6114C34.4873 26.3697 35.9999 22.2541 35.9999 17.9999C35.9999 13.7458 34.4873 9.63015 31.7325 6.38843Z" fill="white"/>
|
||||||
|
<path d="M17.9999 25.5C16.5303 25.4997 15.0936 25.065 13.8704 24.2505L6.38843 31.7325C9.63015 34.4873 13.7458 35.9999 17.9999 35.9999C22.2541 35.9999 26.3697 34.4873 29.6114 31.7325L22.1294 24.2505C20.9062 25.065 19.4695 25.4997 17.9999 25.5Z" fill="white"/>
|
||||||
|
<path d="M17.9999 10.4999C19.4695 10.5002 20.9062 10.9349 22.1294 11.7494L29.6114 4.26741C26.3697 1.51255 22.2541 0 17.9999 0C13.7458 0 9.63015 1.51255 6.38843 4.26741L13.8704 11.7494C15.0936 10.9349 16.5303 10.5002 17.9999 10.4999Z" fill="white"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_32_1689">
|
||||||
|
<rect width="36" height="36" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="20" height="18" viewBox="0 0 20 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M1 0.75H19C19.2652 0.75 19.5196 0.846577 19.7071 1.01849C19.8946 1.19039 20 1.42355 20 1.66667V16.3333C20 16.5764 19.8946 16.8096 19.7071 16.9815C19.5196 17.1534 19.2652 17.25 19 17.25H1C0.734784 17.25 0.48043 17.1534 0.292893 16.9815C0.105357 16.8096 0 16.5764 0 16.3333V1.66667C0 1.42355 0.105357 1.19039 0.292893 1.01849C0.48043 0.846577 0.734784 0.75 1 0.75ZM18 4.63483L10.072 11.1432L2 4.61467V15.4167H18V4.63483ZM2.511 2.58333L10.061 8.69017L17.502 2.58333H2.511Z" fill="#7F54B3"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 600 B |
|
After Width: | Height: | Size: 673 B |
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_36_1829)">
|
||||||
|
<path d="M20 17H22V19H2V17H4V10C4 7.87827 4.84285 5.84344 6.34315 4.34315C7.84344 2.84285 9.87827 2 12 2C14.1217 2 16.1566 2.84285 17.6569 4.34315C19.1571 5.84344 20 7.87827 20 10V17ZM9 21H15V23H9V21Z" fill="white"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_36_1829">
|
||||||
|
<rect width="24" height="24" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 462 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M30.3242 28.2889C29.5991 26.131 26.7094 24.7803 24.6491 23.8749C23.842 23.5214 21.6075 22.9216 21.3389 21.9053C21.2428 21.5392 21.2558 21.194 21.3346 20.8631C21.2104 20.8869 21.0844 20.9009 20.9541 20.9009H19.6117C18.5399 20.9009 17.6691 20.029 17.6691 18.958C17.6691 17.8877 18.5403 17.0176 19.6117 17.0176H20.9541C21.3976 17.0176 21.8181 17.1677 22.1572 17.4356C22.6544 17.3693 23.1339 17.2631 23.581 17.1238C24.1682 15.8937 24.6261 14.4227 24.7287 13.1566C25.1668 7.73933 21.8458 4.56989 17.0841 5.11781C13.622 5.51633 11.5538 8.09789 11.3302 11.4214C11.1041 14.809 12.3602 17.311 13.6943 19.1463C14.2786 19.9487 14.8924 20.4646 14.7981 21.4316C14.6887 22.5749 13.4661 22.8935 12.5917 23.2449C11.5556 23.6611 10.4396 24.2925 9.91254 24.5845C8.09706 25.5871 6.10446 26.7945 5.65662 28.4462C4.66482 32.1067 8.01426 33.2155 10.7794 33.7274C13.1525 34.1651 15.8284 34.1997 18.0295 34.1997C22.0107 34.1997 29.1697 34.0402 30.3242 31.0475C30.6525 30.1983 30.5117 28.8451 30.3242 28.2889Z" fill="white"/>
|
||||||
|
<path d="M21.8037 18.4113C21.6215 18.1337 21.3101 17.9494 20.9548 17.9494H19.6124C19.0522 17.9494 18.6004 18.4019 18.6004 18.9592C18.6004 19.5186 19.0522 19.9719 19.6124 19.9719H20.9548C21.3468 19.9719 21.6795 19.7483 21.8472 19.425C23.7189 19.2778 25.3468 18.7061 26.4894 17.8619C26.7519 18.0311 27.0622 18.1301 27.397 18.1301H27.4812C28.4144 18.1301 29.1689 17.3748 29.1689 16.4406V13.0682C29.1689 12.3964 28.7751 11.8168 28.207 11.5475C27.9593 6.13204 23.4762 1.7998 17.9999 1.7998C12.5236 1.7998 8.03981 6.13204 7.79321 11.5475C7.22405 11.8172 6.83057 12.3964 6.83057 13.0682V16.4406C6.83057 17.3748 7.58585 18.1301 8.51681 18.1301H8.60213C9.53381 18.1301 10.2894 17.3748 10.2894 16.4406V13.0682C10.2894 12.4061 9.90677 11.8352 9.35057 11.559C9.59141 6.99604 13.3779 3.35896 17.9999 3.35896C22.6202 3.35896 26.4084 6.99604 26.6482 11.559C26.0927 11.8355 25.7104 12.4061 25.7104 13.0682V16.4406C25.7104 16.6646 25.754 16.873 25.8306 17.0678C24.8471 17.7716 23.4154 18.273 21.8037 18.4113Z" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="36" height="35" viewBox="0 0 36 35" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.5 4.40625C5.84315 4.40625 4.5 5.67087 4.5 7.23081V27.0027C4.5 28.5627 5.84315 29.8273 7.5 29.8273H28.5C30.1569 29.8273 31.5 28.5627 31.5 27.0027V7.23081C31.5 5.67087 30.1569 4.40625 28.5 4.40625H7.5ZM12 18.5291C12 17.749 11.3284 17.1168 10.5 17.1168C9.67157 17.1168 9 17.749 9 18.5291V24.1782C9 24.9582 9.67157 25.5905 10.5 25.5905C11.3284 25.5905 12 24.9582 12 24.1782V18.5291ZM18 12.8799C18.8284 12.8799 19.5 13.5122 19.5 14.2922V24.1782C19.5 24.9582 18.8284 25.5905 18 25.5905C17.1716 25.5905 16.5 24.9582 16.5 24.1782V14.2922C16.5 13.5122 17.1716 12.8799 18 12.8799ZM27 10.0554C27 9.27536 26.3284 8.64309 25.5 8.64309C24.6716 8.64309 24 9.27536 24 10.0554V24.1782C24 24.9582 24.6716 25.5905 25.5 25.5905C26.3284 25.5905 27 24.9582 27 24.1782V10.0554Z" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 926 B |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 878 B |
@@ -0,0 +1,10 @@
|
|||||||
|
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="puzzle-piece 1" clip-path="url(#clip0_226_336)">
|
||||||
|
<path id="Vector" d="M30 15.75C28.9888 15.7847 27.9837 15.9214 27 16.158V13.5C27 12.3065 26.5259 11.1619 25.682 10.318C24.8381 9.47411 23.6935 9 22.5 9H19.092C19.3296 8.01646 19.4663 7.01126 19.5 6C19.5 4.4087 18.8679 2.88258 17.7426 1.75736C16.6174 0.632141 15.0913 0 13.5 0C11.9087 0 10.3826 0.632141 9.25736 1.75736C8.13214 2.88258 7.5 4.4087 7.5 6C7.53374 7.01126 7.67045 8.01646 7.908 9H4.5C3.30653 9 2.16193 9.47411 1.31802 10.318C0.474106 11.1619 0 12.3065 0 13.5L0 36H9.804L9.45 34.5C9.19694 33.5182 9.04614 32.5128 9 31.5C9 30.3065 9.47411 29.1619 10.318 28.318C11.1619 27.4741 12.3065 27 13.5 27C14.6935 27 15.8381 27.4741 16.682 28.318C17.5259 29.1619 18 30.3065 18 31.5C17.9539 32.5128 17.8031 33.5182 17.55 34.5L17.1945 36H27V27.342C27.9837 27.5786 28.9888 27.7153 30 27.75C31.5913 27.75 33.1174 27.1179 34.2426 25.9926C35.3679 24.8674 36 23.3413 36 21.75C36 20.1587 35.3679 18.6326 34.2426 17.5074C33.1174 16.3821 31.5913 15.75 30 15.75Z" fill="white"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_226_336">
|
||||||
|
<rect width="36" height="36" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 653 B |