cetustek 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +39 -0
- data/README.md +38 -12
- data/lib/cetustek/cancel_allowance.rb +17 -39
- data/lib/cetustek/cancel_invoice.rb +18 -44
- data/lib/cetustek/create_allowance.rb +24 -61
- data/lib/cetustek/create_invoice.rb +4 -2
- data/lib/cetustek/errors.rb +24 -0
- data/lib/cetustek/models/allowance_data.rb +47 -4
- data/lib/cetustek/models/invoice_data.rb +75 -8
- data/lib/cetustek/queries.rb +7 -18
- data/lib/cetustek/query_invoice_by_order_id.rb +6 -13
- data/lib/cetustek/services/invoice_xml_builder.rb +38 -78
- data/lib/cetustek/services/response_handler.rb +9 -21
- data/lib/cetustek/soap.rb +29 -0
- data/lib/cetustek/version.rb +1 -1
- data/lib/cetustek/xml.rb +43 -0
- data/lib/cetustek.rb +2 -0
- metadata +3 -2
- data/lib/cetustek/services/invoice_service.rb +0 -28
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 887153f21f24293aa3ef9f6e4ee7ad48ec803c65f7104605a4d615894a5be19a
|
|
4
|
+
data.tar.gz: 14667650b0e2bf27c060df01cef3bb220d73bdb4ab6deb8bf5c1bf29bd9ba050
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 508a2c1acb9f548116e963c7ce6b536a8e5aec3bc3d18189a56b52403a0d32f485191d5af2d6c5a3eef4a30a1f041a0eb94fe5ba8f73e38c7b30f06022f85a55
|
|
7
|
+
data.tar.gz: e2235999236cd67ee01462050391d1de6bfb4968eea812b7c9b4c7fc96b0559334e0800d5701d485d410d30f10d8486cd68bc217e04e1a9fb80169cc3e227977
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,45 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.9.0] - 2026-08-05
|
|
9
|
+
|
|
10
|
+
比對規格 AVM-26-03 做的 code review 修正:0.8.0 有幾條規則寫得比規格寬或比規格嚴,
|
|
11
|
+
另外把四份重複的 SOAP/XML 樣板收成共用模組。
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
- **Breaking:** `carrier_id2` 只在無顯碼隱碼區分的載具(手機條碼 `3J0002`、自然人憑證
|
|
15
|
+
`CQ0001`)自動鏡射顯碼。會員載具(如鯨躍發票卡 `EJ0011`)兩碼本來就不同,0.8.0 會把顯碼
|
|
16
|
+
當隱碼送出;現在未填 `carrier_id2` 會直接丟 `ArgumentError`
|
|
17
|
+
- `donate_mark: 0` 不再強制 `carrier_type`:規格註明「使用鯨躍發票卡,電子郵件必填,
|
|
18
|
+
載具類別可為空或填 EJ0011」,原本的檢查讓這個情境無法送出。必填改為 `buyer_email`
|
|
19
|
+
與 `carrier_id1`/`carrier_id2`
|
|
20
|
+
- `QueryAllowance.find` 移除規格沒有的 `'nodata'` 哨兵值;非 XML 的回覆(代碼字串)
|
|
21
|
+
改為丟 `ResultError`,只有真的空回覆才回傳 `nil`
|
|
22
|
+
- 修掉註解裡捏造的規格出處(`spec V4.16`)與 TaxType 9 的「限收銀機類型發票」限制,
|
|
23
|
+
Table 1 只寫「9:混合(應稅、零稅率與免稅)」
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
- `Cetustek::CarrierType`:`MOBILE_BARCODE` / `CITIZEN_CERT` / `CETUSTEK_CARD` 常數
|
|
27
|
+
- `AllowanceData` 依 Table 15 驗證必填(折讓單號、折讓日期、發票號碼、發票年份、
|
|
28
|
+
折讓原因、至少一筆明細)與 `round_num` 0-7、`reason` 20 字。原本 `allowance_date`
|
|
29
|
+
為 nil 會在組 XML 時炸成 `NoMethodError`
|
|
30
|
+
- `InvoiceItem` 依 Table 2/16 驗證 `code`/`name`/`quantity`/`unit_price` 必填(`unit` 選填)
|
|
31
|
+
- `CancelAllowance` 驗證 Table 18 的兩個必填欄位與 20 字上限
|
|
32
|
+
- `InvoiceData` 補上規格寫死的條件與範圍:`zero_reason` 限 `tax_type` 2/5、
|
|
33
|
+
`mail_send` 限 `donate_mark: 0`、`round_num` 0-7、`remark` 200 字
|
|
34
|
+
|
|
35
|
+
### Changed
|
|
36
|
+
- **Breaking:** `CancelInvoice` 的 `remark:`(作廢原因)改為必填且限 20 字 —— Table 9 是
|
|
37
|
+
必填欄位,預設 `'退貨'` 等於幫呼叫端編造理由
|
|
38
|
+
- **Breaking:** 移除 `Services::InvoiceService`,它只剩一次 Savon 呼叫;`CreateInvoice`
|
|
39
|
+
直接送出
|
|
40
|
+
- `ResponseHandler` 失敗時丟 `ResultError` 本身,與折讓路徑一致。
|
|
41
|
+
`ResponseHandler::InvalidResponseError` 改為 `ResultError` 的別名常數(deprecated),
|
|
42
|
+
既有 `rescue InvalidResponseError` 仍然攔得到
|
|
43
|
+
- 新增 `Cetustek::Xml` 與 `Cetustek::Soap`,收掉五份 Savon client 與四份 `raw_tag` 複製;
|
|
44
|
+
Table 7/10/17/19 共用的代碼列集中在 `ResultCode::COMMON` 與 `ResultCode::DETAILS`。
|
|
45
|
+
`Cetustek::Queries` 成為 `Cetustek::Soap` 的別名(deprecated)
|
|
46
|
+
|
|
8
47
|
## [0.8.0] - 2026-08-04
|
|
9
48
|
|
|
10
49
|
### Added
|
data/README.md
CHANGED
|
@@ -66,8 +66,8 @@ invoice_data = Cetustek::Models::InvoiceData.new(
|
|
|
66
66
|
buyer_name: invoice.name,
|
|
67
67
|
buyer_email: invoice.email,
|
|
68
68
|
donate_mark: Cetustek::DonateMark::CARRIER,
|
|
69
|
-
carrier_type:
|
|
70
|
-
carrier_id: invoice.barcode, # CarrierId2
|
|
69
|
+
carrier_type: Cetustek::CarrierType::MOBILE_BARCODE, # 3J0002
|
|
70
|
+
carrier_id: invoice.barcode, # 手機條碼無顯隱碼之分,CarrierId2 自動填相同值
|
|
71
71
|
payment_type: Cetustek::PayWay::ATM,
|
|
72
72
|
items: invoice.items.map { |item|
|
|
73
73
|
Cetustek::Models::InvoiceItem.new(
|
|
@@ -96,9 +96,28 @@ Persisting the result is the caller's job — this gem writes to no database.
|
|
|
96
96
|
so a guaranteed rejection never leaves your process:
|
|
97
97
|
|
|
98
98
|
- `order_id`, `order_date` (a `Date`/`Time`), `items`, `donate_mark` and `payment_type` are required
|
|
99
|
-
-
|
|
99
|
+
- every item needs `code`, `name`, `quantity` and `unit_price` (`unit` is optional)
|
|
100
|
+
- `donate_mark: 0` (載具) requires `buyer_email`, `carrier_id1` and `carrier_id2`
|
|
100
101
|
- `donate_mark: 1` (捐贈) requires a 3–7 digit `npo_ban`
|
|
101
102
|
- `tax_type: 4` (特種稅率) requires an explicit `tax_rate` and `invoice_type: '08'`
|
|
103
|
+
- `zero_reason` only with `tax_type` 2 or 5; `mail_send` only with `donate_mark: 0`
|
|
104
|
+
- `round_num` 0–7, `remark` at most 200 characters
|
|
105
|
+
|
|
106
|
+
### Carriers (載具)
|
|
107
|
+
|
|
108
|
+
`Cetustek::CarrierType` names the codes the spec spells out:
|
|
109
|
+
|
|
110
|
+
| Constant | Code | Meaning |
|
|
111
|
+
|----------|------|---------|
|
|
112
|
+
| `MOBILE_BARCODE` | `3J0002` | 手機條碼 |
|
|
113
|
+
| `CITIZEN_CERT` | `CQ0001` | 自然人憑證條碼 |
|
|
114
|
+
| `CETUSTEK_CARD` | `EJ0011` | 鯨躍發票卡 |
|
|
115
|
+
|
|
116
|
+
手機條碼與自然人憑證「無顯碼隱碼區分」, so passing `carrier_id` (or `carrier_id1`)
|
|
117
|
+
alone is enough and `CarrierId2` is filled with the same value. Member carriers
|
|
118
|
+
do carry two different codes, so both `carrier_id1` and `carrier_id2` must be
|
|
119
|
+
given — the gem will not guess a 隱碼 it cannot know. 鯨躍發票卡 may be requested
|
|
120
|
+
with a blank `carrier_type`, as the spec allows.
|
|
102
121
|
|
|
103
122
|
Anything needing an external lookup (是否為有效手機條碼、捐贈碼是否存在) is left
|
|
104
123
|
to the caller — see `Cetustek::PhoneBarcode` below.
|
|
@@ -119,7 +138,7 @@ general invoice type of `07`. Use `Cetustek::TaxType` to switch modes:
|
|
|
119
138
|
| `TAX_FREE` | 3 | 免稅 |
|
|
120
139
|
| `SPECIAL` | 4 | 應稅(特種稅率) — set `tax_rate`, use `invoice_type: '08'` |
|
|
121
140
|
| `ZERO_RATE_CUSTOMS` | 5 | 零稅率(經海關出口) |
|
|
122
|
-
| `MIXED` | 9 | 混合(
|
|
141
|
+
| `MIXED` | 9 | 混合(應稅/零稅率/免稅) |
|
|
123
142
|
|
|
124
143
|
#### Zero-rate invoice (零稅率)
|
|
125
144
|
|
|
@@ -185,11 +204,12 @@ Cetustek::Models::InvoiceItem.new(code: 'DISCOUNT', name: '折抵', quantity: 1,
|
|
|
185
204
|
### Cancel an Invoice (作廢發票確認)
|
|
186
205
|
|
|
187
206
|
```ruby
|
|
188
|
-
|
|
189
|
-
Cetustek::CancelInvoice.new('AB12345678', 2024, remark: '
|
|
207
|
+
# remark 是 Table 9 的必填作廢原因,最多 20 字,沒有預設值
|
|
208
|
+
Cetustek::CancelInvoice.new('AB12345678', 2024, remark: '退貨').execute # => "C0"
|
|
190
209
|
|
|
191
210
|
# 超過申報期間才需要專案作廢核准文號 (否則會收到 C3)
|
|
192
|
-
Cetustek::CancelInvoice.new('AB12345678', 2024,
|
|
211
|
+
Cetustek::CancelInvoice.new('AB12345678', 2024, remark: '明細錯誤',
|
|
212
|
+
return_tax_document_number: '65327645').execute
|
|
193
213
|
```
|
|
194
214
|
|
|
195
215
|
Uploading is not the end of it: the cancellation still has to be confirmed
|
|
@@ -223,7 +243,7 @@ Cetustek::Models::InvoiceData.new(hastax: 0, items: [...])
|
|
|
223
243
|
| `remark` | `Remark` | 備註,200 字 |
|
|
224
244
|
| `zero_reason` | `ZeroReason` | 零稅率原因;未填時平台預設 `72`(TaxType 2)或 `71`(TaxType 5) |
|
|
225
245
|
| `round_num` | `RoundNum` | 金額計算位數,未填預設 4 |
|
|
226
|
-
| `mail_send` | `MailSend` | `0`(預設)由加值中心寄送通知,`1`
|
|
246
|
+
| `mail_send` | `MailSend` | `0`(預設)由加值中心寄送通知,`1` 自行處理;限 `donate_mark: 0` |
|
|
227
247
|
| `rtn_msg` | `RtnMsg` | 預設 `'Json'`;傳 `nil` 退回只回傳 15 碼字串的舊模式 |
|
|
228
248
|
|
|
229
249
|
Fields with a platform-side default (`ZeroReason`, `RoundNum`, `MailSend`,
|
|
@@ -248,13 +268,19 @@ allowance = Cetustek::Models::AllowanceData.new(
|
|
|
248
268
|
)
|
|
249
269
|
Cetustek::CreateAllowance.new(allowance).execute # => "A0" on success
|
|
250
270
|
Cetustek::CancelAllowance.new('AA20240216000001', '明細錯誤').execute # => "C0" on success
|
|
251
|
-
Cetustek::QueryAllowance.find('AA20240216000001') # parsed Hash
|
|
271
|
+
Cetustek::QueryAllowance.find('AA20240216000001') # parsed Hash
|
|
252
272
|
Cetustek::QueryAllowance.query('AA20240216000001') # raw Savon response
|
|
253
273
|
```
|
|
254
274
|
|
|
255
|
-
`
|
|
256
|
-
|
|
257
|
-
|
|
275
|
+
`AllowanceData.new` raises `ArgumentError` for the Table 15 rules: `allowance_number`,
|
|
276
|
+
`allowance_date` (a `Date`/`Time`), `invoice_number`, `invoice_year`, `reason` (20 字)
|
|
277
|
+
and at least one item are required, and `round_num` must be 0–7. `tax_type` only
|
|
278
|
+
accepts `1` 應稅, `2` 零稅率 or `3` 免稅 — the invoice-only values (`4`, `5`, `9`)
|
|
279
|
+
raise. `unit_price` is **tax-inclusive** (there is no `hastax` on allowances).
|
|
280
|
+
`CancelAllowance` takes the two Table 18 fields, both required, 作廢原因 up to 20 字.
|
|
281
|
+
|
|
282
|
+
`QueryAllowance.find` returns `nil` only when the platform answers with nothing at
|
|
283
|
+
all; a non-XML answer is a result code and is raised as `ResultError`.
|
|
258
284
|
|
|
259
285
|
Any other result code raises `Cetustek::ResultError`, whose `#code` is the raw
|
|
260
286
|
code and whose message includes the documented reason (e.g. `A2 - 所有折讓金額加總
|
|
@@ -1,66 +1,44 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'ox'
|
|
4
|
-
require 'cgi'
|
|
5
|
-
|
|
6
3
|
module Cetustek
|
|
7
4
|
# 2.10 CancelAllowance 作廢折讓單. Returns "C0" on success, raises ResultError otherwise.
|
|
8
5
|
class CancelAllowance
|
|
6
|
+
include Soap
|
|
7
|
+
|
|
9
8
|
SUCCESS_CODE = 'C0'
|
|
9
|
+
MAX_REASON_LENGTH = 20
|
|
10
10
|
|
|
11
11
|
# Spec AVM-26-03 Table 19.
|
|
12
|
-
RESULT_MESSAGES =
|
|
13
|
-
'M:' => '欄位未填或格式錯誤',
|
|
14
|
-
'M1' => 'XML 格式錯誤',
|
|
12
|
+
RESULT_MESSAGES = ResultCode::COMMON.merge(
|
|
15
13
|
'C1' => '上傳失敗',
|
|
16
14
|
'C2' => '折讓單已申報,無法作廢',
|
|
17
15
|
'C3' => '折讓單號不存在或須為已確認後的折讓單',
|
|
18
16
|
'C4' => '該作廢折讓單已經上傳',
|
|
19
|
-
'C5' => '折讓單已過作廢期限,無法作廢'
|
|
20
|
-
|
|
21
|
-
}.freeze
|
|
17
|
+
'C5' => '折讓單已過作廢期限,無法作廢'
|
|
18
|
+
).freeze
|
|
22
19
|
|
|
20
|
+
# Table 18 has only these two fields, both 必填.
|
|
23
21
|
def initialize(allowance_number, reason)
|
|
22
|
+
raise ArgumentError, 'allowance_number is required' if allowance_number.to_s.strip.empty?
|
|
23
|
+
raise ArgumentError, 'reason (作廢原因) is required' if reason.to_s.strip.empty?
|
|
24
|
+
raise ArgumentError, "reason must not exceed #{MAX_REASON_LENGTH} characters" if reason.length > MAX_REASON_LENGTH
|
|
25
|
+
|
|
24
26
|
@allowance_number = allowance_number
|
|
25
27
|
@reason = reason
|
|
26
28
|
end
|
|
27
29
|
|
|
28
30
|
def execute
|
|
29
|
-
|
|
30
|
-
ResultCode.check!(
|
|
31
|
-
RESULT_MESSAGES, success: SUCCESS_CODE)
|
|
31
|
+
response = soap_call(:cancel_allowance, allowancexml: generate_xml)
|
|
32
|
+
ResultCode.check!(soap_return(response, :cancel_allowance), RESULT_MESSAGES, success: SUCCESS_CODE)
|
|
32
33
|
end
|
|
33
34
|
|
|
34
35
|
private
|
|
35
36
|
|
|
36
|
-
def perform
|
|
37
|
-
client = Savon.client(wsdl: Cetustek.config.url, open_timeout: 300, read_timeout: 300)
|
|
38
|
-
@response = client.call(:cancel_allowance, message: {
|
|
39
|
-
allowancexml: generate_xml,
|
|
40
|
-
source: Cetustek.config.site_id + Cetustek.config.password,
|
|
41
|
-
rentid: Cetustek.config.username
|
|
42
|
-
})
|
|
43
|
-
end
|
|
44
|
-
|
|
45
37
|
def generate_xml
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
doc << instruct
|
|
51
|
-
|
|
52
|
-
allowance = Ox::Element.new('Allowance')
|
|
53
|
-
allowance[:XSDVersion] = '2.8'
|
|
54
|
-
doc << allowance
|
|
55
|
-
|
|
56
|
-
allowance << raw_tag('AllowanceNumber', @allowance_number)
|
|
57
|
-
allowance << raw_tag('Reason', @reason)
|
|
58
|
-
|
|
59
|
-
Ox.dump(doc).force_encoding('UTF-8')
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
def raw_tag(name, value)
|
|
63
|
-
Ox::Raw.new("<#{name}>#{CGI.escapeHTML(value.to_s)}</#{name}>")
|
|
38
|
+
Xml.document('Allowance') do |allowance|
|
|
39
|
+
Xml.append(allowance, 'AllowanceNumber', @allowance_number)
|
|
40
|
+
Xml.append(allowance, 'Reason', @reason)
|
|
41
|
+
end
|
|
64
42
|
end
|
|
65
43
|
end
|
|
66
44
|
end
|
|
@@ -1,31 +1,29 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'ox'
|
|
4
|
-
require 'cgi'
|
|
5
|
-
|
|
6
3
|
module Cetustek
|
|
7
4
|
# 2.2 CancelInvoice 作廢發票確認. Returns "C0" on success, raises ResultError
|
|
8
5
|
# otherwise. Uploading is not the end of it — the cancellation still has to be
|
|
9
6
|
# confirmed manually on the 鯨躍 platform before the invoice counts as void.
|
|
10
7
|
class CancelInvoice
|
|
8
|
+
include Soap
|
|
9
|
+
|
|
11
10
|
SUCCESS_CODE = 'C0'
|
|
11
|
+
MAX_REMARK_LENGTH = 20
|
|
12
12
|
|
|
13
13
|
# Spec AVM-26-03 Table 10.
|
|
14
|
-
RESULT_MESSAGES =
|
|
15
|
-
'M:' => '欄位未填或格式錯誤',
|
|
16
|
-
'M0' => 'XML 格式錯誤',
|
|
17
|
-
'M1' => 'XML 格式錯誤',
|
|
14
|
+
RESULT_MESSAGES = ResultCode::COMMON.merge(
|
|
18
15
|
'C1' => '資料庫發生錯誤',
|
|
19
16
|
'C2' => '資料有誤',
|
|
20
17
|
'C3' => '該發票已過申報期間,需填入核准作廢文號 (return_tax_document_number)',
|
|
21
18
|
'C4' => '無此發票號碼可以作廢',
|
|
22
19
|
'C5' => '該發票已經作廢過',
|
|
23
|
-
'C6' => '作廢資訊已經傳送過'
|
|
24
|
-
|
|
25
|
-
}.freeze
|
|
20
|
+
'C6' => '作廢資訊已經傳送過'
|
|
21
|
+
).freeze
|
|
26
22
|
|
|
27
|
-
|
|
23
|
+
# remark 是 Table 9 的必填作廢原因,沒有預設值可用:理由是業務決定的。
|
|
24
|
+
def initialize(invoice_number, invoice_year, remark:, return_tax_document_number: nil)
|
|
28
25
|
raise ArgumentError, 'remark (作廢原因) is required' if remark.to_s.strip.empty?
|
|
26
|
+
raise ArgumentError, "remark must not exceed #{MAX_REMARK_LENGTH} characters" if remark.length > MAX_REMARK_LENGTH
|
|
29
27
|
|
|
30
28
|
@invoice_number = invoice_number
|
|
31
29
|
@invoice_year = invoice_year
|
|
@@ -34,44 +32,20 @@ module Cetustek
|
|
|
34
32
|
end
|
|
35
33
|
|
|
36
34
|
def execute
|
|
37
|
-
|
|
38
|
-
ResultCode.check!(
|
|
39
|
-
RESULT_MESSAGES, success: SUCCESS_CODE)
|
|
35
|
+
response = soap_call(:cancel_invoice, invoicexml: generate_xml)
|
|
36
|
+
ResultCode.check!(soap_return(response, :cancel_invoice), RESULT_MESSAGES, success: SUCCESS_CODE)
|
|
40
37
|
end
|
|
41
38
|
|
|
42
39
|
private
|
|
43
40
|
|
|
44
|
-
def perform
|
|
45
|
-
client = Savon.client(wsdl: Cetustek.config.url, open_timeout: 300, read_timeout: 300)
|
|
46
|
-
@response = client.call(:cancel_invoice, message: {
|
|
47
|
-
invoicexml: generate_xml,
|
|
48
|
-
source: Cetustek.config.site_id + Cetustek.config.password,
|
|
49
|
-
rentid: Cetustek.config.username
|
|
50
|
-
})
|
|
51
|
-
end
|
|
52
|
-
|
|
53
41
|
def generate_xml
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
invoice[:XSDVersion] = '2.8'
|
|
62
|
-
doc << invoice
|
|
63
|
-
|
|
64
|
-
invoice << raw_tag('InvoiceNumber', @invoice_number)
|
|
65
|
-
invoice << raw_tag('InvoiceYear', @invoice_year)
|
|
66
|
-
# 專案作廢核准文號:只在超過申報期間作廢時才需要 (Table 10 的 C3)。
|
|
67
|
-
invoice << raw_tag('ReturnTaxDocumentNumber', @return_tax_document_number) if @return_tax_document_number
|
|
68
|
-
invoice << raw_tag('Remark', @remark)
|
|
69
|
-
|
|
70
|
-
Ox.dump(doc).force_encoding('UTF-8')
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
def raw_tag(name, value)
|
|
74
|
-
Ox::Raw.new("<#{name}>#{CGI.escapeHTML(value.to_s)}</#{name}>")
|
|
42
|
+
Xml.document('Invoice') do |invoice|
|
|
43
|
+
Xml.append(invoice, 'InvoiceNumber', @invoice_number)
|
|
44
|
+
Xml.append(invoice, 'InvoiceYear', @invoice_year)
|
|
45
|
+
# 專案作廢核准文號:只在超過申報期間作廢時才需要 (Table 10 的 C3)。
|
|
46
|
+
Xml.append(invoice, 'ReturnTaxDocumentNumber', @return_tax_document_number, skip_nil: true)
|
|
47
|
+
Xml.append(invoice, 'Remark', @remark)
|
|
48
|
+
end
|
|
75
49
|
end
|
|
76
50
|
end
|
|
77
51
|
end
|
|
@@ -1,34 +1,22 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'ox'
|
|
4
|
-
require 'cgi'
|
|
5
|
-
|
|
6
3
|
module Cetustek
|
|
7
4
|
# 2.9 CreateAllowance 開立折讓單. Returns "A0" on success, raises ResultError otherwise.
|
|
8
5
|
class CreateAllowance
|
|
6
|
+
include Soap
|
|
7
|
+
|
|
9
8
|
SUCCESS_CODE = 'A0'
|
|
10
9
|
|
|
11
10
|
# Spec AVM-26-03 Table 17.
|
|
12
|
-
RESULT_MESSAGES =
|
|
13
|
-
'M:' => '欄位未填或格式錯誤',
|
|
14
|
-
'M1' => 'XML 格式錯誤',
|
|
15
|
-
'D0' => '沒有產品明細',
|
|
16
|
-
'D0_' => '產品編號格式錯誤',
|
|
17
|
-
'D1_' => '品名未填或格式錯誤',
|
|
18
|
-
'D2_' => '數量未填或格式錯誤',
|
|
19
|
-
'D3_' => '單價未填或格式錯誤',
|
|
20
|
-
'D4_' => '單位格式錯誤',
|
|
21
|
-
'D5_' => '數量*單價,其小計整數位大於 13 位',
|
|
22
|
-
'D999' => '明細筆數最多 9999 筆',
|
|
11
|
+
RESULT_MESSAGES = ResultCode::COMMON.merge(ResultCode::DETAILS).merge(
|
|
23
12
|
'A1' => '上傳失敗',
|
|
24
13
|
'A2' => '所有折讓金額加總不能大於原發票金額',
|
|
25
14
|
'A3' => '發票號碼不存在',
|
|
26
15
|
'A4' => '發票號碼已經被作廢',
|
|
27
16
|
'A5' => '折讓單已經上傳',
|
|
28
17
|
'A6' => '折讓總金額須大於零',
|
|
29
|
-
'A7' => '折讓日期應大於原發票開立日期'
|
|
30
|
-
|
|
31
|
-
}.freeze
|
|
18
|
+
'A7' => '折讓日期應大於原發票開立日期'
|
|
19
|
+
).freeze
|
|
32
20
|
|
|
33
21
|
# check_allowance 只剩 0(已確認的折讓單):規格 §2.9 已刪除 1,
|
|
34
22
|
# 114/01/01 起上傳的折讓單皆為已確認。
|
|
@@ -40,64 +28,39 @@ module Cetustek
|
|
|
40
28
|
end
|
|
41
29
|
|
|
42
30
|
def execute
|
|
43
|
-
|
|
44
|
-
ResultCode.check!(
|
|
45
|
-
RESULT_MESSAGES, success: SUCCESS_CODE)
|
|
31
|
+
response = soap_call(:create_allowance, allowancexml: generate_xml, checkallowance: @check_allowance)
|
|
32
|
+
ResultCode.check!(soap_return(response, :create_allowance), RESULT_MESSAGES, success: SUCCESS_CODE)
|
|
46
33
|
end
|
|
47
34
|
|
|
48
35
|
private
|
|
49
36
|
|
|
50
|
-
def perform
|
|
51
|
-
client = Savon.client(wsdl: Cetustek.config.url, open_timeout: 300, read_timeout: 300)
|
|
52
|
-
@response = client.call(:create_allowance, message: {
|
|
53
|
-
allowancexml: generate_xml,
|
|
54
|
-
checkallowance: @check_allowance,
|
|
55
|
-
source: Cetustek.config.site_id + Cetustek.config.password,
|
|
56
|
-
rentid: Cetustek.config.username
|
|
57
|
-
})
|
|
58
|
-
end
|
|
59
|
-
|
|
60
37
|
def generate_xml
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
allowance << raw_tag('InvoiceNumber', @data.invoice_number)
|
|
74
|
-
allowance << raw_tag('InvoiceYear', @data.invoice_year)
|
|
75
|
-
allowance << raw_tag('BuyerAddress', @data.buyer_address)
|
|
76
|
-
allowance << raw_tag('BuyerEmailAddress', @data.buyer_email)
|
|
77
|
-
allowance << raw_tag('TaxType', @data.tax_type)
|
|
78
|
-
allowance << raw_tag('Reason', @data.reason)
|
|
79
|
-
allowance << raw_tag('RoundNum', @data.round_num) unless @data.round_num.nil?
|
|
80
|
-
allowance << build_details
|
|
81
|
-
|
|
82
|
-
Ox.dump(doc).force_encoding('UTF-8')
|
|
38
|
+
Xml.document('Allowance') do |allowance|
|
|
39
|
+
Xml.append(allowance, 'AllowanceNumber', @data.allowance_number)
|
|
40
|
+
Xml.append(allowance, 'AllowanceDate', @data.allowance_date.strftime('%Y/%m/%d'))
|
|
41
|
+
Xml.append(allowance, 'InvoiceNumber', @data.invoice_number)
|
|
42
|
+
Xml.append(allowance, 'InvoiceYear', @data.invoice_year)
|
|
43
|
+
Xml.append(allowance, 'BuyerAddress', @data.buyer_address)
|
|
44
|
+
Xml.append(allowance, 'BuyerEmailAddress', @data.buyer_email)
|
|
45
|
+
Xml.append(allowance, 'TaxType', @data.tax_type)
|
|
46
|
+
Xml.append(allowance, 'Reason', @data.reason)
|
|
47
|
+
Xml.append(allowance, 'RoundNum', @data.round_num, skip_nil: true)
|
|
48
|
+
allowance << build_details
|
|
49
|
+
end
|
|
83
50
|
end
|
|
84
51
|
|
|
85
52
|
def build_details
|
|
86
53
|
details = Ox::Element.new('Details')
|
|
87
54
|
@data.items.each do |item|
|
|
88
55
|
product = Ox::Element.new('ProductItem')
|
|
89
|
-
product
|
|
90
|
-
product
|
|
91
|
-
product
|
|
92
|
-
product
|
|
93
|
-
product
|
|
56
|
+
Xml.append(product, 'ProductionCode', item.code)
|
|
57
|
+
Xml.append(product, 'Description', item.name)
|
|
58
|
+
Xml.append(product, 'Quantity', item.quantity)
|
|
59
|
+
Xml.append(product, 'Unit', item.unit)
|
|
60
|
+
Xml.append(product, 'UnitPrice', item.unit_price)
|
|
94
61
|
details << product
|
|
95
62
|
end
|
|
96
63
|
details
|
|
97
64
|
end
|
|
98
|
-
|
|
99
|
-
def raw_tag(name, value)
|
|
100
|
-
Ox::Raw.new("<#{name}>#{CGI.escapeHTML(value.to_s)}</#{name}>")
|
|
101
|
-
end
|
|
102
65
|
end
|
|
103
66
|
end
|
|
@@ -2,20 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative 'models/invoice_data'
|
|
4
4
|
require_relative 'services/invoice_xml_builder'
|
|
5
|
-
require_relative 'services/invoice_service'
|
|
6
5
|
require_relative 'services/response_handler'
|
|
7
6
|
|
|
8
7
|
module Cetustek
|
|
9
8
|
# 2.1 CreateInvoiceV3 開立發票. Returns the issued invoice on success and
|
|
10
9
|
# raises ResultError otherwise. Persisting the result is the caller's job.
|
|
11
10
|
class CreateInvoice
|
|
11
|
+
include Soap
|
|
12
|
+
|
|
12
13
|
def initialize(invoice_data)
|
|
13
14
|
@invoice_data = invoice_data
|
|
14
15
|
end
|
|
15
16
|
|
|
16
17
|
def execute
|
|
17
18
|
xml = Services::InvoiceXmlBuilder.new(@invoice_data).build
|
|
18
|
-
|
|
19
|
+
# hastax: 0 = 明細單價未稅, 1 = 含稅; comes from the order, not a constant.
|
|
20
|
+
response = soap_call(:create_invoice_v3, invoicexml: xml, hastax: @invoice_data.hastax)
|
|
19
21
|
Services::ResponseHandler.new(response, @invoice_data, xml).process
|
|
20
22
|
end
|
|
21
23
|
end
|
data/lib/cetustek/errors.rb
CHANGED
|
@@ -14,6 +14,26 @@ module Cetustek
|
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
module ResultCode
|
|
17
|
+
# Rows that are identical across the spec's result tables (7/10/17/19).
|
|
18
|
+
COMMON = {
|
|
19
|
+
'M:' => '欄位未填或格式錯誤',
|
|
20
|
+
'M0' => 'XML 格式錯誤',
|
|
21
|
+
'M1' => 'XML 格式錯誤',
|
|
22
|
+
'Invalid' => '無效 IP,請通知系統商'
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
# Detail-line rows, identical between Table 7 (發票) and Table 17 (折讓).
|
|
26
|
+
DETAILS = {
|
|
27
|
+
'D0' => '沒有產品明細',
|
|
28
|
+
'D0_' => '產品編號格式錯誤',
|
|
29
|
+
'D1_' => '品名未填或格式錯誤',
|
|
30
|
+
'D2_' => '數量未填或格式錯誤',
|
|
31
|
+
'D3_' => '單價未填或格式錯誤',
|
|
32
|
+
'D4_' => '單位格式錯誤',
|
|
33
|
+
'D5_' => '數量*單價,其小計整數位大於 13 位',
|
|
34
|
+
'D999' => '明細筆數最多 9999 筆'
|
|
35
|
+
}.freeze
|
|
36
|
+
|
|
17
37
|
# Codes may carry a suffix naming the offending field or detail row
|
|
18
38
|
# ("M:AllowanceDate", "D2_3"), so an exact miss falls back to the table key
|
|
19
39
|
# without the suffix.
|
|
@@ -25,6 +45,10 @@ module Cetustek
|
|
|
25
45
|
def self.check!(code, messages, success:)
|
|
26
46
|
return code if code == success
|
|
27
47
|
|
|
48
|
+
raise!(code, messages)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.raise!(code, messages)
|
|
28
52
|
raise ResultError.new(code, describe(code, messages))
|
|
29
53
|
end
|
|
30
54
|
end
|
|
@@ -10,6 +10,8 @@ module Cetustek
|
|
|
10
10
|
# 折讓單稅別只有應稅/零稅率/免稅,發票的 4 (特種稅率)、5 (經海關出口)
|
|
11
11
|
# 與 9 (混合) 在折讓單無效。
|
|
12
12
|
TAX_TYPES = [TaxType::TAXABLE, TaxType::ZERO_RATE, TaxType::TAX_FREE].freeze
|
|
13
|
+
MAX_REASON_LENGTH = 20
|
|
14
|
+
ROUND_NUMS = (0..7).freeze
|
|
13
15
|
|
|
14
16
|
attr_reader :allowance_number, :allowance_date, :invoice_number,
|
|
15
17
|
:invoice_year, :buyer_address, :buyer_email, :tax_type,
|
|
@@ -23,13 +25,54 @@ module Cetustek
|
|
|
23
25
|
@buyer_address = attributes[:buyer_address]
|
|
24
26
|
@buyer_email = attributes[:buyer_email]
|
|
25
27
|
@tax_type = attributes[:tax_type] || TaxType::TAXABLE
|
|
26
|
-
unless TAX_TYPES.include?(@tax_type.to_i)
|
|
27
|
-
raise ArgumentError, "tax_type must be 1 (應稅), 2 (零稅率) or 3 (免稅), got #{@tax_type.inspect}"
|
|
28
|
-
end
|
|
29
|
-
|
|
30
28
|
@reason = attributes[:reason]
|
|
31
29
|
@round_num = attributes[:round_num] # optional 金額計算位數
|
|
32
30
|
@items = attributes[:items] || []
|
|
31
|
+
validate!
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def validate!
|
|
37
|
+
missing = { allowance_number: @allowance_number, invoice_number: @invoice_number,
|
|
38
|
+
invoice_year: @invoice_year, reason: @reason }.select { |_k, v| blank?(v) }.keys
|
|
39
|
+
raise ArgumentError, "#{missing.join(', ')} required" if missing.any?
|
|
40
|
+
|
|
41
|
+
validate_allowance_date!
|
|
42
|
+
validate_tax_type!
|
|
43
|
+
validate_reason!
|
|
44
|
+
validate_round_num!
|
|
45
|
+
raise ArgumentError, 'items must not be empty (沒有產品明細)' if @items.empty?
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def validate_allowance_date!
|
|
49
|
+
raise ArgumentError, 'allowance_date is required' if @allowance_date.nil?
|
|
50
|
+
return if @allowance_date.respond_to?(:strftime)
|
|
51
|
+
|
|
52
|
+
raise ArgumentError, "allowance_date must be a Date or Time, got #{@allowance_date.class}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def validate_tax_type!
|
|
56
|
+
return if TAX_TYPES.include?(@tax_type.to_i)
|
|
57
|
+
|
|
58
|
+
raise ArgumentError, "tax_type must be 1 (應稅), 2 (零稅率) or 3 (免稅), got #{@tax_type.inspect}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def validate_reason!
|
|
62
|
+
return if @reason.to_s.length <= MAX_REASON_LENGTH
|
|
63
|
+
|
|
64
|
+
raise ArgumentError, "reason must not exceed #{MAX_REASON_LENGTH} characters"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def validate_round_num!
|
|
68
|
+
return if @round_num.nil? || ROUND_NUMS.include?(@round_num.to_i)
|
|
69
|
+
|
|
70
|
+
raise ArgumentError, "round_num must be between #{ROUND_NUMS.first} and #{ROUND_NUMS.last}, " \
|
|
71
|
+
"got #{@round_num.inspect}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def blank?(value)
|
|
75
|
+
value.nil? || value.to_s.strip.empty?
|
|
33
76
|
end
|
|
34
77
|
end
|
|
35
78
|
end
|
|
@@ -1,14 +1,26 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Cetustek
|
|
4
|
-
# TaxType (稅別) codes accepted by CreateInvoiceV3
|
|
4
|
+
# TaxType (稅別) codes accepted by CreateInvoiceV3, spec AVM-26-03 Table 1.
|
|
5
5
|
module TaxType
|
|
6
6
|
TAXABLE = 1 # 應稅
|
|
7
7
|
ZERO_RATE = 2 # 零稅率(非經海關出口)
|
|
8
8
|
TAX_FREE = 3 # 免稅
|
|
9
9
|
SPECIAL = 4 # 應稅(特種稅率) — requires TaxRate
|
|
10
10
|
ZERO_RATE_CUSTOMS = 5 # 零稅率(經海關出口)
|
|
11
|
-
MIXED = 9 # 混合(
|
|
11
|
+
MIXED = 9 # 混合(應稅、零稅率與免稅)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# CarrierType (載具類別) codes named in spec AVM-26-03 Table 1. The field is
|
|
15
|
+
# 6 chars and 依電子整合平台核發填入, so any other code is accepted as-is.
|
|
16
|
+
module CarrierType
|
|
17
|
+
MOBILE_BARCODE = '3J0002' # 手機條碼,以「/」起始
|
|
18
|
+
CITIZEN_CERT = 'CQ0001' # 自然人憑證條碼,2 碼大寫字母加 14 碼數字
|
|
19
|
+
CETUSTEK_CARD = 'EJ0011' # 鯨躍發票卡
|
|
20
|
+
|
|
21
|
+
# These carriers 「無顯碼隱碼區分」, so CarrierId1 and CarrierId2 hold the
|
|
22
|
+
# same value. Member carriers (e.g. 鯨躍發票卡) do carry distinct codes.
|
|
23
|
+
WITHOUT_HIDDEN_CODE = [MOBILE_BARCODE, CITIZEN_CERT].freeze
|
|
12
24
|
end
|
|
13
25
|
|
|
14
26
|
# PayWay (付款方式) codes for CreateInvoiceV3, spec AVM-26-03 Table 4.
|
|
@@ -54,8 +66,11 @@ module Cetustek
|
|
|
54
66
|
# Json 回傳才拿得到平台配發的發票日期時間;Intertemporal 回開會讓本機日期失準。
|
|
55
67
|
DEFAULT_RTN_MSG = 'Json'
|
|
56
68
|
MAX_ITEMS = 9999
|
|
69
|
+
MAX_REMARK_LENGTH = 200
|
|
70
|
+
ROUND_NUMS = (0..7).freeze
|
|
57
71
|
TAX_TYPES = [TaxType::TAXABLE, TaxType::ZERO_RATE, TaxType::TAX_FREE,
|
|
58
72
|
TaxType::SPECIAL, TaxType::ZERO_RATE_CUSTOMS, TaxType::MIXED].freeze
|
|
73
|
+
ZERO_RATE_TAX_TYPES = [TaxType::ZERO_RATE, TaxType::ZERO_RATE_CUSTOMS].freeze
|
|
59
74
|
DONATE_MARKS = [DonateMark::CARRIER, DonateMark::DONATE, DonateMark::PAPER].freeze
|
|
60
75
|
|
|
61
76
|
attr_reader :order_id, :order_date, :buyer_identifier, :buyer_name,
|
|
@@ -82,8 +97,7 @@ module Cetustek
|
|
|
82
97
|
@donate_mark = attributes[:donate_mark]
|
|
83
98
|
@carrier_type = attributes[:carrier_type]
|
|
84
99
|
@carrier_id1 = attributes[:carrier_id1] || attributes[:carrier_id]
|
|
85
|
-
|
|
86
|
-
@carrier_id2 = attributes[:carrier_id2] || @carrier_id1
|
|
100
|
+
@carrier_id2 = attributes[:carrier_id2] || mirrored_carrier_id2
|
|
87
101
|
@npo_ban = attributes[:npo_ban]
|
|
88
102
|
@items = attributes[:items] || []
|
|
89
103
|
@payment_type = attributes[:payment_type]
|
|
@@ -102,7 +116,7 @@ module Cetustek
|
|
|
102
116
|
validate!
|
|
103
117
|
end
|
|
104
118
|
|
|
105
|
-
#
|
|
119
|
+
# 混合稅率發票:每筆明細需標註 DType。
|
|
106
120
|
def mixed_tax?
|
|
107
121
|
@tax_type.to_i == TaxType::MIXED
|
|
108
122
|
end
|
|
@@ -114,6 +128,11 @@ module Cetustek
|
|
|
114
128
|
|
|
115
129
|
private
|
|
116
130
|
|
|
131
|
+
# 只有無顯碼隱碼區分的載具能自動補 CarrierId2;會員載具兩碼不同,猜了就是送錯。
|
|
132
|
+
def mirrored_carrier_id2
|
|
133
|
+
@carrier_id1 if CarrierType::WITHOUT_HIDDEN_CODE.include?(@carrier_type.to_s)
|
|
134
|
+
end
|
|
135
|
+
|
|
117
136
|
def validate!
|
|
118
137
|
raise ArgumentError, 'order_id is required' if blank?(@order_id)
|
|
119
138
|
|
|
@@ -122,6 +141,8 @@ module Cetustek
|
|
|
122
141
|
validate_donate_mark!
|
|
123
142
|
validate_pay_way!
|
|
124
143
|
validate_tax!
|
|
144
|
+
validate_remark!
|
|
145
|
+
validate_round_num!
|
|
125
146
|
end
|
|
126
147
|
|
|
127
148
|
def validate_order_date!
|
|
@@ -147,14 +168,22 @@ module Cetustek
|
|
|
147
168
|
when DonateMark::CARRIER then validate_carrier!
|
|
148
169
|
when DonateMark::DONATE then validate_npo_ban!
|
|
149
170
|
end
|
|
171
|
+
|
|
172
|
+
return if @mail_send.nil? || @donate_mark.to_i == DonateMark::CARRIER
|
|
173
|
+
|
|
174
|
+
raise ArgumentError, 'mail_send may only be used when donate_mark is 0 (載具)'
|
|
150
175
|
end
|
|
151
176
|
|
|
177
|
+
# CarrierType is left out of the required set on purpose: 鯨躍發票卡
|
|
178
|
+
# 「載具類別可為空或填 EJ0011」, and a blank value is how the platform is
|
|
179
|
+
# told to use it.
|
|
152
180
|
def validate_carrier!
|
|
153
|
-
missing = { buyer_email: @buyer_email,
|
|
154
|
-
|
|
181
|
+
missing = { buyer_email: @buyer_email, carrier_id1: @carrier_id1,
|
|
182
|
+
carrier_id2: @carrier_id2 }.select { |_name, value| blank?(value) }.keys
|
|
155
183
|
return if missing.empty?
|
|
156
184
|
|
|
157
|
-
raise ArgumentError, "#{missing.join(', ')} required when donate_mark is 0 (載具)"
|
|
185
|
+
raise ArgumentError, "#{missing.join(', ')} required when donate_mark is 0 (載具)" \
|
|
186
|
+
"#{'; 此載具有顯碼與隱碼之分,請分別填入' if missing == [:carrier_id2]}"
|
|
158
187
|
end
|
|
159
188
|
|
|
160
189
|
def validate_npo_ban!
|
|
@@ -171,6 +200,8 @@ module Cetustek
|
|
|
171
200
|
unless TAX_TYPES.include?(@tax_type.to_i)
|
|
172
201
|
raise ArgumentError, "tax_type must be one of #{TAX_TYPES.join(', ')}, got #{@tax_type.inspect}"
|
|
173
202
|
end
|
|
203
|
+
|
|
204
|
+
validate_zero_reason!
|
|
174
205
|
return unless special_tax?
|
|
175
206
|
|
|
176
207
|
raise ArgumentError, 'tax_rate is required when tax_type is 4 (特種稅率)' if blank?(@tax_rate)
|
|
@@ -179,11 +210,32 @@ module Cetustek
|
|
|
179
210
|
raise ArgumentError, "invoice_type must be '08' (特種稅額) when tax_type is 4, got #{@invoice_type.inspect}"
|
|
180
211
|
end
|
|
181
212
|
|
|
213
|
+
def validate_zero_reason!
|
|
214
|
+
return if @zero_reason.nil? || ZERO_RATE_TAX_TYPES.include?(@tax_type.to_i)
|
|
215
|
+
|
|
216
|
+
raise ArgumentError, 'zero_reason may only be used when tax_type is 2 or 5 (零稅率)'
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def validate_remark!
|
|
220
|
+
return if @remark.nil? || @remark.to_s.length <= MAX_REMARK_LENGTH
|
|
221
|
+
|
|
222
|
+
raise ArgumentError, "remark must not exceed #{MAX_REMARK_LENGTH} characters"
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def validate_round_num!
|
|
226
|
+
return if @round_num.nil? || ROUND_NUMS.include?(@round_num.to_i)
|
|
227
|
+
|
|
228
|
+
raise ArgumentError, "round_num must be between #{ROUND_NUMS.first} and #{ROUND_NUMS.last}, " \
|
|
229
|
+
"got #{@round_num.inspect}"
|
|
230
|
+
end
|
|
231
|
+
|
|
182
232
|
def blank?(value)
|
|
183
233
|
value.nil? || value.to_s.strip.empty?
|
|
184
234
|
end
|
|
185
235
|
end
|
|
186
236
|
|
|
237
|
+
# A single 明細 row, spec AVM-26-03 Table 2 (發票) / Table 16 (折讓).
|
|
238
|
+
# 品名代號、品名、數量、單價 are 必填; 單位 is not.
|
|
187
239
|
class InvoiceItem
|
|
188
240
|
# Per-item 稅別註記 (DType) used for mixed-tax invoices (TaxType == 9).
|
|
189
241
|
DTYPE_MAP = {
|
|
@@ -191,6 +243,7 @@ module Cetustek
|
|
|
191
243
|
zero_rate: 'TZ', # 零稅率商品
|
|
192
244
|
tax_free: 'TN' # 免稅商品
|
|
193
245
|
}.freeze
|
|
246
|
+
REQUIRED = %i[code name quantity unit_price].freeze
|
|
194
247
|
|
|
195
248
|
attr_reader :code, :name, :quantity, :unit_price, :tax_type, :unit
|
|
196
249
|
|
|
@@ -201,6 +254,7 @@ module Cetustek
|
|
|
201
254
|
@unit_price = attributes[:unit_price]
|
|
202
255
|
@unit = attributes[:unit]
|
|
203
256
|
@tax_type = attributes[:tax_type] || :taxable
|
|
257
|
+
validate!
|
|
204
258
|
end
|
|
205
259
|
|
|
206
260
|
# Returns the DType code: '', 'TZ' or 'TN'.
|
|
@@ -208,6 +262,19 @@ module Cetustek
|
|
|
208
262
|
def d_type
|
|
209
263
|
DTYPE_MAP.fetch(@tax_type) { @tax_type.to_s }
|
|
210
264
|
end
|
|
265
|
+
|
|
266
|
+
private
|
|
267
|
+
|
|
268
|
+
def validate!
|
|
269
|
+
missing = REQUIRED.select { |name| blank?(public_send(name)) }
|
|
270
|
+
return if missing.empty?
|
|
271
|
+
|
|
272
|
+
raise ArgumentError, "item #{missing.join(', ')} required (品名代號、品名、數量、單價皆必填)"
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def blank?(value)
|
|
276
|
+
value.nil? || value.to_s.strip.empty?
|
|
277
|
+
end
|
|
211
278
|
end
|
|
212
279
|
end
|
|
213
280
|
end
|
data/lib/cetustek/queries.rb
CHANGED
|
@@ -3,21 +3,8 @@
|
|
|
3
3
|
require 'ox'
|
|
4
4
|
|
|
5
5
|
module Cetustek
|
|
6
|
-
#
|
|
7
|
-
|
|
8
|
-
module Queries
|
|
9
|
-
def soap_client
|
|
10
|
-
Savon.client(wsdl: Cetustek.config.url, open_timeout: 300, read_timeout: 300)
|
|
11
|
-
end
|
|
12
|
-
|
|
13
|
-
def source
|
|
14
|
-
Cetustek.config.site_id + Cetustek.config.password
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
def rentid
|
|
18
|
-
Cetustek.config.username
|
|
19
|
-
end
|
|
20
|
-
end
|
|
6
|
+
# @deprecated use Cetustek::Soap. Kept so `extend Queries` keeps working.
|
|
7
|
+
Queries = Soap
|
|
21
8
|
|
|
22
9
|
# 2.4 QueryInvoice 查詢發票資訊 (by invoice number + year)
|
|
23
10
|
class QueryInvoice
|
|
@@ -68,14 +55,16 @@ module Cetustek
|
|
|
68
55
|
|
|
69
56
|
# Same query, with the returned XML parsed into a Hash of snake_case keys
|
|
70
57
|
# plus a :details array. Values are the raw strings from the XML; returns
|
|
71
|
-
# nil when the
|
|
58
|
+
# nil when the platform answers with nothing at all.
|
|
72
59
|
def self.find(allowance_number)
|
|
73
|
-
parse(query(allowance_number)
|
|
60
|
+
parse(soap_return(query(allowance_number), :query_allowance))
|
|
74
61
|
end
|
|
75
62
|
|
|
76
63
|
def self.parse(xml)
|
|
77
64
|
body = xml.to_s.strip
|
|
78
|
-
return nil if body.empty?
|
|
65
|
+
return nil if body.empty?
|
|
66
|
+
# §2.11 只描述成功時的 XML;非 XML 的回覆是代碼字串,原樣拋給呼叫端。
|
|
67
|
+
ResultCode.raise!(body, ResultCode::COMMON) unless body.start_with?('<')
|
|
79
68
|
|
|
80
69
|
root = Ox.parse(body)
|
|
81
70
|
root = root.root if root.is_a?(Ox::Document)
|
|
@@ -1,19 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module Cetustek
|
|
4
|
+
# 2.5 QueryInvoicebyOrderid 以訂單編號查詢發票資訊
|
|
2
5
|
class QueryInvoiceByOrderId
|
|
3
|
-
|
|
4
|
-
url = Cetustek.config.url
|
|
5
|
-
client = Savon.client(
|
|
6
|
-
wsdl: url,
|
|
7
|
-
open_timeout: 300,
|
|
8
|
-
read_timeout: 300
|
|
9
|
-
)
|
|
6
|
+
extend Soap
|
|
10
7
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
orderid: order_id,
|
|
14
|
-
source: Cetustek.config.site_id + Cetustek.config.password,
|
|
15
|
-
rentid: Cetustek.config.username
|
|
16
|
-
})
|
|
8
|
+
def self.query(order_id)
|
|
9
|
+
soap_call(:query_invoice_by_orderid, orderid: order_id)
|
|
17
10
|
end
|
|
18
11
|
end
|
|
19
12
|
end
|
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'ox'
|
|
4
|
-
require 'cgi'
|
|
5
|
-
|
|
6
3
|
module Cetustek
|
|
7
4
|
module Services
|
|
8
5
|
class InvoiceXmlBuilder
|
|
@@ -11,78 +8,47 @@ module Cetustek
|
|
|
11
8
|
end
|
|
12
9
|
|
|
13
10
|
def build
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
11
|
+
Xml.document('Invoice') do |invoice|
|
|
12
|
+
add_basic_info(invoice)
|
|
13
|
+
add_buyer_info(invoice)
|
|
14
|
+
add_invoice_type_info(invoice)
|
|
15
|
+
add_details(invoice)
|
|
16
|
+
end
|
|
19
17
|
end
|
|
20
18
|
|
|
21
19
|
private
|
|
22
20
|
|
|
23
|
-
def create_xml_instruct
|
|
24
|
-
instruct = Ox::Instruct.new(:xml)
|
|
25
|
-
instruct[:version] = '1.0'
|
|
26
|
-
instruct[:encoding] = 'UTF-8'
|
|
27
|
-
instruct
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
# Builds a raw XML element with the value HTML-escaped, so that special
|
|
31
|
-
# characters (&, <, >, ", ') in any dynamic field cannot break the XML
|
|
32
|
-
# or be used for injection.
|
|
33
|
-
def raw_tag(name, value)
|
|
34
|
-
Ox::Raw.new("<#{name}>#{CGI.escapeHTML(value.to_s)}</#{name}>")
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
def create_invoice_element
|
|
38
|
-
invoice = Ox::Element.new('Invoice')
|
|
39
|
-
invoice[:XSDVersion] = '2.8'
|
|
40
|
-
|
|
41
|
-
add_basic_info(invoice)
|
|
42
|
-
add_buyer_info(invoice)
|
|
43
|
-
add_invoice_type_info(invoice)
|
|
44
|
-
add_details(invoice)
|
|
45
|
-
|
|
46
|
-
invoice
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
# Fields whose 備註 says "若未填,預設 X" are omitted entirely when nil, so
|
|
50
|
-
# the platform applies its own default instead of parsing an empty value.
|
|
51
|
-
def optional_tag(invoice, name, value)
|
|
52
|
-
invoice << raw_tag(name, value) unless value.nil?
|
|
53
|
-
end
|
|
54
|
-
|
|
55
21
|
def add_basic_info(invoice)
|
|
56
|
-
invoice
|
|
57
|
-
invoice
|
|
22
|
+
Xml.append(invoice, 'OrderId', @data.order_id)
|
|
23
|
+
Xml.append(invoice, 'OrderDate', @data.order_date.strftime('%Y/%m/%d'))
|
|
58
24
|
end
|
|
59
25
|
|
|
60
26
|
def add_buyer_info(invoice)
|
|
61
|
-
invoice
|
|
62
|
-
invoice
|
|
63
|
-
invoice
|
|
64
|
-
invoice
|
|
65
|
-
invoice
|
|
66
|
-
invoice
|
|
67
|
-
invoice
|
|
68
|
-
invoice
|
|
27
|
+
Xml.append(invoice, 'BuyerIdentifier', @data.buyer_identifier)
|
|
28
|
+
Xml.append(invoice, 'BuyerName', @data.buyer_name)
|
|
29
|
+
Xml.append(invoice, 'BuyerAddress', @data.buyer_address)
|
|
30
|
+
Xml.append(invoice, 'BuyerPersonInCharge', @data.buyer_person_in_charge)
|
|
31
|
+
Xml.append(invoice, 'BuyerTelephoneNumber', @data.buyer_telephone)
|
|
32
|
+
Xml.append(invoice, 'BuyerFacsimileNumber', @data.buyer_facsimile)
|
|
33
|
+
Xml.append(invoice, 'BuyerEmailAddress', @data.buyer_email)
|
|
34
|
+
Xml.append(invoice, 'BuyerCustomerNumber', @data.buyer_customer_number)
|
|
69
35
|
end
|
|
70
36
|
|
|
71
37
|
def add_invoice_type_info(invoice)
|
|
72
|
-
invoice
|
|
73
|
-
invoice
|
|
74
|
-
invoice
|
|
75
|
-
invoice
|
|
76
|
-
invoice
|
|
77
|
-
invoice
|
|
78
|
-
invoice
|
|
79
|
-
invoice
|
|
80
|
-
|
|
81
|
-
invoice
|
|
82
|
-
invoice
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
38
|
+
Xml.append(invoice, 'DonateMark', @data.donate_mark)
|
|
39
|
+
Xml.append(invoice, 'InvoiceType', @data.invoice_type)
|
|
40
|
+
Xml.append(invoice, 'CarrierType', @data.carrier_type)
|
|
41
|
+
Xml.append(invoice, 'CarrierId1', @data.carrier_id1)
|
|
42
|
+
Xml.append(invoice, 'CarrierId2', @data.carrier_id2)
|
|
43
|
+
Xml.append(invoice, 'NPOBAN', @data.npo_ban)
|
|
44
|
+
Xml.append(invoice, 'TaxType', @data.tax_type)
|
|
45
|
+
Xml.append(invoice, 'TaxRate', @data.tax_rate)
|
|
46
|
+
Xml.append(invoice, 'ZeroReason', @data.zero_reason, skip_nil: true)
|
|
47
|
+
Xml.append(invoice, 'PayWay', @data.payment_type)
|
|
48
|
+
Xml.append(invoice, 'Remark', @data.remark)
|
|
49
|
+
Xml.append(invoice, 'MailSend', @data.mail_send, skip_nil: true)
|
|
50
|
+
Xml.append(invoice, 'RoundNum', @data.round_num, skip_nil: true)
|
|
51
|
+
Xml.append(invoice, 'RtnMsg', @data.rtn_msg, skip_nil: true)
|
|
86
52
|
end
|
|
87
53
|
|
|
88
54
|
def add_details(invoice)
|
|
@@ -96,22 +62,16 @@ module Cetustek
|
|
|
96
62
|
|
|
97
63
|
def create_product_item(item)
|
|
98
64
|
product = Ox::Element.new('ProductItem')
|
|
99
|
-
product
|
|
100
|
-
product
|
|
101
|
-
product
|
|
102
|
-
product
|
|
103
|
-
product
|
|
104
|
-
|
|
65
|
+
Xml.append(product, 'ProductionCode', item.code)
|
|
66
|
+
Xml.append(product, 'Description', item.name)
|
|
67
|
+
Xml.append(product, 'Quantity', item.quantity)
|
|
68
|
+
Xml.append(product, 'Unit', item.unit)
|
|
69
|
+
Xml.append(product, 'UnitPrice', item.unit_price)
|
|
70
|
+
# DType (稅別註記) is required on every detail line only for mixed-tax
|
|
71
|
+
# invoices (TaxType == 9).
|
|
72
|
+
Xml.append(product, 'DType', item.d_type) if @data.mixed_tax?
|
|
105
73
|
product
|
|
106
74
|
end
|
|
107
|
-
|
|
108
|
-
# DType (稅別註記) is required on every detail line only for mixed-tax
|
|
109
|
-
# invoices (TaxType == 9).
|
|
110
|
-
def add_dtype(product, value)
|
|
111
|
-
return unless @data.mixed_tax?
|
|
112
|
-
|
|
113
|
-
product << raw_tag('DType', value)
|
|
114
|
-
end
|
|
115
75
|
end
|
|
116
76
|
end
|
|
117
77
|
end
|
|
@@ -8,25 +8,14 @@ module Cetustek
|
|
|
8
8
|
# the Table 8 JSON object (RtnMsg=Json), the 15-character
|
|
9
9
|
# "發票號碼;隨機碼" string, or a bare Table 7 result code.
|
|
10
10
|
class ResponseHandler
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
|
|
11
|
+
# @deprecated rescue Cetustek::ResultError instead. Kept as an alias of
|
|
12
|
+
# the class actually raised so pre-0.7 rescues keep matching.
|
|
13
|
+
InvalidResponseError = ResultError
|
|
14
14
|
|
|
15
15
|
SUCCESS_LENGTH = 15 # 發票號碼 10 碼 + ';' + 隨機碼 4 碼
|
|
16
16
|
|
|
17
17
|
# Spec AVM-26-03 Table 7.
|
|
18
|
-
RESULT_MESSAGES =
|
|
19
|
-
'M:' => '欄位未填或格式錯誤',
|
|
20
|
-
'M0' => 'XML 格式錯誤',
|
|
21
|
-
'M1' => 'XML 格式錯誤',
|
|
22
|
-
'D0' => '沒有產品明細',
|
|
23
|
-
'D0_' => '產品編號格式錯誤',
|
|
24
|
-
'D1_' => '品名未填或格式錯誤',
|
|
25
|
-
'D2_' => '數量未填或格式錯誤',
|
|
26
|
-
'D3_' => '單價未填或格式錯誤',
|
|
27
|
-
'D4_' => '單位格式錯誤',
|
|
28
|
-
'D5_' => '數量*單價,其小計整數位大於 13 位',
|
|
29
|
-
'D999' => '明細筆數最多 9999 筆',
|
|
18
|
+
RESULT_MESSAGES = ResultCode::COMMON.merge(ResultCode::DETAILS).merge(
|
|
30
19
|
'S1' => '資料庫發生錯誤',
|
|
31
20
|
'S2' => '訂單日期超過開立日期',
|
|
32
21
|
'S3' => '未在申報期內',
|
|
@@ -34,11 +23,10 @@ module Cetustek
|
|
|
34
23
|
'S5' => '發票號碼已使用完畢',
|
|
35
24
|
'S6' => '超過租賃張數限制',
|
|
36
25
|
'S7' => '訂單號碼已存在,若需重開請先作廢原發票號碼',
|
|
37
|
-
'S8' => '開立的總金額為負值'
|
|
38
|
-
|
|
39
|
-
}.freeze
|
|
26
|
+
'S8' => '開立的總金額為負值'
|
|
27
|
+
).freeze
|
|
40
28
|
|
|
41
|
-
def initialize(response, invoice_data
|
|
29
|
+
def initialize(response, invoice_data, xml = nil)
|
|
42
30
|
@response = response
|
|
43
31
|
@invoice_data = invoice_data
|
|
44
32
|
@xml = xml
|
|
@@ -97,7 +85,7 @@ module Cetustek
|
|
|
97
85
|
def fail_with(code)
|
|
98
86
|
logger&.error("CreateInvoiceV3 #{order_id} #{code}")
|
|
99
87
|
logger&.debug(@xml) if @xml
|
|
100
|
-
|
|
88
|
+
ResultCode.raise!(code, RESULT_MESSAGES)
|
|
101
89
|
end
|
|
102
90
|
|
|
103
91
|
def logger
|
|
@@ -105,7 +93,7 @@ module Cetustek
|
|
|
105
93
|
end
|
|
106
94
|
|
|
107
95
|
def order_id
|
|
108
|
-
@invoice_data
|
|
96
|
+
@invoice_data.order_id
|
|
109
97
|
end
|
|
110
98
|
end
|
|
111
99
|
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'savon'
|
|
4
|
+
|
|
5
|
+
module Cetustek
|
|
6
|
+
# Shared SOAP plumbing: every operation hits the same WSDL and authenticates
|
|
7
|
+
# with 網站代碼+APIPassword (source) and the 租賃者統編 (rentid).
|
|
8
|
+
module Soap
|
|
9
|
+
def soap_client
|
|
10
|
+
Savon.client(wsdl: Cetustek.config.url, open_timeout: 300, read_timeout: 300)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def source
|
|
14
|
+
Cetustek.config.site_id + Cetustek.config.password
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def rentid
|
|
18
|
+
Cetustek.config.username
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def soap_call(operation, message)
|
|
22
|
+
soap_client.call(operation, message: message.merge(source: source, rentid: rentid))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def soap_return(response, operation)
|
|
26
|
+
response.body[:"#{operation}_response"][:return]
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
data/lib/cetustek/version.rb
CHANGED
data/lib/cetustek/xml.rb
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'ox'
|
|
4
|
+
require 'cgi'
|
|
5
|
+
|
|
6
|
+
module Cetustek
|
|
7
|
+
# Every request document in the spec is the same shape: a UTF-8 declaration
|
|
8
|
+
# and a single <Invoice>/<Allowance> root carrying XSDVersion.
|
|
9
|
+
module Xml
|
|
10
|
+
XSD_VERSION = '2.8'
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def document(root_name)
|
|
15
|
+
doc = Ox::Document.new
|
|
16
|
+
instruct = Ox::Instruct.new(:xml)
|
|
17
|
+
instruct[:version] = '1.0'
|
|
18
|
+
instruct[:encoding] = 'UTF-8'
|
|
19
|
+
doc << instruct
|
|
20
|
+
|
|
21
|
+
root = Ox::Element.new(root_name)
|
|
22
|
+
root[:XSDVersion] = XSD_VERSION
|
|
23
|
+
doc << root
|
|
24
|
+
yield root
|
|
25
|
+
|
|
26
|
+
Ox.dump(doc).force_encoding('UTF-8')
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Values are HTML-escaped so that special characters (&, <, >, ", ') in any
|
|
30
|
+
# dynamic field cannot break the XML or be used for injection.
|
|
31
|
+
def tag(name, value)
|
|
32
|
+
Ox::Raw.new("<#{name}>#{CGI.escapeHTML(value.to_s)}</#{name}>")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Fields whose 備註 says "若未填,預設 X" are left out entirely when nil, so
|
|
36
|
+
# the platform applies its own default instead of parsing an empty value.
|
|
37
|
+
def append(element, name, value, skip_nil: false)
|
|
38
|
+
return if skip_nil && value.nil?
|
|
39
|
+
|
|
40
|
+
element << tag(name, value)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
data/lib/cetustek.rb
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
require_relative "cetustek/version"
|
|
4
4
|
require_relative "cetustek/errors"
|
|
5
5
|
require_relative "cetustek/configuration"
|
|
6
|
+
require_relative "cetustek/xml"
|
|
7
|
+
require_relative "cetustek/soap"
|
|
6
8
|
require_relative "cetustek/models/allowance_data"
|
|
7
9
|
require_relative "cetustek/create_invoice"
|
|
8
10
|
require_relative "cetustek/cancel_invoice"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: cetustek
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.9.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Zac
|
|
@@ -90,10 +90,11 @@ files:
|
|
|
90
90
|
- lib/cetustek/phone_barcode.rb
|
|
91
91
|
- lib/cetustek/queries.rb
|
|
92
92
|
- lib/cetustek/query_invoice_by_order_id.rb
|
|
93
|
-
- lib/cetustek/services/invoice_service.rb
|
|
94
93
|
- lib/cetustek/services/invoice_xml_builder.rb
|
|
95
94
|
- lib/cetustek/services/response_handler.rb
|
|
95
|
+
- lib/cetustek/soap.rb
|
|
96
96
|
- lib/cetustek/version.rb
|
|
97
|
+
- lib/cetustek/xml.rb
|
|
97
98
|
- sig/cetustek.rbs
|
|
98
99
|
homepage: https://github.com/7a6163/cetustek
|
|
99
100
|
licenses:
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'savon'
|
|
4
|
-
|
|
5
|
-
module Cetustek
|
|
6
|
-
module Services
|
|
7
|
-
# 2.1 CreateInvoiceV3(invoicexml, hastax, rentid, source)
|
|
8
|
-
class InvoiceService
|
|
9
|
-
def initialize(xml, hastax = 1)
|
|
10
|
-
@xml = xml
|
|
11
|
-
@hastax = hastax
|
|
12
|
-
end
|
|
13
|
-
|
|
14
|
-
def create
|
|
15
|
-
Savon.client(
|
|
16
|
-
wsdl: Cetustek.config.url,
|
|
17
|
-
open_timeout: 300,
|
|
18
|
-
read_timeout: 300
|
|
19
|
-
).call(:create_invoice_v3, message: {
|
|
20
|
-
invoicexml: @xml,
|
|
21
|
-
source: Cetustek.config.site_id + Cetustek.config.password,
|
|
22
|
-
rentid: Cetustek.config.username,
|
|
23
|
-
hastax: @hastax
|
|
24
|
-
})
|
|
25
|
-
end
|
|
26
|
-
end
|
|
27
|
-
end
|
|
28
|
-
end
|