[{"data":1,"prerenderedAt":910},["ShallowReactive",2],{"portfolio-\u002Fportfolio\u002Finvoice-system":3},{"id":4,"title":5,"award":6,"body":7,"cover":891,"description":54,"extension":892,"featured":893,"link":6,"meta":894,"navigation":131,"order":174,"path":895,"pinned":893,"repo":896,"role":897,"seo":898,"stack":899,"stem":906,"summary":907,"year":908,"__hash__":909},"portfolio\u002Fportfolio\u002Finvoice-system.md","訂單開立發票系統",null,{"type":8,"value":9,"toc":883},"minimark",[10,14,23,26,48,273,283,287,303,441,469,472,490,494,517,577,849,852,879],[11,12,13],"h2",{"id":13},"專案概述",[15,16,17,22],"p",{},[18,19,21],"a",{"href":20},"\u002Fportfolio\u002Ftraining-hangfire","氣象開放資料排程系統"," 之後的練習專案，把同一\n套「分層架構＋Hangfire 排程」的模式，套用到更貼近實務的業務情境：訂單成立\n後，由排程工作每日批次為訂單開立發票。程式庫是私有的練習專案，這裡整理設計\n上的重點。",[11,24,25],{"id":25},"業務流程",[15,27,28,32,33,36,37,40,41,36,44,47],{},[29,30,31],"code",{},"Product","（商品）→ ",[29,34,35],{},"Order"," \u002F ",[29,38,39],{},"OrderDetail","（訂單／訂單明細）→\n",[29,42,43],{},"Invoice",[29,45,46],{},"InvoiceDetail","（發票／發票明細）層層關聯：建立商品、下訂單\n後，背景排程每天固定時間抓取所有訂單，批次呼叫開立發票的邏輯，並把執行\n過程記錄下來（工作 ID、來源工作 ID、起訖時間），方便事後追查某天的發票是哪\n次排程工作產生的。",[49,50,55],"pre",{"className":51,"code":52,"language":53,"meta":54,"style":54},"language-csharp shiki shiki-themes github-dark","\u002F\u002F 排程工作：先建立一筆「工作紀錄」，再串接呼叫實際開立發票的工作，\n\u002F\u002F 並用 sourceJobId 把兩個工作串起來方便追查\npublic void CreateJob(PerformContext context)\n{\n    var jobId = context.BackgroundJob.Id;\n    \u002F\u002F ...寫入 LogContent...\n\n    BackgroundJob.Enqueue\u003CIHangfireJobs>(x => x.CreateInvoiceAsync(null, jobId));\n}\n\npublic async Task CreateInvoiceAsync(PerformContext context, string sourceJobId)\n{\n    var orders = await _orderService.GetAllAsync();\n    var result = await _invoiceService.CreateManyAsync(orders);\n    \u002F\u002F ...記錄結果...\n}\n","csharp","",[29,56,57,66,72,99,105,120,126,133,172,178,183,214,219,241,262,268],{"__ignoreMap":54},[58,59,62],"span",{"class":60,"line":61},"line",1,[58,63,65],{"class":64},"sAwPA","\u002F\u002F 排程工作：先建立一筆「工作紀錄」，再串接呼叫實際開立發票的工作，\n",[58,67,69],{"class":60,"line":68},2,[58,70,71],{"class":64},"\u002F\u002F 並用 sourceJobId 把兩個工作串起來方便追查\n",[58,73,75,79,82,86,90,93,96],{"class":60,"line":74},3,[58,76,78],{"class":77},"snl16","public",[58,80,81],{"class":77}," void",[58,83,85],{"class":84},"svObZ"," CreateJob",[58,87,89],{"class":88},"s95oV","(",[58,91,92],{"class":84},"PerformContext",[58,94,95],{"class":84}," context",[58,97,98],{"class":88},")\n",[58,100,102],{"class":60,"line":101},4,[58,103,104],{"class":88},"{\n",[58,106,108,111,114,117],{"class":60,"line":107},5,[58,109,110],{"class":77},"    var",[58,112,113],{"class":84}," jobId",[58,115,116],{"class":77}," =",[58,118,119],{"class":88}," context.BackgroundJob.Id;\n",[58,121,123],{"class":60,"line":122},6,[58,124,125],{"class":64},"    \u002F\u002F ...寫入 LogContent...\n",[58,127,129],{"class":60,"line":128},7,[58,130,132],{"emptyLinePlaceholder":131},true,"\n",[58,134,136,139,142,145,148,151,154,157,160,163,165,169],{"class":60,"line":135},8,[58,137,138],{"class":88},"    BackgroundJob.",[58,140,141],{"class":84},"Enqueue",[58,143,144],{"class":88},"\u003C",[58,146,147],{"class":84},"IHangfireJobs",[58,149,150],{"class":88},">(",[58,152,153],{"class":84},"x",[58,155,156],{"class":77}," =>",[58,158,159],{"class":88}," x.",[58,161,162],{"class":84},"CreateInvoiceAsync",[58,164,89],{"class":88},[58,166,168],{"class":167},"sDLfK","null",[58,170,171],{"class":88},", jobId));\n",[58,173,175],{"class":60,"line":174},9,[58,176,177],{"class":88},"}\n",[58,179,181],{"class":60,"line":180},10,[58,182,132],{"emptyLinePlaceholder":131},[58,184,186,188,191,194,197,199,201,203,206,209,212],{"class":60,"line":185},11,[58,187,78],{"class":77},[58,189,190],{"class":77}," async",[58,192,193],{"class":84}," Task",[58,195,196],{"class":84}," CreateInvoiceAsync",[58,198,89],{"class":88},[58,200,92],{"class":84},[58,202,95],{"class":84},[58,204,205],{"class":88},", ",[58,207,208],{"class":77},"string",[58,210,211],{"class":84}," sourceJobId",[58,213,98],{"class":88},[58,215,217],{"class":60,"line":216},12,[58,218,104],{"class":88},[58,220,222,224,227,229,232,235,238],{"class":60,"line":221},13,[58,223,110],{"class":77},[58,225,226],{"class":84}," orders",[58,228,116],{"class":77},[58,230,231],{"class":77}," await",[58,233,234],{"class":88}," _orderService.",[58,236,237],{"class":84},"GetAllAsync",[58,239,240],{"class":88},"();\n",[58,242,244,246,249,251,253,256,259],{"class":60,"line":243},14,[58,245,110],{"class":77},[58,247,248],{"class":84}," result",[58,250,116],{"class":77},[58,252,231],{"class":77},[58,254,255],{"class":88}," _invoiceService.",[58,257,258],{"class":84},"CreateManyAsync",[58,260,261],{"class":88},"(orders);\n",[58,263,265],{"class":60,"line":264},15,[58,266,267],{"class":64},"    \u002F\u002F ...記錄結果...\n",[58,269,271],{"class":60,"line":270},16,[58,272,177],{"class":88},[15,274,275,276,279,280,282],{},"Hangfire 排程改為每日定時批次開立發票（cron ",[29,277,278],{},"0 0 * * *","），而不是即時觸\n發，符合發票通常是「日結」的業務性質；",[29,281,258],{}," 會先篩掉已經開過\n發票的訂單，只針對尚未開票的訂單批次寫入，避免重複開票。",[11,284,286],{"id":285},"api-輸入驗證fluentvalidation-actionfilter","API 輸入驗證（FluentValidation + ActionFilter）",[15,288,289,290,294,295,298,299,302],{},"API 層的輸入驗證改用 ",[291,292,293],"strong",{},"FluentValidation","，並且不是在每個 Action 手動呼叫\n驗證，而是包成一個共用的 ",[29,296,297],{},"ParameterValidatorAttribute","：Attribute 建構時\n指定要用哪個 Validator 類別，執行時從 Action 的參數裡取出目標物件跑驗證，\n驗證失敗就直接組出帶錯誤碼／CorrelationId 的 ",[29,300,301],{},"FailResultViewModel"," 短路回\n傳，Action 本體完全不用寫任何驗證邏輯：",[49,304,306],{"className":51,"code":305,"language":53,"meta":54,"style":54},"[HttpPost]\n[CoreProfiling]\n[ParameterValidator(typeof(OrderCreateParameterValidator))]\npublic async Task\u003CIActionResult> CreateAsync([FromBody] OrderCreateParameter orderCreateParameter)\n{\n    var dto = _mapper.Map\u003COrderCreateParameter, OrderDto>(orderCreateParameter);\n    var result = await _orderService.CreateAsync(dto);\n    \u002F\u002F ...\n}\n",[29,307,308,319,328,348,384,388,415,432,437],{"__ignoreMap":54},[58,309,310,313,316],{"class":60,"line":61},[58,311,312],{"class":88},"[",[58,314,315],{"class":84},"HttpPost",[58,317,318],{"class":88},"]\n",[58,320,321,323,326],{"class":60,"line":68},[58,322,312],{"class":88},[58,324,325],{"class":84},"CoreProfiling",[58,327,318],{"class":88},[58,329,330,332,335,337,340,342,345],{"class":60,"line":74},[58,331,312],{"class":88},[58,333,334],{"class":84},"ParameterValidator",[58,336,89],{"class":88},[58,338,339],{"class":77},"typeof",[58,341,89],{"class":88},[58,343,344],{"class":84},"OrderCreateParameterValidator",[58,346,347],{"class":88},"))]\n",[58,349,350,352,354,356,358,361,364,367,370,373,376,379,382],{"class":60,"line":101},[58,351,78],{"class":77},[58,353,190],{"class":77},[58,355,193],{"class":84},[58,357,144],{"class":88},[58,359,360],{"class":84},"IActionResult",[58,362,363],{"class":88},"> ",[58,365,366],{"class":84},"CreateAsync",[58,368,369],{"class":88},"([",[58,371,372],{"class":84},"FromBody",[58,374,375],{"class":88},"] ",[58,377,378],{"class":84},"OrderCreateParameter",[58,380,381],{"class":84}," orderCreateParameter",[58,383,98],{"class":88},[58,385,386],{"class":60,"line":107},[58,387,104],{"class":88},[58,389,390,392,395,397,400,403,405,407,409,412],{"class":60,"line":122},[58,391,110],{"class":77},[58,393,394],{"class":84}," dto",[58,396,116],{"class":77},[58,398,399],{"class":88}," _mapper.",[58,401,402],{"class":84},"Map",[58,404,144],{"class":88},[58,406,378],{"class":84},[58,408,205],{"class":88},[58,410,411],{"class":84},"OrderDto",[58,413,414],{"class":88},">(orderCreateParameter);\n",[58,416,417,419,421,423,425,427,429],{"class":60,"line":128},[58,418,110],{"class":77},[58,420,248],{"class":84},[58,422,116],{"class":77},[58,424,231],{"class":77},[58,426,234],{"class":88},[58,428,366],{"class":84},[58,430,431],{"class":88},"(dto);\n",[58,433,434],{"class":60,"line":135},[58,435,436],{"class":64},"    \u002F\u002F ...\n",[58,438,439],{"class":60,"line":174},[58,440,177],{"class":88},[15,442,443,445,446,449,450,445,453,456,457,460,461,464,465,468],{},[29,444,344],{},"／",[29,447,448],{},"OrderUpdateParameterValidator","／\n",[29,451,452],{},"ProductCreateParameterValidator",[29,454,455],{},"ProductUpdateParameterValidator"," 依\nCreate／Update 情境分別寫規則，跟另一層內部模型驗證（",[29,458,459],{},"ModelValidator","，\n封裝 ",[29,462,463],{},"DataAnnotations"," 的 ",[29,466,467],{},"Validator.TryValidateObject","）分開職責：前者管\n「API 請求格式對不對」，後者管「往下傳的內部模型合不合法」，兩層不混在一起。",[11,470,471],{"id":471},"自動化效能量測",[15,473,474,475,478,479,36,482,485,486,489],{},"同樣用 ActionFilter 包一層效能量測（",[29,476,477],{},"CoreProfilingAttribute","，底層是\nCoreProfiler），在 ",[29,480,481],{},"OnActionExecuting",[29,483,484],{},"OnActionExecuted"," 自動開始／結\n束一個 Profiling Step，Action 上只要掛一個 ",[29,487,488],{},"[CoreProfiling]"," 就好，不需要\n每個 Action 手動加碼計時。",[11,491,493],{"id":492},"測試策略mstest","測試策略（MSTest）",[15,495,496,497,500,501,504,505,508,509,512,513,516],{},"延續 Controller／Service／Repository／UnitOfWork 分層，並補上\n",[29,498,499],{},"InvoiceSystem.ServiceTests"," 與 ",[29,502,503],{},"InvoiceSystem.IntegrationTest"," 兩個測試\n專案，",[291,506,507],{},"同樣全部使用 MSTest","（",[29,510,511],{},"MSTest.TestFramework"," +\n",[29,514,515],{},"MSTest.TestAdapter","），搭配 FluentAssertions、NSubstitute、AutoFixture：",[518,519,520,558],"ul",{},[521,522,523,527,528,531,532,535,536,539,540,543,544,543,547,543,550,553,554,557],"li",{},[291,524,525],{},[29,526,503],{},"：用 ",[29,529,530],{},"TestEnvironment.Docker"," 起一個\n真的 MSSQL 容器，測試前先用 ",[29,533,534],{},"DbScript"," 目錄下的 SQL 腳本（",[29,537,538],{},"Product_Data","、\n",[29,541,542],{},"Order_Data","、",[29,545,546],{},"OrderDetail_Data",[29,548,549],{},"Invoice_Data",[29,551,552],{},"InvoiceDetail_Data","）\n灌一批固定的測試資料，每個測試包在交易裡、跑完 ",[29,555,556],{},"RollbackTransaction","，驗\n證「訂單已開過發票就不能重複開立」「批次開立時只處理尚未開票的訂單」這類\n跟真實資料庫狀態有關的行為",[521,559,560,561,566,567,445,570,445,573,576],{},"大量使用 MSTest 的 ",[291,562,563],{},[29,564,565],{},"[DataRow]"," 做參數化測試，例如驗證發票查詢在\n",[29,568,569],{},"OrderId",[29,571,572],{},"ProductId",[29,574,575],{},"InvoiceId"," 七種組合下都能篩出正確筆數，一個測試\n方法涵蓋七種情境，不用複製貼上七個測試",[49,578,580],{"className":51,"code":579,"language":53,"meta":54,"style":54},"[TestMethod()]\n[DataRow(1, 0, 0, DisplayName = \"輸入orderId為1\")]\n[DataRow(0, 1, 0, DisplayName = \"輸入productId為1\")]\n[DataRow(0, 0, 1, DisplayName = \"輸入invoiceId為1\")]\n[DataRow(1, 1, 0, DisplayName = \"輸入orderId為1與productId為1\")]\n[DataRow(0, 1, 1, DisplayName = \"輸入productId為1與invoiceId為1\")]\n[DataRow(1, 0, 1, DisplayName = \"輸入orderId為1與invoiceId為1\")]\n[DataRow(1, 1, 1, DisplayName = \"輸入orderId為1與productId為1與invoiceId為1\")]\npublic async Task GetAsync_輸入特定參數_輸出相關資料數量(int orderId, int productId, int invoiceId)\n{\n    \u002F\u002F arrange \u002F act \u002F assert\n}\n",[29,581,582,592,627,656,685,714,743,772,801,836,840,845],{"__ignoreMap":54},[58,583,584,586,589],{"class":60,"line":61},[58,585,312],{"class":88},[58,587,588],{"class":84},"TestMethod",[58,590,591],{"class":88},"()]\n",[58,593,594,596,599,601,604,606,609,611,613,615,618,620,624],{"class":60,"line":68},[58,595,312],{"class":88},[58,597,598],{"class":84},"DataRow",[58,600,89],{"class":88},[58,602,603],{"class":167},"1",[58,605,205],{"class":88},[58,607,608],{"class":167},"0",[58,610,205],{"class":88},[58,612,608],{"class":167},[58,614,205],{"class":88},[58,616,617],{"class":84},"DisplayName",[58,619,116],{"class":77},[58,621,623],{"class":622},"sU2Wk"," \"輸入orderId為1\"",[58,625,626],{"class":88},")]\n",[58,628,629,631,633,635,637,639,641,643,645,647,649,651,654],{"class":60,"line":74},[58,630,312],{"class":88},[58,632,598],{"class":84},[58,634,89],{"class":88},[58,636,608],{"class":167},[58,638,205],{"class":88},[58,640,603],{"class":167},[58,642,205],{"class":88},[58,644,608],{"class":167},[58,646,205],{"class":88},[58,648,617],{"class":84},[58,650,116],{"class":77},[58,652,653],{"class":622}," \"輸入productId為1\"",[58,655,626],{"class":88},[58,657,658,660,662,664,666,668,670,672,674,676,678,680,683],{"class":60,"line":101},[58,659,312],{"class":88},[58,661,598],{"class":84},[58,663,89],{"class":88},[58,665,608],{"class":167},[58,667,205],{"class":88},[58,669,608],{"class":167},[58,671,205],{"class":88},[58,673,603],{"class":167},[58,675,205],{"class":88},[58,677,617],{"class":84},[58,679,116],{"class":77},[58,681,682],{"class":622}," \"輸入invoiceId為1\"",[58,684,626],{"class":88},[58,686,687,689,691,693,695,697,699,701,703,705,707,709,712],{"class":60,"line":107},[58,688,312],{"class":88},[58,690,598],{"class":84},[58,692,89],{"class":88},[58,694,603],{"class":167},[58,696,205],{"class":88},[58,698,603],{"class":167},[58,700,205],{"class":88},[58,702,608],{"class":167},[58,704,205],{"class":88},[58,706,617],{"class":84},[58,708,116],{"class":77},[58,710,711],{"class":622}," \"輸入orderId為1與productId為1\"",[58,713,626],{"class":88},[58,715,716,718,720,722,724,726,728,730,732,734,736,738,741],{"class":60,"line":122},[58,717,312],{"class":88},[58,719,598],{"class":84},[58,721,89],{"class":88},[58,723,608],{"class":167},[58,725,205],{"class":88},[58,727,603],{"class":167},[58,729,205],{"class":88},[58,731,603],{"class":167},[58,733,205],{"class":88},[58,735,617],{"class":84},[58,737,116],{"class":77},[58,739,740],{"class":622}," \"輸入productId為1與invoiceId為1\"",[58,742,626],{"class":88},[58,744,745,747,749,751,753,755,757,759,761,763,765,767,770],{"class":60,"line":128},[58,746,312],{"class":88},[58,748,598],{"class":84},[58,750,89],{"class":88},[58,752,603],{"class":167},[58,754,205],{"class":88},[58,756,608],{"class":167},[58,758,205],{"class":88},[58,760,603],{"class":167},[58,762,205],{"class":88},[58,764,617],{"class":84},[58,766,116],{"class":77},[58,768,769],{"class":622}," \"輸入orderId為1與invoiceId為1\"",[58,771,626],{"class":88},[58,773,774,776,778,780,782,784,786,788,790,792,794,796,799],{"class":60,"line":135},[58,775,312],{"class":88},[58,777,598],{"class":84},[58,779,89],{"class":88},[58,781,603],{"class":167},[58,783,205],{"class":88},[58,785,603],{"class":167},[58,787,205],{"class":88},[58,789,603],{"class":167},[58,791,205],{"class":88},[58,793,617],{"class":84},[58,795,116],{"class":77},[58,797,798],{"class":622}," \"輸入orderId為1與productId為1與invoiceId為1\"",[58,800,626],{"class":88},[58,802,803,805,807,809,812,814,817,820,822,824,827,829,831,834],{"class":60,"line":174},[58,804,78],{"class":77},[58,806,190],{"class":77},[58,808,193],{"class":84},[58,810,811],{"class":84}," GetAsync_輸入特定參數_輸出相關資料數量",[58,813,89],{"class":88},[58,815,816],{"class":77},"int",[58,818,819],{"class":84}," orderId",[58,821,205],{"class":88},[58,823,816],{"class":77},[58,825,826],{"class":84}," productId",[58,828,205],{"class":88},[58,830,816],{"class":77},[58,832,833],{"class":84}," invoiceId",[58,835,98],{"class":88},[58,837,838],{"class":60,"line":180},[58,839,104],{"class":88},[58,841,842],{"class":60,"line":185},[58,843,844],{"class":64},"    \u002F\u002F arrange \u002F act \u002F assert\n",[58,846,847],{"class":60,"line":216},[58,848,177],{"class":88},[11,850,851],{"id":851},"學到的東西",[518,853,854,857,864,870,876],{},[521,855,856],{},"同一套分層架構重複套用在第二個專案上，能明顯感覺到「哪些是可以直接複用的\n骨架、哪些要依業務情境重新設計」",[521,858,859,860,863],{},"排程工作之間用 ",[29,861,862],{},"sourceJobId"," 串起因果關係，是後來理解「如何追蹤一連串背景\n工作」的起點",[521,865,866,867,869],{},"把請求驗證（FluentValidation + ActionFilter）跟內部模型驗證\n（",[29,868,463],{},"）分成兩層，職責更清楚，也是後來重視分層設計的起點",[521,871,872,873,875],{},"用 MSTest 的 ",[29,874,565],{}," 把同一個方法的多種輸入組合收斂成一個測試方法，\n比每種情境各寫一個測試方法更好維護",[521,877,878],{},"用 Docker 化的 MSSQL 搭配 SQL 腳本灌測試資料、每個測試跑完自動回滾，讓\n整合測試可以重複執行也不互相污染",[880,881,882],"style",{},"html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}",{"title":54,"searchDepth":68,"depth":68,"links":884},[885,886,887,888,889,890],{"id":13,"depth":68,"text":13},{"id":25,"depth":68,"text":25},{"id":285,"depth":68,"text":286},{"id":471,"depth":68,"text":471},{"id":492,"depth":68,"text":493},{"id":851,"depth":68,"text":851},"https:\u002F\u002Fpicsum.photos\u002Fseed\u002Finvoice-system\u002F1200\u002F800","md",false,{},"\u002Fportfolio\u002Finvoice-system","https:\u002F\u002Fgithub.com\u002Ftom3019\u002FInvoiceSystem","個人練習專案 ‧ 剛入行",{"title":5,"description":54},[900,901,902,903,293,904,905],"ASP.NET Core","C#","Hangfire","EF Core","MSTest","MSSQL","portfolio\u002Finvoice-system","延續氣象排程練習專案的分層架構，套用到「訂單→發票」的業務情境：每日排程批次開立發票，並補上 FluentValidation 請求驗證、ActionFilter 效能量測，以及用 Docker 化 MSSQL 跑的 MSTest 整合測試。",2021,"31fVne_Q8OTuDS58G_ORSOMMQX0u_biZadgy82TXT4E",1792959149969]