cetustek 0.7.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 +71 -0
- data/README.md +102 -19
- data/lib/cetustek/cancel_allowance.rb +17 -39
- data/lib/cetustek/cancel_invoice.rb +38 -41
- data/lib/cetustek/configuration.rb +2 -1
- data/lib/cetustek/create_allowance.rb +24 -61
- data/lib/cetustek/create_invoice.rb +7 -21
- data/lib/cetustek/errors.rb +24 -0
- data/lib/cetustek/models/allowance_data.rb +47 -4
- data/lib/cetustek/models/invoice_data.rb +181 -10
- 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 -61
- data/lib/cetustek/services/response_handler.rb +75 -13
- 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 +7 -6
- data/lib/cetustek/services/invoice_service.rb +0 -49
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.
|
|
@@ -44,14 +56,32 @@ module Cetustek
|
|
|
44
56
|
end
|
|
45
57
|
|
|
46
58
|
module Models
|
|
59
|
+
# Request data for CreateInvoiceV3 (開立發票), spec AVM-26-03 Table 1/2.
|
|
60
|
+
# Rules the spec fixes in print are enforced here; anything needing an
|
|
61
|
+
# external lookup (捐贈碼、手機條碼是否存在) is left to the caller.
|
|
47
62
|
class InvoiceData
|
|
48
63
|
DEFAULT_TAX_RATE = 0.05
|
|
49
|
-
DEFAULT_INVOICE_TYPE = '07'
|
|
64
|
+
DEFAULT_INVOICE_TYPE = '07' # 一般稅額電子發票
|
|
65
|
+
SPECIAL_INVOICE_TYPE = '08' # 特種稅額電子發票
|
|
66
|
+
# Json 回傳才拿得到平台配發的發票日期時間;Intertemporal 回開會讓本機日期失準。
|
|
67
|
+
DEFAULT_RTN_MSG = 'Json'
|
|
68
|
+
MAX_ITEMS = 9999
|
|
69
|
+
MAX_REMARK_LENGTH = 200
|
|
70
|
+
ROUND_NUMS = (0..7).freeze
|
|
71
|
+
TAX_TYPES = [TaxType::TAXABLE, TaxType::ZERO_RATE, TaxType::TAX_FREE,
|
|
72
|
+
TaxType::SPECIAL, TaxType::ZERO_RATE_CUSTOMS, TaxType::MIXED].freeze
|
|
73
|
+
ZERO_RATE_TAX_TYPES = [TaxType::ZERO_RATE, TaxType::ZERO_RATE_CUSTOMS].freeze
|
|
74
|
+
DONATE_MARKS = [DonateMark::CARRIER, DonateMark::DONATE, DonateMark::PAPER].freeze
|
|
50
75
|
|
|
51
76
|
attr_reader :order_id, :order_date, :buyer_identifier, :buyer_name,
|
|
52
|
-
:buyer_email, :
|
|
53
|
-
:
|
|
54
|
-
:
|
|
77
|
+
:buyer_email, :buyer_address, :buyer_person_in_charge,
|
|
78
|
+
:buyer_telephone, :buyer_facsimile, :buyer_customer_number,
|
|
79
|
+
:donate_mark, :carrier_type, :carrier_id1, :carrier_id2,
|
|
80
|
+
:npo_ban, :items, :payment_type, :tax_type, :tax_rate,
|
|
81
|
+
:zero_reason, :invoice_type, :hastax, :remark, :round_num,
|
|
82
|
+
:mail_send, :rtn_msg
|
|
83
|
+
|
|
84
|
+
alias carrier_id carrier_id1
|
|
55
85
|
|
|
56
86
|
def initialize(attributes = {})
|
|
57
87
|
@order_id = attributes[:order_id]
|
|
@@ -59,27 +89,153 @@ module Cetustek
|
|
|
59
89
|
@buyer_identifier = attributes[:buyer_identifier]
|
|
60
90
|
@buyer_name = attributes[:buyer_name]
|
|
61
91
|
@buyer_email = attributes[:buyer_email]
|
|
92
|
+
@buyer_address = attributes[:buyer_address]
|
|
93
|
+
@buyer_person_in_charge = attributes[:buyer_person_in_charge]
|
|
94
|
+
@buyer_telephone = attributes[:buyer_telephone]
|
|
95
|
+
@buyer_facsimile = attributes[:buyer_facsimile]
|
|
96
|
+
@buyer_customer_number = attributes[:buyer_customer_number]
|
|
62
97
|
@donate_mark = attributes[:donate_mark]
|
|
63
98
|
@carrier_type = attributes[:carrier_type]
|
|
64
|
-
@
|
|
65
|
-
@carrier_id2 = attributes[:carrier_id2]
|
|
99
|
+
@carrier_id1 = attributes[:carrier_id1] || attributes[:carrier_id]
|
|
100
|
+
@carrier_id2 = attributes[:carrier_id2] || mirrored_carrier_id2
|
|
66
101
|
@npo_ban = attributes[:npo_ban]
|
|
67
102
|
@items = attributes[:items] || []
|
|
68
103
|
@payment_type = attributes[:payment_type]
|
|
69
104
|
@tax_type = attributes[:tax_type] || TaxType::TAXABLE
|
|
70
|
-
|
|
105
|
+
# 特種稅率是營業性質決定的,不能沿用 5% 預設值。
|
|
106
|
+
@tax_rate = attributes.fetch(:tax_rate) { special_tax? ? nil : DEFAULT_TAX_RATE }
|
|
107
|
+
@zero_reason = attributes[:zero_reason]
|
|
71
108
|
@invoice_type = attributes[:invoice_type] || DEFAULT_INVOICE_TYPE
|
|
72
109
|
# hastax: 0 = item prices are tax-exclusive, 1 = tax-inclusive.
|
|
73
110
|
# Comes from the order (e.g. tax-free purchases), not a fixed value.
|
|
74
111
|
@hastax = attributes.fetch(:hastax, 1)
|
|
112
|
+
@remark = attributes[:remark]
|
|
113
|
+
@round_num = attributes[:round_num]
|
|
114
|
+
@mail_send = attributes[:mail_send]
|
|
115
|
+
@rtn_msg = attributes.fetch(:rtn_msg, DEFAULT_RTN_MSG)
|
|
116
|
+
validate!
|
|
75
117
|
end
|
|
76
118
|
|
|
77
|
-
#
|
|
119
|
+
# 混合稅率發票:每筆明細需標註 DType。
|
|
78
120
|
def mixed_tax?
|
|
79
121
|
@tax_type.to_i == TaxType::MIXED
|
|
80
122
|
end
|
|
123
|
+
|
|
124
|
+
# 特種稅額發票:TaxRate 必填,InvoiceType 必須為 08。
|
|
125
|
+
def special_tax?
|
|
126
|
+
@tax_type.to_i == TaxType::SPECIAL
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
# 只有無顯碼隱碼區分的載具能自動補 CarrierId2;會員載具兩碼不同,猜了就是送錯。
|
|
132
|
+
def mirrored_carrier_id2
|
|
133
|
+
@carrier_id1 if CarrierType::WITHOUT_HIDDEN_CODE.include?(@carrier_type.to_s)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def validate!
|
|
137
|
+
raise ArgumentError, 'order_id is required' if blank?(@order_id)
|
|
138
|
+
|
|
139
|
+
validate_order_date!
|
|
140
|
+
validate_items!
|
|
141
|
+
validate_donate_mark!
|
|
142
|
+
validate_pay_way!
|
|
143
|
+
validate_tax!
|
|
144
|
+
validate_remark!
|
|
145
|
+
validate_round_num!
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def validate_order_date!
|
|
149
|
+
raise ArgumentError, 'order_date is required' if @order_date.nil?
|
|
150
|
+
return if @order_date.respond_to?(:strftime)
|
|
151
|
+
|
|
152
|
+
raise ArgumentError, "order_date must be a Date or Time, got #{@order_date.class}"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def validate_items!
|
|
156
|
+
raise ArgumentError, 'items must not be empty (沒有產品明細)' if @items.empty?
|
|
157
|
+
raise ArgumentError, "items must not exceed #{MAX_ITEMS} lines" if @items.size > MAX_ITEMS
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def validate_donate_mark!
|
|
161
|
+
raise ArgumentError, 'donate_mark is required (0 載具, 1 捐贈, 2 紙本)' if blank?(@donate_mark)
|
|
162
|
+
|
|
163
|
+
unless DONATE_MARKS.include?(@donate_mark.to_i)
|
|
164
|
+
raise ArgumentError, "donate_mark must be 0 (載具), 1 (捐贈) or 2 (紙本), got #{@donate_mark.inspect}"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
case @donate_mark.to_i
|
|
168
|
+
when DonateMark::CARRIER then validate_carrier!
|
|
169
|
+
when DonateMark::DONATE then validate_npo_ban!
|
|
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 (載具)'
|
|
175
|
+
end
|
|
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.
|
|
180
|
+
def validate_carrier!
|
|
181
|
+
missing = { buyer_email: @buyer_email, carrier_id1: @carrier_id1,
|
|
182
|
+
carrier_id2: @carrier_id2 }.select { |_name, value| blank?(value) }.keys
|
|
183
|
+
return if missing.empty?
|
|
184
|
+
|
|
185
|
+
raise ArgumentError, "#{missing.join(', ')} required when donate_mark is 0 (載具)" \
|
|
186
|
+
"#{'; 此載具有顯碼與隱碼之分,請分別填入' if missing == [:carrier_id2]}"
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def validate_npo_ban!
|
|
190
|
+
return if @npo_ban.to_s.match?(/\A\d{3,7}\z/)
|
|
191
|
+
|
|
192
|
+
raise ArgumentError, "npo_ban must be a 3-7 digit 捐贈碼 when donate_mark is 1 (捐贈), got #{@npo_ban.inspect}"
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def validate_pay_way!
|
|
196
|
+
raise ArgumentError, 'payment_type is required (see Cetustek::PayWay)' if blank?(@payment_type)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def validate_tax!
|
|
200
|
+
unless TAX_TYPES.include?(@tax_type.to_i)
|
|
201
|
+
raise ArgumentError, "tax_type must be one of #{TAX_TYPES.join(', ')}, got #{@tax_type.inspect}"
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
validate_zero_reason!
|
|
205
|
+
return unless special_tax?
|
|
206
|
+
|
|
207
|
+
raise ArgumentError, 'tax_rate is required when tax_type is 4 (特種稅率)' if blank?(@tax_rate)
|
|
208
|
+
return if @invoice_type.to_s == SPECIAL_INVOICE_TYPE
|
|
209
|
+
|
|
210
|
+
raise ArgumentError, "invoice_type must be '08' (特種稅額) when tax_type is 4, got #{@invoice_type.inspect}"
|
|
211
|
+
end
|
|
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
|
+
|
|
232
|
+
def blank?(value)
|
|
233
|
+
value.nil? || value.to_s.strip.empty?
|
|
234
|
+
end
|
|
81
235
|
end
|
|
82
236
|
|
|
237
|
+
# A single 明細 row, spec AVM-26-03 Table 2 (發票) / Table 16 (折讓).
|
|
238
|
+
# 品名代號、品名、數量、單價 are 必填; 單位 is not.
|
|
83
239
|
class InvoiceItem
|
|
84
240
|
# Per-item 稅別註記 (DType) used for mixed-tax invoices (TaxType == 9).
|
|
85
241
|
DTYPE_MAP = {
|
|
@@ -87,6 +243,7 @@ module Cetustek
|
|
|
87
243
|
zero_rate: 'TZ', # 零稅率商品
|
|
88
244
|
tax_free: 'TN' # 免稅商品
|
|
89
245
|
}.freeze
|
|
246
|
+
REQUIRED = %i[code name quantity unit_price].freeze
|
|
90
247
|
|
|
91
248
|
attr_reader :code, :name, :quantity, :unit_price, :tax_type, :unit
|
|
92
249
|
|
|
@@ -97,6 +254,7 @@ module Cetustek
|
|
|
97
254
|
@unit_price = attributes[:unit_price]
|
|
98
255
|
@unit = attributes[:unit]
|
|
99
256
|
@tax_type = attributes[:tax_type] || :taxable
|
|
257
|
+
validate!
|
|
100
258
|
end
|
|
101
259
|
|
|
102
260
|
# Returns the DType code: '', 'TZ' or 'TN'.
|
|
@@ -104,6 +262,19 @@ module Cetustek
|
|
|
104
262
|
def d_type
|
|
105
263
|
DTYPE_MAP.fetch(@tax_type) { @tax_type.to_s }
|
|
106
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
|
|
107
278
|
end
|
|
108
279
|
end
|
|
109
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,62 +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
21
|
def add_basic_info(invoice)
|
|
50
|
-
invoice
|
|
51
|
-
invoice
|
|
22
|
+
Xml.append(invoice, 'OrderId', @data.order_id)
|
|
23
|
+
Xml.append(invoice, 'OrderDate', @data.order_date.strftime('%Y/%m/%d'))
|
|
52
24
|
end
|
|
53
25
|
|
|
54
26
|
def add_buyer_info(invoice)
|
|
55
|
-
invoice
|
|
56
|
-
invoice
|
|
57
|
-
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)
|
|
58
35
|
end
|
|
59
36
|
|
|
60
37
|
def add_invoice_type_info(invoice)
|
|
61
|
-
invoice
|
|
62
|
-
invoice
|
|
63
|
-
invoice
|
|
64
|
-
invoice
|
|
65
|
-
invoice
|
|
66
|
-
invoice
|
|
67
|
-
invoice
|
|
68
|
-
invoice
|
|
69
|
-
invoice
|
|
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)
|
|
70
52
|
end
|
|
71
53
|
|
|
72
54
|
def add_details(invoice)
|
|
@@ -80,21 +62,16 @@ module Cetustek
|
|
|
80
62
|
|
|
81
63
|
def create_product_item(item)
|
|
82
64
|
product = Ox::Element.new('ProductItem')
|
|
83
|
-
product
|
|
84
|
-
product
|
|
85
|
-
product
|
|
86
|
-
product
|
|
87
|
-
|
|
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?
|
|
88
73
|
product
|
|
89
74
|
end
|
|
90
|
-
|
|
91
|
-
# DType (稅別註記) is required on every detail line only for mixed-tax
|
|
92
|
-
# invoices (TaxType == 9).
|
|
93
|
-
def add_dtype(product, value)
|
|
94
|
-
return unless @data.mixed_tax?
|
|
95
|
-
|
|
96
|
-
product << raw_tag('DType', value)
|
|
97
|
-
end
|
|
98
75
|
end
|
|
99
76
|
end
|
|
100
77
|
end
|
|
@@ -1,11 +1,30 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require '
|
|
3
|
+
require 'json'
|
|
4
4
|
|
|
5
5
|
module Cetustek
|
|
6
6
|
module Services
|
|
7
|
+
# Parses the CreateInvoiceV3 return value. Three shapes are possible:
|
|
8
|
+
# the Table 8 JSON object (RtnMsg=Json), the 15-character
|
|
9
|
+
# "發票號碼;隨機碼" string, or a bare Table 7 result code.
|
|
7
10
|
class ResponseHandler
|
|
8
|
-
|
|
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
|
+
|
|
15
|
+
SUCCESS_LENGTH = 15 # 發票號碼 10 碼 + ';' + 隨機碼 4 碼
|
|
16
|
+
|
|
17
|
+
# Spec AVM-26-03 Table 7.
|
|
18
|
+
RESULT_MESSAGES = ResultCode::COMMON.merge(ResultCode::DETAILS).merge(
|
|
19
|
+
'S1' => '資料庫發生錯誤',
|
|
20
|
+
'S2' => '訂單日期超過開立日期',
|
|
21
|
+
'S3' => '未在申報期內',
|
|
22
|
+
'S4' => '未取得發票號碼',
|
|
23
|
+
'S5' => '發票號碼已使用完畢',
|
|
24
|
+
'S6' => '超過租賃張數限制',
|
|
25
|
+
'S7' => '訂單號碼已存在,若需重開請先作廢原發票號碼',
|
|
26
|
+
'S8' => '開立的總金額為負值'
|
|
27
|
+
).freeze
|
|
9
28
|
|
|
10
29
|
def initialize(response, invoice_data, xml = nil)
|
|
11
30
|
@response = response
|
|
@@ -14,24 +33,67 @@ module Cetustek
|
|
|
14
33
|
end
|
|
15
34
|
|
|
16
35
|
def process
|
|
17
|
-
|
|
18
|
-
|
|
36
|
+
body = @response.body[:create_invoice_v3_response][:return].to_s.strip
|
|
37
|
+
json = parse_json(body)
|
|
19
38
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
raise InvalidResponseError, "Invalid response: #{response_body}"
|
|
23
|
-
end
|
|
39
|
+
return success(json_result(json)) if json && json['msg'] == 'Success'
|
|
40
|
+
return success(string_result(body)) if json.nil? && success_string?(body)
|
|
24
41
|
|
|
25
|
-
|
|
42
|
+
fail_with(json ? json['msg'].to_s : body)
|
|
26
43
|
end
|
|
27
44
|
|
|
28
45
|
private
|
|
29
46
|
|
|
30
|
-
|
|
31
|
-
|
|
47
|
+
# M1 (XML 格式錯誤) 與 Invalid 不會回傳 JSON,所以形狀要靠內容判斷。
|
|
48
|
+
def parse_json(body)
|
|
49
|
+
return nil unless body.start_with?('{')
|
|
50
|
+
|
|
51
|
+
JSON.parse(body)
|
|
52
|
+
rescue JSON::ParserError
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def success_string?(body)
|
|
57
|
+
body.length == SUCCESS_LENGTH && body.include?(';')
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def json_result(json)
|
|
61
|
+
{
|
|
62
|
+
number: json['invnumber'],
|
|
63
|
+
random_number: json['random'],
|
|
64
|
+
date: json['invdate'],
|
|
65
|
+
time: json['invtime'],
|
|
66
|
+
sale_amount: json['saleamt'],
|
|
67
|
+
zero_amount: json['zeroamt'],
|
|
68
|
+
free_amount: json['freeamt'],
|
|
69
|
+
tax_amount: json['taxamt'],
|
|
70
|
+
total_amount: json['totalamt'],
|
|
71
|
+
carrier_url: json['ctkurl']
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def string_result(body)
|
|
76
|
+
number, random_number = body.split(';')
|
|
77
|
+
{ number: number, random_number: random_number }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def success(result)
|
|
81
|
+
logger&.info("CreateInvoiceV3 #{order_id} #{result[:number]}")
|
|
82
|
+
result
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def fail_with(code)
|
|
86
|
+
logger&.error("CreateInvoiceV3 #{order_id} #{code}")
|
|
87
|
+
logger&.debug(@xml) if @xml
|
|
88
|
+
ResultCode.raise!(code, RESULT_MESSAGES)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def logger
|
|
92
|
+
Cetustek.config.logger
|
|
93
|
+
end
|
|
32
94
|
|
|
33
|
-
|
|
34
|
-
|
|
95
|
+
def order_id
|
|
96
|
+
@invoice_data.order_id
|
|
35
97
|
end
|
|
36
98
|
end
|
|
37
99
|
end
|