Skip to content

Commit 81e7910

Browse files
author
John75SunCity
committed
feat: Add bin size → product → billing integration
SHREDDING SERVICE BIN: - Added product_id field (Many2one to product.product) - Added _compute_product_from_size to auto-select product by bin size - Updated _compute_price_per_service to use negotiated rates properly CUSTOMER NEGOTIATED RATE: - Added 'shredding' to rate_type selection - Added bin_size field (Selection matching shredding.service.bin) - Added bin_product_id to specify product for billing - Updated views with full form showing shredding configuration WORK ORDER SHREDDING: - Updated invoice creation to: - Look up customer's negotiated rate by bin size - Use negotiated price or fall back to base rate - Use bin's product_id for invoice line (or rate's bin_product_id) - Include bin size in invoice line description WORKFLOW: 1. Create products in Sales > Products (e.g., '32 Gallon Bin Service') 2. Create negotiated rates per customer with bin_size + per_service_rate 3. When work order scanned bins are invoiced, system uses correct product/price
1 parent 15e72f7 commit 81e7910

File tree

4 files changed

+202
-18
lines changed

4 files changed

+202
-18
lines changed

records_management/models/customer_negotiated_rate.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,24 @@ class CustomerNegotiatedRate(models.Model):
6464
rate_type = fields.Selection([
6565
('storage', 'Storage'),
6666
('service', 'Service'),
67+
('shredding', 'Shredding Bin Service'),
6768
('volume_discount', 'Volume Discount')
6869
], string='Rate Type', required=True, default='storage', tracking=True)
6970
container_type_id = fields.Many2one(comodel_name='records.container.type', string='Container Type', help="Apply this rate to a specific container type.")
7071
service_type_id = fields.Many2one(comodel_name='records.service.type', string='Service Type', help="Apply this rate to a specific service type.")
72+
bin_size = fields.Selection([
73+
('23', '23 Gallon Shredinator'),
74+
('32g', '32 Gallon Bin'),
75+
('32c', '32 Gallon Console'),
76+
('64', '64 Gallon Bin'),
77+
('96', '96 Gallon Bin'),
78+
], string="Shredding Bin Size", help="For shredding rates, specify the bin size this rate applies to.")
79+
bin_product_id = fields.Many2one(
80+
comodel_name='product.product',
81+
string="Shredding Product",
82+
domain="[('is_records_management_product', '=', True)]",
83+
help="Product to use when invoicing this bin size for this customer"
84+
)
7185
effective_date = fields.Date(string='Effective Date', required=True, default=fields.Date.context_today, tracking=True)
7286
expiration_date = fields.Date(string='Expiration Date', tracking=True)
7387
is_current = fields.Boolean(string='Is Currently Active', compute='_compute_is_current', store=True)

records_management/models/shredding_service_bin.py

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,16 @@ class ShreddingServiceBin(models.Model):
214214
# ============================================================================
215215
# BILLING INTEGRATION
216216
# ============================================================================
217+
product_id = fields.Many2one(
218+
comodel_name='product.product',
219+
string="Service Product",
220+
domain="[('is_records_management_product', '=', True)]",
221+
compute='_compute_product_from_size',
222+
store=True,
223+
readonly=False,
224+
help="Product used for invoicing. Auto-set from bin size but can be overridden."
225+
)
226+
217227
base_rate_per_service = fields.Monetary(
218228
string="Base Rate per Service",
219229
compute='_compute_billing_rates',
@@ -494,18 +504,56 @@ def _compute_billing_rates(self):
494504
}
495505
record.base_rate_per_service = fallback_rates.get(record.bin_size, 35.00)
496506

507+
@api.depends('bin_size')
508+
def _compute_product_from_size(self):
509+
"""Auto-select product based on bin size for invoicing."""
510+
# Build mapping of bin size to product (search once per bin size)
511+
size_products = {}
512+
for record in self:
513+
if not record.bin_size:
514+
record.product_id = False
515+
continue
516+
517+
if record.bin_size not in size_products:
518+
# Search for product matching bin size
519+
# Convention: Product name contains bin size like "32 Gallon" or internal ref
520+
size_name_map = {
521+
'23': '23 Gallon',
522+
'32g': '32 Gallon Bin',
523+
'32c': '32 Gallon Console',
524+
'64': '64 Gallon',
525+
'96': '96 Gallon',
526+
}
527+
size_name = size_name_map.get(record.bin_size, '')
528+
product = self.env['product.product'].search([
529+
('is_records_management_product', '=', True),
530+
'|',
531+
('name', 'ilike', size_name),
532+
('default_code', '=', record.bin_size),
533+
], limit=1)
534+
size_products[record.bin_size] = product
535+
536+
record.product_id = size_products.get(record.bin_size, False)
537+
497538
@api.depends('bin_size', 'current_customer_id')
498539
def _compute_price_per_service(self):
499-
for bin in self:
500-
if bin.bin_size and bin.current_customer_id:
540+
"""Compute price from customer negotiated rate or base rate."""
541+
for bin_rec in self:
542+
if bin_rec.bin_size and bin_rec.current_customer_id:
501543
# Lookup negotiated rate for customer/bin size
502544
rate = self.env['customer.negotiated.rate'].search([
503-
('partner_id', '=', bin.current_customer_id.id),
504-
('bin_size', '=', bin.bin_size)
545+
('partner_id', '=', bin_rec.current_customer_id.id),
546+
('rate_type', '=', 'shredding'),
547+
('bin_size', '=', bin_rec.bin_size),
548+
('is_current', '=', True),
505549
], limit=1)
506-
bin.price_per_service = rate.price if rate else 0.0
550+
if rate and rate.per_service_rate:
551+
bin_rec.price_per_service = rate.per_service_rate
552+
else:
553+
# Fall back to base rate
554+
bin_rec.price_per_service = bin_rec.base_rate_per_service
507555
else:
508-
bin.price_per_service = 0.0
556+
bin_rec.price_per_service = bin_rec.base_rate_per_service or 0.0
509557

510558
@api.depends('service_event_ids')
511559
def _compute_service_statistics(self):

records_management/models/work_order_shredding.py

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1012,25 +1012,50 @@ def action_create_invoice(self):
10121012

10131013
invoice = self.env['account.move'].create(invoice_vals)
10141014

1015-
# Get the default shredding service product
1016-
shredding_product = self.env.ref(
1015+
# Get the default shredding service product (fallback)
1016+
default_shredding_product = self.env.ref(
10171017
'records_management.product_shredding_service',
10181018
raise_if_not_found=False
10191019
)
10201020

10211021
# Create invoice lines for each billable event
10221022
for event in self.service_event_ids.filtered(lambda e: e.is_billable):
1023+
bin_rec = event.bin_id
1024+
1025+
# Get price from negotiated rate or bin's base rate
1026+
if bin_rec and bin_rec.current_customer_id:
1027+
# Check for customer negotiated rate
1028+
negotiated_rate = self.env['customer.negotiated.rate'].search([
1029+
('partner_id', '=', self.partner_id.id),
1030+
('rate_type', '=', 'shredding'),
1031+
('bin_size', '=', bin_rec.bin_size),
1032+
('is_current', '=', True),
1033+
], limit=1)
1034+
1035+
if negotiated_rate:
1036+
price = negotiated_rate.per_service_rate or event.billable_amount
1037+
product = negotiated_rate.bin_product_id or bin_rec.product_id or default_shredding_product
1038+
else:
1039+
price = event.billable_amount
1040+
product = bin_rec.product_id or default_shredding_product
1041+
else:
1042+
price = event.billable_amount
1043+
product = bin_rec.product_id if bin_rec else default_shredding_product
1044+
1045+
# Build line description
1046+
size_label = dict(bin_rec._fields['bin_size'].selection).get(bin_rec.bin_size, '') if bin_rec else ''
10231047
line_vals = {
10241048
'move_id': invoice.id,
1025-
'name': _("%s - Bin %s") % (
1049+
'name': _("%s - %s Bin %s") % (
10261050
dict(event._fields['service_type'].selection).get(event.service_type),
1027-
event.bin_id.barcode
1051+
size_label,
1052+
bin_rec.barcode if bin_rec else 'Unknown'
10281053
),
10291054
'quantity': 1,
1030-
'price_unit': event.billable_amount,
1055+
'price_unit': price,
10311056
}
1032-
if shredding_product:
1033-
line_vals['product_id'] = shredding_product.id
1057+
if product:
1058+
line_vals['product_id'] = product.id
10341059

10351060
self.env['account.move.line'].create(line_vals)
10361061

records_management/views/customer_negotiated_rate_views.xml

Lines changed: 102 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,34 +5,131 @@
55
<field name="name">customer.negotiated.rate.view.list</field>
66
<field name="model">customer.negotiated.rate</field>
77
<field name="arch" type="xml">
8-
<list>
9-
<field name="name" />
8+
<list decoration-success="is_current == True" decoration-muted="state == 'expired'">
9+
<field name="name"/>
10+
<field name="partner_id"/>
11+
<field name="rate_type" widget="badge"/>
12+
<field name="bin_size" optional="show"/>
13+
<field name="container_type_id" optional="show"/>
14+
<field name="per_service_rate" widget="monetary" optional="show"/>
15+
<field name="monthly_rate" widget="monetary" optional="show"/>
16+
<field name="effective_date"/>
17+
<field name="expiration_date" optional="show"/>
18+
<field name="is_current" widget="boolean"/>
19+
<field name="state" widget="badge"/>
1020
</list>
1121
</field>
1222
</record>
23+
1324
<record id="customer_negotiated_rate_view_form" model="ir.ui.view">
1425
<field name="name">customer.negotiated.rate.view.form</field>
1526
<field name="model">customer.negotiated.rate</field>
1627
<field name="arch" type="xml">
1728
<form string="Customer Negotiated Rate">
29+
<header>
30+
<field name="state" widget="statusbar"
31+
statusbar_visible="draft,submitted,approved,active"/>
32+
</header>
1833
<sheet>
34+
<div class="oe_button_box" name="button_box">
35+
<button name="toggle_active" type="object" class="oe_stat_button" icon="fa-archive">
36+
<field name="active" widget="boolean_button" options='{"terminology": "archive"}'/>
37+
</button>
38+
</div>
39+
1940
<div class="oe_title">
20-
<label for="name" />
41+
<label for="name"/>
2142
<h1>
22-
<field name="name" placeholder="Name..." />
43+
<field name="name" placeholder="Rate Name..."/>
2344
</h1>
2445
</div>
46+
47+
<group>
48+
<group string="Customer">
49+
<field name="partner_id"/>
50+
<field name="billing_profile_id"
51+
domain="[('partner_id', '=', partner_id)]"/>
52+
<field name="contract_reference"/>
53+
</group>
54+
<group string="Rate Configuration">
55+
<field name="rate_type"/>
56+
<field name="container_type_id"
57+
invisible="rate_type != 'storage'"/>
58+
<field name="service_type_id"
59+
invisible="rate_type != 'service'"/>
60+
<!-- Shredding-specific fields -->
61+
<field name="bin_size"
62+
invisible="rate_type != 'shredding'"/>
63+
<field name="bin_product_id"
64+
invisible="rate_type != 'shredding'"
65+
domain="[('is_records_management_product', '=', True)]"/>
66+
</group>
67+
</group>
68+
69+
<group>
70+
<group string="Pricing">
71+
<field name="monthly_rate" invisible="rate_type == 'shredding'"/>
72+
<field name="per_service_rate"/>
73+
<field name="per_hour_rate" optional="hide"/>
74+
<field name="per_document_rate" optional="hide"/>
75+
<field name="setup_fee" optional="hide"/>
76+
<field name="discount_percentage" optional="hide"/>
77+
</group>
78+
<group string="Validity">
79+
<field name="effective_date"/>
80+
<field name="expiration_date"/>
81+
<field name="is_current" readonly="1"/>
82+
<field name="auto_apply"/>
83+
<field name="priority"/>
84+
</group>
85+
</group>
86+
87+
<group string="Volume Limits" invisible="rate_type != 'volume_discount'">
88+
<field name="minimum_volume"/>
89+
<field name="maximum_volume"/>
90+
</group>
91+
92+
<group string="Analytics" invisible="rate_type not in ['storage', 'shredding']">
93+
<group>
94+
<field name="base_rate_comparison" readonly="1"/>
95+
<field name="savings_amount" readonly="1"/>
96+
<field name="savings_percentage" readonly="1"/>
97+
</group>
98+
<group>
99+
<field name="containers_using_rate" readonly="1"
100+
invisible="rate_type != 'storage'"/>
101+
<field name="monthly_revenue_impact" readonly="1"/>
102+
</group>
103+
</group>
25104
</sheet>
105+
<div class="oe_chatter">
106+
<field name="message_follower_ids"/>
107+
<field name="message_ids"/>
108+
</div>
26109
</form>
27110
</field>
28111
</record>
112+
29113
<record id="customer_negotiated_rate_view_search" model="ir.ui.view">
30114
<field name="name">customer.negotiated.rate.view.search</field>
31115
<field name="model">customer.negotiated.rate</field>
32116
<field name="arch" type="xml">
33117
<search>
118+
<field name="name"/>
119+
<field name="partner_id"/>
120+
<field name="rate_type"/>
121+
<field name="bin_size"/>
122+
<separator/>
123+
<filter string="Active Rates" name="active_rates" domain="[('is_current', '=', True)]"/>
124+
<filter string="Storage" name="storage" domain="[('rate_type', '=', 'storage')]"/>
125+
<filter string="Shredding" name="shredding" domain="[('rate_type', '=', 'shredding')]"/>
126+
<filter string="Service" name="service" domain="[('rate_type', '=', 'service')]"/>
127+
<separator/>
34128
<group expand="1" string="Group By">
35-
<filter string="Name" name="name" domain="[]" context="{'group_by':'name'}" />
129+
<filter string="Customer" name="group_customer" context="{'group_by':'partner_id'}"/>
130+
<filter string="Rate Type" name="group_type" context="{'group_by':'rate_type'}"/>
131+
<filter string="Bin Size" name="group_bin_size" context="{'group_by':'bin_size'}"/>
132+
<filter string="Status" name="group_state" context="{'group_by':'state'}"/>
36133
</group>
37134
</search>
38135
</field>

0 commit comments

Comments
 (0)