Loading
示例:验证访问限制

示例:验证访问限制

此示例脚本强制执行访问限制,并使用自定义元数据和权限集。它检查每个代表和每个产品的访问限制,根据用户的权限集返回警告和错误,并验证子出席者访问。

所需的 Edition

适用于:Lightning Experience
适用于:具有Life Sciences Cloud、Life Sciences Cloud for Customer Engagement加载项许可证和Life Sciences Customer Engagement受管软件包的EnterpriseUnlimited Edition。

验证规则

示例脚本包含三个验证规则。

规则 使用 触发器 错误消息 成功时
perRepVisitLimit 对于单个销售代表可以为给定的 HCP(医疗保健专业人员)或 HCO(医疗保健组织)客户记录的访问次数,强制执行每个代表的访问次数限制。 只有当操作符合条件(默认提交、签名)并且客户和代表存在有效的每个代表限制时。 此访问将超出每个代表的 <region> 限制(无剩余)。当仍有一次访问时发出警告:此访问将达到每个代表的 <region> 限制(允许上次访问)。 在限制内静默。
perProductVisitLimit 对于所有销售代表中可以详细说明客户给定产品的走访次数,强制执行每个产品的走访次数限制。区域产品变体累计到共享父级限制。 只有当访问详细描述了至少一个产品时,该操作才有资格,并且该客户和产品存在有效的产品限制。 此访问将超出 <product><region> 产品限制(无剩余)。当仍有一次访问时发出警告:此访问将达到 <product><region> 产品限制(允许上次访问)。 在限制内静默。
AttendeeVisitLimits 将每个代表和每个产品的限制应用于每个子级出席者访问,以便添加出席者不会绕过限制。 只有当父级访问保存在合格的操作上并且有出席者访问时。在提交时,没有产品的出席者会继承父产品进行检查。 与每个代表和每个产品规则相同的消息,前缀是子访问标签。 在限制内静默。

助手方法

示例脚本包含可重复使用的帮助程序方法。

  • rowField(row, apiName)
    • 从查询行中读取字段值,容忍平台密钥大小写
    • 首先检查 Salesforce API 大小写(web),然后检查小写密钥(移动设备)
    • 字段缺失时返回undefined
  • formatLabelTranslation(key, defaultValue, args)
    • 使用位置{0}替换解析 Salesforce 自定义标签
    • 在 Web 上,返回框架解压的结构标签对象
    • 在移动设备上,返回预翻译的纯字符串
    • 当标签包装不可用时,返回 JS 侧替代
  • labelResult(result, visitLabel)
    • 使用标签为结果的消息添加前缀,以标识来自哪个走访,例如,“父走访”或“子走访 2”
    • 在 Web 上,将前缀注入框架解压的结构标签对象
    • 在移动设备上,将前缀添加到纯字符串消息

示例脚本

(() => {

    // ===========================================================================
    // Sample visit action validation script for visit limit validation.
    //
    // Enforces per-rep and per-product visit limits when a sales rep saves,
    // submits, or signs a visit. Runs on Salesforce web and the Life Sciences Cloud mobile
    // app (offline) from this single source. Read-only; it inspects limit records
    // and returns success, warning, and error results. It never writes data.
    //
    // High-level flow (see runLimitChecksAllVisits):
    //   1. Read configurable settings (which actions count, parent on/off).
    //   2. Resolve the per-region enforcement mode (soft / hard / default).
    //   3. For the visit being saved and any child visits: Compare the
    //      remaining limit against the per-rep (UserLimit) and per-product
    //      (ProductLimit) caps and produce a tiered result.
    //
    // Adapt for your org: Replace the object/field API names below with your own,
    // and adjust the limit model to match your data.
    // ===========================================================================

    // ---------------------------------------------------------------------------
    // Field-name helper. If your visit limit objects/fields are installed under a
    // managed package namespace, set NAMESPACE and namespaceNeededForCustomObject
    // so ns() prefixes custom names. If your objects are local (no namespace),
    // leave the flag false and ns() is a no-op.
    //   • Web ignores this; the platform resolves the namespace server-side, so
    //     ns() returns the bare name on web.
    //   • Mobile addresses fields by their on-device name, so prefixing is 
    //     applied there when the flag is true.
    // Standard objects/fields (User, PermissionSet, Account, Visit, Id, Name, …)
    // are never namespaced and must NOT be passed through ns().
    // ---------------------------------------------------------------------------
    const namespaceNeededForCustomObject = false;
    const NAMESPACE = 'lsc4ce__';
    const isMobilePlatform = (function () {
        try {
            if (!record || typeof record.getContextData !== 'function') return true;
            const raw = record.getContextData();
            const ctx = (typeof raw === 'string') ? JSON.parse(raw) : (raw || {});
            return ctx['ProviderVisit'] === undefined;
        } catch (e) {
            return true; // on detection error, treat as mobile (drives row-key handling below)
        }
    })();
    function ns(name) {
        if (!isMobilePlatform) return name;                 // web resolves the namespace itself
        if (!namespaceNeededForCustomObject || !name) return name;
        if (name.indexOf(NAMESPACE) === 0) return name;
        return NAMESPACE + name;
    }

    // Fail-safe defaults for the configurable settings (see resolveSettings).
    // Used when the settings record can't be read (not deployed, read error, or a
    // blank field). "Fail safe" here means "fail enforcing": a missing or
    // unreadable settings record keeps limits running with these defaults rather
    // than silently disabling enforcement.
    const DEFAULT_ALLOWED_ACTIONS = ['Submit', 'Sign'];
    const DEFAULT_ACTIVE = true;

    // When a parent visit with attendees is saved with one of these actions, the
    // platform copies the parent's product details onto any child visit
    // that has none of its own, but only AFTER validation runs. So while
    // validating the parent on these actions, a child with no product details of
    // its own is treated as if it will inherit the parent's products; otherwise
    // the script would under-count product-limit breaches. Other actions leave an
    // empty child empty (no product-limit checks for it).
    const actionsThatCloneParentProductDetailsToChildren = ['Submit'];

    /** Read env.actionName ('Save' / 'Submit' / 'Sign'). */
    function getActionName(env) {
        try {
            if (env && typeof env.getOption === 'function') {
                return env.getOption('actionName') || '';
            }
        } catch (e) { /* fall through */ }
        return '';
    }

    /**
     * Resolve a Salesforce Custom Label with positional substitution. The
     * return shape differs by platform because the framework's
     * CustomScriptResult.title contract differs:
     *   - On desktop, env.formatCustomLabel returns a structured object
     *     { key, defaultValue, argumentsList } and the validation-result
     *     handler unpacks it (resolves the label, substitutes args). Pass
     *     that object straight through as title; do NOT coerce to a string
     *     (string concat would render as '[object Object]').
     *   - On mobile (iOS), env.formatCustomLabel returns a pre-translated
     *     plain string (the JS sandbox wrapper does localizedString +
     *     String(format:) at script-time). Title must be a plain string
     *     because the framework calls JSValue.toString() on it.
     * Falls back to JS-side {0} substitution when the wrapper isn't
     * available, returning a plain string. Either way, the result is the
     * appropriate shape for the current platform.
     */
    function formatLabelTranslation(key, defaultValue, args) {
        const a = args || [];
        try {
            if (env && typeof env.formatCustomLabel === 'function') {
                return env.formatCustomLabel(key, defaultValue, a);
            }
        } catch (e) { /* fall through to JS-side substitution */ }
        let out = defaultValue;
        for (let i = 0; i < a.length; i++) {
            out = out.split('{' + i + '}').join(String(a[i]));
        }
        return out;
    }

    /**
     * Read configurable eligibility settings from the VisitLimitSettings__mdt
     * 'Default' custom-metadata record — drives the validation from configuration
     * instead of hardcoding:
     *   EligibleActions__c (text) → comma-separated actions that count toward
     *                               limits, e.g. "Save,Submit,Sign"
     *   IsActive__c        (bool) → master on/off switch for the whole check
     *
     * Works on both web and mobile. The custom metadata is readable on-device when
     * it is registered for offline sync. If the record can't be read (not
     * deployed, read error, or a blank field), this returns the DEFAULT_* values.
     * The custom-metadata type and its custom fields go through ns();
     * DeveloperName is a standard field and is never prefixed.
     * Fails safe (defaults) on any error, empty result, or blank field.
     */
    async function resolveSettings(db) {
        try {

            // The settings type and its custom fields go through ns();
            // DeveloperName is a standard field, never prefixed. The running user
            // needs read access to the settings type for this query to resolve.
            const rows = await db.query(
                ns('VisitLimitSettings__mdt'),
                await new ConditionBuilder(
                    ns('VisitLimitSettings__mdt'),
                    new FieldCondition('DeveloperName', '=', 'Default')
                ).build(),
                ['DeveloperName', ns('EligibleActions__c'), ns('IsActive__c')]
            );
            const list = rows || [];
            if (list.length === 0) {
                return { allowedActions: DEFAULT_ALLOWED_ACTIONS, active: DEFAULT_ACTIVE };
            }

            // EligibleActions__c → array (trim, drop blanks). Blank → default.
            let allowedActions = DEFAULT_ALLOWED_ACTIONS;
            const raw = list[0].stringValue(ns('EligibleActions__c'));
            if (raw) {
                const parsed = [];
                const parts = raw.split(',');
                for (let i = 0; i < parts.length; i++) {
                    const a = (parts[i] || '').trim();
                    if (a) parsed.push(a);
                }
                if (parsed.length > 0) allowedActions = parsed;
            }

            // IsActive__c → boolean. Only an explicit false disables; null/undefined
            // (e.g. FLS-hidden) defaults to true (fail enforcing).
            let active = DEFAULT_ACTIVE;
            let activeVal;
            try { activeVal = list[0].boolValue(ns('IsActive__c')); }
            catch (inner) { activeVal = list[0].stringValue(ns('IsActive__c')); }
            if (activeVal === false || activeVal === 'false') active = false;
            return { allowedActions: allowedActions, active: active };
        } catch (e) {

            // Any read error (web or mobile) lands here → enforce with hardcoded
            // defaults, never disable, never throw.
            return { allowedActions: DEFAULT_ALLOWED_ACTIONS, active: DEFAULT_ACTIVE };
        }
    }

    /** Safe JSON parse of record.getContextData(). */
    function parseContextData(record) {
        try {
            if (!record || typeof record.getContextData !== 'function') return {};
            const ctx = record.getContextData();
            if (typeof ctx === 'string') return JSON.parse(ctx);
            if (typeof ctx === 'object' && ctx !== null) return ctx;
        } catch (e) { /* fall through */ }
        return {};
    }

    /**
     * Web uses dotted relationship paths ("ProviderVisitProdDetailing.VisitId").
     * Mobile uses flat object names ("ProviderVisitProdDetailing"). Check both.
     */
    function getFieldData(ctx, baseFieldName) {
        const webField = baseFieldName + '.VisitId';
        return ctx[webField] || ctx[baseFieldName];
    }

    /** Resolve the AccountId of the visit's primary HCP/HCO. */
    function resolveAccountId(record) {
        try {
            const direct = record.stringValue('AccountId');
            if (direct) return direct;
        } catch (e) { /* fall through */ }
        const ctx = parseContextData(record);
        return (ctx.ProviderVisit && ctx.ProviderVisit.AccountId)
            || (ctx.Visit && ctx.Visit.AccountId)
            || ctx.AccountId
            || null;
    }

    /**
     * True when THIS visit has already been processed by the limit engine — i.e.
     * its Visit_Limit_Status__c is already populated (non-empty). Such a visit's
     * outcome was decided on a prior pass, so we skip re-validating it (e.g. on a
     * re-save / edit) to avoid re-prompting or double-counting it. An empty/blank
     * status means "not yet processed" → validate normally.
     *
     * Reads the parent visit's value via record.stringValue first (custom field →
     * ns()), falling back to the parsed context (ctx.Visit / ctx.ProviderVisit) 
     * so it works on both web and mobile, same robustness pattern as resolveAccountId.
     */
    function visitAlreadyProcessed(record) {
        let val;
        try { val = record.stringValue(ns('Visit_Limit_Status__c')); } catch (e) { /* fall through */ }
        if (val === undefined || val === null || val === '') {
            const ctx = parseContextData(record);
            val = (ctx.Visit && (rowField(ctx.Visit, 'Visit_Limit_Status__c')))
                || (ctx.ProviderVisit && (rowField(ctx.ProviderVisit, 'Visit_Limit_Status__c')))
                || null;
        }
        return !!(val && String(val).trim() !== '');
    }

    /** As visitAlreadyProcessed, but for a child visit's context row. */
    function childVisitAlreadyProcessed(childRow) {
        const val = rowField(childRow, 'Visit_Limit_Status__c');
        return !!(val && String(val).trim() !== '');
    }

    /**
     * Resolve the running rep's UserId.
     * Prefers env.getOption('userId') (always present, never throws). Falls back
     * to the user record's 'uid' pseudo-field, then 'Id'. Reading 'uid' is more
     * robust than 'Id' because it is always accessible regardless of the running
     * user's field-level security on User.Id.
     */
    function resolveUserId(env, user) {
        try {
            if (env && typeof env.getOption === 'function') {
                const fromEnv = env.getOption('userId');
                if (fromEnv) return fromEnv;
            }
        } catch (e) { /* fall through */ }
        try {
            const uid = user.stringValue('uid');
            if (uid) return uid;
        } catch (e) { /* fall through */ }
        try {
            return user.stringValue('Id');
        } catch (e) {
            return null;
        }
    }

    /**
     * Read a row's value tolerant of mobile (lowercased keys) vs web (API casing).
     * DataRow lowercases every key on iOS serialization; web preserves Salesforce
     * API casing. Always check API casing first, fall back to lower.
     */
    function rowField(row, apiName) {
        if (!row || !apiName) return undefined;
        if (row[apiName] !== undefined) return row[apiName];
        return row[apiName.toLowerCase()];
    }

    /**
     * Pull product IDs out of a list of detail rows. Used for both the parent
     * visit (rows from context-data top level) and child visits (rows from a DB
     * query — see resolveChildProductIds). Indexed loop for Proxy-array safety.
     */
    function productIdsFromDetailRows(rows) {
        const productIds = [];
        for (let i = 0; i < rows.length; i++) {
            const d = rows[i];
            const productId = d && rowField(d, 'ProductId');
            if (productId) productIds.push(productId);
        }
        return productIds;
    }

    /**
     * Extract the Product2 IDs being detailed on this (parent) visit (may be
     * empty).
     */
    function resolveProductIds(record) {
        const ctx = parseContextData(record);
        const details = getFieldData(ctx, 'ProviderVisitProdDetailing') || [];
        return productIdsFromDetailRows(details);
    }

    /**
     * Extract the array of child-visit context objects from the parent record.
     * Cross-platform: web ships the children under the 'Visit.ParentVisitId'
     * relationship dot-path; mobile flattens them to a top-level 'ChildVisit'
     * array. Returns [] when the visit has no children (or the context object
     * IS the child — in which case validation runs once for that child as a
     * normal Save and the iteration over [] is a no-op).
     */
    function extractChildVisits(ctx) {
        if (!ctx) return [];
        const fromWeb = ctx['Visit.ParentVisitId'];
        const fromMobile = ctx['ChildVisit'];
        return (Array.isArray(fromWeb) && fromWeb.length > 0) ? fromWeb
             : (Array.isArray(fromMobile) && fromMobile.length > 0) ? fromMobile
             : [];
    }

    /** Resolve the AccountId of one child-visit row (lowercased on iOS). */
    function resolveChildAccountId(childRow) {
        return rowField(childRow, 'AccountId') || null;
    }

    /**
     * Resolve product-detail rows for one child visit by local DB query.
     * Child product details are never nested under the parent payload on either
     * platform, so we query ProviderVisitProdDetailing for the child visit.
     *
     * The attendee child rows ARE Visit records (their Id is a Visit Id), and
     * ProviderVisitProdDetailing links to a Visit via its VisitId field (it also
     * has a separate ProviderVisitId → ProviderVisit). So we match
     * ProviderVisitProdDetailing.VisitId = <child Visit Id>. The child's own Id
     * is the Visit Id; a 'VisitId' field on the row (when present) is the parent
     * link, so we prefer Id and only fall back to VisitId.
     *
     * For brand-new unsaved children whose detail rows haven't reached the DB
     * yet, this returns nothing and the orchestrator applies the
     * actionsThatCloneParentProductDetailsToChildren rule. Fails open to [].
     */
    async function resolveChildProductIds(childRow) {
        const childVisitId = rowField(childRow, 'Id') || rowField(childRow, 'VisitId');
        if (!childVisitId) return [];
        try {
            const rows = await db.query(
                'ProviderVisitProdDetailing',
                await new ConditionBuilder(
                    'ProviderVisitProdDetailing',
                    new FieldCondition('VisitId', '=', childVisitId)
                ).build(),
                ['Id', 'ProductId', 'VisitId']
            );
            const list = rows || [];
            const productIds = [];
            for (let i = 0; i < list.length; i++) {
                const pid = list[i].stringValue('ProductId');
                if (pid) productIds.push(pid);
            }
            return productIds;
        } catch (e) {
            return [];
        }
    }

    /**
     * Convert a remaining count into a validation result.
     *   remaining > 1  → success (silent)
     *   remaining = 1  → warning (last visit allowed — Continue/Cancel popup)
     *   remaining <= 0 → error   (blocks save)
     *
     * Localization: the warning and error titles are pre-translated via
     * formatLabelTranslation, which resolves the Custom Label key and
     * substitutes {0} positionally. The framework expects title to be a
     * plain string (CustomScriptResult.toString is called on the JS value);
     * returning a structured object renders as '[object Object]' on iOS.
     * Success stays a plain English string because it's silent.
     */
    function evaluateLimit(remaining, label, verb) {
        if (remaining > 1) {
            return { status: 'success', title: label + ' — within limits' };
        }
        if (remaining === 1) {
            return {
                status: 'warning',
                title: formatLabelTranslation(
                    'VisitLimitWarning',
                    'This visit will reach the {0} (last visit allowed).',
                    [label]
                )
            };
        }
        return {
            status: 'error',
            title: formatLabelTranslation(
                'VisitLimitError',
                'This visit will exceed the {0} (no remaining).',
                [label]
            )
        };
    }

    /**
     * Resolve the enforcement mode from the rep's assigned permission sets. This
     * lets one profile span regions while enforcement differs by region, driven
     * purely by permission set assignment. Rename these to match your org:
     *   'hard' — "VisitLimits_UK" assigned → breaches block (warning → error)
     *   'soft' — "VisitLimits_IT" assigned → breaches advise (error → warning)
     *   null   — neither assigned → default tiering
     * Permission sets are read as ordinary queries; no special API needed.
     * Fails open (returns null) on any query error, so a configuration or
     * permission issue never blocks the rep.
     */
    async function resolveEnforcementMode(userId) {
        if (!userId) return null;
        try {

            // (1) name -> id for our two control permission sets.
            let itId = null, ukId = null;
            const psRows = await db.query(
                'PermissionSet',
                await new ConditionBuilder(
                    'PermissionSet',
                    new SetCondition('Name', 'IN', ['VisitLimits_IT', 'VisitLimits_UK'])
                ).build(),
                ['Id', 'Name']
            );
            const psList = psRows || [];
            for (let i = 0; i < psList.length; i++) {
                const nm = psList[i].stringValue('Name');
                const id = psList[i].stringValue('Id');
                if (nm === 'VisitLimits_IT') itId = id;
                if (nm === 'VisitLimits_UK') ukId = id;
            }
            if (!itId && !ukId) return null;

            // (2) the rep's assigned permission-set ids (flat field works).
            const psaRows = await db.query(
                'PermissionSetAssignment',
                await new ConditionBuilder(
                    'PermissionSetAssignment',
                    new FieldCondition('AssigneeId', '=', userId)
                ).build(),
                ['PermissionSetId']
            );
            const assignedIds = {};
            const psaList = psaRows || [];
            for (let i = 0; i < psaList.length; i++) {
                const pid = psaList[i].stringValue('PermissionSetId');
                if (pid) assignedIds[pid] = true;
            }

            // hard wins if a rep somehow has both.
            if (ukId && assignedIds[ukId]) return 'hard';
            if (itId && assignedIds[itId]) return 'soft';
            return null;
        } catch (e) {
            return null;
        }
    }

    /**
     * Apply the enforcement mode to one result.
     *   'soft' — downgrades errors → warnings (rep can proceed past a breach)
     *   'hard' — upgrades warnings → errors (any breach blocks the save)
     * success is never changed.
     */
    function applyEnforcementMode(result, mode) {
        if (!result || !mode) return result;
        if (mode === 'soft' && result.status === 'error') {
            return { status: 'warning', title: result.title };
        }
        if (mode === 'hard' && result.status === 'warning') {
            return { status: 'error', title: result.title };
        }
        return result;
    }

    /**
     * Prefix a result's title with which visit it came from, so reps can tell a
     * parent breach from a specific attendee's breach (e.g. "Parent visit — …"
     * vs "Child visit 2 (acct …) — …"). Only changes the title; preserves status.
     *
     * Title shape depends on platform — see formatLabelTranslation:
     *   - Web: structured { key, defaultValue, argumentsList }. Prepend the
     *     visit label into defaultValue (the resolved value when the label
     *     isn't deployed) AND prepend a literal '{ visit } — ' prefix into
     *     a copy of the structured object so the validation-result handler
     *     sees it. We can't string-concat — that produces '[object Object]'.
     *   - Mobile: plain string. Direct concat works.
     */
    function labelResult(result, visitLabel) {
        if (!result || !visitLabel) return result;
        const t = result.title;
        if (t && typeof t === 'object' && t.key) {
            return {
                status: result.status,
                title: {
                    key: t.key,
                    defaultValue: visitLabel + ' — ' + t.defaultValue,
                    argumentsList: t.argumentsList
                }
            };
        }
        return { status: result.status, title: visitLabel + ' — ' + t };
    }

    /**
     * Find Visit rows for an account that have not yet been processed by the
     * engine (Visit_Limit_Status__c is null or empty), excluding the visits we
     * are validating in this very run. Used to add pending in-flight visits to
     * the limit check so we don't under-count breaches between save-time and
     * engine-job-time. excludeVisitIds is parent + every child Id from the
     * current validation context, so a re-save of an already-saved-but-unprocessed
     * visit doesn't double-count itself.
     *
     * Counts every visit with an empty Visit_Limit_Status__c as pending,
     * regardless of its Status (including Completed). This matters for mobile
     * offline: the engine does not debit the used count until the visit syncs, so
     * a visit submitted offline is Completed yet still genuinely uncounted. An
     * empty Visit_Limit_Status__c is the single "not yet processed" signal.
     *
     * Returns [{id, ownerId}]. Fails open to [] on any query error.
     *
     * Note: the exclude-list clause is omitted when the list is empty, because an
     * empty "NOT IN ()" predicate is invalid on the mobile (SQLite) query path.
     */
    async function findPendingVisitsForAccount(accountId, excludeVisitIds) {
        if (!accountId) return [];
        const exclude = excludeVisitIds || [];
        try {

            // The exclude-list field differs by platform: mobile rows are keyed
            // by 'uid' (always populated, even for local pre-sync visits), web
            // rows by 'Id' (the server-side record Id).
            const excludeField = isMobilePlatform ? 'uid' : 'Id';
            const statusEmpty = new OrCondition()
                .add(new FieldCondition(ns('Visit_Limit_Status__c'), '=', null))
                .add(new FieldCondition(ns('Visit_Limit_Status__c'), '=', ''));
            const condition = new AndCondition()
                .add(new FieldCondition('AccountId', '=', accountId))
                .add(statusEmpty);
            if (exclude.length > 0) {
                condition.add(new SetCondition(excludeField, 'NOT IN', exclude));
            }
            const rows = await db.query(
                'Visit',
                await new ConditionBuilder('Visit', condition).build(),
                ['Id', 'OwnerId']
            );
            const list = rows || [];
            const out = [];
            for (let i = 0; i < list.length; i++) {

                // Prefer 'uid' on mobile (local-only visits have a uid but no Id),
                // fall back to 'Id' on web (no uid column there).
                const id = isMobilePlatform
                    ? (list[i].stringValue('uid') || list[i].stringValue('Id'))
                    : list[i].stringValue('Id');
                const ownerId = list[i].stringValue('OwnerId');
                if (id) out.push({ id: id, ownerId: ownerId });
            }
            return out;
        } catch (e) {
            return [];
        }
    }

    /**
     * Given a set of pending Visit Ids (from findPendingVisitsForAccount) and
     * the product Ids in scope (the rolled-up list from perProductVisitLimit),
     * return { productId: count-of-distinct-visit-ids-detailing-that-product }.
     *
     * Counts DISTINCT VisitId per product, so a pending visit with two detail
     * rows for the same product still adds 1 to that product's pending count
     * (one visit consumes one slot per product, regardless of how many detail
     * lines it has — matches the engine's eventual behavior).
     *
     * Fails open to {} on any query error.
     */
    async function countPendingVisitsByProduct(pendingVisitIds, productIds) {
        const out = {};
        if (!pendingVisitIds || pendingVisitIds.length === 0) return out;
        if (!productIds || productIds.length === 0) return out;
        try {
            const condition = new AndCondition()
                .add(new SetCondition('VisitId', 'IN', pendingVisitIds))
                .add(new SetCondition('ProductId', 'IN', productIds));
            const rows = await db.query(
                'ProviderVisitProdDetailing',
                await new ConditionBuilder('ProviderVisitProdDetailing', condition).build(),
                ['VisitId', 'ProductId']
            );
            const list = rows || [];
            const seen = {}; // productId -> { visitId: true } so we de-dup multi-detail rows
            for (let i = 0; i < list.length; i++) {
                const vid = list[i].stringValue('VisitId');
                const pid = list[i].stringValue('ProductId');
                if (!vid || !pid) continue;
                if (!seen[pid]) seen[pid] = {};
                if (!seen[pid][vid]) {
                    seen[pid][vid] = true;
                    out[pid] = (out[pid] || 0) + 1;
                }
            }
            return out;
        } catch (e) {
            return {};
        }
    }

    /**
     * UserLimit check — per-rep cap for (Account, User).
     * We filter by User__c = userId, which naturally isolates UserLimit-record-type
     * rows: ProductLimit rows are cross-rep and leave User__c blank, so they can't
     * match a specific userId. This keeps the query to proven-safe flat-field
     * conditions (no RecordType relationship-path filter needed).
     *
     * pendingForThisRep is the count of in-flight visits owned by this rep on
     * this account that the engine hasn't processed yet (status null/empty),
     * EXCLUDING the visits in the current validation context. We subtract it
     * from each row's RemainingCount__c so the rep sees the correct effective
     * remaining (RemainingCount__c lags behind the engine's eventual UsedCount).
     *
     * Fails open (returns []) on any query error.
     */
    async function perRepVisitLimit(accountId, userId, verb, pendingForThisRep) {
        if (!accountId || !userId) return [];
        try {
            const condition = new AndCondition()
                .add(new FieldCondition(ns('Account__c'), '=', accountId))
                .add(new FieldCondition(ns('User__c'), '=', userId))
                .add(new FieldCondition(ns('IsActive__c'), '=', true));
            const rows = await db.query(
                ns('ProviderVisitLimit__c'),
                await new ConditionBuilder(ns('ProviderVisitLimit__c'), condition).build(),
                ['Id', ns('Region__c'), ns('LimitCount__c'), ns('UsedCount__c'), ns('RemainingCount__c')]
            );
            const pendingDeduction = (typeof pendingForThisRep === 'number') ? pendingForThisRep : 0;
            const results = [];
            const list = rows || [];
            for (let i = 0; i < list.length; i++) {
                const remainingRaw = list[i].numValue(ns('RemainingCount__c'));
                const remaining = (typeof remainingRaw === 'number') ? (remainingRaw - pendingDeduction) : remainingRaw;
                const region = list[i].stringValue(ns('Region__c')) || 'region';
                results.push(evaluateLimit(remaining, region + ' per-rep limit', verb));
            }
            return results;
        } catch (e) {
            return [];
        }
    }

    /**
     * Roll product variants up to their parent products via ProductLimitMap__c,
     * then dedupe. For example, two regional variants of a product can both roll
     * up to the same parent so they share one limit. Returns a deduped array of
     * (parent-or-self) product IDs. Fails open (returns the inputs un-rolled).
     */
    async function rollUpProducts(productIds) {
        if (!productIds || productIds.length === 0) return [];

        // Map of childId -> parentId from ProductLimitMap__c.
        const parentOf = {};
        try {
            const rows = await db.query(
                ns('ProductLimitMap__c'),
                await new ConditionBuilder(
                    ns('ProductLimitMap__c'),
                    new SetCondition(ns('Product__c'), 'IN', productIds)
                ).build(),
                [ns('Product__c'), ns('ParentProduct__c')]
            );
            const list = rows || [];
            for (let i = 0; i < list.length; i++) {
                const childId = list[i].stringValue(ns('Product__c'));
                const parentId = list[i].stringValue(ns('ParentProduct__c'));
                if (childId && parentId) parentOf[childId] = parentId;
            }
        } catch (e) {

            // No map / query failed → every product is its own parent.\
        }

        // Roll up + dedupe with a seen-object accumulator.
        const seen = {};
        const unique = [];
        for (let i = 0; i < productIds.length; i++) {
            const rolled = parentOf[productIds[i]] || productIds[i];
            if (!seen[rolled]) {
                seen[rolled] = true;
                unique.push(rolled);
            }
        }
        return unique;
    }

    /**
     * Build an id → Name map for a set of LifeSciMarketableProduct ids, so limit
     * messages can show the product name instead of the raw id. Fails open to an
     * empty map (the caller then falls back to showing the id).
     */
    async function resolveProductNames(productIds) {
        const nameOf = {};
        if (!productIds || productIds.length === 0) return nameOf;
        try {
            const rows = await db.query(
                'LifeSciMarketableProduct',
                await new ConditionBuilder(
                    'LifeSciMarketableProduct',
                    new SetCondition('Id', 'IN', productIds)
                ).build(),
                ['Id', 'Name']
            );
            const list = rows || [];
            for (let i = 0; i < list.length; i++) {
                const id = list[i].stringValue('Id');
                const nm = list[i].stringValue('Name');
                if (id && nm) nameOf[id] = nm;
            }
        } catch (e) {

            // Fail open — caller falls back to showing the raw id.
        }
        return nameOf;
    }

    /**
     * ProductLimit check — cross-rep cap for (Account, Product); counts across all
     * reps (not filtered by user). Matching on Product__c naturally selects only
     * ProductLimit rows, since per-rep UserLimit rows leave Product__c blank.
     *
     * pendingByProduct: { productId: count of distinct in-flight visits detailing
     * that product for THIS account }, keyed by the rolled-up product, subtracted
     * from each row's remaining so the rep sees the correct effective remaining.
     */
    async function perProductVisitLimit(accountId, productIds, verb, pendingByProduct) {
        if (!accountId || !productIds || productIds.length === 0) return [];
        const rolledProductIds = await rollUpProducts(productIds);
        if (rolledProductIds.length === 0) return [];
        try {
            const condition = new AndCondition()
                .add(new FieldCondition(ns('Account__c'), '=', accountId))
                .add(new SetCondition(ns('Product__c'), 'IN', rolledProductIds))
                .add(new FieldCondition(ns('IsActive__c'), '=', true));
            const rows = await db.query(
                ns('ProviderVisitLimit__c'),
                await new ConditionBuilder(ns('ProviderVisitLimit__c'), condition).build(),
                ['Id', ns('Product__c'), ns('Region__c'), ns('LimitCount__c'), ns('UsedCount__c'), ns('RemainingCount__c')]
            );
            const list = rows || [];

            // Resolve product names for friendlier messages.
            const limitProductIds = [];
            for (let i = 0; i < list.length; i++) {
                const pid = list[i].stringValue(ns('Product__c'));
                if (pid) limitProductIds.push(pid);
            }
            const nameOf = await resolveProductNames(limitProductIds);
            const pending = pendingByProduct || {};
            const results = [];
            for (let i = 0; i < list.length; i++) {
                const remainingRaw = list[i].numValue(ns('RemainingCount__c'));
                const region = list[i].stringValue(ns('Region__c')) || 'region';
                const productId = list[i].stringValue(ns('Product__c'));
                const productName = nameOf[productId] || productId;
                const pendingDeduction = pending[productId] || 0;
                const remaining = (typeof remainingRaw === 'number') ? (remainingRaw - pendingDeduction) : remainingRaw;
                results.push(evaluateLimit(remaining, region + ' product limit for ' + productName, verb));
            }
            return results;
        } catch (e) {
            return [];
        }
    }

    /**
     * Run UserLimit + ProductLimit for ONE visit (parent OR child), tagged with
     * visitLabel so the rep can tell which visit each result came from. The
     * enforcement mode is resolved once at the top level and passed in, so we
     * don't re-query PermissionSet for every child. Returns a (possibly empty)
     * array of labelled result objects.
     */
    async function runLimitChecksForVisit(visitLabel, accountId, userId, productIds, verb, mode, excludeVisitIds) {
        if (!accountId) return [];

        // Pending in-flight visits for THIS visit's account (status null/empty,
        // not in the current validation context). Derive per-rep count for
        // UserLimit and per-product distinct-VisitId map for ProductLimit.
        const pendingVisits = await findPendingVisitsForAccount(accountId, excludeVisitIds || []);
        const pendingVisitIds = [];
        let pendingForThisRep = 0;
        for (let i = 0; i < pendingVisits.length; i++) {
            pendingVisitIds.push(pendingVisits[i].id);
            if (userId && pendingVisits[i].ownerId === userId) pendingForThisRep += 1;
        }
        const rolledProductIds = (productIds && productIds.length > 0) ? await rollUpProducts(productIds) : [];
        const pendingByProduct = await countPendingVisitsByProduct(pendingVisitIds, rolledProductIds);
        let rawResults = [];
        const userResults = await perRepVisitLimit(accountId, userId, verb, pendingForThisRep);
        rawResults = rawResults.concat(userResults);
        const productResults = await perProductVisitLimit(accountId, productIds, verb, pendingByProduct);
        rawResults = rawResults.concat(productResults);
        const labeled = [];
        for (let i = 0; i < rawResults.length; i++) {
            labeled.push(labelResult(applyEnforcementMode(rawResults[i], mode), visitLabel));
        }
        return labeled;
    }

    /**
     * Top-level orchestrator. On a parent Save/Sign/Submit with attendees,
     * validates the parent AND every child visit; on a child Save (no ChildVisit
     * array in context) it validates just that one visit (the loop over [] is a
     * no-op). Settings kill-switch + action eligibility are evaluated once here,
     * up front, before any per-visit work.
     *
     * Child product details are NOT in the parent payload on either platform, so
     * we fetch them per child via local DB query (resolveChildProductIds). On the
     * actions in actionsThatCloneParentProductDetailsToChildren, the engine clones
     * the parent's products onto a detail-empty child AFTER validation — so to
     * avoid under-counting, an empty child inherits the parent's productIds for
     * the validation pass (skipped when the parent has no products to clone).
     */
    async function runLimitChecksAllVisits(parentRecord, parentAccountId, userId, parentProductIds, verb, actionName) {

        // (1) Settings: master kill-switch + action gating. Read from __mdt on
        // web and on mobile when the CMT is registered for sync; hardcoded
        // defaults otherwise. Only an explicit IsActive__c=false disables — any
        // read failure defaults to enforcing, never silently off.
        const settings = await resolveSettings(db);
        if (settings.active === false) {
            return [{ status: 'success', title: 'Visit-limit enforcement disabled by settings' }];
        }
        if (settings.allowedActions.indexOf(actionName) === -1) {
            return [{ status: 'success', title: 'Visit-limit check skipped — action is "' + actionName + '"' }];
        }

        // (2) Per-country enforcement mode — resolved once, applied to every result.
        const mode = await resolveEnforcementMode(userId);

        // (2b) Build the excludeVisitIds list once — every visit Id being
        // validated in this run (parent + each child). The pending-visit queries
        // subtract these so a re-save of an already-saved-but-unprocessed visit
        // doesn't double-count itself in its own limit check.
        const ctx = parseContextData(parentRecord);
        const childRows = extractChildVisits(ctx);

        // Resolve the parent visit's own Id robustly across platforms. We try, in
        // order: the record's 'uid' (mobile), then the Visit / ProviderVisit
        // context Id (web), then the record's 'Id'. First non-empty wins.
        const excludeVisitIds = [];
        let parentVisitId = null;
        try { parentVisitId = parentRecord && typeof parentRecord.stringValue === 'function' ? parentRecord.stringValue('uid') : null; } catch (e) { /* fall through */ }
        if (!parentVisitId && ctx && ctx.Visit) parentVisitId = rowField(ctx.Visit, 'VisitId') || rowField(ctx.Visit, 'Id');
        if (!parentVisitId && ctx && ctx.ProviderVisit) parentVisitId = rowField(ctx.ProviderVisit, 'VisitId') || rowField(ctx.ProviderVisit, 'Id');
        if (!parentVisitId) {
            try { parentVisitId = parentRecord && typeof parentRecord.stringValue === 'function' ? parentRecord.stringValue('Id') : null; } catch (e) { /* fall through */ }
        }
        if (parentVisitId) excludeVisitIds.push(parentVisitId);
        for (let i = 0; i < childRows.length; i++) {
            const cid = rowField(childRows[i], 'Id') || rowField(childRows[i], 'VisitId');
            if (cid) excludeVisitIds.push(cid);
        }
        const allResults = [];

        // (3) Parent visit (or the lone visit, if we arrived via a child Save).
        // Skip if this visit was already processed (Visit_Limit_Status__c set) —
        // its outcome was decided on a prior pass; re-validating a re-save would
        // re-prompt / double-count it.
        if (!visitAlreadyProcessed(parentRecord)) {
            const parentResults = await runLimitChecksForVisit(
                'Parent visit', parentAccountId, userId, parentProductIds, verb, mode, excludeVisitIds);
            for (let i = 0; i < parentResults.length; i++) allResults.push(parentResults[i]);
        }

        // (4) Child visits — only present when the parent is the visit being saved.
        const cloneTriggered =
            actionsThatCloneParentProductDetailsToChildren.indexOf(actionName) !== -1
            && parentProductIds.length > 0;
        for (let i = 0; i < childRows.length; i++) {
            const child = childRows[i];

            // Skip already-processed children too (same reason as the parent).
            if (childVisitAlreadyProcessed(child)) continue;
            const childAccountId = resolveChildAccountId(child);
            let childProductIds = await resolveChildProductIds(child);
            const ownProductCount = childProductIds.length;
            const willClone = (ownProductCount === 0 && cloneTriggered);
            if (willClone) {
                childProductIds = parentProductIds;
            }
            const childLabel = 'Child visit ' + (i + 1) +
                (childAccountId ? ' (acct ' + childAccountId + ')' : '');
            const childResults = await runLimitChecksForVisit(
                childLabel, childAccountId, userId, childProductIds, verb, mode, excludeVisitIds);
            for (let j = 0; j < childResults.length; j++) allResults.push(childResults[j]);
        }

        // No matching limits anywhere → explicit success (never [] / null, which
        // the framework treats as a generic failure).
        if (allResults.length === 0) {
            allResults.push({ status: 'success', title: 'No active visit limits configured for this account' });
        }
        return allResults;
    }

    // ─── Entry point ────────────────────────────────────────────────────────
    if (record && user && env && db) {
        const actionName = getActionName(env);

        // Action-aware verb wording for messages.
        const verbByAction = {
            Save:   'Saving',
            Submit: 'Submitting',
            Sign:   'Capturing signature for'
        };
        const verb = verbByAction[actionName] || 'Logging';

        // Parse the visit context (the parent visit; children are resolved inside
        // the orchestrator). Settings and action-eligibility are evaluated there.
        const accountId = resolveAccountId(record);
        const userId = resolveUserId(env, user);
        const productIds = resolveProductIds(record);

        // The two surfaces expect a different return shape, so we detect which one
        // we're on and return accordingly. Web awaits a promise; mobile evaluates
        // synchronously and expects a plain array. We detect the surface from the
        // context (web exposes a top-level "ProviderVisit" key, mobile "Visit"),
        // which lets the same source run on both.
        const ctxData = parseContextData(record) || {};
        const hasWebField = ctxData['ProviderVisit'] !== undefined;
        try {
            if (hasWebField) {

                // Web — return [promise] so the framework can await it.
                const resultsPromise = runLimitChecksAllVisits(record, accountId, userId, productIds, verb, actionName)
                    .then(function (results) {
                        return results;
                    })
                    .catch(function (error) {
                        return [{ status: 'success', title: 'Visit-limit check skipped — technical error' }];
                    });
                return [resultsPromise];
            } else {

                // Mobile — resolves to an array directly.
                const results = runLimitChecksAllVisits(record, accountId, userId, productIds, verb, actionName);
                return results;
            }
        } catch (error) {
            return [{ status: 'success', title: 'Visit-limit check skipped — technical error' }];
        }
    }
    return [];
})();
 
正在加载
Salesforce Help | Article