Auto Card Recon

Uploads card recon receipts, sets descriptions, and uses Mass Allocate for Accounting.

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Auto Card Recon
// @namespace    http://tampermonkey.net/
// @version      3.0
// @description  Uploads card recon receipts, sets descriptions, and uses Mass Allocate for Accounting.
// @author       Gemini & Elder Benjamin Finch
// @match        https://card.churchofjesuschrist.org/psc/card/*
// @grant        GM_addStyle
// @require      https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js
// @run-at       document-idle
// ==/UserScript==

(function() {
    'use strict';

    if (window.self !== window.top) return;

    if (typeof pdfjsLib !== 'undefined') {
        pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
    }

    const log = (msg) => console.log(`[Recon v3.0] ${msg}`);
    const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));

    const waitForElement = (selector, context = document, timeout = 30000) => {
        return new Promise((resolve, reject) => {
            log(`Waiting for: "${selector}"`);
            const intervalTime = 100;
            let elapsedTime = 0;
            const interval = setInterval(() => {
                const element = context.querySelector(selector);
                if (element) {
                    // log(`Found: "${selector}"`); // Reduce noise
                    clearInterval(interval);
                    resolve(element);
                } else {
                    elapsedTime += intervalTime;
                    if (elapsedTime >= timeout) {
                        clearInterval(interval);
                        log(`FATAL ERROR: Timeout waiting for "${selector}"`);
                        reject(new Error(`Timeout waiting for: "${selector}"`));
                    }
                }
            }, intervalTime);
        });
    };

    const findNewestIframe = async (currentCount, timeout = 30000) => {
        log(`Looking for iframe > ${currentCount}...`);
        const start = Date.now();
        while (Date.now() - start < timeout) {
            const frames = document.querySelectorAll('iframe[id^="ptModFrame_"]');
            if (frames.length > currentCount) {
                const newFrame = frames[frames.length - 1];
                try {
                    if (newFrame.contentDocument && newFrame.contentDocument.body.innerHTML.length > 50) {
                        return newFrame;
                    }
                } catch (e) { /* cross-origin wait */ }
            }
            await sleep(500);
        }
        throw new Error("Timeout waiting for new iframe to load.");
    };

    // --- METADATA ---
    async function extractKeywordsFromPDF(file) {
        try {
            const pdf = await pdfjsLib.getDocument({ data: await file.arrayBuffer() }).promise;
            const meta = await pdf.getMetadata();
            if (meta?.info?.Keywords) {
                const kw = meta.info.Keywords.trim();
                try { return JSON.parse(kw); } catch (e) { return kw.split(/[,; \t]+/).filter(k => k.trim()); }
            }
        } catch (e) { log(`Metadata error for ${file.name}: ${e.message}`); }
        return null;
    }

    // --- GUI ---
    function addStyles() {
        GM_addStyle(`
            #recon-float-btn { position: fixed; bottom: 20px; right: 20px; z-index: 9999; padding: 12px 20px; background: #007bff; color: white; border: none; border-radius: 50px; cursor: pointer; box-shadow: 0 4px 10px rgba(0,0,0,0.3); font-size: 16px; }
            #recon-float-btn:hover { background: #0056b3; }
            #recon-float-btn:disabled { background: #999; cursor: wait; }
        `);
    }

    function createUI() {
        const btn = document.createElement('button');
        btn.id = 'recon-float-btn';
        btn.textContent = 'Start Recon';
        document.body.appendChild(btn);
        const fileIn = document.createElement('input');
        fileIn.type = 'file'; fileIn.multiple = true; fileIn.accept = '.pdf'; fileIn.style.display = 'none';
        document.body.appendChild(fileIn);
        btn.onclick = () => fileIn.click();
        fileIn.onchange = handleFiles;
    }

    // --- MAIN LOOP ---
    async function handleFiles(e) {
        const btn = document.getElementById('recon-float-btn');
        btn.disabled = true; btn.textContent = 'Processing...';

        try {
            const files = Array.from(e.target.files);
            if (!files.length) return;

            log(`Reading ${files.length} files...`);
            const receipts = [];
            for (const file of files) {
                const name = file.name.replace(/\.pdf$/i, '');
                const parts = name.split('-');
                if (parts.length >= 2) {
                    const [valStr, cur] = parts[0].trim().split(' ');
                    const val = parseFloat(valStr.replace(/,/g, ''));
                    if (!isNaN(val)) {
                        receipts.push({
                            file,
                            value: val,
                            currency: (cur || 'USD').toUpperCase(),
                            lineItem: parts[parts.length - 1].trim(),
                            keywords: await extractKeywordsFromPDF(file)
                        });
                    }
                }
            }

            if (!receipts.length) { alert("No valid filenames found."); return; }

            // 1. Process Uploads & Descriptions
            for (const r of receipts) {
                await processReceipt(r);
            }

            // 2. Perform Mass Allocation
            await runMassAllocation(receipts);

            alert("Processing Complete!");

        } catch (err) {
            log("FATAL ERROR: " + err.message);
            console.error(err);
            alert("Error: " + err.message);
        } finally {
            btn.disabled = false; btn.textContent = 'Start Recon';
            e.target.value = '';
        }
    }

    async function processReceipt(r) {
        log(`>>> Processing: ${r.lineItem} (${r.value})`);

        // Find row by Amount on main page
        const rows = document.querySelectorAll('li.ps_grid-row');
        let row = null;
        for (const rCandidate of rows) {
            const amt = rCandidate.querySelector("[id^='MONETARY_AMT_DTL\\$']")?.textContent.replace(/,/g, '');
            if (amt && Math.abs(parseFloat(amt) - r.value) < 0.01) {
                row = rCandidate;
                break;
            }
        }

        if (!row) { log(`No match for ${r.value}`); return; }

        // 1. Set Description
        row.click();
        log("Waiting for row details...");
        await sleep(4000);

        const desc = await waitForElement('#DESCR\\$0');
        desc.focus(); desc.value = r.lineItem;
        desc.dispatchEvent(new Event('change', { bubbles: true }));
        desc.blur();

        log("Waiting for description change...");
        await sleep(4000);

        // 2. Attach File
        let iframes = document.querySelectorAll('iframe').length;
        (await waitForElement("a[id^='EX_LINE_WRK_ATTACH_PB']")).click();
        const f1 = await findNewestIframe(iframes);
        const d1 = f1.contentDocument;

        iframes = document.querySelectorAll('iframe').length;
        (await waitForElement("a[id^='C_EX_ATT_WRK_ATTACHADD']", d1)).click();
        const f2 = await findNewestIframe(iframes);

        log("Waiting for upload modal...");
        await sleep(3500);
        const d2 = f2.contentDocument;

        const fileIn = await waitForElement('input[type="file"]#\\#ICOrigFileName', d2);
        const dt = new DataTransfer(); dt.items.add(r.file);
        fileIn.files = dt.files;
        fileIn.dispatchEvent(new Event('input', { bubbles: true }));
        fileIn.dispatchEvent(new Event('change', { bubbles: true }));
        fileIn.blur();
        await sleep(1500);

        (await waitForElement('a#\\#ICUpload', d2)).click();

        try {
            await waitForElement('.ps_attach-completetext', d2, 45000);
            log("Upload success.");
        } catch (e) {
            // Check if file exists in list
            if (!d2.body.textContent.includes(r.file.name)) throw new Error("Upload failed.");
        }

        (await waitForElement('a#\\#ICOK', d2)).click();
        await sleep(3000);

        // Save Attachment Modal
        const attDesc = await waitForElement("input[id^='ATTACH_DESCR\\$']", d1);
        attDesc.value = r.lineItem; attDesc.dispatchEvent(new Event('change', { bubbles: true }));
        (await waitForElement("a#\\#ICSave", d1)).click();
        await sleep(5000);

        // Removed Individual Accounting Logic from here
        log(`<<< Done with upload: ${r.lineItem}`);
    }

    // --- MASS ALLOCATION LOGIC ---

    async function runMassAllocation(receipts) {
        log(">>> STARTING MASS ALLOCATION PHASE <<<");

        // 1. Group receipts by Account Key (DeptSuffix + AccountCode)
        const groups = {};
        for (const r of receipts) {
            if (r.keywords && r.keywords.length >= 2) {
                // Key format: "400-5170" (DeptSuffix-Account)
                const key = `${r.keywords[0]}-${r.keywords[1]}`;
                if (!groups[key]) groups[key] = [];
                groups[key].push(r);
            } else {
                log(`Skipping allocation for ${r.lineItem} - Missing keywords`);
            }
        }

        const keys = Object.keys(groups);
        if (keys.length === 0) { log("No accounting keywords found."); return; }

        for (const key of keys) {
            const groupReceipts = groups[key];
            const deptSuffix = groupReceipts[0].keywords[0];
            const accountCode = groupReceipts[0].keywords[1];

            log(`Allocating Group: Dept 1863${deptSuffix} | Acct ${accountCode} | Count: ${groupReceipts.length}`);

            await performSingleMassAllocation(groupReceipts, deptSuffix, accountCode);

            // Wait for main page refresh before next group
            await sleep(4000);
        }
    }

    async function performSingleMassAllocation(groupReceipts, deptSuffix, accountCode) {
        // 1. Open Mass Allocate Modal
        const iframes = document.querySelectorAll('iframe').length;

        // Try finding the button on the main document or active iframe
        let massBtn = document.querySelector("a#EX_SHEET_FL_WRK_MASS_CHANGE_PB");
        if (!massBtn) {
            // Check existing iframes if button not on top
            const frames = document.querySelectorAll('iframe');
            for(let f of frames) {
                 massBtn = f.contentDocument?.querySelector("a#EX_SHEET_FL_WRK_MASS_CHANGE_PB");
                 if(massBtn) break;
            }
        }

        if (!massBtn) {
             log("Cannot find Mass Allocate button!");
             return;
        }

        massBtn.click();

        // 2. Wait for Modal
        const frame = await findNewestIframe(iframes);
        const doc = frame.contentDocument;
        await sleep(3000); // Wait for grid to render

        // 3. Find Rows & Select Matches
        const rows = doc.querySelectorAll("tr[id^='C_EX_DIST_DVW$0_row']");
        let matchesFound = 0;

        for (const row of rows) {
            // Extract Data from Row
            const amountEl = row.querySelector("span[id^='C_EX_DIST_DVW_TXN_AMOUNT']");
            const descEl = row.querySelector("p[id^='C_EX_DIST_DVW_DESCR254']");

            if (!amountEl || !descEl) continue;

            const rowAmount = parseFloat(amountEl.textContent.replace(/,/g, ''));
            const rowDesc = descEl.textContent.trim();

            // Check if this row matches ANY receipt in our current group
            // We match by Description (which we set earlier) AND Amount
            const isMatch = groupReceipts.some(r =>
                Math.abs(r.value - rowAmount) < 0.01 &&
                rowDesc === r.lineItem
            );

            if (isMatch) {
                const checkbox = row.querySelector("input[type='checkbox']");
                if (checkbox && !checkbox.checked) {
                    checkbox.click();
                    matchesFound++;
                    log(`Selected row: ${rowDesc} (${rowAmount})`);
                }
            }
        }

        // 4. Fill Data OR Cancel
        if (matchesFound > 0) {
            log(`Matches selected: ${matchesFound}. Filling data...`);

            // Helper for inputs
            const setVal = async (sel, val) => {
                const el = await waitForElement(sel, doc);
                el.focus();
                el.value = val;
                el.dispatchEvent(new Event('change', { bubbles: true }));
                // Simulate Enter for validation
                el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', keyCode: 13, bubbles: true }));
                await sleep(1500);
                el.blur();
                await sleep(1000);
            };

            await setVal('input#C_EX_MASSUPD_VW_DEPTID\\$0', "1863" + deptSuffix);
            await setVal('input#C_EX_MASSUPD_VW_ACCOUNT\\$0', accountCode);

            log("Clicking Apply...");
            const applyBtn = await waitForElement('a#C_EX_MASSUPD_WK_PB_APPLY', doc);
            applyBtn.click();

            // Wait for processing loop to finish
            await sleep(5000);

        } else {
            log("No matching rows found for this group in Mass Allocate window. Cancelling...");

            // Click Cancel to close modal without errors
            const cancelBtn = doc.querySelector('a#C_EX_MASSUPD_WK_PB_CANCEL');
            if (cancelBtn) cancelBtn.click();
            else {
                // If Cancel isn't standard, try closing via OK (fallback)
                const okBtn = doc.querySelector('a#\\#ICOK'); // Note: escaped ID
                if (okBtn) okBtn.click();
            }
        }
    }

    createUI();
    addStyles();
})();