cetustek 0.8.0 → 0.10.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 +66 -0
- data/README.md +70 -15
- data/lib/cetustek/cancel_allowance.rb +17 -39
- data/lib/cetustek/cancel_invoice.rb +20 -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 +64 -56
- data/lib/cetustek/query_invoice_by_order_id.rb +11 -12
- 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 +72 -0
- data/lib/cetustek.rb +3 -1
- metadata +3 -2
- data/lib/cetustek/services/invoice_service.rb +0 -28
|
@@ -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
|
@@ -1,54 +1,81 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'ox'
|
|
4
|
-
|
|
5
3
|
module Cetustek
|
|
6
|
-
#
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
4
|
+
# @deprecated use Cetustek::Soap. Kept so `extend Queries` keeps working.
|
|
5
|
+
Queries = Soap
|
|
6
|
+
|
|
7
|
+
# 2.4 QueryInvoice 查詢發票資訊 (by invoice number + year)
|
|
8
|
+
class QueryInvoice
|
|
9
|
+
extend Soap
|
|
10
|
+
|
|
11
|
+
# Spec AVM-26-03 Table 13.
|
|
12
|
+
FIELDS = %w[OrderID InvoiceNumber InvoiceDate InvoiceTime MainRemark CheckNumber
|
|
13
|
+
RandomNumber InvoiceStatus DonateMark SalesAmount FreeTaxSalesAmount
|
|
14
|
+
ZeroTaxSalesAmount TaxAmount TotalAmount CtkUrl].freeze
|
|
15
|
+
PARTY_FIELDS = %w[Identifier Name Address PersonInCharge TelephoneNumber
|
|
16
|
+
FacsimileNumber EmailAddress].freeze
|
|
17
|
+
DETAIL_FIELDS = %w[ProductCode Description Quantity Unit UnitPrice Amount SequenceNumber].freeze
|
|
12
18
|
|
|
13
|
-
def
|
|
14
|
-
|
|
19
|
+
def self.query(invoice_number, invoice_year)
|
|
20
|
+
soap_call(:query_invoice, invoicenumber: invoice_number, invoiceyear: invoice_year)
|
|
15
21
|
end
|
|
16
22
|
|
|
17
|
-
|
|
18
|
-
|
|
23
|
+
# Same query, with the returned XML parsed into a Hash of snake_case keys
|
|
24
|
+
# plus :seller/:buyer sub-hashes and a :details array. nil when the
|
|
25
|
+
# platform answers with nothing at all, or the documented "nodata".
|
|
26
|
+
def self.find(invoice_number, invoice_year)
|
|
27
|
+
parse(soap_return(query(invoice_number, invoice_year), :query_invoice))
|
|
19
28
|
end
|
|
20
|
-
end
|
|
21
29
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
30
|
+
# §2.5 QueryInvoicebyOrderid shares this exact XML shape (Table 13), so
|
|
31
|
+
# QueryInvoiceByOrderId#find reuses this instead of duplicating it.
|
|
32
|
+
def self.parse(xml)
|
|
33
|
+
root = Xml.parse_response(xml, nil_values: %w[nodata])
|
|
34
|
+
return nil unless root
|
|
25
35
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
rentid: rentid
|
|
32
|
-
})
|
|
36
|
+
data = Xml.parse_fields(root, FIELDS)
|
|
37
|
+
data[:seller] = parse_party(root, 'Seller')
|
|
38
|
+
data[:buyer] = parse_party(root, 'Buyer')
|
|
39
|
+
data[:details] = root.locate('Details/ProductItem').map { |item| Xml.parse_fields(item, DETAIL_FIELDS) }
|
|
40
|
+
data
|
|
33
41
|
end
|
|
42
|
+
|
|
43
|
+
def self.parse_party(root, name)
|
|
44
|
+
element = root.locate(name).first
|
|
45
|
+
element && Xml.parse_fields(element, PARTY_FIELDS)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private_class_method :parse_party
|
|
34
49
|
end
|
|
35
50
|
|
|
36
51
|
# 2.6 QueryInvoiceNumberbyOrderid 以訂單編號查詢發票號碼
|
|
37
52
|
class QueryInvoiceNumberByOrderId
|
|
38
|
-
extend
|
|
53
|
+
extend Soap
|
|
54
|
+
|
|
55
|
+
# 發票號碼格式,字軌 2 碼大寫字母+8 碼數字 (Table 9). Anything else that
|
|
56
|
+
# isn't the "nodata" sentinel is a bare result code, not a number.
|
|
57
|
+
FORMAT = /\A[A-Z]{2}\d{8}\z/
|
|
39
58
|
|
|
40
59
|
def self.query(order_id)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
60
|
+
soap_call(:query_invoice_number_by_orderid, orderid: order_id)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Same query, returning just the bare invoice number string, or nil for
|
|
64
|
+
# an empty response or the documented "nodata". A response that isn't
|
|
65
|
+
# either shape is a result code, raised as ResultError like the other
|
|
66
|
+
# Query* classes — it used to be returned as if it were the number.
|
|
67
|
+
def self.find(order_id)
|
|
68
|
+
body = soap_return(query(order_id), :query_invoice_number_by_orderid).to_s.strip
|
|
69
|
+
return nil if body.empty? || body == 'nodata'
|
|
70
|
+
return body if body.match?(FORMAT)
|
|
71
|
+
|
|
72
|
+
ResultCode.raise!(body, ResultCode::COMMON)
|
|
46
73
|
end
|
|
47
74
|
end
|
|
48
75
|
|
|
49
76
|
# 2.11 QueryAllowance 查詢折讓資料
|
|
50
77
|
class QueryAllowance
|
|
51
|
-
extend
|
|
78
|
+
extend Soap
|
|
52
79
|
|
|
53
80
|
# Spec AVM-26-03 Table 20. Note the query response uses ProductCode and
|
|
54
81
|
# InvoiceDate, unlike CreateAllowance's ProductionCode and InvoiceYear.
|
|
@@ -59,42 +86,23 @@ module Cetustek
|
|
|
59
86
|
UnitPrice Amount Tax TaxType].freeze
|
|
60
87
|
|
|
61
88
|
def self.query(allowance_number)
|
|
62
|
-
|
|
63
|
-
allowancenumber: allowance_number,
|
|
64
|
-
source: source,
|
|
65
|
-
rentid: rentid
|
|
66
|
-
})
|
|
89
|
+
soap_call(:query_allowance, allowancenumber: allowance_number)
|
|
67
90
|
end
|
|
68
91
|
|
|
69
92
|
# Same query, with the returned XML parsed into a Hash of snake_case keys
|
|
70
93
|
# plus a :details array. Values are the raw strings from the XML; returns
|
|
71
|
-
# nil when the
|
|
94
|
+
# nil when the platform answers with nothing at all.
|
|
72
95
|
def self.find(allowance_number)
|
|
73
|
-
parse(query(allowance_number)
|
|
96
|
+
parse(soap_return(query(allowance_number), :query_allowance))
|
|
74
97
|
end
|
|
75
98
|
|
|
76
99
|
def self.parse(xml)
|
|
77
|
-
|
|
78
|
-
return nil
|
|
79
|
-
|
|
80
|
-
root = Ox.parse(body)
|
|
81
|
-
root = root.root if root.is_a?(Ox::Document)
|
|
100
|
+
root = Xml.parse_response(xml)
|
|
101
|
+
return nil unless root
|
|
82
102
|
|
|
83
|
-
data =
|
|
84
|
-
data[:details] = root.locate('Details/ProductItem').map
|
|
85
|
-
DETAIL_FIELDS.to_h { |field| [snake_case(field), text_of(item, field)] }
|
|
86
|
-
end
|
|
103
|
+
data = Xml.parse_fields(root, FIELDS)
|
|
104
|
+
data[:details] = root.locate('Details/ProductItem').map { |item| Xml.parse_fields(item, DETAIL_FIELDS) }
|
|
87
105
|
data
|
|
88
106
|
end
|
|
89
|
-
|
|
90
|
-
def self.text_of(element, name)
|
|
91
|
-
element.locate(name).first&.text
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
def self.snake_case(name)
|
|
95
|
-
name.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase.to_sym
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
private_class_method :text_of, :snake_case
|
|
99
107
|
end
|
|
100
108
|
end
|
|
@@ -1,19 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module Cetustek
|
|
4
|
+
# 2.5 QueryInvoicebyOrderid 以訂單編號查詢發票資訊
|
|
2
5
|
class QueryInvoiceByOrderId
|
|
6
|
+
extend Soap
|
|
7
|
+
|
|
3
8
|
def self.query(order_id)
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
wsdl: url,
|
|
7
|
-
open_timeout: 300,
|
|
8
|
-
read_timeout: 300
|
|
9
|
-
)
|
|
9
|
+
soap_call(:query_invoice_by_orderid, orderid: order_id)
|
|
10
|
+
end
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
rentid: Cetustek.config.username
|
|
16
|
-
})
|
|
12
|
+
# Same query, parsed into a Hash. §2.5 shares Table 13's XML shape with
|
|
13
|
+
# §2.4, so this reuses QueryInvoice.parse instead of duplicating it.
|
|
14
|
+
def self.find(order_id)
|
|
15
|
+
QueryInvoice.parse(soap_return(query(order_id), :query_invoice_by_orderid))
|
|
17
16
|
end
|
|
18
17
|
end
|
|
19
18
|
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
|