跳轉到

應用情境 - ERP 客製 UOF X 簽核箱

「維護表單」功能用於讓外部系統可以取得已申請、待處理及可申請的表單,並可透過 SDK 將表單作廢。亦可搭配「Url 登入」功能,自動登入 UOF X 並直接開啟對應表單,讓 ERP 系統與 UOF X 無縫整合。 以下會展示一個應用情境:「ERP 客製 UOF X 簽核箱」,並提供具體的實作步驟與方法作為參考。

應用情境

企業過去在 ERP 系統內管理申請表單時,需手動查閱 BPM 系統的簽核狀態,或切換至 BPM 介面進行相關操作,流程繁瑣且容易導致延誤或遺漏。

透過 UOF X 的「維護表單」與「Url 登入」功能,ERP 系統可直接查看 UOF X 內表單,並提供作廢與自動登入功能,使流程更加順暢且資訊即時同步。 ERP 系統的首頁面板中可以看到 UOF X 中可申請、已申請與待處理三種狀態表單,當使用者點擊面板中的表單時,系統將透過 URL 登入機制,自動登入 UOF X 並開啟對應表單,無須額外登入,此外,亦可透過 SDK 直接作廢 UOF X 中的表單,提升使用體驗與作業效率。

Image open-form-manager

ERP 面板示意圖

UOF X 與 ERP 整合優勢

  • 流程透明:可於 ERP 系統隨時查閱簽核進度,確保流程順暢並有效控管。
  • 自動登入:使用者無須重新登入 UOF X,提升使用者體驗。
  • 直接作廢表單:可透過 SDK 直接作廢 UOF X 內的表單,無需進入 UOF X 操作,提高作業效率並確保數據同步。

啟動專案

在繼續往下說明之前,請確認您下列事項皆已經準備完成:

  1. 已取得 UOF X 站台網址
  2. 已取得 金鑰

範例程式碼:UOFX-SDK-Training

完成以下步驟後,即可啟動專案觀看成果,下方 實作 會對程式碼進行逐步說明:

  • 設定 applicationUrl
  • 設定金鑰、站台網址

設定 applicationUrl

下載範例程式碼後,請先至 launchSettings.json 中的 http 設定 applicationUrl,並選擇 http 方式啟動專案

launchSettings.json
{
  ...
  "profiles": {
    "http": {
      "commandName": "Project",
      "dotnetRunMessages": true,
      "launchBrowser": true,
      "applicationUrl": "<您的 IP 位址>:5115",
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      }
    },
    ...
}

成功啟動專案後會看到以下畫面

Image open-form-manager

設定金鑰、站台網址

接著我們進行以下設定,以顯示表單資訊

A. 設定 appsettings.json

分別將金鑰與站台網址填入 appsettings.json 中的 UofxServiceKeyUofxServiceUrl

appsettings.json
"UofxServiceSettings": {
  "UofxServiceKey": "xxx",
  "UofxServiceUrl": "https://myuofx.com.tw/demo"
}

B. 設定取得表單相關資訊

  • ErpController 中設定 _targetUrl(站台網址)、_corpCode(公司代號) 與 _account(要查詢的帳號)
  • 將 Panel() 中的「取得表單資訊 model」取消註解,並將「空白 model」註解
ErpController.cs
namespace Url_Login_Sample.Controllers
{
    public class ErpController : Controller
    {
        ...
        private static string _targetUrl = "https://myuofx.com.tw/";
        private static string _corpCode = "_corpCode";
        private static string _account = "_account";
        ...
    }

    public async Task<IActionResult> Panel()
    {

        /// <summary>
        /// 取得表單資訊 model
        /// 請確保以下事項已完成,即可將下方程式取消註解,取得 UOF X 表單資料
        /// 1. 已於 appsettings.json 中設定 UofxServiceKey 與 UofxServiceUrl
        /// 2. 已設定上方 _targetUrl(站台網址)、_corpCode(公司代號)、_account(要查詢的帳號)
        /// 3. 將下方空白 model 註解
        /// </summary>
        // 取得可申請表單
        var canApply = await UofxService.BPM.GetAllCanApplyForms(_account);

        // 取得 3 年內表單
        var since = DateTimeOffset.Now.AddMonths(-36);
        var until = DateTimeOffset.Now;

        // 取得已申請表單前 100 筆資料
        var applyForm = await UofxService.BPM.SearchFormByApply(new SearchFormByApplyReqModel
        {
            Account = _account,
            Since = since,
            Until = until,
            Order = TaskListOrder.ApplicantDate,
            By = OrderBy.Descending
        });

        //取得待處理表單前 100 筆資料
        var awaitingForm = await UofxService.BPM.SearchFormByAwaiting(new SearchFormByAwaitingReqModel
        {
            Account = _account,
            Since = since,
            Until = until,
            Order = TaskListOrder.ApplicantDate,
            By = OrderBy.Descending
        });

        var model = new ErpPanelViewModel
        {
            allCanApplyForm = canApply,
            applyForm = applyForm,
            awaitingForm = awaitingForm,
            IsRequired = true
        };

        /// <summary>
        /// 空白 model,若已完成設定,請將下方註解
        /// </summary>
        //var model = new ErpPanelViewModel
        //{
        //    allCanApplyForm = new AllCanApplyFormViewModel
        //    {
        //        CategoryList = Enumerable.Empty<FormCategoryViewModel>(),
        //        FormList = Enumerable.Empty<ApplyTaskViewModel>()
        //    },
        //    applyForm = new SearchByPage<SearchFormByApplyResultModel>
        //    {
        //        PageInfo = new PageInfoModel(),
        //        Items = Enumerable.Empty<SearchFormByApplyResultModel>()
        //    },
        //    awaitingForm = new SearchByPage<SearchFormByAwaitingResultModel>
        //    {
        //        PageInfo = new PageInfoModel(),
        //        Items = Enumerable.Empty<SearchFormByAwaitingResultModel>()
        //    },
        //    IsRequired = false
        //};

        return View(model);
    }
}

設定完成並重新啟動站台後,會看到帶有表單資訊的畫面

Image open-form-manager

UOF X 設定 URL 登入

新增一個「URL登入驗證」,可參考:URL 登入驗證設定

Note

  1. 顯示名稱必須與 ButtonClick 中的 urlLoginName 相同
  2. Hash Key 必須與 ErpController 中的 _HashKey 相同
  3. Callback URL 必須設定為 Callback() 的網址

接著到「組織人員維護」,並進到欲顯示該人員表單資訊的「編輯人員」畫面,勾選「URL登入」並填入帳號

Image open-form-manager

Note

帳號需與 ErpController 中的 _account 相同

實作

以下為此情境中外部起單的實作步驟:

  • 建立模擬 ERP 系統畫面
  • 取得表單資料並顯示
  • 設定點擊表單後連結到 UOF X
  • 成果展示

建立模擬 ERP 系統畫面

A. 刻一個 ERP 系統畫面

先建立一個面板頁面,放上示意資料並保留空白的區域,後續將用來顯示 UOF X 中的表單資料

Image open-form-manager

Panel.cshtml
@{
    ViewBag.Title = "ERP 系統";
    Layout = null;
}

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>@ViewData["Title"]</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
    <style>
        /* body 樣式 */
        body {
            background-color: #f0f0f0;
            padding: 10px;
            height: 100vh;
        }

        /* 設定最大寬度 */
        .container-fluid {
            max-width: 1440px;
        }

        /* 標題列樣式 */
        .header-card {
            background-color: white;
            padding: 15px;
            border-radius: 10px;
            box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
        }

        /* 標題樣式 */
        .header-title {
            font-size: 24px;
            font-weight: bold;
            margin: 0;
        }

        /* 卡片樣式 */
        .card {
            border-radius: 10px;
            box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
            height: 100%;
        }

        /* 卡片標題 */
        .card-title {
            font-weight: bold;
            text-align: center;
            margin-bottom: 15px;
        }

        /* 第二行卡片內容高度 */
        .card-second-height {
            height: calc(100vh - 480px);
        }

        /* 螢幕寬度 >= 768px 設定 max-height-md 樣式 */
        @@media (min-width: 768px) {
            .max-height-md {
                max-height: 300px;
                overflow-y: hidden;
            }
        }

        /* 銷售比例圖樣式 */
        .donut-container {
            display: flex;
            justify-content: center;
            align-items: center;
            height: 200px;
        }

        /* 銷售趨勢圖樣式 */
        .chart-container {
            position: relative;
            width: 100%;
        }

        /* 異常樣式 */
        .exception {
            color: #6f42c1 !important;
            border-color: #6f42c1 !important;
        }
    </style>
</head>
<body>
    <div class="container-fluid">
        <!-- 標題列 -->
        <div class="header-card mb-4">
            <h1 class="header-title">ERP 系統</h1>
        </div>

        <div class="d-flex flex-column flex-fill">
            <!-- 第一列卡片 -->
            <div class="row mb-4 max-height-md">
                <!-- 銷售比例圖(示意資料) -->
                <div class="col-md-4">
                    <div class="card">
                        <div class="card-body">
                            <h5 class="card-title">北、中、南銷售比例</h5>
                            <div class="donut-container">
                                <canvas id="salesRatioChart"></canvas>
                            </div>
                        </div>
                    </div>
                </div>

                <!-- 本月營收(示意資料) -->
                <div class="col-md-2">
                    <div class="card">
                        <div class="card-body">
                            <h5 class="card-title">本月營收</h5>
                            <div class="d-flex justify-content-center align-items-center h-75">
                                <h3 id="monthlyRevenue">$ 1,250,000</h3>
                            </div>
                        </div>
                    </div>
                </div>

                <!-- 庫存異常(示意資料) -->
                <div class="col-md-2">
                    <div class="card">
                        <div class="card-body">
                            <h5 class="card-title">庫存異常</h5>
                            <div class="d-flex justify-content-center align-items-center h-75">
                                <h3 id="inventoryIssues">12</h3>
                            </div>
                        </div>
                    </div>
                </div>

                <!-- 採購金額(示意資料) -->
                <div class="col-md-2">
                    <div class="card">
                        <div class="card-body">
                            <h5 class="card-title">採購金額</h5>
                            <div class="d-flex justify-content-center align-items-center h-75">
                                <h3 id="purchaseAmount">$ 450,000</h3>
                            </div>
                        </div>
                    </div>
                </div>

                <!-- 待簽表單 -->
                <div class="col-md-2">
                    <div class="card">
                        <div class="card-body">
                            <h5 class="card-title">待處理表單</h5>
                            <div class="d-flex justify-content-center align-items-center h-75">

                            </div>
                        </div>
                    </div>
                </div>
            </div>

            <!-- 第二列卡片 -->
            <div class="row flex-grow-1">
                <!-- 銷售趨勢圖(示意資料) -->
                <div class="col-md-3">
                    <div class="card">
                        <div class="card-body">
                            <h5 class="card-title">銷售趨勢</h5>
                            <div class="chart-container card-second-height">
                                <canvas id="trendChart"></canvas>
                            </div>
                        </div>
                    </div>
                </div>

                <!-- 可申請表單 -->
                <div class="col-md-3">
                    <div class="card">
                        <div class="card-body">
                            <h5 class="card-title">可申請表單</h5>

                        </div>
                    </div>
                </div>

                <!-- 已申請表單 -->
                <div class="col-md-3">
                    <div class="card">
                        <div class="card-body">
                            <h5 class="card-title">已申請表單</h5>

                        </div>
                    </div>
                </div>

                <!-- 待處理表單 -->
                <div class="col-md-3">
                    <div class="card">
                        <div class="card-body">
                            <h5 class="card-title">待處理表單</h5>
                            <div class="list-group list-group-flush">

                            </div>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div>

    <!-- loaing 動畫 -->
    <div id="loading-overlay" class="position-fixed top-0 start-0 w-100 h-100 bg-dark bg-opacity-50 d-flex justify-content-center align-items-center z-3 d-none">
        <div class="spinner-border text-primary" role="status" style="width: 3rem; height: 3rem;">
            <span class="visually-hidden">Loading...</span>
        </div>
    </div>

    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
    <script>
        // 產生圖表
        document.addEventListener('DOMContentLoaded', function() {
            // 銷售比例圖(示意資料)
            const salesRatioCtx = document.getElementById('salesRatioChart').getContext('2d');
            const salesRatioChart = new Chart(salesRatioCtx, {
                type: 'doughnut',
                data: {
                    labels: ['北區', '中區', '南區'],
                    datasets: [{
                        data: [45, 30, 25],
                        backgroundColor: [
                            'rgba(54, 162, 235, 0.8)',
                            'rgba(255, 159, 64, 0.8)',
                            'rgba(255, 99, 132, 0.8)'
                        ],
                        borderColor: [
                            'rgba(54, 162, 235, 1)',
                            'rgba(255, 159, 64, 1)',
                            'rgba(255, 99, 132, 1)'
                        ],
                        borderWidth: 1
                    }]
                },
                options: {
                    responsive: true,
                    maintainAspectRatio: false,
                    plugins: {
                        legend: {
                            position: 'bottom'
                        }
                    },
                    cutout: '60%'
                }
            });

            // 銷售趨勢圖(示意資料)
            const trendCtx = document.getElementById('trendChart').getContext('2d');
            const trendChart = new Chart(trendCtx, {
                type: 'line',
                data: {
                    labels: ['1月', '2月', '3月', '4月', '5月', '6月'],
                    datasets: [{
                        label: '銷售額',
                        data: [650000, 700000, 690000, 750000, 810000, 950000],
                        borderColor: 'rgba(54, 162, 235, 1)',
                        backgroundColor: 'rgba(54, 162, 235, 0.1)',
                        borderWidth: 2,
                        tension: 0.3,
                        fill: true
                    }]
                },
                options: {
                    responsive: true,
                    maintainAspectRatio: false,
                    scales: {
                        y: {
                            beginAtZero: false,
                            ticks: {
                                callback: function(value) {
                                    return '$' + value.toLocaleString();
                                }
                            }
                        }
                    },
                    plugins: {
                        legend: {
                            display: false
                        }
                    }
                }
            });
        });
    </script>
</body>
</html>

B. 設定路由

Program.cs 中設定路由,導向 ErpControllerPanel

Program.cs
app.MapControllerRoute(
    name: "default",
    pattern: "{controller=Erp}/{action=Panel}/{id?}");

取得表單資料並顯示

A. 建立前端頁面所需的 ViewModel

Models 資料夾建立 ErpPanelViewModel.cs,用來接收從 Controller 傳到前端的資料

ErpPanelViewModel.cs
using Ede.Uofx.PubApi.Sdk.NetStd.Models;
using Ede.Uofx.PubApi.Sdk.NetStd.Models.Bpm;

namespace Url_Login_Sample.Models
{
    public class ErpPanelViewModel
    {
        public AllCanApplyFormViewModel allCanApplyForm { get; set; }
        public SearchByPage<SearchFormByApplyResultModel> applyForm { get; set; }
        public SearchByPage<SearchFormByAwaitingResultModel> awaitingForm { get; set; }
    }
}

B. 取得資料並傳給前端

分別呼叫 UofxService.BPM.GetAllCanApplyFormsUofxService.BPM.SearchFormByApplyUofxService.BPM.SearchFormByAwaiting 取得可申請、已申請與待處理的表單資料,並將資料傳到 Panel.cshtml

ErpController.cs
using Microsoft.AspNetCore.Mvc;
using Url_Login_Sample.Helper;
using Url_Login_Sample.Models;
using Ede.Uofx.PubApi.Sdk.NetStd.Service;
using System.Text;
using System.Text.Json;
using Ede.Uofx.PubApi.Sdk.NetStd.Models.Bpm;
using Ede.Uofx.PubApi.Sdk.NetStd;


namespace Url_Login_Sample.Controllers
{
    public class ErpController : Controller
    {
        /// <summary>
        /// Hash Key (UOF X 中設定的也要一樣)
        /// </summary>
        private readonly string _HashKey = "201N0tz3ArwcRohRoKxm8TMBdH6gT5SHJQGFWDgczRM=";

        private static readonly Dictionary<string, string> _SidMap = [];

        private static string _targetUrl = "https://myuofx.com.tw/";
        private static string _corpCode = "_corpCode";
        private static string _account = "_account";

        private readonly IConfiguration _configuration;
        public ErpController(IConfiguration configuration)
        {
            _configuration = configuration;

            // 從 appsetting.json 取得 serviceKey 與 serviceUrl
            var uofxServiceSettings = _configuration.GetSection("UofxServiceSettings");
            var _serviceKey = uofxServiceSettings["UofxServiceKey"];
            var _serviceUrl = uofxServiceSettings["UofxServiceUrl"];

            //設定金鑰
            UofxService.Key = _serviceKey;
            //設定 UOF X 站台網址
            UofxService.UofxServerUrl = _serviceUrl;
        }

        public async Task<IActionResult> Panel()
        {
            // 取得可申請表單
            var canApply = await UofxService.BPM.GetAllCanApplyForms(_account);

            // 半年前
            var since = DateTimeOffset.Now.AddMonths(-6);
            var until = DateTimeOffset.Now;

            // 取得已申請表單
            var applyForm = await UofxService.BPM.SearchFormByApply(new SearchFormByApplyReqModel
            {
                Account = _account,
                Since = since,
                Until = until,
                Order = TaskListOrder.ApplicantDate,
                By = OrderBy.Descending
            });

            // 取得待處理表單
            var awaitingForm = await UofxService.BPM.SearchFormByAwaiting(new SearchFormByAwaitingReqModel
            {
                Account = _account,
                Since = since,
                Until = until,
                Order = TaskListOrder.ApplicantDate,
                By = OrderBy.Descending
            });

            var model = new ErpPanelViewModel
            {
                allCanApplyForm = canApply,
                applyForm = applyForm,
                awaitingForm = awaitingForm
            };

            return View(model);
        }
    }
}

C. 前端顯示表單資料

Panel.cshtml 中使用 @Model 來取得並顯示可申請、已申請與待處理的表單資料

Image open-form-manager

Panel.cshtml
@model ErpPanelViewModel

...

<!-- 待簽表單 -->
<div class="col-md-2">
    <div class="card">
        <div class="card-body">
            <h5 class="card-title">待處理表單</h5>
            <div class="d-flex justify-content-center align-items-center h-75">
                <h3 id="pendingForms">@Model.awaitingForm.PageInfo.ItemsCount</h3>
            </div>
        </div>
    </div>
</div>

...

<!-- 可申請表單 -->
<div class="col-md-3">
    <div class="card">
        <div class="card-body">
            <h5 class="card-title">可申請表單</h5>
            @if (Model.allCanApplyForm.CategoryList != null && Model.allCanApplyForm.CategoryList.Any())
            {
                <div class="accordion overflow-y-auto overflow-x-hidden card-second-height">
                    @foreach (var category in Model.allCanApplyForm.CategoryList)
                    {
                        <div class="accordion-item">
                            <h2 class="accordion-header">
                                <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#@category.Id" aria-expanded="false" aria-controls="@category.Id">
                                    @category.Category
                                </button>
                            </h2>
                            <div id="@category.Id" class="accordion-collapse collapse">
                                <div class="accordion-body">
                                    <div class="list-group list-group-flush">
                                        @foreach (var form in Model.allCanApplyForm.FormList)
                                        {
                                            @if (@form.CategoryId == category.Id && @form.FormCode != null)
                                            {
                                                <form method="post" action="/Erp/ButtonClick" target="_blank">
                                                    <input type="hidden" name="selectMagicLinkType" value="BpmApply" />
                                                    <input type="hidden" name="maginLinkPayload" value="@form.FormCode" />
                                                    <button type="submit" class="list-group-item list-group-item-action text-start border-top-0 border-start-0 border-end-0">
                                                        @form.Name
                                                    </button>
                                                </form>
                                            }
                                        }
                                    </div>
                                </div>
                            </div>
                        </div>
                    }
                </div>
            }
            else
            {
                <p class="text-center">目前沒有可申請表單</p>
            }
        </div>
    </div>
</div>

<!-- 已申請表單 -->
<div class="col-md-3">
    <div class="card">
        <div class="card-body">
            <h5 class="card-title">已申請表單</h5>
            @if (Model.applyForm.Items != null && Model.applyForm.Items.Any())
            {
                <div class="list-group list-group-flush overflow-y-auto overflow-x-hidden card-second-height">
                    @foreach (var form in Model.applyForm.Items)
                    {
                        <form method="post" action="/Erp/ButtonClick" target="_blank" id="form-@form.FormSn">
                            <input type="hidden" name="selectMagicLinkType" value="BpmSign" />
                            <input type="hidden" name="maginLinkPayload" value="@form.FormSn" />
                            <div onclick="submitForm('@form.FormSn')" style="cursor: pointer;" class="d-flex flex-column gap-1 list-group-item list-group-item-action text-start border-top-0 border-start-0 border-end-0">
                                <div class="d-flex justify-content-between gap-1">
                                    <div>
                                        <!-- 依據表單狀態設定 tag 樣式 -->
                                        @switch (form.TaskViewStatus)
                                        {
                                            case Ede.Uofx.PubApi.Sdk.NetStd.TaskStatus.Processing:
                                                <span class="badge rounded-pill border border-primary text-primary">簽核中</span>
                                                break;
                                            case Ede.Uofx.PubApi.Sdk.NetStd.TaskStatus.Complete:
                                                <span class="badge rounded-pill bg-success text-white">已結案</span>
                                                break;
                                            case Ede.Uofx.PubApi.Sdk.NetStd.TaskStatus.Approve:
                                                <span class="badge rounded-pill border border-success text-success">通過</span>
                                                break;
                                            case Ede.Uofx.PubApi.Sdk.NetStd.TaskStatus.Reject:
                                                <span class="badge rounded-pill border border-danger text-danger">否決</span>
                                                break;
                                            case Ede.Uofx.PubApi.Sdk.NetStd.TaskStatus.Cancel:
                                                <span class="badge rounded-pill border border-secondary text-secondary">作廢</span>
                                                break;
                                            case Ede.Uofx.PubApi.Sdk.NetStd.TaskStatus.Exception:
                                                <span class="badge rounded-pill border exception">異常</span>
                                                break;
                                            default:
                                                break;
                                        }
                                        <span>@form.FormName</span>
                                    </div>
                                    <!--如果可以作廢,顯示作廢按鈕-->
                                    @if (@form.AllowedToCancel)
                                    {
                                        <button type="button" class="btn btn-sm btn-outline-danger"
                                                onclick="event.stopPropagation(); cancelForm('@form.FormSn')">
                                            作廢
                                        </button>
                                    }
                                </div>
                                <div class="d-flex justify-content-between">
                                    <span>@form.FormSn</span>
                                </div>
                            </div>
                        </form>
                    }
                </div>
            }
            else
            {
                <p class="text-center">目前沒有已申請表單</p>
            }
        </div>
    </div>
</div>

<!-- 待處理表單 -->
<div class="col-md-3">
    <div class="card">
        <div class="card-body">
            <h5 class="card-title">待處理表單</h5>
            <div class="list-group list-group-flush">
                @if (Model.awaitingForm.Items != null && Model.awaitingForm.Items.Any())
                {
                    <div class="list-group list-group-flush overflow-y-auto overflow-x-hidden card-second-height">
                        @foreach (var form in Model.awaitingForm.Items)
                        {
                            <form method="post" action="/Erp/ButtonClick" target="_blank">
                                <input type="hidden" name="selectMagicLinkType" value="BpmSign" />
                                <input type="hidden" name="maginLinkPayload" value="@form.FormSn" />
                                <button type="submit" class="d-flex flex-column gap-1 list-group-item list-group-item-action text-start border-top-0 border-start-0 border-end-0">
                                    <div>@form.FormName</div>
                                    <div>@form.FormSn</div>
                                </button>
                            </form>
                        }
                    </div>
                }
                else
                {
                    <p class="text-center">目前沒有待處理表單</p>
                }
            </div>
        </div>
    </div>
</div>

設定點擊表單後連結到 UOF X

A. ErpController 設定 CancelForm()、ButtonClick()、Callback() 方法

URL 登入驗證的說明可參考設定URL登入驗證

ErpController.cs
namespace Url_Login_Sample.Controllers
{
    public class ErpController : Controller
    {
        ...

        /// <summary>
        /// 點擊作廢將表單作廢
        /// </summary>
        [HttpPost]
        public async Task<IActionResult> CancelForm(string formSn)
        {
            await UofxService.BPM.CancelTask(formSn, _account, "規格不符,從 ERP 作廢");
            return Ok();
        }

        /// <summary>
        /// 點擊按鈕後前往 UOF X
        /// </summary>
        [HttpPost]
        public ActionResult ButtonClick(HomeViewModel body)
        {
            //------------------------------------------------------
            //---             裡應隨實際需求動態調整                ---
            //------------------------------------------------------

            var uofxUrl = body.targetUrl ?? _targetUrl;  //<== 要登入的站台網址
            var uofxAccount = body.account ?? _account;         //<== 要登入的使用者帳號
            var uofxCorpCode = body.corpCode ?? _corpCode;          //<== 登入的公司代號
            var uofxUrlLoginName = body.urlLoginName ?? "Url登入";     //<==  UOF X 設定的URL登入名稱
            var magicLinkType = body.selectMagicLinkType ?? "Default"; //<== 選擇的魔法連結類型
            var magicLinkPayload = body.maginLinkPayload ?? ""; //<== 根據魔法連結所需提供的資訊

            // 產生要登入的一次性帳號識別碼並放入 Token 中, callback 時用來識別身分 
            // (請依自己需求調整,但不建議把真實帳號放入 Token 中,因為 token 內容是公開的)
            var sid = GetSid(uofxAccount);

            // 產生短時效 Token (可改成使用自己製作的 token)
            var accessToken = TokenHelper.GenToken(sid, DateTimeOffset.Now.AddMinutes(5));

            // 進入 UOF X 後要前往的頁面
            MagicLinkModel target = null;

            if (magicLinkType != "Default")
            {
                target = new MagicLinkModel();
                target.Module = ModuleType.Bpm;
                if (magicLinkType == "BpmApply")
                {
                    // 轉往申請表單提供 formCode 表單代號
                    target.Action = BpmActionType.Apply;
                    target.Payload = new { formCode = magicLinkPayload };
                }
                else if (magicLinkType == "BpmSign")
                {
                    // 轉往簽核表單提供 formSn 表單編號
                    target.Action = BpmActionType.Sign;
                    target.Payload = new { formSn = magicLinkPayload };
                }
            }

            //------------------------------------------------------

            // 產生 SSO Model 並使用 Base64 編碼
            // Target = null  預設會導頁到使用者大廳
            var urlLoginModel = new UrlLoginModel()
            {
                CorpCode = uofxCorpCode,
                UrlLoginName = uofxUrlLoginName,
                Token = accessToken,
                Target = target == null ? null : JsonSerializer.Serialize(target),
                Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
            };

            var urlLoginJsonString = JsonSerializer.Serialize(urlLoginModel);
            var urlLoginBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(urlLoginJsonString));

            // 產生 Hash
            /*
                哈希演算法:SHA-256
                金鑰哈希演算法:HMAC(Hash-based Message Authentication Code)
                編碼:使用 UTF-8 編碼將輸入字串和金鑰轉換為位元組
                輸出格式:將哈希結果轉換為十六進制字串
            */
            var hashString = HashHelper.HMACSHA256(urlLoginBase64, _HashKey);

            // 要開啟 UOF X 的 URL
            var fullUrl = new Uri(new Uri(uofxUrl), $"/UniversalLink/url?p={Uri.EscapeDataString(urlLoginBase64)}&h={Uri.EscapeDataString(hashString)}").ToString();
            return Redirect(fullUrl);
        }

        /// <summary>
        /// UOF X 登入時的 Callback 驗證 API
        /// </summary>
        [HttpGet("/url/erp/callback")]
        public ActionResult Callback()
        {
            // 從網址中接收 token 參數 (UOFX 會以 query-string 't' 傳遞過來)
            if (!Request.Query.TryGetValue("t", out var accessToken))
                throw new Exception("Token not found");

            // 驗證 Token,並從 Token 中取得 sid
            if (!TokenHelper.VirtyfyAndGetData(accessToken, out var sid))
                throw new Exception("Invalid Token");

            // 根據 sid 取得帳號
            var account = GetAccountBySid(sid);

            // 產生要回傳的 model
            var result = new CallbackResponseModel()
            {
                AccountKey = account,
                Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds() //務必放當下時間,會依此時間來判斷是否過期
            };

            // 使用 AES 加密 model
            /*
                加密演算法:AES(Advanced Encryption Standard)
                填充模式:PKCS7
                加密模式:CBC(Cipher Block Chaining)
                反饋大小:128 位元
            */
            byte[] aeskeyBytes = HashHelper.SHA256ToBytes(_HashKey);
            var aesKey = aeskeyBytes;   // HashKey 的 SHA256 Hash
            var aesIv = aeskeyBytes.Skip(16).ToArray(); // HashKey 的 SHA256 Hash 後 16 bytes
            var encodeResult = AesHelper.EncodeData(JsonSerializer.Serialize(result), aesKey, aesIv);

            return Ok(encodeResult);
        }

        /// <summary>
        /// 產生一次性帳號識別碼
        /// </summary>
        private string GetSid(string account)
        {
            //這只是 sample code,實務上應該更嚴謹的規畫
            var id = Guid.NewGuid().ToString();
            _SidMap.Add(id, account);
            return id;
        }

        /// <summary>
        /// 根據一次性帳號識別碼取得帳號
        /// </summary>
        private string GetAccountBySid(string sid)
        {
            //這只是 sample code,實務上應該更嚴謹的規畫
            if (_SidMap.TryGetValue(sid, out string account))
            {
                return account;
            }

            throw new Exception("Invalid Sid");
        }
    }
}

B. 前端設定 script

  • 設定點擊 [作廢] 按鈕時觸發 cancelForm() 方法,並傳遞表單編號呼叫 Controller CancelForm() 方法
  • 設定點擊 [已申請表單] div 時,觸發 form submit,呼叫 Controller ButtonClick() 方法
Panel.cshtml
...

<script>
    // 作廢表單
    function cancelForm(formSn) {
        showLoading();
        fetch('/Erp/CancelForm', {
            method: 'POST',
            body: new URLSearchParams({ FormSn: formSn })
        }).then(response => {
            if (response.ok) {
                location.reload();
            }
        })
    }

    // 顯示 loading
    function showLoading() {
        document.getElementById('loading-overlay').classList.remove('d-none');
    }

    // 隱藏 loading
    function hideLoading() {
        document.getElementById('loading-overlay').classList.add('d-none');
    }

    // 點擊已申請表單 div 時,觸發 submit
    // 因為已申請表單列表中有 button,因此使用 div 來實作 submit
    function submitForm(formSn){
        document.getElementById('form-' + formSn).submit();
    }
    ...
</script>

成果展示

自動登入並開啟申請表單畫面

Image open-form-manager

自動登入並開啟已申請表單

Image open-form-manager

於外部系統直接作廢表單

Image open-form-manager

自動登入並開啟待處理表單

Image open-form-manager