Skip to content

Code Examples

This page contains fully annotated, production-oriented rule examples drawn from real P21 implementations. Each example notes its configuration, explains the design decisions, and highlights patterns that apply broadly across many rule scenarios.


Example 1: Order Credit Check (Multi-Row Validator)

This rule validates an entire order's line items and controls allocation status based on whether the order total exceeds a credit threshold. It demonstrates the multi-row Validator pattern, UpdateByOrderCoded, and controlled cascade.

Configuration:

Setting Value
Window Order Entry
Tab Line Items
Field unit_quantity
Type Validator
Multi-Row Yes
using System.Data;
using System.Linq;
using P21.Extensions.BusinessRule;

namespace YourCompany.P21.Rules;

/// <summary>
/// Multi-row Validator: examines the full order total on every quantity change
/// and sets each line's allocation and disposition based on whether the total
/// exceeds the credit threshold.
///
/// Design notes:
/// - Multi-row mode is required so we can sum all lines' extended_price.
/// - UpdateByOrderCoded routes each row through P21's update pipeline, ensuring
///   that field-change events fire correctly per row.
/// - The credit threshold here is a constant, but a production implementation
///   might read it from a configuration table via Data.ConnectionString.
/// </summary>
public class OrderCreditCheck : Rule
{
    private const decimal CreditThreshold = 1000m;

    public override string GetName() => "OrderCreditCheck";

    public override string GetDescription() =>
        "Holds order lines (disposition=H, allocated_qty=0) when order total " +
        "meets or exceeds the credit threshold.";

    public override RuleResult Execute()
    {
        var orderLines = Data.Set.Tables["oe_line"];

        if (orderLines == null)
            return RuleResult.Success; // Nothing to validate

        // Sum only active (non-deleted) lines
        decimal orderTotal = orderLines.AsEnumerable()
            .Where(r => r.RowState != DataRowState.Deleted)
            .Sum(r => r.Field<decimal>("extended_price"));

        bool overLimit = orderTotal >= CreditThreshold;

        // UpdateByOrderCoded processes each row through P21's pipeline.
        // This fires field-change events for allocated_qty and disposition,
        // which may in turn update warehouse reservation records.
        Data.UpdateByOrderCoded("oe_line", rowIndex =>
        {
            var row = orderLines.Rows[rowIndex];

            if (row.RowState == DataRowState.Deleted) return;

            if (overLimit)
            {
                // Hold the line: zero allocation, disposition = H (Hold)
                row.SetField("allocated_qty", 0m);
                row.SetField("disposition",   "H");
            }
            else
            {
                // Release: allocate full quantity
                var qty = row.Field<decimal>("unit_quantity");
                row.SetField("allocated_qty", qty > 0 ? qty : 0m);
                row.SetField("disposition",   "O"); // O = Open/Normal
            }
        });

        // Return cursor to quantity field so the user can continue editing
        Data.SetFocus("unit_quantity");

        // Note: this rule does NOT return RuleResult.Error — it does not block the
        // save. It controls allocation silently. If you wanted to block the save
        // instead, return RuleResult.Error("Order is over credit limit.") here.
        return RuleResult.Success;
    }
}

Key patterns illustrated:

  • orderLines.AsEnumerable().Where(r => r.RowState != DataRowState.Deleted) — always filter deleted rows in multi-row aggregates
  • UpdateByOrderCoded — use when row changes must pass through P21's update pipeline
  • Returning RuleResult.Success with side effects — sometimes a rule's job is to silently adjust data, not to block

Example 2: Suppress a P21 Message Box

This rule suppresses a specific P21 message dialog that fires during automated processing, preventing it from blocking an unattended workflow. It demonstrates the MessageBoxOpening event pattern and the critical importance of matching on both message number and text.

Configuration:

Setting Value
Type OnEvent
Event MessageBoxOpening
Multi-Row No
using P21.Extensions.BusinessRule;

namespace YourCompany.P21.Rules;

/// <summary>
/// OnEvent rule: automatically dismisses a specific P21 warning dialog.
///
/// Design notes:
/// - ALWAYS match on both message_no AND message_text. Matching on number alone
///   risks suppressing unrelated messages in other contexts that share the same
///   message number. This has caused real production incidents.
/// - The default_button value (1) selects the first button in the dialog,
///   which for most P21 warnings is OK or Yes. Verify this for your specific message.
/// - Find the correct message_no by intentionally triggering the dialog with
///   logging enabled and observing the value written to the log.
/// </summary>
public class SuppressExpediteDateMessage : Rule
{
    private const int    TargetMessageNo   = 12345; // Replace with actual number
    private const string TargetMessageText = "Expedite date is in the past";

    public override string GetName() => "SuppressExpediteDateMessage";

    public override string GetDescription() =>
        "Suppresses the 'expedite date is in the past' warning in automated workflows.";

    public override RuleResult Execute()
    {
        var messageNo   = Data.Fields["message_no"]?.Value<int>() ?? 0;
        var messageText = Data.Fields["message_text"]?.ToString() ?? string.Empty;

        // Log every execution during initial testing — remove or conditionalize
        // this line once the rule is validated in production.
        Log.AddAndPersist(
            $"[SuppressExpediteDateMessage] messageNo={messageNo}, " +
            $"text='{messageText}'");

        if (messageNo == TargetMessageNo &&
            messageText.Contains(TargetMessageText, StringComparison.OrdinalIgnoreCase))
        {
            Data.Fields["suppress_message"].Value = true;

            // 1 = first button (typically OK/Yes). Use 2 for the second button (No/Cancel).
            Data.Fields["default_button"].Value = 1;
        }

        return RuleResult.Success;
    }
}

Key patterns illustrated:

  • Double-check suppression: match both message_no and message_text
  • Log during development to discover the correct message number — then optionally remove the log line in production
  • default_button = 1 auto-clicks the first button (OK/Yes); 2 would click the second (No/Cancel)

Finding the Message Number

To find the message_no for a specific P21 dialog: temporarily deploy a rule that logs every MessageBoxOpening event, then trigger the dialog manually. Check the application log for the entry. Once you have the number, add the text check and remove the broad logging rule.


Example 3: Form Datastream Manipulation

This rule intercepts the form datastream for an invoice just before it is sent to the report engine, injecting custom data groups that appear as bands in a Crystal Reports template.

Configuration:

Setting Value
Type OnEvent
Event FormDatastreamCreated
Form Type Invoice
using System.Collections.Generic;
using System.Data;
using P21.Extensions.BusinessRule;

namespace YourCompany.P21.Rules;

/// <summary>
/// OnEvent rule: adds custom Crystal Reports data groups to invoice form datastreams.
///
/// Design notes:
/// - The group names (e.g., "HDRTSTXDEF") must exactly match the section/band names
///   in your Crystal Reports .rpt file. Case sensitivity depends on your report engine
///   configuration — match exactly to be safe.
/// - Header groups appear once per invoice. Line groups appear once per invoice line.
/// - This rule runs asynchronously and cannot modify the invoice record itself,
///   only the datastream (the data payload sent to the report engine).
/// - Test by printing an invoice and verifying the custom fields appear in the output.
/// </summary>
public class InvoiceFormDatastreamModifier : Rule
{
    private const string HeaderGroupName = "HDRTSTXDEF";
    private const string LineGroupName   = "LINETSTDEF";

    public override string GetName() => "InvoiceFormDatastreamModifier";

    public override string GetDescription() =>
        "Injects custom header and line groups into invoice form datastreams " +
        "for Crystal Reports rendering.";

    public override RuleResult Execute()
    {
        var datastream = Data.XMLDatastream;

        if (datastream == null)
        {
            Log.AddAndPersist("[InvoiceFormDatastreamModifier] XMLDatastream is null — skipping.");
            return RuleResult.Success;
        }

        // --- Header-level group ---
        // Reads data from the invoice header fields available in the event context
        var headerData = new Dictionary<string, string>
        {
            { "custom_po_ref",     Data.Fields["po_no"]?.ToString() ?? string.Empty },
            { "custom_project_id", Data.Fields["project_id"]?.ToString() ?? string.Empty },
            { "custom_note_1",     Data.Fields["special_instructions"]?.ToString() ?? string.Empty }
        };

        datastream.AddHeaderGroup(HeaderGroupName, headerData);

        // --- Line-level groups ---
        // One group per invoice line, containing line-specific data
        var invoiceLines = Data.Set.Tables["oe_line_hist"];

        if (invoiceLines != null)
        {
            foreach (DataRow line in invoiceLines.Rows)
            {
                if (line.RowState == DataRowState.Deleted) continue;

                var lineData = new Dictionary<string, string>
                {
                    { "custom_line_ref",    line.Field<string>("line_reference") ?? string.Empty },
                    { "custom_serial_list", line.Field<string>("serial_number") ?? string.Empty }
                };

                datastream.AddLineGroup(LineGroupName, lineData);
            }
        }

        Log.AddAndPersist(
            $"[InvoiceFormDatastreamModifier] Added header group and " +
            $"{invoiceLines?.Rows.Count ?? 0} line groups.");

        return RuleResult.Success;
    }
}

Key patterns illustrated:

  • Always null-check Data.XMLDatastream — not all form events populate it
  • Header groups are added once; line groups are added in a loop, one per row
  • Use Log.AddAndPersist to confirm the rule is running and report the line count when debugging report output

Example 4: URL Field Validator

This rule validates that a field contains a well-formed URL. It demonstrates a single-row Validator that returns a descriptive error message referencing the specific field that failed.

Configuration:

Setting Value
Window Any window with a URL-type field
Type Validator
Multi-Row No
using System;
using System.Linq;
using System.Text.RegularExpressions;
using P21.Extensions.BusinessRule;

namespace YourCompany.P21.Rules;

/// <summary>
/// Validator: ensures that fields contain valid, well-formed URLs.
///
/// Design notes:
/// - Accepts http://, https://, and file:// schemes via Uri.TryCreate.
/// - Also accepts bare www. URLs as a convenience (e.g., "www.example.com").
///   These do not pass Uri.TryCreate with UriKind.Absolute — the Regex fallback
///   handles them.
/// - Empty/null fields are allowed — pair with a required-field rule if needed.
/// - The foreach over Data.Fields handles the case where the rule is assigned to
///   multiple fields at once; typically only one field will be present.
/// </summary>
public class ValidUrl : Rule
{
    private static readonly string[] ValidSchemes = { "http", "https", "file" };

    // Matches bare www. URLs: www.example.com, www.my-site.co.uk, etc.
    private static readonly Regex WwwPattern = new Regex(
        @"^www\.[a-zA-Z0-9\-]+\.[a-zA-Z]{2,}",
        RegexOptions.Compiled | RegexOptions.IgnoreCase);

    public override string GetName() => "ValidUrl";

    public override string GetDescription() =>
        "Validates that fields contain a valid URL (http, https, file, or www. prefix).";

    public override RuleResult Execute()
    {
        foreach (var field in Data.Fields)
        {
            var value = field.Value?.ToString();

            // Empty values are valid — use a separate required-field rule to enforce presence
            if (string.IsNullOrWhiteSpace(value))
                continue;

            bool isValid = IsValidUrl(value);

            if (!isValid)
            {
                return RuleResult.Error(
                    $"'{field.Name}' must be a valid URL. " +
                    $"Accepted formats: https://example.com, http://example.com, " +
                    $"www.example.com, file:///path/to/file. " +
                    $"Received: '{value}'");
            }
        }

        return RuleResult.Success;
    }

    private bool IsValidUrl(string value)
    {
        // Check for absolute URI with an accepted scheme
        if (Uri.TryCreate(value, UriKind.Absolute, out var uri))
        {
            return ValidSchemes.Contains(uri.Scheme, StringComparer.OrdinalIgnoreCase);
        }

        // Check for bare www. URL (no scheme)
        return WwwPattern.IsMatch(value);
    }
}

Key patterns illustrated:

  • foreach (var field in Data.Fields) — generalizes the rule to work on any field it is assigned to
  • Return RuleResult.Error(message) with a descriptive message that tells the user exactly what is wrong and what is expected
  • Compile the Regex as a static field so it is only compiled once across all rule executions

Example 5: DateTime Validator (Multi-Field)

A practical standalone validator that checks every field in its scope for valid date formatting. Useful when you have several date fields on a window and want a single rule to cover them all.

Configuration:

Setting Value
Window Any window with date fields
Type Validator
Multi-Row No
Assignment Assign to each date field, or assign to Save
using System;
using System.Globalization;
using P21.Extensions.BusinessRule;

namespace YourCompany.P21.Rules;

/// <summary>
/// Validator: checks that each field in scope contains a valid date in MM/dd/yyyy format.
///
/// Design notes:
/// - Assign this rule to individual date fields rather than globally, to avoid
///   false positives on non-date fields that happen to contain numeric strings.
/// - The formats array is ordered from most to least specific. TryParseExact checks
///   them in order and stops at the first match.
/// - P21 date fields typically use MM/dd/yyyy. This rule does not accept ISO 8601
///   (yyyy-MM-dd) to prevent ambiguity with P21's format expectations.
/// </summary>
public class ValidDatetimeRule : Rule
{
    private static readonly string[] AcceptedFormats =
    {
        "MM/dd/yyyy",
        "M/d/yyyy",
        "MM/d/yyyy",
        "M/dd/yyyy"
    };

    public override string GetName() => "ValidDatetime";

    public override string GetDescription() =>
        "Validates that each assigned field contains a properly formatted date (MM/dd/yyyy).";

    public override RuleResult Execute()
    {
        foreach (var field in Data.Fields)
        {
            var value = field.Value?.ToString();

            if (string.IsNullOrWhiteSpace(value))
                continue;

            if (!DateTime.TryParseExact(
                    value,
                    AcceptedFormats,
                    CultureInfo.InvariantCulture,
                    DateTimeStyles.None,
                    out _))
            {
                return RuleResult.Error(
                    $"'{field.Name}' contains an invalid date: '{value}'. " +
                    "Expected format: MM/dd/yyyy (e.g., 03/15/2026).");
            }
        }

        return RuleResult.Success;
    }
}

Patterns Summary

Pattern When to Use Key API
Single-row field iteration Validate/transform fields on the current row foreach (var field in Data.Fields)
Multi-row aggregate Sum, count, or cross-row comparison Data.Set.Tables["x"].AsEnumerable().Where(...).Sum(...)
Sequential row update Modify each row through P21's update pipeline Data.UpdateByOrderCoded("x", idx => { ... })
Bulk row stamping Set a value on every row without events Data.SetCascade(false) + row.SetField(...) loop
Message suppression Auto-dismiss a known P21 dialog Data.Fields["suppress_message"].Value = true
Datastream injection Add custom data to a form before printing Data.XMLDatastream.AddHeaderGroup(...)
Custom dialog Gather user input before proceeding ResponseAttributes + callback rule
External DB lookup Read related data not on the current window new SqlConnection(Data.ConnectionString)