digiwin_dsp 0.1.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 +7 -0
- data/CHANGELOG.md +51 -0
- data/LICENSE.txt +21 -0
- data/README.md +216 -0
- data/lib/digiwin_dsp/authenticator.rb +16 -0
- data/lib/digiwin_dsp/client.rb +128 -0
- data/lib/digiwin_dsp/configuration.rb +45 -0
- data/lib/digiwin_dsp/resources/cancellation.rb +23 -0
- data/lib/digiwin_dsp/resources/invoice.rb +23 -0
- data/lib/digiwin_dsp/resources/order.rb +23 -0
- data/lib/digiwin_dsp/resources/return.rb +23 -0
- data/lib/digiwin_dsp/serializers/base.rb +56 -0
- data/lib/digiwin_dsp/serializers/cancellation_serializer.rb +14 -0
- data/lib/digiwin_dsp/serializers/invoice_serializer.rb +15 -0
- data/lib/digiwin_dsp/serializers/return_serializer.rb +16 -0
- data/lib/digiwin_dsp/serializers/sales_order_serializer.rb +17 -0
- data/lib/digiwin_dsp/version.rb +5 -0
- data/lib/digiwin_dsp.rb +45 -0
- metadata +106 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: dfd6bd54cc74a0a92198baf85f66c940882a9ef23e9f8c957fcf1ce10e137725
|
|
4
|
+
data.tar.gz: 07f3727ce554845d88f989a89206ec814ce83e8ddbf3c3f9587f4ca60c3b6131
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 21846a4c401f66c10df4f2fee23af8291c8c9819b64393285333762ddee19541fecc799dcc446f7e7d6b30d7529046ddbe221979dc043e3455aef141878d539e
|
|
7
|
+
data.tar.gz: dcd9694dd636c648a5e973980542a99eb476f421bb18d082934370749ca7d61a2473146183a54eda64d3d37d2e587f7f4147371eeccecb39e839c0125ddc6543
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `digiwin_dsp` are documented here.
|
|
4
|
+
|
|
5
|
+
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Release workflow (`.github/workflows/release.yml`): tag push (`v*`) triggers RubyGems publish via OIDC Trusted Publishing, runs the full spec suite as a guard, and updates the GitHub Release with the built `.gem` artifact.
|
|
12
|
+
|
|
13
|
+
## [0.1.0] - 2026-05-21
|
|
14
|
+
|
|
15
|
+
Initial release. Covers the four 自有官網模組 endpoints under `/v1/SalesOrder/*`.
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- `DigiwinDsp.configure` block + per-setting ENV fallback (`DIGIWIN_DSP_API_KEY`, `DIGIWIN_DSP_API_SECRET`, `DIGIWIN_DSP_PLATFORM_ID`, `DIGIWIN_DSP_ENV`, `DIGIWIN_DSP_BASE_URL`)
|
|
20
|
+
- `DigiwinDsp::Configuration` — UAT/production base URL resolver, timeout knobs, injectable logger, `#validate!` guard
|
|
21
|
+
- `DigiwinDsp::Authenticator` — static `DSP-api-key` header
|
|
22
|
+
- `DigiwinDsp::Client` — Faraday 2 connection with:
|
|
23
|
+
- `faraday-retry` for HTTP 429 / 5xx (3 attempts, exponential backoff)
|
|
24
|
+
- Path-prefix-safe URL joining (`/DSP_UAT/api/DSP` + `/v1/SalesOrder/add` → correct full URL)
|
|
25
|
+
- JSON serialization / parsing
|
|
26
|
+
- Status-code → exception mapping
|
|
27
|
+
- **Body-envelope `Status`/`Message` inspection** — DSP returns HTTP 200 even on failure, so the gem classifies `Duplicated:`, `Processing:`, `WrongStatus:`, `系統異常:` patterns into typed exceptions
|
|
28
|
+
- Resources, all exposing `#create(records, idempotency_key:, digi_header:)`:
|
|
29
|
+
- `Resources::Order` → `POST /v1/SalesOrder/add`
|
|
30
|
+
- `Resources::Cancellation` → `POST /v1/SalesOrder/cancel`
|
|
31
|
+
- `Resources::Invoice` → `POST /v1/SalesOrder/invoice`
|
|
32
|
+
- `Resources::Return` → `POST /v1/SalesOrder/return`
|
|
33
|
+
- Serializers with per-endpoint `REQUIRED_FIELDS` (22 / 8 / 11 / 19) sharing a `Serializers::Base` module that wraps records into the `digi_body.std_data.parameter.request.request_detail[]` envelope
|
|
34
|
+
- Exception hierarchy: `Error`, `ConfigurationError`, `AuthenticationError`, `ValidationError`, `RateLimitError`, `ServerError`, `NetworkError`, `DuplicateRequestError` — each carrying `#code`, `#dsp_message`, `#http_status`, `#request_id`, `#response_body`
|
|
35
|
+
- `docs/dsp-api-spec.md` summary + `docs/dsp-specs/DSPOOFFICIAL00{1,2,4,5}.yaml` (Digiwin's OpenAPI 3.1 source)
|
|
36
|
+
- 134 RSpec examples, WebMock-driven, 100% line + 100% branch coverage, RuboCop clean
|
|
37
|
+
|
|
38
|
+
### Tooling
|
|
39
|
+
|
|
40
|
+
- SimpleCov + simplecov-lcov coverage reporting (line + branch); minimum thresholds 80% line / 70% branch
|
|
41
|
+
- GitHub Actions CI workflow (`.github/workflows/ci.yml`): Ruby 3.2 / 3.3 / 3.4 matrix, runs RuboCop + RSpec, uploads `coverage/lcov.info` to Codecov on the 3.3 row
|
|
42
|
+
- CI status + Codecov coverage badges in README
|
|
43
|
+
|
|
44
|
+
### Design notes
|
|
45
|
+
|
|
46
|
+
- `digi_header` is **omitted by default**; only passed through when caller provides a custom hash. Standard DSP traffic doesn't need it.
|
|
47
|
+
- The gem is **synchronous on purpose**. Callers wrap requests in their own background job runner (e.g. ActiveJob) when needed.
|
|
48
|
+
- Idempotency: clients can send `X-Idempotency-Key` via the `idempotency_key:` kwarg. DSP also dedupes server-side by `form_no + platform_id`.
|
|
49
|
+
|
|
50
|
+
[Unreleased]: https://github.com/7a6163/digiwin_dsp/compare/v0.1.0...HEAD
|
|
51
|
+
[0.1.0]: https://github.com/7a6163/digiwin_dsp/releases/tag/v0.1.0
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zac
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# digiwin_dsp
|
|
2
|
+
|
|
3
|
+
[](https://github.com/7a6163/digiwin_dsp/actions/workflows/ci.yml)
|
|
4
|
+
[](https://codecov.io/gh/7a6163/digiwin_dsp)
|
|
5
|
+
[](https://www.ruby-lang.org/)
|
|
6
|
+
[](./LICENSE.txt)
|
|
7
|
+
|
|
8
|
+
Ruby client for the Digiwin DSP **自有官網模組** (Official Website Module) API. Lets a Rails 自有官網 push orders, cancellations, invoice updates, and returns into the Digiwin ERP through the DSP gateway.
|
|
9
|
+
|
|
10
|
+
| Operation | Resource | Endpoint |
|
|
11
|
+
|---|---|---|
|
|
12
|
+
| 新增訂單 | `DigiwinDsp::Resources::Order` | `POST /v1/SalesOrder/add` |
|
|
13
|
+
| 取消訂單 | `DigiwinDsp::Resources::Cancellation` | `POST /v1/SalesOrder/cancel` |
|
|
14
|
+
| 發票更新 | `DigiwinDsp::Resources::Invoice` | `POST /v1/SalesOrder/invoice` |
|
|
15
|
+
| 退貨 | `DigiwinDsp::Resources::Return` | `POST /v1/SalesOrder/return` |
|
|
16
|
+
|
|
17
|
+
See [`docs/dsp-api-spec.md`](./docs/dsp-api-spec.md) (plus `docs/dsp-specs/*.yaml`) for the wire spec.
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
# Gemfile
|
|
23
|
+
gem "digiwin_dsp"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
bundle install
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Ruby ≥ 3.2 required.
|
|
31
|
+
|
|
32
|
+
## Configuration
|
|
33
|
+
|
|
34
|
+
Configure once at boot (e.g. `config/initializers/digiwin_dsp.rb` in Rails):
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
DigiwinDsp.configure do |c|
|
|
38
|
+
c.api_key = ENV.fetch("DIGIWIN_DSP_API_KEY")
|
|
39
|
+
c.platform_id = ENV.fetch("DIGIWIN_DSP_PLATFORM_ID")
|
|
40
|
+
c.environment = :sandbox # :sandbox (UAT) | :production
|
|
41
|
+
c.logger = Rails.logger # any Logger-like object
|
|
42
|
+
c.timeout = 10 # request timeout (seconds)
|
|
43
|
+
c.open_timeout = 5
|
|
44
|
+
end
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Every setting also falls back to an ENV var:
|
|
48
|
+
|
|
49
|
+
| Setting | ENV var | Default |
|
|
50
|
+
|---|---|---|
|
|
51
|
+
| `api_key` | `DIGIWIN_DSP_API_KEY` | _(required)_ |
|
|
52
|
+
| `api_secret` | `DIGIWIN_DSP_API_SECRET` | `nil` |
|
|
53
|
+
| `platform_id` | `DIGIWIN_DSP_PLATFORM_ID` | `nil` |
|
|
54
|
+
| `environment` | `DIGIWIN_DSP_ENV` | `:sandbox` |
|
|
55
|
+
| `base_url` | `DIGIWIN_DSP_BASE_URL` | resolved from `environment` |
|
|
56
|
+
| `timeout` | — | `10` |
|
|
57
|
+
| `open_timeout` | — | `5` |
|
|
58
|
+
| `logger` | — | `Logger.new(IO::NULL)` |
|
|
59
|
+
|
|
60
|
+
Base URLs (resolved from `environment`):
|
|
61
|
+
|
|
62
|
+
- `:sandbox` → `https://digiwindsp.digiwin.com/DSP_UAT/api/DSP`
|
|
63
|
+
- `:production` → `https://digiwindsp.digiwin.com/DSP/api/DSP`
|
|
64
|
+
|
|
65
|
+
See [`.env.local.example`](./.env.local.example) for a starter env file.
|
|
66
|
+
|
|
67
|
+
## Usage
|
|
68
|
+
|
|
69
|
+
Each resource exposes a single `#create(records, idempotency_key:, digi_header:)` method that returns the parsed `response_detail` array on success or raises a typed exception on failure.
|
|
70
|
+
|
|
71
|
+
### Create an order
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
record = {
|
|
75
|
+
"platform_id" => "acme_storefront_test",
|
|
76
|
+
"create_datetime" => Time.now.strftime("%Y-%m-%d %H:%M:%S"),
|
|
77
|
+
"site_no" => "acme_storefront_test",
|
|
78
|
+
"form_no" => "WEB202605200001", # 官網訂單編號
|
|
79
|
+
"order_date" => "20260520",
|
|
80
|
+
"buyer_name" => "王小明",
|
|
81
|
+
"receiver_name" => "王小明",
|
|
82
|
+
"pay_type" => "9104",
|
|
83
|
+
"shipping_type" => "9102",
|
|
84
|
+
"tax_type" => "1",
|
|
85
|
+
"sno" => "1", # line index
|
|
86
|
+
"form_subno" => "1",
|
|
87
|
+
"product_no" => "P-001",
|
|
88
|
+
"product_name" => "測試商品",
|
|
89
|
+
"unit" => "EA",
|
|
90
|
+
"qty" => "1",
|
|
91
|
+
"free_qty" => "0",
|
|
92
|
+
"price" => "100",
|
|
93
|
+
"subtotal" => "100",
|
|
94
|
+
"payment" => "100",
|
|
95
|
+
"order_status" => "3", # 3 = 新增
|
|
96
|
+
"last_record" => "Y" # "Y" on the final line
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
response_detail = DigiwinDsp::Resources::Order.create(record)
|
|
100
|
+
# => [{ "form_no" => "WEB202605200001", ... }]
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Multi-line orders: pass an array. Each element must carry the order-level fields plus its own line fields. Set `"last_record" => "Y"` on the final element and `"N"` on the rest:
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
records = [
|
|
107
|
+
base_fields.merge("sno" => "1", "product_no" => "P-001", "qty" => "1", "last_record" => "N"),
|
|
108
|
+
base_fields.merge("sno" => "2", "product_no" => "P-002", "qty" => "3", "last_record" => "Y")
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
DigiwinDsp::Resources::Order.create(records)
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Cancel, invoice update, return
|
|
115
|
+
|
|
116
|
+
```ruby
|
|
117
|
+
DigiwinDsp::Resources::Cancellation.create(cancel_record)
|
|
118
|
+
DigiwinDsp::Resources::Invoice.create(invoice_record)
|
|
119
|
+
DigiwinDsp::Resources::Return.create(return_record)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Each has its own required-field set (8 / 11 / 19 fields respectively). Inspect `REQUIRED_FIELDS` for the exact list, e.g.:
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
DigiwinDsp::Serializers::CancellationSerializer::REQUIRED_FIELDS
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Idempotency
|
|
129
|
+
|
|
130
|
+
Pass `idempotency_key:` to attach an `X-Idempotency-Key` request header. DSP also dedupes server-side by `form_no + platform_id` and returns `Duplicated:訂單不可重複` on a re-send (mapped to `DuplicateRequestError`).
|
|
131
|
+
|
|
132
|
+
```ruby
|
|
133
|
+
DigiwinDsp::Resources::Order.create(record, idempotency_key: "order-#{record['form_no']}")
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### Background jobs
|
|
137
|
+
|
|
138
|
+
The gem is synchronous on purpose. Wrap calls in your own job runner:
|
|
139
|
+
|
|
140
|
+
```ruby
|
|
141
|
+
class SyncOrderToDigiwinJob < ApplicationJob
|
|
142
|
+
retry_on DigiwinDsp::RateLimitError, wait: :polynomially_longer, attempts: 5
|
|
143
|
+
discard_on DigiwinDsp::DuplicateRequestError
|
|
144
|
+
|
|
145
|
+
def perform(order_id)
|
|
146
|
+
order = Order.find(order_id)
|
|
147
|
+
DigiwinDsp::Resources::Order.create(order.to_dsp_payload, idempotency_key: "order-#{order.id}")
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## Error handling
|
|
153
|
+
|
|
154
|
+
All exceptions inherit from `DigiwinDsp::Error` and carry rich attributes (`#code`, `#dsp_message`, `#http_status`, `#request_id`, `#response_body`).
|
|
155
|
+
|
|
156
|
+
| Exception | Raised when |
|
|
157
|
+
|---|---|
|
|
158
|
+
| `DigiwinDsp::ConfigurationError` | `api_key` missing at request time |
|
|
159
|
+
| `DigiwinDsp::ValidationError` | Required field missing locally, or DSP returns `WrongStatus:` / `Processing:取消訂單處理中` / HTTP 400 |
|
|
160
|
+
| `DigiwinDsp::AuthenticationError` | HTTP 401 / 403 |
|
|
161
|
+
| `DigiwinDsp::DuplicateRequestError` | DSP returns `Duplicated:` or HTTP 409 |
|
|
162
|
+
| `DigiwinDsp::RateLimitError` | DSP returns `Processing:資料處理中` (retryable) or persistent HTTP 429 |
|
|
163
|
+
| `DigiwinDsp::ServerError` | DSP returns `系統異常:` or HTTP 5xx that exhausts retries |
|
|
164
|
+
| `DigiwinDsp::NetworkError` | TCP connect failure or timeout |
|
|
165
|
+
| `DigiwinDsp::Error` | Catch-all (unmapped failure message or unexpected status) |
|
|
166
|
+
|
|
167
|
+
> ⚠️ Digiwin DSP returns **HTTP 200** even on application-level failure. The gem parses the response body's `Status` / `Message` fields and raises the appropriate typed exception so callers can rescue normally.
|
|
168
|
+
|
|
169
|
+
```ruby
|
|
170
|
+
begin
|
|
171
|
+
DigiwinDsp::Resources::Order.create(record)
|
|
172
|
+
rescue DigiwinDsp::DuplicateRequestError
|
|
173
|
+
Rails.logger.info("Order already pushed, skipping")
|
|
174
|
+
rescue DigiwinDsp::ValidationError => e
|
|
175
|
+
Rails.logger.error("Payload rejected: #{e.dsp_message}")
|
|
176
|
+
raise
|
|
177
|
+
rescue DigiwinDsp::RateLimitError, DigiwinDsp::ServerError
|
|
178
|
+
raise # let the job retry
|
|
179
|
+
end
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## Custom `digi_header`
|
|
183
|
+
|
|
184
|
+
By default the gem **omits `digi_header`** from the request body (it's only required for certain custom Digiwin integrations). If your DSP setup expects one, pass it through:
|
|
185
|
+
|
|
186
|
+
```ruby
|
|
187
|
+
DigiwinDsp::Resources::Order.create(
|
|
188
|
+
record,
|
|
189
|
+
digi_header: {
|
|
190
|
+
"digi_host" => { "prod" => "EC-SHOP", "ip" => "10.0.0.42", "timestamp" => "20260520123456789" },
|
|
191
|
+
"digi_service" => { "prod" => "ECP", "name" => "salesorder.add" }
|
|
192
|
+
}
|
|
193
|
+
)
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## Development
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
bin/setup # bundle install
|
|
200
|
+
bundle exec rspec # run the full test suite (134 examples, 100% coverage)
|
|
201
|
+
bundle exec rubocop # lint
|
|
202
|
+
bin/console # IRB with the gem loaded
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
The full DSP OpenAPI 3.1 specs live under `docs/dsp-specs/`. If Digiwin updates them, replace the YAML files and re-run the test suite — the `REQUIRED_FIELDS` constants in each serializer pin the contract.
|
|
206
|
+
|
|
207
|
+
## Contributing
|
|
208
|
+
|
|
209
|
+
1. Fork & branch
|
|
210
|
+
2. `bin/setup`
|
|
211
|
+
3. Write tests first (TDD); ensure `bundle exec rspec` and `bundle exec rubocop` are green
|
|
212
|
+
4. Open a PR
|
|
213
|
+
|
|
214
|
+
## License
|
|
215
|
+
|
|
216
|
+
MIT. See [`LICENSE.txt`](./LICENSE.txt).
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DigiwinDsp
|
|
4
|
+
class Authenticator
|
|
5
|
+
HEADER_NAME = "DSP-api-key"
|
|
6
|
+
|
|
7
|
+
def initialize(configuration = DigiwinDsp.configuration)
|
|
8
|
+
@configuration = configuration
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def auth_headers
|
|
12
|
+
@configuration.validate!
|
|
13
|
+
{ HEADER_NAME => @configuration.api_key }
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "faraday/retry"
|
|
5
|
+
|
|
6
|
+
module DigiwinDsp
|
|
7
|
+
class Client
|
|
8
|
+
RETRY_STATUSES = [429, 500, 502, 503, 504].freeze
|
|
9
|
+
USER_AGENT = "digiwin_dsp/#{VERSION} (Faraday/#{Faraday::VERSION})".freeze
|
|
10
|
+
|
|
11
|
+
STATUS_ERROR_MAP = {
|
|
12
|
+
400 => ValidationError,
|
|
13
|
+
401 => AuthenticationError,
|
|
14
|
+
403 => AuthenticationError,
|
|
15
|
+
409 => DuplicateRequestError,
|
|
16
|
+
429 => RateLimitError
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
# Order matters — more-specific patterns first.
|
|
20
|
+
ENVELOPE_FAILURE_MAP = [
|
|
21
|
+
[/\ADuplicated:/, DuplicateRequestError],
|
|
22
|
+
[/\AProcessing:資料處理中/, RateLimitError],
|
|
23
|
+
[/\AProcessing:取消訂單處理中/, ValidationError],
|
|
24
|
+
[/\AWrongStatus:/, ValidationError],
|
|
25
|
+
[/\A系統異常:/, ServerError]
|
|
26
|
+
].freeze
|
|
27
|
+
|
|
28
|
+
def initialize(configuration: DigiwinDsp.configuration, authenticator: nil)
|
|
29
|
+
@configuration = configuration
|
|
30
|
+
@authenticator = authenticator || Authenticator.new(configuration)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def post(path, body, idempotency_key: nil, headers: {})
|
|
34
|
+
@configuration.validate!
|
|
35
|
+
response = connection.post(normalize_path(path)) do |req|
|
|
36
|
+
req.headers["X-Idempotency-Key"] = idempotency_key if idempotency_key
|
|
37
|
+
headers.each { |k, v| req.headers[k] = v }
|
|
38
|
+
req.body = body
|
|
39
|
+
end
|
|
40
|
+
handle_response(response)
|
|
41
|
+
rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
|
|
42
|
+
raise NetworkError, e.message
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def connection
|
|
48
|
+
@connection ||= Faraday.new(url: connection_base_url, headers: default_headers) do |f|
|
|
49
|
+
f.request :json
|
|
50
|
+
f.request :retry,
|
|
51
|
+
max: 3,
|
|
52
|
+
interval: 0.0,
|
|
53
|
+
backoff_factor: 1,
|
|
54
|
+
retry_statuses: RETRY_STATUSES,
|
|
55
|
+
methods: %i[get post put patch delete]
|
|
56
|
+
f.response :json, content_type: /\bjson\z/
|
|
57
|
+
f.response :logger, @configuration.logger, headers: false, bodies: false, log_level: :debug
|
|
58
|
+
f.adapter Faraday.default_adapter
|
|
59
|
+
f.options.timeout = @configuration.timeout
|
|
60
|
+
f.options.open_timeout = @configuration.open_timeout
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def connection_base_url
|
|
65
|
+
base = @configuration.base_url
|
|
66
|
+
base.end_with?("/") ? base : "#{base}/"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def normalize_path(path)
|
|
70
|
+
path.sub(%r{\A/+}, "")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def default_headers
|
|
74
|
+
{
|
|
75
|
+
"User-Agent" => USER_AGENT,
|
|
76
|
+
"Accept" => "application/json"
|
|
77
|
+
}.merge(@authenticator.auth_headers)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def handle_response(response)
|
|
81
|
+
status = response.status
|
|
82
|
+
body = response.body
|
|
83
|
+
return inspect_envelope(body) if status.between?(200, 299)
|
|
84
|
+
|
|
85
|
+
raise classify_http_error(status, body)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def classify_http_error(status, body)
|
|
89
|
+
klass = STATUS_ERROR_MAP[status] || (status.between?(500, 599) ? ServerError : Error)
|
|
90
|
+
message = klass == Error ? "unexpected HTTP status #{status}" : http_message(status, body)
|
|
91
|
+
klass.new(message, **error_attrs(status, body))
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def inspect_envelope(body)
|
|
95
|
+
return body unless body.is_a?(Hash) && body["Status"].to_s.casecmp("failure").zero?
|
|
96
|
+
|
|
97
|
+
message = body["Message"].to_s
|
|
98
|
+
klass = ENVELOPE_FAILURE_MAP.find { |regex, _| regex.match?(message) }&.last || Error
|
|
99
|
+
raise klass.new(message, **envelope_error_attrs(body))
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def http_message(status, body)
|
|
103
|
+
dsp_msg = body.is_a?(Hash) ? body["error_message"] || body["message"] : nil
|
|
104
|
+
dsp_msg ? "HTTP #{status}: #{dsp_msg}" : "HTTP #{status}"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def error_attrs(status, body)
|
|
108
|
+
hash = body.is_a?(Hash) ? body : {}
|
|
109
|
+
{
|
|
110
|
+
code: hash["error_code"] || hash["code"],
|
|
111
|
+
dsp_message: hash["error_message"] || hash["message"],
|
|
112
|
+
request_id: hash["request_id"],
|
|
113
|
+
http_status: status,
|
|
114
|
+
response_body: body
|
|
115
|
+
}
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def envelope_error_attrs(body)
|
|
119
|
+
{
|
|
120
|
+
code: body["Status"],
|
|
121
|
+
dsp_message: body["Message"],
|
|
122
|
+
request_id: body["request_id"],
|
|
123
|
+
http_status: 200,
|
|
124
|
+
response_body: body
|
|
125
|
+
}
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
|
|
5
|
+
module DigiwinDsp
|
|
6
|
+
class Configuration
|
|
7
|
+
DEFAULT_TIMEOUT = 10
|
|
8
|
+
DEFAULT_OPEN_TIMEOUT = 5
|
|
9
|
+
|
|
10
|
+
BASE_URLS = {
|
|
11
|
+
sandbox: "https://digiwindsp.digiwin.com/DSP_UAT/api/DSP",
|
|
12
|
+
production: "https://digiwindsp.digiwin.com/DSP/api/DSP"
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
attr_accessor :api_key, :api_secret, :platform_id, :environment, :logger, :timeout, :open_timeout
|
|
16
|
+
attr_writer :base_url
|
|
17
|
+
|
|
18
|
+
def initialize
|
|
19
|
+
@api_key = ENV.fetch("DIGIWIN_DSP_API_KEY", nil)
|
|
20
|
+
@api_secret = ENV.fetch("DIGIWIN_DSP_API_SECRET", nil)
|
|
21
|
+
@platform_id = ENV.fetch("DIGIWIN_DSP_PLATFORM_ID", nil)
|
|
22
|
+
@environment = ENV.fetch("DIGIWIN_DSP_ENV", "sandbox").to_sym
|
|
23
|
+
@base_url = ENV.fetch("DIGIWIN_DSP_BASE_URL", nil)
|
|
24
|
+
@timeout = DEFAULT_TIMEOUT
|
|
25
|
+
@open_timeout = DEFAULT_OPEN_TIMEOUT
|
|
26
|
+
@logger = Logger.new(IO::NULL)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def base_url
|
|
30
|
+
return @base_url if @base_url
|
|
31
|
+
|
|
32
|
+
BASE_URLS.fetch(environment) do
|
|
33
|
+
raise ConfigurationError,
|
|
34
|
+
"unknown environment #{environment.inspect}; expected one of #{BASE_URLS.keys.inspect}"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def validate!
|
|
39
|
+
return unless api_key.nil? || api_key.to_s.empty?
|
|
40
|
+
|
|
41
|
+
raise ConfigurationError,
|
|
42
|
+
"api_key is required (set via DigiwinDsp.configure or DIGIWIN_DSP_API_KEY)"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DigiwinDsp
|
|
4
|
+
module Resources
|
|
5
|
+
class Cancellation
|
|
6
|
+
PATH = "/v1/SalesOrder/cancel"
|
|
7
|
+
|
|
8
|
+
def self.create(records, **)
|
|
9
|
+
new.create(records, **)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(client = Client.new)
|
|
13
|
+
@client = client
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def create(records, idempotency_key: nil, digi_header: nil)
|
|
17
|
+
body = Serializers::CancellationSerializer.serialize(records, digi_header: digi_header)
|
|
18
|
+
response = @client.post(PATH, body, idempotency_key: idempotency_key)
|
|
19
|
+
response["response_detail"]
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DigiwinDsp
|
|
4
|
+
module Resources
|
|
5
|
+
class Invoice
|
|
6
|
+
PATH = "/v1/SalesOrder/invoice"
|
|
7
|
+
|
|
8
|
+
def self.create(records, **)
|
|
9
|
+
new.create(records, **)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(client = Client.new)
|
|
13
|
+
@client = client
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def create(records, idempotency_key: nil, digi_header: nil)
|
|
17
|
+
body = Serializers::InvoiceSerializer.serialize(records, digi_header: digi_header)
|
|
18
|
+
response = @client.post(PATH, body, idempotency_key: idempotency_key)
|
|
19
|
+
response["response_detail"]
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DigiwinDsp
|
|
4
|
+
module Resources
|
|
5
|
+
class Order
|
|
6
|
+
PATH = "/v1/SalesOrder/add"
|
|
7
|
+
|
|
8
|
+
def self.create(records, **)
|
|
9
|
+
new.create(records, **)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(client = Client.new)
|
|
13
|
+
@client = client
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def create(records, idempotency_key: nil, digi_header: nil)
|
|
17
|
+
body = Serializers::SalesOrderSerializer.serialize(records, digi_header: digi_header)
|
|
18
|
+
response = @client.post(PATH, body, idempotency_key: idempotency_key)
|
|
19
|
+
response["response_detail"]
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DigiwinDsp
|
|
4
|
+
module Resources
|
|
5
|
+
class Return
|
|
6
|
+
PATH = "/v1/SalesOrder/return"
|
|
7
|
+
|
|
8
|
+
def self.create(records, **)
|
|
9
|
+
new.create(records, **)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(client = Client.new)
|
|
13
|
+
@client = client
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def create(records, idempotency_key: nil, digi_header: nil)
|
|
17
|
+
body = Serializers::ReturnSerializer.serialize(records, digi_header: digi_header)
|
|
18
|
+
response = @client.post(PATH, body, idempotency_key: idempotency_key)
|
|
19
|
+
response["response_detail"]
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DigiwinDsp
|
|
4
|
+
module Serializers
|
|
5
|
+
# Mix into a serializer module via `extend Base`. The hosting module must
|
|
6
|
+
# define a REQUIRED_FIELDS constant (array of string field names).
|
|
7
|
+
module Base
|
|
8
|
+
def serialize(records, digi_header: nil)
|
|
9
|
+
normalized = normalize(records)
|
|
10
|
+
validate!(normalized)
|
|
11
|
+
wrap(normalized, digi_header)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def normalize(records)
|
|
17
|
+
list = records.is_a?(Array) ? records : [records]
|
|
18
|
+
list.map { |r| r.transform_keys(&:to_s) }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def validate!(records)
|
|
22
|
+
problems = records.each_with_index.flat_map { |record, index| missing_fields(record, index, records.size) }
|
|
23
|
+
return if problems.empty?
|
|
24
|
+
|
|
25
|
+
raise ValidationError, "missing or empty required fields: #{problems.join(", ")}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def missing_fields(record, index, total)
|
|
29
|
+
missing = self::REQUIRED_FIELDS.reject { |f| present?(record[f]) }
|
|
30
|
+
return [] if missing.empty?
|
|
31
|
+
|
|
32
|
+
prefix = total > 1 ? "[#{index}]." : ""
|
|
33
|
+
missing.map { |f| "#{prefix}#{f}" }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def present?(value)
|
|
37
|
+
!value.nil? && !value.to_s.strip.empty?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def wrap(records, digi_header)
|
|
41
|
+
body = {
|
|
42
|
+
"digi_body" => {
|
|
43
|
+
"std_data" => {
|
|
44
|
+
"parameter" => {
|
|
45
|
+
"request" => {
|
|
46
|
+
"request_detail" => records
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
digi_header ? { "digi_header" => digi_header }.merge(body) : body
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DigiwinDsp
|
|
4
|
+
module Serializers
|
|
5
|
+
module CancellationSerializer
|
|
6
|
+
extend Base
|
|
7
|
+
|
|
8
|
+
REQUIRED_FIELDS = %w[
|
|
9
|
+
platform_id create_datetime site_no form_no order_date
|
|
10
|
+
sno product_no order_status
|
|
11
|
+
].freeze
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DigiwinDsp
|
|
4
|
+
module Serializers
|
|
5
|
+
module InvoiceSerializer
|
|
6
|
+
extend Base
|
|
7
|
+
|
|
8
|
+
REQUIRED_FIELDS = %w[
|
|
9
|
+
platform_id create_datetime site_no form_no
|
|
10
|
+
invoice_no invoice_date invoice_time invoice_status invoice_type random_code
|
|
11
|
+
order_status
|
|
12
|
+
].freeze
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DigiwinDsp
|
|
4
|
+
module Serializers
|
|
5
|
+
module ReturnSerializer
|
|
6
|
+
extend Base
|
|
7
|
+
|
|
8
|
+
REQUIRED_FIELDS = %w[
|
|
9
|
+
platform_id create_datetime site_no form_no form_subno
|
|
10
|
+
original_form_no tax_type sno product_no
|
|
11
|
+
qty free_qty price subtotal payment order_status
|
|
12
|
+
returner_name returner_address returner_phone returner_zip_code
|
|
13
|
+
].freeze
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DigiwinDsp
|
|
4
|
+
module Serializers
|
|
5
|
+
module SalesOrderSerializer
|
|
6
|
+
extend Base
|
|
7
|
+
|
|
8
|
+
REQUIRED_FIELDS = %w[
|
|
9
|
+
platform_id create_datetime site_no form_no order_date
|
|
10
|
+
buyer_name receiver_name pay_type shipping_type tax_type
|
|
11
|
+
sno form_subno product_no product_name unit
|
|
12
|
+
qty free_qty price subtotal payment
|
|
13
|
+
order_status last_record
|
|
14
|
+
].freeze
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
data/lib/digiwin_dsp.rb
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "zeitwerk"
|
|
4
|
+
|
|
5
|
+
module DigiwinDsp
|
|
6
|
+
class Error < StandardError
|
|
7
|
+
attr_reader :code, :dsp_message, :request_id, :http_status, :response_body
|
|
8
|
+
|
|
9
|
+
def initialize(message = nil, code: nil, dsp_message: nil, request_id: nil, http_status: nil, response_body: nil)
|
|
10
|
+
super(message)
|
|
11
|
+
@code = code
|
|
12
|
+
@dsp_message = dsp_message
|
|
13
|
+
@request_id = request_id
|
|
14
|
+
@http_status = http_status
|
|
15
|
+
@response_body = response_body
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
class ConfigurationError < Error; end
|
|
20
|
+
class AuthenticationError < Error; end
|
|
21
|
+
class ValidationError < Error; end
|
|
22
|
+
class RateLimitError < Error; end
|
|
23
|
+
class ServerError < Error; end
|
|
24
|
+
class NetworkError < Error; end
|
|
25
|
+
class DuplicateRequestError < Error; end
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
attr_writer :configuration
|
|
29
|
+
|
|
30
|
+
def configuration
|
|
31
|
+
@configuration ||= Configuration.new
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def configure
|
|
35
|
+
yield(configuration)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def reset_configuration!
|
|
39
|
+
@configuration = Configuration.new
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
loader = Zeitwerk::Loader.for_gem
|
|
45
|
+
loader.setup
|
metadata
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: digiwin_dsp
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Zac
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: exe
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-05-21 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: faraday
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '2.9'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '2.9'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: faraday-retry
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '2.2'
|
|
34
|
+
type: :runtime
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '2.2'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: zeitwerk
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '2.6'
|
|
48
|
+
type: :runtime
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '2.6'
|
|
55
|
+
description: Synchronous Ruby gem wrapping the Digiwin DSP 自有官網模組 endpoints (create
|
|
56
|
+
order, cancel order, invoice update, return) for use from a Rails site.
|
|
57
|
+
email:
|
|
58
|
+
- 579103+7a6163@users.noreply.github.com
|
|
59
|
+
executables: []
|
|
60
|
+
extensions: []
|
|
61
|
+
extra_rdoc_files: []
|
|
62
|
+
files:
|
|
63
|
+
- CHANGELOG.md
|
|
64
|
+
- LICENSE.txt
|
|
65
|
+
- README.md
|
|
66
|
+
- lib/digiwin_dsp.rb
|
|
67
|
+
- lib/digiwin_dsp/authenticator.rb
|
|
68
|
+
- lib/digiwin_dsp/client.rb
|
|
69
|
+
- lib/digiwin_dsp/configuration.rb
|
|
70
|
+
- lib/digiwin_dsp/resources/cancellation.rb
|
|
71
|
+
- lib/digiwin_dsp/resources/invoice.rb
|
|
72
|
+
- lib/digiwin_dsp/resources/order.rb
|
|
73
|
+
- lib/digiwin_dsp/resources/return.rb
|
|
74
|
+
- lib/digiwin_dsp/serializers/base.rb
|
|
75
|
+
- lib/digiwin_dsp/serializers/cancellation_serializer.rb
|
|
76
|
+
- lib/digiwin_dsp/serializers/invoice_serializer.rb
|
|
77
|
+
- lib/digiwin_dsp/serializers/return_serializer.rb
|
|
78
|
+
- lib/digiwin_dsp/serializers/sales_order_serializer.rb
|
|
79
|
+
- lib/digiwin_dsp/version.rb
|
|
80
|
+
homepage: https://github.com/7a6163/digiwin_dsp
|
|
81
|
+
licenses:
|
|
82
|
+
- MIT
|
|
83
|
+
metadata:
|
|
84
|
+
homepage_uri: https://github.com/7a6163/digiwin_dsp
|
|
85
|
+
source_code_uri: https://github.com/7a6163/digiwin_dsp
|
|
86
|
+
rubygems_mfa_required: 'true'
|
|
87
|
+
post_install_message:
|
|
88
|
+
rdoc_options: []
|
|
89
|
+
require_paths:
|
|
90
|
+
- lib
|
|
91
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - ">="
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: 3.2.0
|
|
96
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
97
|
+
requirements:
|
|
98
|
+
- - ">="
|
|
99
|
+
- !ruby/object:Gem::Version
|
|
100
|
+
version: '0'
|
|
101
|
+
requirements: []
|
|
102
|
+
rubygems_version: 3.5.22
|
|
103
|
+
signing_key:
|
|
104
|
+
specification_version: 4
|
|
105
|
+
summary: Ruby client for the Digiwin DSP 自有官網模組 API.
|
|
106
|
+
test_files: []
|