feat: Add sequence suffix to sign items, improve popover handling, and refine backend saving for new items.
This commit is contained in:
parent
7092737ec4
commit
1074212728
27
README.md
Normal file
27
README.md
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
# Sign Sequence Field
|
||||||
|
|
||||||
|
Add a Sequence field type to Odoo Sign templates to automatically generate numbers upon document completion.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **New Field Type**: Adds a "Sequence" field to the Sign template editor toolbar.
|
||||||
|
- **Auto-Generation**: Automatically generates the next sequence number from a linked `ir.sequence` when all signers have completed the document.
|
||||||
|
- **Live Editing**: Edit Sequence Prefix, Suffix, Padding, and Next Number directly from the template editor sidebar.
|
||||||
|
- **Dynamic Positioning**: Place the sequence number anywhere on your document just like any other sign field.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
1. **Edit a Template**: Go to Sign -> Templates and open a template for editing.
|
||||||
|
2. **Drag & Drop**: Drag the "Sequence" field from the left toolbar onto your document.
|
||||||
|
3. **Configure**: Click on the sequence field to open its configuration popover:
|
||||||
|
- Select an existing **Sequence** record.
|
||||||
|
- (Optional) Modify the **Prefix**, **Suffix**, **Padding**, or **Next Number**.
|
||||||
|
4. **Save**: Save the template.
|
||||||
|
5. **Sign**: When a document created from this template is fully signed, the sequence number will be generated and stamped onto the final PDF.
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
|
||||||
|
- **Module Name**: `sign_sequence_field`
|
||||||
|
- **Dependency**: `sign` (Enterprise)
|
||||||
|
- **Odoo Version**: 19.0
|
||||||
|
- **Authors**: Suherdy Yacob
|
||||||
@ -1,5 +1,6 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
import io
|
import io
|
||||||
|
import logging
|
||||||
from odoo import models
|
from odoo import models
|
||||||
from odoo.exceptions import ValidationError
|
from odoo.exceptions import ValidationError
|
||||||
from odoo.tools.pdf import PdfFileReader, PdfFileWriter, PdfReadError, reshape_text
|
from odoo.tools.pdf import PdfFileReader, PdfFileWriter, PdfReadError, reshape_text
|
||||||
@ -10,6 +11,8 @@ try:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
stringWidth = None
|
stringWidth = None
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class SignDocument(models.Model):
|
class SignDocument(models.Model):
|
||||||
_inherit = 'sign.document'
|
_inherit = 'sign.document'
|
||||||
|
|
||||||
@ -26,7 +29,9 @@ class SignDocument(models.Model):
|
|||||||
for item in page_items
|
for item in page_items
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_logger.info("SignSequenceField: render_document_with_items started")
|
||||||
if not has_sequence_items:
|
if not has_sequence_items:
|
||||||
|
_logger.info("SignSequenceField: No sequence items found in document, skipping custom render")
|
||||||
return base_output
|
return base_output
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -91,7 +96,9 @@ class SignDocument(models.Model):
|
|||||||
can.showPage()
|
can.showPage()
|
||||||
|
|
||||||
can.save()
|
can.save()
|
||||||
|
_logger.info("SignSequenceField: Custom canvas saved to packet")
|
||||||
|
|
||||||
|
packet.seek(0)
|
||||||
item_pdf = PdfFileReader(packet)
|
item_pdf = PdfFileReader(packet)
|
||||||
new_pdf = PdfFileWriter()
|
new_pdf = PdfFileWriter()
|
||||||
|
|
||||||
@ -105,6 +112,9 @@ class SignDocument(models.Model):
|
|||||||
try:
|
try:
|
||||||
new_pdf.write(output)
|
new_pdf.write(output)
|
||||||
except PdfReadError:
|
except PdfReadError:
|
||||||
|
_logger.error("SignSequenceField: PdfReadError during final PDF write")
|
||||||
raise ValidationError(self.env._("There was an issue generating the document."))
|
raise ValidationError(self.env._("There was an issue generating the document."))
|
||||||
|
|
||||||
|
_logger.info("SignSequenceField: Custom rendering completed successfully")
|
||||||
|
output.seek(0)
|
||||||
return output
|
return output
|
||||||
|
|||||||
@ -43,18 +43,26 @@ class SignRequest(models.Model):
|
|||||||
# We only generate if we are about to complete the signing process.
|
# We only generate if we are about to complete the signing process.
|
||||||
# The logic in _post_fill_request_item calls _sign only when all items are completed.
|
# The logic in _post_fill_request_item calls _sign only when all items are completed.
|
||||||
|
|
||||||
|
_logger.info("SignSequenceField: Starting sequence generation for sign.request %s", self.ids)
|
||||||
for request in self:
|
for request in self:
|
||||||
# Generate sequence numbers for sequence items
|
# Generate sequence numbers for sequence items
|
||||||
for sign_item in request.template_id.sign_item_ids:
|
# Use template_id.document_ids.sign_item_ids to be safe
|
||||||
|
signer_items = request.template_id.document_ids.sign_item_ids
|
||||||
|
_logger.info("SignSequenceField: Found %s total sign items on template documents for request %s", len(signer_items), request.id)
|
||||||
|
for sign_item in signer_items:
|
||||||
|
_logger.debug("SignSequenceField: Checking item %s: type=%s, sequence_id=%s", sign_item.id, sign_item.type_id.item_type, sign_item.sequence_id.id if sign_item.sequence_id else 'None')
|
||||||
if sign_item.type_id.item_type == 'sequence' and sign_item.sequence_id:
|
if sign_item.type_id.item_type == 'sequence' and sign_item.sequence_id:
|
||||||
|
_logger.info("SignSequenceField: Processing sequence item %s (ID: %s) for request %s", sign_item.name, sign_item.id, request.id)
|
||||||
# Find the responsible request item (signer)
|
# Find the responsible request item (signer)
|
||||||
# Use role_id to match.
|
# Use role_id to match.
|
||||||
# Fallback to the first signer if no specific matching signer is found (e.g. role is "Anyone")
|
# Fallback to the first signer if no specific matching signer is found (e.g. role is "Anyone")
|
||||||
request_items = request.request_item_ids.filtered(lambda r: r.role_id == sign_item.responsible_id)
|
request_items = request.request_item_ids.filtered(lambda r: r.role_id == sign_item.responsible_id)
|
||||||
if not request_items:
|
if not request_items:
|
||||||
|
_logger.info("SignSequenceField: No matching request items for role %s, falling back to first signer", sign_item.responsible_id.name)
|
||||||
request_items = request.request_item_ids[:1]
|
request_items = request.request_item_ids[:1]
|
||||||
|
|
||||||
if not request_items:
|
if not request_items:
|
||||||
|
_logger.warning("SignSequenceField: No request items found at all for request %s", request.id)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# We only generate if it hasn't been generated yet for this sign_item across the whole request
|
# We only generate if it hasn't been generated yet for this sign_item across the whole request
|
||||||
@ -64,16 +72,24 @@ class SignRequest(models.Model):
|
|||||||
('sign_item_id', '=', sign_item.id)
|
('sign_item_id', '=', sign_item.id)
|
||||||
], limit=1)
|
], limit=1)
|
||||||
|
|
||||||
# If it exists but value is empty/false OR it equals the placeholder "Sequence Number"
|
placeholder = (sign_item.type_id.placeholder or "Sequence Number").strip().lower()
|
||||||
|
current_val = str(existing_value.value or "").strip().lower()
|
||||||
|
_logger.debug("SignSequenceField: existing_value check for item %s: '%s' (placeholder: '%s')", sign_item.id, current_val, placeholder)
|
||||||
|
|
||||||
|
# If it exists but value is empty/false OR it equals the placeholder
|
||||||
# we must regenerate it.
|
# we must regenerate it.
|
||||||
if not existing_value or not existing_value.value or existing_value.value == "Sequence Number":
|
if not existing_value or not current_val or current_val == placeholder:
|
||||||
|
_logger.info("SignSequenceField: Generating sequence for item %s", sign_item.id)
|
||||||
new_seq = sign_item.sequence_id.next_by_id()
|
new_seq = sign_item.sequence_id.next_by_id()
|
||||||
|
_logger.info("SignSequenceField: Generated new sequence: %s", new_seq)
|
||||||
if new_seq:
|
if new_seq:
|
||||||
if existing_value:
|
if existing_value:
|
||||||
# Update existing empty record
|
# Update existing empty record
|
||||||
|
_logger.debug("SignSequenceField: Updating existing value record %s", existing_value.id)
|
||||||
existing_value.write({'value': new_seq})
|
existing_value.write({'value': new_seq})
|
||||||
else:
|
else:
|
||||||
# Create value for the first matching signer
|
# Create value for the first matching signer
|
||||||
|
_logger.debug("SignSequenceField: Creating new value record for signer %s", request_items[0].id)
|
||||||
request.env['sign.request.item.value'].create({
|
request.env['sign.request.item.value'].create({
|
||||||
'sign_request_item_id': request_items[0].id,
|
'sign_request_item_id': request_items[0].id,
|
||||||
'sign_item_id': sign_item.id,
|
'sign_item_id': sign_item.id,
|
||||||
@ -85,7 +101,12 @@ class SignRequest(models.Model):
|
|||||||
# request.reference is the document name.
|
# request.reference is the document name.
|
||||||
# Check if already renamed to avoid double prefixing
|
# Check if already renamed to avoid double prefixing
|
||||||
if not request.reference.startswith(f"{new_seq} - "):
|
if not request.reference.startswith(f"{new_seq} - "):
|
||||||
|
_logger.info("SignSequenceField: Prefixing document name with sequence: %s", new_seq)
|
||||||
request.write({'reference': f"{new_seq} - {request.reference}"})
|
request.write({'reference': f"{new_seq} - {request.reference}"})
|
||||||
|
else:
|
||||||
|
_logger.error("SignSequenceField: next_by_id() returned None for sequence %s", sign_item.sequence_id.id)
|
||||||
|
else:
|
||||||
|
_logger.info("SignSequenceField: Sequence already exists for item %s: %s", sign_item.id, existing_value.value)
|
||||||
|
|
||||||
return super()._sign()
|
return super()._sign()
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,10 @@ import { patch } from "@web/core/utils/patch";
|
|||||||
|
|
||||||
patch(SignTemplateIframe.prototype, {
|
patch(SignTemplateIframe.prototype, {
|
||||||
async openSignItemPopup(signItem) {
|
async openSignItemPopup(signItem) {
|
||||||
|
// Save the item to the backend if it has a negative ID, as it indicates the item is new and not yet linked to the template.
|
||||||
|
if (signItem.data.id < 0) {
|
||||||
|
await this.saveChangesOnBackend();
|
||||||
|
}
|
||||||
const shouldOpenNewPopover = !(signItem.data.id in this.closePopoverFns);
|
const shouldOpenNewPopover = !(signItem.data.id in this.closePopoverFns);
|
||||||
this.closePopover();
|
this.closePopover();
|
||||||
if (shouldOpenNewPopover) {
|
if (shouldOpenNewPopover) {
|
||||||
@ -20,20 +24,20 @@ patch(SignTemplateIframe.prototype, {
|
|||||||
SignItemCustomPopover,
|
SignItemCustomPopover,
|
||||||
{
|
{
|
||||||
debug: this.env.debug,
|
debug: this.env.debug,
|
||||||
responsible: signItem.data.responsible,
|
|
||||||
roles: this.signRolesById,
|
|
||||||
alignment: signItem.data.alignment,
|
alignment: signItem.data.alignment,
|
||||||
required: signItem.data.required,
|
required: signItem.data.required,
|
||||||
|
constant: signItem.data.constant,
|
||||||
header_title: header_title,
|
header_title: header_title,
|
||||||
placeholder: signItem.data.placeholder,
|
placeholder: signItem.data.placeholder,
|
||||||
id: signItem.data.id,
|
id: signItem.data.id,
|
||||||
type: signItem.data.type,
|
type: signItem.data.type,
|
||||||
option_ids: signItem.data.option_ids,
|
option_ids: signItem.data.option_ids,
|
||||||
num_options: this.getSignItemById(signItem.data.id).data.num_options,
|
num_options: this.getSignItemById(signItem.data.id)?.data.num_options,
|
||||||
radio_set_id: signItem.data.radio_set_id,
|
radio_set_id: signItem.data.radio_set_id,
|
||||||
// PATCH START
|
// PATCH START
|
||||||
sequence_id: signItem.data.sequence_id,
|
sequence_id: signItem.data.sequence_id,
|
||||||
sequence_prefix: signItem.data.sequence_prefix,
|
sequence_prefix: signItem.data.sequence_prefix,
|
||||||
|
sequence_suffix: signItem.data.sequence_suffix,
|
||||||
sequence_padding: signItem.data.sequence_padding,
|
sequence_padding: signItem.data.sequence_padding,
|
||||||
sequence_number_next: signItem.data.sequence_number_next,
|
sequence_number_next: signItem.data.sequence_number_next,
|
||||||
// PATCH END
|
// PATCH END
|
||||||
@ -45,18 +49,26 @@ patch(SignTemplateIframe.prototype, {
|
|||||||
this.closePopover();
|
this.closePopover();
|
||||||
this.deleteSignItem(signItem);
|
this.deleteSignItem(signItem);
|
||||||
},
|
},
|
||||||
|
onDuplicate: () => {
|
||||||
|
this.closePopover();
|
||||||
|
this.duplicateSignItem(signItem);
|
||||||
|
},
|
||||||
onClose: () => {
|
onClose: () => {
|
||||||
this.closePopover();
|
this.closePopover();
|
||||||
},
|
},
|
||||||
updateSelectionOptions: (ids) => this.updateSelectionOptions(ids),
|
onCopyItem: (id) => this.onCopyItem(id),
|
||||||
updateRoles: (id) => this.updateRoles(id),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
position: "right",
|
position: "right",
|
||||||
onClose: () => {
|
onClose: () => {
|
||||||
this.closePopoverFns = {};
|
this.closePopoverFns = {};
|
||||||
},
|
},
|
||||||
closeOnClickAway: (target) => !target.closest(".modal"),
|
closeOnClickAway: (target) => {
|
||||||
|
if (!target.closest(".popover")) {
|
||||||
|
this.closePopover();
|
||||||
|
}
|
||||||
|
return !target.closest(".popover");
|
||||||
|
},
|
||||||
popoverClass: "sign-popover",
|
popoverClass: "sign-popover",
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@ -69,7 +81,7 @@ patch(SignTemplateIframe.prototype, {
|
|||||||
|
|
||||||
updateSignItem(signItem, data) {
|
updateSignItem(signItem, data) {
|
||||||
// Ensure sequence fields are not filtered out if they were missing from initial load
|
// Ensure sequence fields are not filtered out if they were missing from initial load
|
||||||
const sequenceFields = ['sequence_id', 'sequence_prefix', 'sequence_padding', 'sequence_number_next'];
|
const sequenceFields = ['sequence_id', 'sequence_prefix', 'sequence_suffix', 'sequence_padding', 'sequence_number_next'];
|
||||||
for (const field of sequenceFields) {
|
for (const field of sequenceFields) {
|
||||||
if (field in data && !(field in signItem.data)) {
|
if (field in data && !(field in signItem.data)) {
|
||||||
signItem.data[field] = false; // Initialize with dummy value to pass the check
|
signItem.data[field] = false; // Initialize with dummy value to pass the check
|
||||||
@ -82,21 +94,24 @@ patch(SignTemplateIframe.prototype, {
|
|||||||
patch(SignTemplateBody.prototype, {
|
patch(SignTemplateBody.prototype, {
|
||||||
prepareTemplateData() {
|
prepareTemplateData() {
|
||||||
const [updatedSignItems, Id2UpdatedItem] = super.prepareTemplateData();
|
const [updatedSignItems, Id2UpdatedItem] = super.prepareTemplateData();
|
||||||
const items = this.iframe?.signItems ?? {};
|
const items = this.props.iframe?.signItems ?? {};
|
||||||
|
|
||||||
for (const page in items) {
|
for (const page in items) {
|
||||||
for (const id in items[page]) {
|
for (const id in items[page]) {
|
||||||
const signItem = items[page][id].data;
|
const signItem = items[page][id].data;
|
||||||
// updatedSignItems is keyed by ID. Check if this item is in the updated list.
|
// updatedSignItems is keyed by ID. Check if this item is in the updated list.
|
||||||
if (updatedSignItems[id]) {
|
if (updatedSignItems[id]) {
|
||||||
if (signItem.sequence_id) {
|
if (signItem.type === 'sequence') {
|
||||||
// specific handling for Many2one: take ID if it's an array
|
if (signItem.sequence_id) {
|
||||||
updatedSignItems[id].sequence_id = Array.isArray(signItem.sequence_id) ? signItem.sequence_id[0] : signItem.sequence_id;
|
// specific handling for Many2one: take ID if it's an array
|
||||||
|
updatedSignItems[id].sequence_id = Array.isArray(signItem.sequence_id) ? signItem.sequence_id[0] : signItem.sequence_id;
|
||||||
|
}
|
||||||
|
// For related fields, we want to save them if they changed.
|
||||||
|
if (signItem.sequence_prefix !== undefined) updatedSignItems[id].sequence_prefix = signItem.sequence_prefix;
|
||||||
|
if (signItem.sequence_suffix !== undefined) updatedSignItems[id].sequence_suffix = signItem.sequence_suffix;
|
||||||
|
if (signItem.sequence_padding !== undefined) updatedSignItems[id].sequence_padding = signItem.sequence_padding;
|
||||||
|
if (signItem.sequence_number_next !== undefined) updatedSignItems[id].sequence_number_next = signItem.sequence_number_next;
|
||||||
}
|
}
|
||||||
// For related fields, we want to save them if they changed.
|
|
||||||
if (signItem.sequence_prefix !== undefined) updatedSignItems[id].sequence_prefix = signItem.sequence_prefix;
|
|
||||||
if (signItem.sequence_padding !== undefined) updatedSignItems[id].sequence_padding = signItem.sequence_padding;
|
|
||||||
if (signItem.sequence_number_next !== undefined) updatedSignItems[id].sequence_number_next = signItem.sequence_number_next;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,7 @@ patch(SignItemCustomPopover.prototype, {
|
|||||||
super.setup();
|
super.setup();
|
||||||
this.state.sequence_id = this.props.sequence_id || false;
|
this.state.sequence_id = this.props.sequence_id || false;
|
||||||
this.state.sequence_prefix = this.props.sequence_prefix || "";
|
this.state.sequence_prefix = this.props.sequence_prefix || "";
|
||||||
|
this.state.sequence_suffix = this.props.sequence_suffix || "";
|
||||||
this.state.sequence_padding = this.props.sequence_padding || 0;
|
this.state.sequence_padding = this.props.sequence_padding || 0;
|
||||||
this.state.sequence_number_next = this.props.sequence_number_next || 1;
|
this.state.sequence_number_next = this.props.sequence_number_next || 1;
|
||||||
|
|
||||||
@ -17,7 +18,8 @@ patch(SignItemCustomPopover.prototype, {
|
|||||||
|
|
||||||
if (this.props.sequence_id) {
|
if (this.props.sequence_id) {
|
||||||
this.state.sequence_value = "Loading...";
|
this.state.sequence_value = "Loading...";
|
||||||
this.orm.read("ir.sequence", [this.props.sequence_id], ["display_name"]).then((res) => {
|
const sequenceId = Array.isArray(this.props.sequence_id) ? this.props.sequence_id[0] : this.props.sequence_id;
|
||||||
|
this.orm.read("ir.sequence", [sequenceId], ["display_name"]).then((res) => {
|
||||||
if (res && res.length) {
|
if (res && res.length) {
|
||||||
this.state.sequence_value = res[0].display_name;
|
this.state.sequence_value = res[0].display_name;
|
||||||
} else {
|
} else {
|
||||||
@ -36,14 +38,16 @@ patch(SignItemCustomPopover.prototype, {
|
|||||||
this.state.sequence_id = sequenceId;
|
this.state.sequence_id = sequenceId;
|
||||||
|
|
||||||
if (sequenceId) {
|
if (sequenceId) {
|
||||||
const result = await this.orm.read("ir.sequence", [sequenceId], ["prefix", "padding", "number_next"]);
|
const result = await this.orm.read("ir.sequence", [sequenceId], ["prefix", "suffix", "padding", "number_next"]);
|
||||||
if (result && result.length) {
|
if (result && result.length) {
|
||||||
this.state.sequence_prefix = result[0].prefix || "";
|
this.state.sequence_prefix = result[0].prefix || "";
|
||||||
|
this.state.sequence_suffix = result[0].suffix || "";
|
||||||
this.state.sequence_padding = result[0].padding || 0;
|
this.state.sequence_padding = result[0].padding || 0;
|
||||||
this.state.sequence_number_next = result[0].number_next || 1;
|
this.state.sequence_number_next = result[0].number_next || 1;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.state.sequence_prefix = "";
|
this.state.sequence_prefix = "";
|
||||||
|
this.state.sequence_suffix = "";
|
||||||
this.state.sequence_padding = 0;
|
this.state.sequence_padding = 0;
|
||||||
this.state.sequence_number_next = 1;
|
this.state.sequence_number_next = 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,6 +24,10 @@
|
|||||||
<label class="form-label">Prefix</label>
|
<label class="form-label">Prefix</label>
|
||||||
<input type="text" class="form-control" t-att-value="state.sequence_prefix" t-on-change="(e) => this.onChange('sequence_prefix', e.target.value)" />
|
<input type="text" class="form-control" t-att-value="state.sequence_prefix" t-on-change="(e) => this.onChange('sequence_prefix', e.target.value)" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label">Suffix</label>
|
||||||
|
<input type="text" class="form-control" t-att-value="state.sequence_suffix" t-on-change="(e) => this.onChange('sequence_suffix', e.target.value)" />
|
||||||
|
</div>
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<label class="form-label">Next Number</label>
|
<label class="form-label">Next Number</label>
|
||||||
<input type="number" class="form-control" t-att-value="state.sequence_number_next" t-on-change="(e) => this.onChange('sequence_number_next', parseInt(e.target.value))" />
|
<input type="number" class="form-control" t-att-value="state.sequence_number_next" t-on-change="(e) => this.onChange('sequence_number_next', parseInt(e.target.value))" />
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user