feat: Add sequence suffix to sign items, improve popover handling, and refine backend saving for new items.

This commit is contained in:
Suherdy Yacob 2026-03-13 15:25:10 +07:00
parent 7092737ec4
commit 1074212728
6 changed files with 101 additions and 20 deletions

27
README.md Normal file
View 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

View File

@ -1,5 +1,6 @@
# -*- coding: utf-8 -*-
import io
import logging
from odoo import models
from odoo.exceptions import ValidationError
from odoo.tools.pdf import PdfFileReader, PdfFileWriter, PdfReadError, reshape_text
@ -10,6 +11,8 @@ try:
except ImportError:
stringWidth = None
_logger = logging.getLogger(__name__)
class SignDocument(models.Model):
_inherit = 'sign.document'
@ -26,7 +29,9 @@ class SignDocument(models.Model):
for item in page_items
)
_logger.info("SignSequenceField: render_document_with_items started")
if not has_sequence_items:
_logger.info("SignSequenceField: No sequence items found in document, skipping custom render")
return base_output
try:
@ -91,7 +96,9 @@ class SignDocument(models.Model):
can.showPage()
can.save()
_logger.info("SignSequenceField: Custom canvas saved to packet")
packet.seek(0)
item_pdf = PdfFileReader(packet)
new_pdf = PdfFileWriter()
@ -105,6 +112,9 @@ class SignDocument(models.Model):
try:
new_pdf.write(output)
except PdfReadError:
_logger.error("SignSequenceField: PdfReadError during final PDF write")
raise ValidationError(self.env._("There was an issue generating the document."))
_logger.info("SignSequenceField: Custom rendering completed successfully")
output.seek(0)
return output

View File

@ -43,18 +43,26 @@ class SignRequest(models.Model):
# 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.
_logger.info("SignSequenceField: Starting sequence generation for sign.request %s", self.ids)
for request in self:
# 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:
_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)
# Use role_id to match.
# 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)
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]
if not request_items:
_logger.warning("SignSequenceField: No request items found at all for request %s", request.id)
continue
# 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)
], 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.
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()
_logger.info("SignSequenceField: Generated new sequence: %s", new_seq)
if new_seq:
if existing_value:
# Update existing empty record
_logger.debug("SignSequenceField: Updating existing value record %s", existing_value.id)
existing_value.write({'value': new_seq})
else:
# 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({
'sign_request_item_id': request_items[0].id,
'sign_item_id': sign_item.id,
@ -85,7 +101,12 @@ class SignRequest(models.Model):
# request.reference is the document name.
# Check if already renamed to avoid double prefixing
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}"})
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()

View File

@ -8,6 +8,10 @@ import { patch } from "@web/core/utils/patch";
patch(SignTemplateIframe.prototype, {
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);
this.closePopover();
if (shouldOpenNewPopover) {
@ -20,20 +24,20 @@ patch(SignTemplateIframe.prototype, {
SignItemCustomPopover,
{
debug: this.env.debug,
responsible: signItem.data.responsible,
roles: this.signRolesById,
alignment: signItem.data.alignment,
required: signItem.data.required,
constant: signItem.data.constant,
header_title: header_title,
placeholder: signItem.data.placeholder,
id: signItem.data.id,
type: signItem.data.type,
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,
// PATCH START
sequence_id: signItem.data.sequence_id,
sequence_prefix: signItem.data.sequence_prefix,
sequence_suffix: signItem.data.sequence_suffix,
sequence_padding: signItem.data.sequence_padding,
sequence_number_next: signItem.data.sequence_number_next,
// PATCH END
@ -45,18 +49,26 @@ patch(SignTemplateIframe.prototype, {
this.closePopover();
this.deleteSignItem(signItem);
},
onDuplicate: () => {
this.closePopover();
this.duplicateSignItem(signItem);
},
onClose: () => {
this.closePopover();
},
updateSelectionOptions: (ids) => this.updateSelectionOptions(ids),
updateRoles: (id) => this.updateRoles(id),
onCopyItem: (id) => this.onCopyItem(id),
},
{
position: "right",
onClose: () => {
this.closePopoverFns = {};
},
closeOnClickAway: (target) => !target.closest(".modal"),
closeOnClickAway: (target) => {
if (!target.closest(".popover")) {
this.closePopover();
}
return !target.closest(".popover");
},
popoverClass: "sign-popover",
}
);
@ -69,7 +81,7 @@ patch(SignTemplateIframe.prototype, {
updateSignItem(signItem, data) {
// 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) {
if (field in data && !(field in signItem.data)) {
signItem.data[field] = false; // Initialize with dummy value to pass the check
@ -82,21 +94,24 @@ patch(SignTemplateIframe.prototype, {
patch(SignTemplateBody.prototype, {
prepareTemplateData() {
const [updatedSignItems, Id2UpdatedItem] = super.prepareTemplateData();
const items = this.iframe?.signItems ?? {};
const items = this.props.iframe?.signItems ?? {};
for (const page in items) {
for (const id in items[page]) {
const signItem = items[page][id].data;
// updatedSignItems is keyed by ID. Check if this item is in the updated list.
if (updatedSignItems[id]) {
if (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;
if (signItem.type === 'sequence') {
if (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;
}
}
}

View File

@ -9,6 +9,7 @@ patch(SignItemCustomPopover.prototype, {
super.setup();
this.state.sequence_id = this.props.sequence_id || false;
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_number_next = this.props.sequence_number_next || 1;
@ -17,7 +18,8 @@ patch(SignItemCustomPopover.prototype, {
if (this.props.sequence_id) {
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) {
this.state.sequence_value = res[0].display_name;
} else {
@ -36,14 +38,16 @@ patch(SignItemCustomPopover.prototype, {
this.state.sequence_id = 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) {
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_number_next = result[0].number_next || 1;
}
} else {
this.state.sequence_prefix = "";
this.state.sequence_suffix = "";
this.state.sequence_padding = 0;
this.state.sequence_number_next = 1;
}

View File

@ -24,6 +24,10 @@
<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)" />
</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">
<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))" />