mirror of
https://github.com/openfoodfoundation/openfoodnetwork
synced 2026-01-13 18:46:49 +00:00
Compare commits
352 Commits
v4.6.6
...
RachL-patc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5020cc740 | ||
|
|
7a2a6fab21 | ||
|
|
0f4ca50d0e | ||
|
|
3ec8cd24d3 | ||
|
|
d0dcc92ca7 | ||
|
|
22f3afc7f7 | ||
|
|
46048dcd18 | ||
|
|
a8fb6492f4 | ||
|
|
4610141ed8 | ||
|
|
8098131dba | ||
|
|
597d9ad314 | ||
|
|
1ce0b25bb0 | ||
|
|
c07ec6cdfd | ||
|
|
48e8ad3dd0 | ||
|
|
60d4cd60ff | ||
|
|
d62d3041b4 | ||
|
|
42fc0f7230 | ||
|
|
328aee6a03 | ||
|
|
db79af45fb | ||
|
|
ed7685222e | ||
|
|
4965e2bb9a | ||
|
|
6b3b29ac39 | ||
|
|
9bcdac8f30 | ||
|
|
e2d999da8d | ||
|
|
bc57447d54 | ||
|
|
f3e086ad59 | ||
|
|
298c0e8d7f | ||
|
|
ed559b5257 | ||
|
|
1fbdf25296 | ||
|
|
ec0d2d346b | ||
|
|
68c0d98736 | ||
|
|
458c8f7608 | ||
|
|
654263a823 | ||
|
|
77f9c6587c | ||
|
|
13a614a5aa | ||
|
|
add973f1ff | ||
|
|
4349e42a84 | ||
|
|
7cb28fd064 | ||
|
|
122a64e488 | ||
|
|
39fa8e0ace | ||
|
|
8c6c1e28ff | ||
|
|
d9809fc1f4 | ||
|
|
889bec7404 | ||
|
|
3f91027c51 | ||
|
|
2f9200b68b | ||
|
|
5d9bb9a8d5 | ||
|
|
7b677796c1 | ||
|
|
528c851e89 | ||
|
|
eb66244b74 | ||
|
|
9afd545897 | ||
|
|
36f7063897 | ||
|
|
a53a697e66 | ||
|
|
e9349ce79d | ||
|
|
8709c137c7 | ||
|
|
271475893d | ||
|
|
49a24ebd33 | ||
|
|
a08b0a8b32 | ||
|
|
0d97f992b9 | ||
|
|
996d2f0d46 | ||
|
|
f01a33c545 | ||
|
|
48c88d426e | ||
|
|
f646a30dca | ||
|
|
1e21939963 | ||
|
|
337113000f | ||
|
|
3756e368c8 | ||
|
|
54acc97fa1 | ||
|
|
0f6f7b332c | ||
|
|
946471923a | ||
|
|
3f353690c7 | ||
|
|
b8822ee179 | ||
|
|
701504fbb3 | ||
|
|
e9900ec1c7 | ||
|
|
8a0d9d99e5 | ||
|
|
decf1e6f03 | ||
|
|
e0638b1765 | ||
|
|
a5f677f748 | ||
|
|
63c83a19d6 | ||
|
|
762e6ec568 | ||
|
|
d2e5087668 | ||
|
|
169e1cf288 | ||
|
|
45ca2961ec | ||
|
|
1d75aa45ef | ||
|
|
a123369f8d | ||
|
|
90589ae868 | ||
|
|
167a69d2ef | ||
|
|
09524e266f | ||
|
|
1c58b061b4 | ||
|
|
24df29ddf5 | ||
|
|
9f084057a1 | ||
|
|
3f22e8cca7 | ||
|
|
c3b5456433 | ||
|
|
7b0519dab9 | ||
|
|
355541e8de | ||
|
|
e10c3dc59b | ||
|
|
2609298d88 | ||
|
|
783de09987 | ||
|
|
7b8aeb7ef8 | ||
|
|
9c7105e764 | ||
|
|
afff200680 | ||
|
|
6c431d4052 | ||
|
|
2b8487cc6d | ||
|
|
b9a72381fc | ||
|
|
ea8e925077 | ||
|
|
a13e5ced3d | ||
|
|
aa7fffa5a2 | ||
|
|
3227922c76 | ||
|
|
aa2a5757ec | ||
|
|
197363b199 | ||
|
|
a023443c75 | ||
|
|
2e29426834 | ||
|
|
8e4d306901 | ||
|
|
38196e8ff3 | ||
|
|
475c9fb4ab | ||
|
|
c48162388c | ||
|
|
f024aff45d | ||
|
|
ed668ded0a | ||
|
|
b461d499ad | ||
|
|
c1c281122f | ||
|
|
8c4cc051a4 | ||
|
|
d5b2408947 | ||
|
|
1eb70370c7 | ||
|
|
97b6289263 | ||
|
|
bda28dfaf7 | ||
|
|
d1ebe4e1d1 | ||
|
|
a64aea4b9c | ||
|
|
cc9b764f0f | ||
|
|
ac5fa21ff2 | ||
|
|
781fcf21b9 | ||
|
|
56d2642191 | ||
|
|
f54552f939 | ||
|
|
fb5740b38b | ||
|
|
db14080a7f | ||
|
|
01337c12f0 | ||
|
|
f8eeca856e | ||
|
|
67c11333f3 | ||
|
|
40afe7e0ab | ||
|
|
ef1f3207f7 | ||
|
|
b0433bd8f5 | ||
|
|
755a394704 | ||
|
|
04e14bf38b | ||
|
|
ce0c7929a7 | ||
|
|
2a671d491d | ||
|
|
7c2c614f90 | ||
|
|
3bb2232bc1 | ||
|
|
377f035ea8 | ||
|
|
dbca2e2b56 | ||
|
|
0695b434a2 | ||
|
|
9db417319d | ||
|
|
a500c75ee9 | ||
|
|
630c398b12 | ||
|
|
64f60d1c8c | ||
|
|
218d07c90d | ||
|
|
83a619b097 | ||
|
|
fa986f3fc2 | ||
|
|
977b6e6c2a | ||
|
|
f7446749ff | ||
|
|
844cab458e | ||
|
|
8ec1f61cd7 | ||
|
|
893b541dca | ||
|
|
4ae392490b | ||
|
|
cda57fdb44 | ||
|
|
25171413ef | ||
|
|
4ad6971121 | ||
|
|
8f38762393 | ||
|
|
d55950a3c5 | ||
|
|
45075a0ccd | ||
|
|
144a09916c | ||
|
|
00dfe6810f | ||
|
|
058d7eeb69 | ||
|
|
324a4ff591 | ||
|
|
7f16b6acde | ||
|
|
ce268ec175 | ||
|
|
cc85fed7cc | ||
|
|
45b0686130 | ||
|
|
4cd83d3fd4 | ||
|
|
768825d689 | ||
|
|
e8234ee4a0 | ||
|
|
94030527a4 | ||
|
|
6ff9650eaf | ||
|
|
b1b534aa1b | ||
|
|
cd74a73680 | ||
|
|
36c4d24c93 | ||
|
|
9b4cd014bf | ||
|
|
c8bf23bdc2 | ||
|
|
df82dd0759 | ||
|
|
5ec39f994a | ||
|
|
8a31153d6d | ||
|
|
4109fbde70 | ||
|
|
37ae217afc | ||
|
|
4fd115897a | ||
|
|
e22804712e | ||
|
|
d7d253e58d | ||
|
|
e2c762f06b | ||
|
|
1ad7123a9d | ||
|
|
1793aa3532 | ||
|
|
d0fe1585d7 | ||
|
|
f58a3a859f | ||
|
|
3b89cd5957 | ||
|
|
e33ed5141b | ||
|
|
4d81b145ca | ||
|
|
7211b0d64a | ||
|
|
641b7beee3 | ||
|
|
60e8db9adc | ||
|
|
b7285e48b3 | ||
|
|
52c1491b15 | ||
|
|
95ff0d8d4a | ||
|
|
7d2d14320f | ||
|
|
7d1551ed04 | ||
|
|
3e71459346 | ||
|
|
ce2c80283c | ||
|
|
2be3f7b86d | ||
|
|
2d975c5534 | ||
|
|
86c91143b7 | ||
|
|
cde757efbd | ||
|
|
260e7ba817 | ||
|
|
bda506528f | ||
|
|
e429cb7198 | ||
|
|
a838ef4a21 | ||
|
|
f0b6403c1d | ||
|
|
71ca292c92 | ||
|
|
bc87c98e92 | ||
|
|
5b8e0d734f | ||
|
|
216883101e | ||
|
|
adf0340153 | ||
|
|
664f324db6 | ||
|
|
08308ba08e | ||
|
|
c609107379 | ||
|
|
df67b53971 | ||
|
|
6f2c5b5f7f | ||
|
|
a3d8ae693d | ||
|
|
b14a1e72f3 | ||
|
|
224738e0a1 | ||
|
|
10c3c53aad | ||
|
|
e5b7f89b32 | ||
|
|
b7c34ced26 | ||
|
|
f5baa42bfc | ||
|
|
86238cc0ee | ||
|
|
61aa02b3c3 | ||
|
|
4b2099625c | ||
|
|
f8bd0a1cc7 | ||
|
|
09de223c93 | ||
|
|
74c80c9fff | ||
|
|
11f3bbc566 | ||
|
|
e5ee398f26 | ||
|
|
99c098f567 | ||
|
|
4b1d7d8a41 | ||
|
|
1e3c18f3f6 | ||
|
|
22428fc78d | ||
|
|
f980cb45f6 | ||
|
|
097c6dee2f | ||
|
|
63a1b390e2 | ||
|
|
1a30cf6495 | ||
|
|
f7708d69a7 | ||
|
|
6eb5986c68 | ||
|
|
4d9f396f40 | ||
|
|
ac3730096f | ||
|
|
662467a1a4 | ||
|
|
af07358914 | ||
|
|
8e7e5fc20f | ||
|
|
aa5feb6605 | ||
|
|
3c613f80a3 | ||
|
|
83b6f58100 | ||
|
|
17c32ae09a | ||
|
|
0474c591de | ||
|
|
196956140e | ||
|
|
b2b6847882 | ||
|
|
d01d312b4f | ||
|
|
a74cf97083 | ||
|
|
03dbd54b25 | ||
|
|
fafd86a2db | ||
|
|
91f2ca9286 | ||
|
|
3015beab99 | ||
|
|
da0660c119 | ||
|
|
852dd41f89 | ||
|
|
48993232d1 | ||
|
|
0002b2e019 | ||
|
|
84a2e6c24d | ||
|
|
be4e0a259e | ||
|
|
c362e8dd0d | ||
|
|
1550ca5da0 | ||
|
|
f474afaceb | ||
|
|
8c71760556 | ||
|
|
37ab832b86 | ||
|
|
a11873559b | ||
|
|
51b3770188 | ||
|
|
989a6d57e0 | ||
|
|
495634b60c | ||
|
|
49fd1dc4a6 | ||
|
|
e31e45b875 | ||
|
|
61fec653cf | ||
|
|
eece738865 | ||
|
|
2465780c1c | ||
|
|
21b7e6e567 | ||
|
|
eb8050d61d | ||
|
|
9f43244312 | ||
|
|
66f080232f | ||
|
|
7f62b49da5 | ||
|
|
070b93c531 | ||
|
|
fb96f8f936 | ||
|
|
4303f0e974 | ||
|
|
2eec4c73bf | ||
|
|
5ef85aef3e | ||
|
|
283db8f9d0 | ||
|
|
95e620a78b | ||
|
|
c948efd9ce | ||
|
|
95bc0cc679 | ||
|
|
efe2b724e6 | ||
|
|
14c32c0d2e | ||
|
|
8f4f873ba0 | ||
|
|
c0ae2ede2c | ||
|
|
3ec53a7d71 | ||
|
|
3849db7c48 | ||
|
|
7b286ea31d | ||
|
|
3e0eb8708e | ||
|
|
c7fa3ff819 | ||
|
|
f839452df9 | ||
|
|
a7a38890f4 | ||
|
|
caa6d284f0 | ||
|
|
827e37cada | ||
|
|
6c6927af84 | ||
|
|
439f0cac64 | ||
|
|
98966f6b89 | ||
|
|
260e4f7b00 | ||
|
|
0824430da5 | ||
|
|
099da3fc6c | ||
|
|
7078c4ef03 | ||
|
|
318790d207 | ||
|
|
2be8ef96be | ||
|
|
c0a49df150 | ||
|
|
9d5806b858 | ||
|
|
35f9c420fd | ||
|
|
052e3b6380 | ||
|
|
1545708d4e | ||
|
|
2a4d275f4b | ||
|
|
c08683412c | ||
|
|
4a38d7ef57 | ||
|
|
60afa4d465 | ||
|
|
dd5175558e | ||
|
|
98951161b1 | ||
|
|
a745249f3b | ||
|
|
63c62cae08 | ||
|
|
ef6e37e7ca | ||
|
|
ffc2fed9b5 | ||
|
|
08ab405893 | ||
|
|
ae2e92f09d | ||
|
|
b174080e29 | ||
|
|
a2c3ac2f60 | ||
|
|
429e2b0a86 | ||
|
|
70ca03173c | ||
|
|
7961ff7976 | ||
|
|
2d6ffc0ca1 | ||
|
|
a6d3909e95 |
9
.env
9
.env
@@ -61,3 +61,12 @@ SMTP_PASSWORD="f00d"
|
||||
# NEW_RELIC_AGENT_ENABLED=true
|
||||
# NEW_RELIC_APP_NAME="Open Food Network"
|
||||
# NEW_RELIC_LICENSE_KEY="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
|
||||
# Database encryption configuration, required for VINE connected app
|
||||
# Generate with bin/rails db:encryption:init
|
||||
# ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
# ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
# ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
|
||||
# VINE API settings
|
||||
# VINE_API_URL="https://vine-staging.openfoodnetwork.org.au/api/v1"
|
||||
|
||||
@@ -24,3 +24,8 @@ SITE_URL="0.0.0.0:3000"
|
||||
RACK_TIMEOUT_SERVICE_TIMEOUT="0"
|
||||
RACK_TIMEOUT_WAIT_TIMEOUT="0"
|
||||
RACK_TIMEOUT_WAIT_OVERTIME="0"
|
||||
|
||||
# Database encryption configuration, required for VINE connected app
|
||||
ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY="dev_primary_key"
|
||||
ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY="dev_determinnistic_key"
|
||||
ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT="dev_derivation_salt"
|
||||
|
||||
@@ -16,5 +16,9 @@ STRIPE_PUBLIC_TEST_API_KEY="bogus_stripe_publishable_key"
|
||||
SITE_URL="test.host"
|
||||
|
||||
OPENID_APP_ID="test-provider"
|
||||
OPENID_APP_SECRET="12345"
|
||||
OPENID_APP_SECRET="dummy-openid-app-secret-token"
|
||||
OPENID_REFRESH_TOKEN="dummy-refresh-token"
|
||||
|
||||
ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY="test_primary_key"
|
||||
ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY="test_deterministic_key"
|
||||
ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT="test_derivation_salt"
|
||||
|
||||
9
.github/ISSUE_TEMPLATE/release.md
vendored
9
.github/ISSUE_TEMPLATE/release.md
vendored
@@ -7,7 +7,7 @@ assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## 1. Preparation on Thursday
|
||||
## 1. Drafting on Friday
|
||||
|
||||
- [ ] Merge pull requests in the [Ready To Go] column
|
||||
- [ ] Include translations: `script/release/update_locales`
|
||||
@@ -26,8 +26,9 @@ assignees: ''
|
||||
- [ ] Move this issue to Test Ready.
|
||||
- [ ] Notify `@testers` in [#testing].
|
||||
- [ ] Test build: [Deploy to Staging] with release tag.
|
||||
- [ ] Notify a deployer to deploy it
|
||||
|
||||
## 3. Finish on Tuesday
|
||||
## 3. Deployment at beginning of week
|
||||
|
||||
- [ ] Publish and notify [#global-community] (this is automatically posted with a plugin)
|
||||
- [ ] Deploy the new release to all managed instances.
|
||||
@@ -40,7 +41,7 @@ assignees: ''
|
||||
</details>
|
||||
- [ ] Notify [#instance-managers]:
|
||||
> @instance_managers The new release has been deployed.
|
||||
- [ ] [Create issue] for next release and confirm with next release manager in [#core-devs].
|
||||
- [ ] [Create issue] for next release and confirm with next release drafter in [#delivery-circle].
|
||||
|
||||
The full process is described at https://github.com/openfoodfoundation/openfoodnetwork/wiki/Releasing.
|
||||
|
||||
@@ -53,5 +54,5 @@ The full process is described at https://github.com/openfoodfoundation/openfoodn
|
||||
[Deploy to Staging]: https://github.com/openfoodfoundation/openfoodnetwork/actions/workflows/stage.yml
|
||||
[#global-community]: https://app.slack.com/client/T02G54U79/C59ADD8F2
|
||||
[Create issue]: https://github.com/openfoodfoundation/openfoodnetwork/issues/new?assignees=&labels=&projects=&template=release.md&title=Release
|
||||
[#core-devs]: https://openfoodnetwork.slack.com/archives/GK2T38QPJ
|
||||
[#delivery-circle]: https://openfoodnetwork.slack.com/archives/C01T75H6G0Z
|
||||
[Transifex Client]: https://developers.transifex.com/docs/cli
|
||||
|
||||
8
.github/workflows/mapi.yml
vendored
8
.github/workflows/mapi.yml
vendored
@@ -14,12 +14,12 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- run: docker/build
|
||||
- run: docker-compose up --detach
|
||||
- run: docker compose up --detach
|
||||
- run: until curl -f -s http://localhost:3000; do echo "waiting for api server"; sleep 1; done
|
||||
- run: docker-compose exec -T db psql postgresql://ofn:f00d@localhost:5432/open_food_network_dev --command="update spree_users set spree_api_key='testing' where login='ofn@example.com'"
|
||||
- run: docker compose exec -T db psql postgresql://ofn:f00d@localhost:5432/open_food_network_dev --command="update spree_users set spree_api_key='testing' where login='ofn@example.com'"
|
||||
# equivalent to Flipper.enable(:api_v1)
|
||||
- run: docker-compose exec -T db psql postgresql://ofn:f00d@localhost:5432/open_food_network_dev --command="insert into flipper_features (key, created_at, updated_at) values ('api_v1', localtimestamp, localtimestamp)"
|
||||
- run: docker-compose exec -T db psql postgresql://ofn:f00d@localhost:5432/open_food_network_dev --command="insert into flipper_gates (feature_key, key, value, created_at, updated_at) values ('api_v1', 'boolean', 'true', localtimestamp, localtimestamp)"
|
||||
- run: docker compose exec -T db psql postgresql://ofn:f00d@localhost:5432/open_food_network_dev --command="insert into flipper_features (key, created_at, updated_at) values ('api_v1', localtimestamp, localtimestamp)"
|
||||
- run: docker compose exec -T db psql postgresql://ofn:f00d@localhost:5432/open_food_network_dev --command="insert into flipper_gates (feature_key, key, value, created_at, updated_at) values ('api_v1', 'boolean', 'true', localtimestamp, localtimestamp)"
|
||||
|
||||
# Run Mayhem for API
|
||||
- name: Run Mayhem for API
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*.yaml
|
||||
*.json
|
||||
*.html
|
||||
**/*.rb
|
||||
|
||||
# JS
|
||||
# Enabled: app/webpacker/controllers/*.js and app/webpacker/packs/*.js
|
||||
@@ -27,6 +28,5 @@ postcss.config.js
|
||||
/coverage/
|
||||
/engines/
|
||||
/public/
|
||||
/spec/
|
||||
/tmp/
|
||||
/vendor/
|
||||
|
||||
@@ -31,7 +31,7 @@ This project needs specific ruby/bundler versions as well as node/yarn specific
|
||||
* Install or change your Ruby version according to the one specified at [.ruby-version](https://github.com/openfoodfoundation/openfoodnetwork/blob/master/.ruby-version) file.
|
||||
- To manage versions, it's recommended to use [rbenv](https://github.com/rbenv/rbenv) or [RVM](https://rvm.io/).
|
||||
* Install [nodenv](https://github.com/nodenv/nodenv) to ensure the correct [.node-version](https://github.com/openfoodfoundation/openfoodnetwork/blob/master/.node-version) is used.
|
||||
- [nodevn](https://github.com/nodenv/nodenv) is recommended as a node version manager.
|
||||
- [nodenv](https://github.com/nodenv/nodenv) is recommended as a node version manager.
|
||||
* PostgreSQL database
|
||||
* Redis (for background jobs)
|
||||
* Chrome (for testing)
|
||||
|
||||
@@ -246,7 +246,7 @@ GEM
|
||||
activerecord (>= 5.a)
|
||||
database_cleaner-core (~> 2.0.0)
|
||||
database_cleaner-core (2.0.1)
|
||||
datafoodconsortium-connector (1.0.0.pre.alpha.12)
|
||||
datafoodconsortium-connector (1.0.0.pre.alpha.13)
|
||||
virtual_assembly-semantizer (~> 1.0, >= 1.0.5)
|
||||
date (3.3.4)
|
||||
debug (1.9.2)
|
||||
@@ -379,13 +379,14 @@ GEM
|
||||
bindata
|
||||
faraday (~> 2.0)
|
||||
faraday-follow_redirects
|
||||
json-ld (3.3.1)
|
||||
json-ld (3.3.2)
|
||||
htmlentities (~> 4.3)
|
||||
json-canonicalization (~> 1.0)
|
||||
link_header (~> 0.0, >= 0.0.8)
|
||||
multi_json (~> 1.15)
|
||||
rack (>= 2.2, < 4)
|
||||
rdf (~> 3.3)
|
||||
rexml (~> 3.2)
|
||||
json-schema (4.1.1)
|
||||
addressable (>= 2.8)
|
||||
json_spec (1.1.5)
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
//= require angular
|
||||
//= require angular-resource
|
||||
//= require angular-animate
|
||||
//= require angular-sanitize
|
||||
//= require angularjs-file-upload
|
||||
//= require ../shared/ng-infinite-scroll.min.js
|
||||
//= require ../shared/ng-tags-input.min.js
|
||||
@@ -60,11 +61,6 @@
|
||||
//= require ./variant_overrides/variant_overrides
|
||||
|
||||
// text, dates and translations
|
||||
//= require textAngular-rangy.min.js
|
||||
// This replaces angular-sanitize. We should include only one.
|
||||
// https://github.com/textAngular/textAngular#where-to-get-it
|
||||
//= require textAngular-sanitize.min.js
|
||||
//= require textAngular.min.js
|
||||
//= require i18n/translations
|
||||
//= require darkswarm/i18n.translate.js
|
||||
|
||||
|
||||
@@ -187,9 +187,8 @@ angular.module("ofn.admin").controller "AdminProductEditCtrl", ($scope, $timeout
|
||||
product.variants.length > 0
|
||||
|
||||
|
||||
$scope.hasUnit = (product) ->
|
||||
product.variant_unit_with_scale?
|
||||
|
||||
$scope.hasUnit = (variant) ->
|
||||
variant.variant_unit_with_scale?
|
||||
|
||||
$scope.variantSaved = (variant) ->
|
||||
variant.hasOwnProperty('id') && variant.id > 0
|
||||
@@ -242,32 +241,28 @@ angular.module("ofn.admin").controller "AdminProductEditCtrl", ($scope, $timeout
|
||||
$window.location = destination
|
||||
|
||||
$scope.packProduct = (product) ->
|
||||
if product.variant_unit_with_scale
|
||||
match = product.variant_unit_with_scale.match(/^([^_]+)_([\d\.]+)$/)
|
||||
if match
|
||||
product.variant_unit = match[1]
|
||||
product.variant_unit_scale = parseFloat(match[2])
|
||||
else
|
||||
product.variant_unit = product.variant_unit_with_scale
|
||||
product.variant_unit_scale = null
|
||||
else
|
||||
product.variant_unit = product.variant_unit_scale = null
|
||||
|
||||
|
||||
if product.variants
|
||||
for id, variant of product.variants
|
||||
$scope.packVariant product, variant
|
||||
$scope.packVariant variant
|
||||
|
||||
|
||||
$scope.packVariant = (product, variant) ->
|
||||
$scope.packVariant = (variant) ->
|
||||
if variant.variant_unit_with_scale
|
||||
match = variant.variant_unit_with_scale.match(/^([^_]+)_([\d\.]+)$/)
|
||||
if match
|
||||
variant.variant_unit = match[1]
|
||||
variant.variant_unit_scale = parseFloat(match[2])
|
||||
else
|
||||
variant.variant_unit = variant.variant_unit_with_scale
|
||||
variant.variant_unit_scale = null
|
||||
|
||||
if variant.hasOwnProperty("unit_value_with_description")
|
||||
match = variant.unit_value_with_description.match(/^([\d\.\,]+(?= |$)|)( |)(.*)$/)
|
||||
if match
|
||||
product = BulkProducts.find product.id
|
||||
variant.unit_value = parseFloat(match[1].replace(",", "."))
|
||||
variant.unit_value = null if isNaN(variant.unit_value)
|
||||
if variant.unit_value && product.variant_unit_scale
|
||||
variant.unit_value = parseFloat(window.bigDecimal.multiply(variant.unit_value, product.variant_unit_scale, 2))
|
||||
if variant.unit_value && variant.variant_unit_scale
|
||||
variant.unit_value = parseFloat(window.bigDecimal.multiply(variant.unit_value, variant.variant_unit_scale, 2))
|
||||
variant.unit_description = match[3]
|
||||
|
||||
$scope.incrementLimit = ->
|
||||
@@ -321,13 +316,6 @@ filterSubmitProducts = (productsToFilter) ->
|
||||
if product.hasOwnProperty("price")
|
||||
filteredProduct.price = product.price
|
||||
hasUpdatableProperty = true
|
||||
if product.hasOwnProperty("variant_unit_with_scale")
|
||||
filteredProduct.variant_unit = product.variant_unit
|
||||
filteredProduct.variant_unit_scale = product.variant_unit_scale
|
||||
hasUpdatableProperty = true
|
||||
if product.hasOwnProperty("variant_unit_name")
|
||||
filteredProduct.variant_unit_name = product.variant_unit_name
|
||||
hasUpdatableProperty = true
|
||||
if product.hasOwnProperty("on_hand") and filteredVariants.length == 0 #only update if no variants present
|
||||
filteredProduct.on_hand = product.on_hand
|
||||
hasUpdatableProperty = true
|
||||
@@ -383,6 +371,14 @@ filterSubmitVariant = (variant) ->
|
||||
if variant.hasOwnProperty("producer_id")
|
||||
filteredVariant.supplier_id = variant.producer_id
|
||||
hasUpdatableProperty = true
|
||||
if variant.hasOwnProperty("variant_unit_with_scale")
|
||||
filteredVariant.variant_unit = variant.variant_unit
|
||||
filteredVariant.variant_unit_scale = variant.variant_unit_scale
|
||||
hasUpdatableProperty = true
|
||||
if variant.hasOwnProperty("variant_unit_name")
|
||||
filteredVariant.variant_unit_name = variant.variant_unit_name
|
||||
hasUpdatableProperty = true
|
||||
|
||||
{filteredVariant: filteredVariant, hasUpdatableProperty: hasUpdatableProperty}
|
||||
|
||||
|
||||
|
||||
@@ -4,31 +4,30 @@ angular.module("ofn.admin").directive "ofnDisplayAs", (OptionValueNamer) ->
|
||||
scope.$watchCollection ->
|
||||
return [
|
||||
scope.$eval(attrs.ofnDisplayAs).unit_value_with_description
|
||||
scope.product.variant_unit_name
|
||||
scope.product.variant_unit_with_scale
|
||||
scope.variant.variant_unit_name
|
||||
scope.variant.variant_unit_with_scale
|
||||
]
|
||||
, ->
|
||||
[variant_unit, variant_unit_scale] = productUnitProperties()
|
||||
[unit_value, unit_description] = variantUnitProperties(variant_unit_scale)
|
||||
variant_object =
|
||||
variant_object =
|
||||
unit_value: unit_value
|
||||
unit_description: unit_description
|
||||
product:
|
||||
variant_unit_scale: variant_unit_scale
|
||||
variant_unit: variant_unit
|
||||
variant_unit_name: scope.product.variant_unit_name
|
||||
variant_unit_scale: variant_unit_scale
|
||||
variant_unit: variant_unit
|
||||
variant_unit_name: scope.variant.variant_unit_name
|
||||
|
||||
scope.placeholder_text = new OptionValueNamer(variant_object).name()
|
||||
|
||||
productUnitProperties = ->
|
||||
# get relevant product properties
|
||||
if scope.product.variant_unit_with_scale?
|
||||
match = scope.product.variant_unit_with_scale.match(/^([^_]+)_([\d\.]+)$/)
|
||||
if scope.variant.variant_unit_with_scale?
|
||||
match = scope.variant.variant_unit_with_scale.match(/^([^_]+)_([\d\.]+)$/)
|
||||
if match
|
||||
variant_unit = match[1]
|
||||
variant_unit_scale = parseFloat(match[2])
|
||||
else
|
||||
variant_unit = scope.product.variant_unit_with_scale
|
||||
variant_unit = scope.variant.variant_unit_with_scale
|
||||
variant_unit_scale = null
|
||||
else
|
||||
variant_unit = variant_unit_scale = null
|
||||
@@ -45,4 +44,4 @@ angular.module("ofn.admin").directive "ofnDisplayAs", (OptionValueNamer) ->
|
||||
unit_value = null if isNaN(unit_value)
|
||||
unit_value *= variant_unit_scale if unit_value && variant_unit_scale
|
||||
unit_description = match[3]
|
||||
[unit_value, unit_description]
|
||||
[unit_value, unit_description]
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
angular.module("ofn.admin").directive "ofnMaintainUnitScale", ->
|
||||
require: "ngModel"
|
||||
link: (scope, element, attrs, ngModel) ->
|
||||
scope.$watch 'product.variant_unit_with_scale', (newValue, oldValue) ->
|
||||
if not (oldValue == newValue)
|
||||
# Triggers track-variant directive to track the unit_value, so that changes to the unit are passed to the server
|
||||
ngModel.$setViewValue ngModel.$viewValue
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
angular.module("ofn.admin").directive "ofnTrackMaster", (DirtyProducts) ->
|
||||
require: "ngModel"
|
||||
link: (scope, element, attrs, ngModel) ->
|
||||
ngModel.$parsers.push (viewValue) ->
|
||||
if ngModel.$dirty
|
||||
DirtyProducts.addMasterProperty scope.product.id, scope.product.master.id, attrs.ofnTrackMaster, viewValue
|
||||
scope.displayDirtyProducts()
|
||||
viewValue
|
||||
@@ -1 +1 @@
|
||||
angular.module("admin.enterprise_groups", ["admin.side_menu", "admin.users", "textAngular"])
|
||||
angular.module("admin.enterprise_groups", ["admin.side_menu", "admin.users", "ngSanitize"])
|
||||
|
||||
@@ -3,7 +3,6 @@ angular.module("admin.enterprises", [
|
||||
"admin.utils",
|
||||
"admin.shippingMethods",
|
||||
"admin.users",
|
||||
"textAngular",
|
||||
"admin.side_menu",
|
||||
"admin.taxons",
|
||||
'admin.indexUtils',
|
||||
@@ -11,16 +10,3 @@ angular.module("admin.enterprises", [
|
||||
'admin.dropdown',
|
||||
'ngSanitize']
|
||||
)
|
||||
# For more options: https://github.com/textAngular/textAngular/blob/master/src/textAngularSetup.js
|
||||
.config [
|
||||
'$provide', ($provide) ->
|
||||
$provide.decorator 'taTranslations', [
|
||||
'$delegate'
|
||||
(taTranslations) ->
|
||||
taTranslations.insertLink = {
|
||||
tooltip: t('admin.enterprises.form.shop_preferences.shopfront_message_link_tooltip'),
|
||||
dialogPrompt: t('admin.enterprises.form.shop_preferences.shopfront_message_link_prompt')
|
||||
}
|
||||
taTranslations
|
||||
]
|
||||
]
|
||||
|
||||
@@ -199,14 +199,14 @@ angular.module("admin.lineItems").controller 'LineItemsCtrl', ($scope, $timeout,
|
||||
$scope.refreshData()
|
||||
|
||||
$scope.getLineItemScale = (lineItem) ->
|
||||
if lineItem.units_product && lineItem.units_variant && (lineItem.units_product.variant_unit == "weight" || lineItem.units_product.variant_unit == "volume")
|
||||
lineItem.units_product.variant_unit_scale
|
||||
if lineItem.units_variant && lineItem.units_variant.variant_unit_scale && (lineItem.units_variant.variant_unit == "weight" || lineItem.units_variant.variant_unit == "volume")
|
||||
lineItem.units_variant.variant_unit_scale
|
||||
else
|
||||
1
|
||||
|
||||
$scope.sumUnitValues = ->
|
||||
sum = $scope.filteredLineItems?.reduce (sum, lineItem) ->
|
||||
if lineItem.units_product.variant_unit == "items"
|
||||
if lineItem.units_variant.variant_unit == "items"
|
||||
sum + lineItem.quantity
|
||||
else
|
||||
sum + $scope.roundToThreeDecimals(lineItem.final_weight_volume / $scope.getLineItemScale(lineItem))
|
||||
@@ -214,7 +214,7 @@ angular.module("admin.lineItems").controller 'LineItemsCtrl', ($scope, $timeout,
|
||||
|
||||
$scope.sumMaxUnitValues = ->
|
||||
sum = $scope.filteredLineItems?.reduce (sum,lineItem) ->
|
||||
if lineItem.units_product.variant_unit == "items"
|
||||
if lineItem.units_variant.variant_unit == "items"
|
||||
sum + lineItem.max_quantity
|
||||
else
|
||||
sum + lineItem.max_quantity * $scope.roundToThreeDecimals(lineItem.units_variant.unit_value / $scope.getLineItemScale(lineItem))
|
||||
@@ -228,39 +228,41 @@ angular.module("admin.lineItems").controller 'LineItemsCtrl', ($scope, $timeout,
|
||||
return false if !lineItem.hasOwnProperty('final_weight_volume') || !(lineItem.final_weight_volume > 0)
|
||||
true
|
||||
|
||||
$scope.getScale = (unitsProduct, unitsVariant) ->
|
||||
if unitsProduct.hasOwnProperty("variant_unit") && (unitsProduct.variant_unit == "weight" || unitsProduct.variant_unit == "volume")
|
||||
unitsProduct.variant_unit_scale
|
||||
else if unitsProduct.hasOwnProperty("variant_unit") && unitsProduct.variant_unit == "items"
|
||||
$scope.getScale = (unitsVariant) ->
|
||||
if unitsVariant.hasOwnProperty("variant_unit") && (unitsVariant.variant_unit == "weight" || unitsVariant.variant_unit == "volume")
|
||||
unitsVariant.variant_unit_scale
|
||||
else if unitsVariant.hasOwnProperty("variant_unit") && unitsVariant.variant_unit == "items"
|
||||
1
|
||||
else
|
||||
null
|
||||
|
||||
$scope.getFormattedValueWithUnitName = (value, unitsProduct, unitsVariant, scale) ->
|
||||
unit_name = VariantUnitManager.getUnitName(scale, unitsProduct.variant_unit)
|
||||
$scope.getFormattedValueWithUnitName = (value, unitsVariant, scale) ->
|
||||
unit_name = VariantUnitManager.getUnitName(scale, unitsVariant.variant_unit)
|
||||
$scope.roundToThreeDecimals(value) + " " + unit_name
|
||||
|
||||
$scope.getGroupBySizeFormattedValueWithUnitName = (value, unitsProduct, unitsVariant) ->
|
||||
scale = $scope.getScale(unitsProduct, unitsVariant)
|
||||
$scope.getGroupBySizeFormattedValueWithUnitName = (value, unitsVariant) ->
|
||||
scale = $scope.getScale(unitsVariant)
|
||||
if scale && value
|
||||
value = value / scale if scale != 28.35 && scale != 1 && scale != 453.6 # divide by scale if not smallest unit
|
||||
$scope.getFormattedValueWithUnitName(value, unitsProduct, unitsVariant, scale)
|
||||
$scope.getFormattedValueWithUnitName(value, unitsVariant, scale)
|
||||
else
|
||||
''
|
||||
|
||||
$scope.formattedValueWithUnitName = (value, unitsProduct, unitsVariant) ->
|
||||
scale = $scope.getScale(unitsProduct, unitsVariant)
|
||||
$scope.formattedValueWithUnitName = (value, unitsVariant) ->
|
||||
scale = $scope.getScale(unitsVariant)
|
||||
if scale
|
||||
$scope.getFormattedValueWithUnitName(value, unitsProduct, unitsVariant, scale)
|
||||
$scope.getFormattedValueWithUnitName(value, unitsVariant, scale)
|
||||
else
|
||||
''
|
||||
|
||||
$scope.fulfilled = (sumOfUnitValues) ->
|
||||
# A Units Variant is an API object which holds unit properies of a variant
|
||||
if $scope.selectedUnitsProduct.hasOwnProperty("group_buy_unit_size")&& $scope.selectedUnitsProduct.group_buy_unit_size > 0 &&
|
||||
$scope.selectedUnitsProduct.hasOwnProperty("variant_unit")
|
||||
if $scope.selectedUnitsProduct.variant_unit == "weight" || $scope.selectedUnitsProduct.variant_unit == "volume"
|
||||
scale = $scope.selectedUnitsProduct.variant_unit_scale
|
||||
if $scope.selectedUnitsProduct.hasOwnProperty("group_buy_unit_size") && $scope.selectedUnitsProduct.group_buy_unit_size > 0 &&
|
||||
$scope.selectedUnitsVariant.hasOwnProperty("variant_unit")
|
||||
|
||||
if $scope.selectedUnitsVariant.variant_unit == "weight" || $scope.selectedUnitsVariant.variant_unit == "volume"
|
||||
|
||||
scale = $scope.selectedUnitsVariant.variant_unit_scale
|
||||
sumOfUnitValues = sumOfUnitValues * scale unless scale == 28.35 || scale == 453.6
|
||||
$scope.roundToThreeDecimals(sumOfUnitValues / $scope.selectedUnitsProduct.group_buy_unit_size)
|
||||
else
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
angular.module("admin.products").controller "editUnitsCtrl", ($scope, VariantUnitManager) ->
|
||||
|
||||
$scope.product =
|
||||
variant_unit: angular.element('#variant_unit').val()
|
||||
variant_unit_scale: angular.element('#variant_unit_scale').val()
|
||||
|
||||
$scope.variant_unit_options = VariantUnitManager.variantUnitOptions()
|
||||
|
||||
if $scope.product.variant_unit == 'items'
|
||||
$scope.variant_unit_with_scale = 'items'
|
||||
else
|
||||
$scope.variant_unit_with_scale = $scope.product.variant_unit + '_' + $scope.product.variant_unit_scale.replace(/\.0$/, '');
|
||||
|
||||
$scope.setFields = ->
|
||||
if $scope.variant_unit_with_scale == 'items'
|
||||
variant_unit = 'items'
|
||||
variant_unit_scale = null
|
||||
else
|
||||
options = $scope.variant_unit_with_scale.split('_')
|
||||
variant_unit = options[0]
|
||||
variant_unit_scale = options[1]
|
||||
|
||||
$scope.product.variant_unit = variant_unit
|
||||
$scope.product.variant_unit_scale = variant_unit_scale
|
||||
@@ -1,15 +1,14 @@
|
||||
# Controller for "New Products" form (spree/admin/products/new)
|
||||
angular.module("admin.products")
|
||||
.controller "unitsCtrl", ($scope, VariantUnitManager, OptionValueNamer, UnitPrices, PriceParser) ->
|
||||
$scope.product = { master: {} }
|
||||
$scope.product.master.product = $scope.product
|
||||
$scope.product = {}
|
||||
$scope.placeholder_text = ""
|
||||
|
||||
$scope.$watchCollection '[product.variant_unit_with_scale, product.master.unit_value_with_description, product.price, product.variant_unit_name]', ->
|
||||
$scope.$watchCollection '[product.variant_unit_with_scale, product.unit_value_with_description, product.price, product.variant_unit_name]', ->
|
||||
$scope.processVariantUnitWithScale()
|
||||
$scope.processUnitValueWithDescription()
|
||||
$scope.processUnitPrice()
|
||||
$scope.placeholder_text = new OptionValueNamer($scope.product.master).name() if $scope.product.variant_unit_scale
|
||||
$scope.placeholder_text = new OptionValueNamer($scope.product).name() if $scope.product.variant_unit_scale
|
||||
|
||||
$scope.variant_unit_options = VariantUnitManager.variantUnitOptions()
|
||||
|
||||
@@ -38,24 +37,24 @@ angular.module("admin.products")
|
||||
# Extract unit_value and unit_description from text field unit_value_with_description,
|
||||
# and update hidden variant fields
|
||||
$scope.processUnitValueWithDescription = ->
|
||||
if $scope.product.master.hasOwnProperty("unit_value_with_description")
|
||||
match = $scope.product.master.unit_value_with_description.match(/^([\d\.,]+(?= *|$)|)( *)(.*)$/)
|
||||
if $scope.product.hasOwnProperty("unit_value_with_description")
|
||||
match = $scope.product.unit_value_with_description.match(/^([\d\.,]+(?= *|$)|)( *)(.*)$/)
|
||||
if match
|
||||
$scope.product.master.unit_value = PriceParser.parse(match[1])
|
||||
$scope.product.master.unit_value = null if isNaN($scope.product.master.unit_value)
|
||||
$scope.product.master.unit_value = window.bigDecimal.multiply($scope.product.master.unit_value, $scope.product.variant_unit_scale, 2) if $scope.product.master.unit_value && $scope.product.variant_unit_scale
|
||||
$scope.product.master.unit_description = match[3]
|
||||
$scope.product.unit_value = PriceParser.parse(match[1])
|
||||
$scope.product.unit_value = null if isNaN($scope.product.unit_value)
|
||||
$scope.product.unit_value = window.bigDecimal.multiply($scope.product.unit_value, $scope.product.variant_unit_scale, 2) if $scope.product.unit_value && $scope.product.variant_unit_scale
|
||||
$scope.product.unit_description = match[3]
|
||||
else
|
||||
value = $scope.product.master.unit_value
|
||||
value = window.bigDecimal.divide(value, $scope.product.variant_unit_scale, 2) if $scope.product.master.unit_value && $scope.product.variant_unit_scale
|
||||
$scope.product.master.unit_value_with_description = value + " " + $scope.product.master.unit_description
|
||||
value = $scope.product.unit_value
|
||||
value = window.bigDecimal.divide(value, $scope.product.variant_unit_scale, 2) if $scope.product.unit_value && $scope.product.variant_unit_scale
|
||||
$scope.product.unit_value_with_description = value + " " + $scope.product.unit_description
|
||||
|
||||
# Calculate unit price based on product price and variant_unit_scale
|
||||
$scope.processUnitPrice = ->
|
||||
price = $scope.product.price
|
||||
scale = $scope.product.variant_unit_scale
|
||||
unit_type = $scope.product.variant_unit
|
||||
unit_value = $scope.product.master.unit_value
|
||||
unit_value = $scope.product.unit_value
|
||||
variant_unit_name = $scope.product.variant_unit_name
|
||||
$scope.unit_price = UnitPrices.displayableUnitPrice(price, scale, unit_type, unit_value, variant_unit_name)
|
||||
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
angular.module("admin.products").controller "variantUnitsCtrl", ($scope, VariantUnitManager, $timeout, UnitPrices, PriceParser) ->
|
||||
|
||||
$scope.unitName = (scale, type) ->
|
||||
VariantUnitManager.getUnitName(scale, type)
|
||||
|
||||
$scope.$watchCollection "[unit_value_human, variant.price]", ->
|
||||
$scope.processUnitPrice()
|
||||
|
||||
$scope.processUnitPrice = ->
|
||||
if ($scope.variant)
|
||||
price = $scope.variant.price
|
||||
scale = $scope.scale
|
||||
unit_type = angular.element("#product_variant_unit").val()
|
||||
if (unit_type != "items")
|
||||
$scope.updateValue()
|
||||
unit_value = $scope.unit_value
|
||||
else
|
||||
unit_value = 1
|
||||
variant_unit_name = angular.element("#product_variant_unit_name").val()
|
||||
$scope.unit_price = UnitPrices.displayableUnitPrice(price, scale, unit_type, unit_value, variant_unit_name)
|
||||
|
||||
$scope.scale = angular.element('#product_variant_unit_scale').val()
|
||||
|
||||
$scope.updateValue = ->
|
||||
unit_value_human = angular.element('#unit_value_human').val()
|
||||
$scope.unit_value = bigDecimal.multiply(PriceParser.parse(unit_value_human), $scope.scale, 2)
|
||||
|
||||
variant_unit_value = angular.element('#variant_unit_value').val()
|
||||
$scope.unit_value_human = parseFloat(bigDecimal.divide(variant_unit_value, $scope.scale, 2))
|
||||
|
||||
$timeout -> $scope.processUnitPrice()
|
||||
$timeout -> $scope.updateValue()
|
||||
@@ -1,19 +0,0 @@
|
||||
angular.module("admin.products").directive "setOnDemand", ->
|
||||
link: (scope, element, attr) ->
|
||||
onHand = element.context.querySelector("#variant_on_hand")
|
||||
onDemand = element.context.querySelector("#variant_on_demand")
|
||||
|
||||
disableOnHandIfOnDemand = ->
|
||||
if onDemand.checked
|
||||
onHand.disabled = 'disabled'
|
||||
onHand.dataStock = onHand.value
|
||||
onHand.value = t('admin.products.variants.infinity')
|
||||
|
||||
disableOnHandIfOnDemand()
|
||||
|
||||
onDemand.addEventListener 'change', (event) ->
|
||||
disableOnHandIfOnDemand()
|
||||
|
||||
if !onDemand.checked
|
||||
onHand.removeAttribute('disabled')
|
||||
onHand.value = onHand.dataStock
|
||||
@@ -1 +1 @@
|
||||
angular.module("admin.products", ["textAngular", "admin.utils", "OFNShared"])
|
||||
angular.module("admin.products", ["ngSanitize", "admin.utils", "OFNShared"])
|
||||
|
||||
@@ -13,16 +13,16 @@ angular.module("admin.products").factory "OptionValueNamer", (VariantUnitManager
|
||||
name_fields.join ' '
|
||||
|
||||
value_scaled: ->
|
||||
@variant.product.variant_unit_scale?
|
||||
@variant.variant_unit_scale?
|
||||
|
||||
option_value_value_unit: ->
|
||||
if @variant.unit_value?
|
||||
if @variant.product.variant_unit in ["weight", "volume"]
|
||||
if @variant.variant_unit in ["weight", "volume"]
|
||||
[value, unit_name] = @option_value_value_unit_scaled()
|
||||
|
||||
else
|
||||
value = @variant.unit_value
|
||||
unit_name = @pluralize(@variant.product.variant_unit_name, value)
|
||||
unit_name = @pluralize(@variant.variant_unit_name, value)
|
||||
|
||||
value = parseInt(value, 10) if value == parseInt(value, 10)
|
||||
|
||||
@@ -58,14 +58,13 @@ angular.module("admin.products").factory "OptionValueNamer", (VariantUnitManager
|
||||
# to >= 1 when expressed in it.
|
||||
# If there is none available where this is true, use the smallest
|
||||
# available unit.
|
||||
product = @variant.product
|
||||
scales = VariantUnitManager.compatibleUnitScales(product.variant_unit_scale, product.variant_unit)
|
||||
scales = VariantUnitManager.compatibleUnitScales(@variant.variant_unit_scale, @variant.variant_unit)
|
||||
variantUnitValue = @variant.unit_value
|
||||
|
||||
# sets largestScale = last element in filtered scales array
|
||||
[_, ..., largestScale] = (scales.filter (s) -> variantUnitValue / s >= 1)
|
||||
|
||||
if (largestScale)
|
||||
[largestScale, VariantUnitManager.getUnitName(largestScale, product.variant_unit)]
|
||||
[largestScale, VariantUnitManager.getUnitName(largestScale, @variant.variant_unit)]
|
||||
else
|
||||
[scales[0], VariantUnitManager.getUnitName(scales[0], product.variant_unit)]
|
||||
[scales[0], VariantUnitManager.getUnitName(scales[0], @variant.variant_unit)]
|
||||
|
||||
@@ -19,7 +19,7 @@ angular.module("ofn.admin").factory "BulkProducts", (ProductResource, dataFetche
|
||||
for server_product in serverProducts
|
||||
product = @findProductInList(server_product.id, @products)
|
||||
product.variants = server_product.variants
|
||||
@loadVariantUnitValues product
|
||||
@loadVariantUnitValues product.variants
|
||||
|
||||
find: (id) ->
|
||||
@findProductInList id, @products
|
||||
@@ -38,34 +38,32 @@ angular.module("ofn.admin").factory "BulkProducts", (ProductResource, dataFetche
|
||||
@products.splice(index + 1, 0, newProduct)
|
||||
|
||||
unpackProduct: (product) ->
|
||||
#$scope.matchProducer product
|
||||
@loadVariantUnit product
|
||||
|
||||
loadVariantUnit: (product) ->
|
||||
product.variant_unit_with_scale =
|
||||
if product.variant_unit && product.variant_unit_scale && product.variant_unit != 'items'
|
||||
"#{product.variant_unit}_#{product.variant_unit_scale}"
|
||||
else if product.variant_unit
|
||||
product.variant_unit
|
||||
@loadVariantUnitValues product.variants if product.variants
|
||||
|
||||
loadVariantUnitValues: (variants) ->
|
||||
for variant in variants
|
||||
@loadVariantUnitValue variant
|
||||
|
||||
loadVariantUnitValue: (variant) ->
|
||||
variant.variant_unit_with_scale =
|
||||
if variant.variant_unit && variant.variant_unit_scale && variant.variant_unit != 'items'
|
||||
"#{variant.variant_unit}_#{variant.variant_unit_scale}"
|
||||
else if variant.variant_unit
|
||||
variant.variant_unit
|
||||
else
|
||||
null
|
||||
|
||||
@loadVariantUnitValues product if product.variants
|
||||
@loadVariantUnitValue product, product.master if product.master
|
||||
|
||||
loadVariantUnitValues: (product) ->
|
||||
for variant in product.variants
|
||||
@loadVariantUnitValue product, variant
|
||||
|
||||
loadVariantUnitValue: (product, variant) ->
|
||||
unit_value = @variantUnitValue product, variant
|
||||
unit_value = @variantUnitValue variant
|
||||
unit_value = if unit_value? then unit_value else ''
|
||||
variant.unit_value_with_description = "#{unit_value} #{variant.unit_description || ''}".trim()
|
||||
|
||||
variantUnitValue: (product, variant) ->
|
||||
variantUnitValue: (variant) ->
|
||||
if variant.unit_value?
|
||||
if product.variant_unit_scale
|
||||
variant_unit_value = @divideAsInteger variant.unit_value, product.variant_unit_scale
|
||||
if variant.variant_unit_scale
|
||||
variant_unit_value = @divideAsInteger variant.unit_value, variant.variant_unit_scale
|
||||
parseFloat(window.bigDecimal.round(variant_unit_value, 2))
|
||||
else
|
||||
variant.unit_value
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
angular.module("admin.utils").directive "textangularLinksTargetBlank", () ->
|
||||
restrict: 'CA'
|
||||
link: (scope, element, attrs) ->
|
||||
setTimeout ->
|
||||
element.find(".ta-editor").scope().defaultTagAttributes.a.target = '_blank'
|
||||
, 500
|
||||
@@ -1,11 +0,0 @@
|
||||
angular.module("admin.utils").directive "textangularStrip", () ->
|
||||
restrict: 'CA'
|
||||
link: (scope, element, attrs) ->
|
||||
scope.stripFormatting = ($html) ->
|
||||
element = document.createElement("div")
|
||||
element.innerHTML = String($html)
|
||||
allTags = element.getElementsByTagName("*")
|
||||
for child in allTags
|
||||
child.removeAttribute("style")
|
||||
child.removeAttribute("class")
|
||||
return element.innerHTML
|
||||
@@ -9,6 +9,7 @@ angular.module("admin.utils")
|
||||
$window.onbeforeunload = @onBeforeUnloadHandler
|
||||
|
||||
$rootScope.$on "$locationChangeStart", @locationChangeStartHandler
|
||||
$window.onBeforeUnloadHandler = @onBeforeUnloadHandler
|
||||
|
||||
# Action for regular browser navigation.
|
||||
onBeforeUnloadHandler: ($event) =>
|
||||
|
||||
@@ -5,12 +5,7 @@ module Admin
|
||||
def create
|
||||
authorize! :admin, enterprise
|
||||
|
||||
attributes = {}
|
||||
attributes[:type] = connected_app_params[:type] if connected_app_params[:type]
|
||||
|
||||
app = ConnectedApp.create!(enterprise_id: enterprise.id, **attributes)
|
||||
app.connect(api_key: spree_current_user.spree_api_key,
|
||||
channel: SessionChannel.for_request(request))
|
||||
connect
|
||||
|
||||
render_panel
|
||||
end
|
||||
@@ -26,6 +21,47 @@ module Admin
|
||||
|
||||
private
|
||||
|
||||
def create_connected_app
|
||||
attributes = {}
|
||||
attributes[:type] = connected_app_params[:type] if connected_app_params[:type]
|
||||
|
||||
@app = ConnectedApp.create!(enterprise_id: enterprise.id, **attributes)
|
||||
end
|
||||
|
||||
def connect
|
||||
return connect_vine if connected_app_params[:type] == "ConnectedApps::Vine"
|
||||
|
||||
create_connected_app
|
||||
@app.connect(api_key: spree_current_user.spree_api_key,
|
||||
channel: SessionChannel.for_request(request))
|
||||
end
|
||||
|
||||
def connect_vine
|
||||
if vine_params_empty?
|
||||
return flash[:error] =
|
||||
I18n.t("admin.enterprises.form.connected_apps.vine.api_parameters_empty")
|
||||
end
|
||||
|
||||
create_connected_app
|
||||
|
||||
jwt_service = VineJwtService.new(secret: connected_app_params[:vine_secret])
|
||||
vine_api = VineApiService.new(api_key: connected_app_params[:vine_api_key],
|
||||
jwt_generator: jwt_service)
|
||||
|
||||
if !@app.connect(api_key: connected_app_params[:vine_api_key],
|
||||
secret: connected_app_params[:vine_secret], vine_api:)
|
||||
error_message = "#{@app.errors.full_messages.to_sentence}. \
|
||||
#{I18n.t('admin.enterprises.form.connected_apps.vine.api_parameters_error')}".squish
|
||||
handle_error(error_message)
|
||||
end
|
||||
rescue Faraday::Error => e
|
||||
log_and_notify_exception(e)
|
||||
handle_error(I18n.t("admin.enterprises.form.connected_apps.vine.connection_error"))
|
||||
rescue KeyError => e
|
||||
log_and_notify_exception(e)
|
||||
handle_error(I18n.t("admin.enterprises.form.connected_apps.vine.setup_error"))
|
||||
end
|
||||
|
||||
def enterprise
|
||||
@enterprise ||= Enterprise.find(params.require(:enterprise_id))
|
||||
end
|
||||
@@ -34,8 +70,22 @@ module Admin
|
||||
redirect_to "#{edit_admin_enterprise_path(enterprise)}#/connected_apps_panel"
|
||||
end
|
||||
|
||||
def handle_error(message)
|
||||
flash[:error] = message
|
||||
@app.destroy
|
||||
end
|
||||
|
||||
def log_and_notify_exception(exception)
|
||||
Rails.logger.error exception.inspect
|
||||
Bugsnag.notify(exception)
|
||||
end
|
||||
|
||||
def vine_params_empty?
|
||||
connected_app_params[:vine_api_key].empty? || connected_app_params[:vine_secret].empty?
|
||||
end
|
||||
|
||||
def connected_app_params
|
||||
params.permit(:type)
|
||||
params.permit(:type, :vine_api_key, :vine_secret)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -26,10 +26,23 @@ module Admin
|
||||
# * First step: import all products for given enterprise.
|
||||
# * Second step: render table and let user decide which ones to import.
|
||||
imported = graph.map do |subject|
|
||||
import_product(subject, enterprise)
|
||||
next unless subject.is_a? DataFoodConsortium::Connector::SuppliedProduct
|
||||
|
||||
existing_variant = enterprise.supplied_variants.linked_to(subject.semanticId)
|
||||
|
||||
if existing_variant
|
||||
SuppliedProductBuilder.update_product(subject, existing_variant)
|
||||
else
|
||||
SuppliedProductBuilder.store_product(subject, enterprise)
|
||||
end
|
||||
end
|
||||
|
||||
@count = imported.compact.count
|
||||
rescue Faraday::Error,
|
||||
Addressable::URI::InvalidURIError,
|
||||
ActionController::ParameterMissing => e
|
||||
flash[:error] = e.message
|
||||
redirect_to admin_product_import_path
|
||||
end
|
||||
|
||||
private
|
||||
@@ -37,18 +50,5 @@ module Admin
|
||||
def fetch_catalog(url)
|
||||
DfcRequest.new(spree_current_user).call(url)
|
||||
end
|
||||
|
||||
# Most of this code is the same as in the DfcProvider::SuppliedProductsController.
|
||||
def import_product(subject, enterprise)
|
||||
return unless subject.is_a? DataFoodConsortium::Connector::SuppliedProduct
|
||||
|
||||
variant = SuppliedProductBuilder.import_variant(subject, enterprise)
|
||||
product = variant.product
|
||||
|
||||
product.save! if product.new_record?
|
||||
variant.save! if variant.new_record?
|
||||
|
||||
variant
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -21,8 +21,7 @@ module Admin
|
||||
@importer = ProductImport::ProductImporter.new(File.new(@filepath), spree_current_user,
|
||||
params[:settings])
|
||||
@original_filename = params[:file].try(:original_filename)
|
||||
@non_updatable_fields = ProductImport::EntryValidator.non_updatable_fields
|
||||
|
||||
@non_updatable_fields = ProductImport::EntryValidator.non_updatable_variant_fields
|
||||
return if contains_errors? @importer
|
||||
|
||||
@ams_data = ams_data
|
||||
|
||||
@@ -88,8 +88,8 @@ module Admin
|
||||
@producer_options = producers
|
||||
@category_options = categories
|
||||
@tax_category_options = tax_category_options
|
||||
rescue ActiveRecord::ActiveRecordError => _e
|
||||
flash.now[:error] = t('.error')
|
||||
rescue ActiveRecord::ActiveRecordError => e
|
||||
flash.now[:error] = clone_error_message(e)
|
||||
status = :unprocessable_entity
|
||||
@product_index = "-1" # Create a unique enough index
|
||||
end
|
||||
@@ -213,6 +213,15 @@ module Admin
|
||||
params.permit(products: ::PermittedAttributes::Product.attributes)
|
||||
.to_h.with_indifferent_access
|
||||
end
|
||||
|
||||
def clone_error_message(error)
|
||||
case error
|
||||
when ActiveRecord::RecordInvalid
|
||||
error.record.errors.full_messages.to_sentence
|
||||
else
|
||||
t('.error')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
# rubocop:enable Metrics/ClassLength
|
||||
|
||||
@@ -21,7 +21,7 @@ module Api
|
||||
authorize! :create, Spree::Product
|
||||
@product = Spree::Product.new(product_params)
|
||||
|
||||
if @product.save
|
||||
if @product.save(context: :create_and_create_standard_variant)
|
||||
render json: @product, serializer: Api::Admin::ProductSerializer, status: :created
|
||||
else
|
||||
invalid_resource!(@product)
|
||||
|
||||
@@ -11,6 +11,8 @@ class CartController < BaseController
|
||||
order.cap_quantity_at_stock!
|
||||
order.recreate_all_fees!
|
||||
|
||||
StockSyncJob.sync_linked_catalogs(order)
|
||||
|
||||
render json: { error: false, stock_levels: stock_levels(order) }, status: :ok
|
||||
else
|
||||
render json: { error: cart_service.errors.full_messages.join(",") },
|
||||
|
||||
@@ -9,6 +9,12 @@ module CheckoutCallbacks
|
||||
# Otherwise we fail on duplicate indexes or end up with negative stock.
|
||||
prepend_around_action CurrentOrderLocker, only: [:edit, :update]
|
||||
|
||||
# We want to download the latest stock data before anything else happens.
|
||||
# We don't want it to be in the same database transaction as the order
|
||||
# locking because this action locks a different set of variants and it
|
||||
# could cause race conditions.
|
||||
prepend_around_action :sync_stock, only: :update
|
||||
|
||||
prepend_before_action :check_hub_ready_for_checkout
|
||||
prepend_before_action :check_order_cycle_expiry
|
||||
prepend_before_action :require_order_cycle
|
||||
@@ -25,6 +31,14 @@ module CheckoutCallbacks
|
||||
|
||||
private
|
||||
|
||||
def sync_stock
|
||||
if current_order&.state == "confirmation"
|
||||
StockSyncJob.sync_linked_catalogs_now(current_order)
|
||||
end
|
||||
|
||||
yield
|
||||
end
|
||||
|
||||
def load_order
|
||||
@order = current_order
|
||||
@order.manual_shipping_selection = true
|
||||
@@ -63,12 +77,6 @@ module CheckoutCallbacks
|
||||
end
|
||||
end
|
||||
|
||||
def valid_order_line_items?
|
||||
@order.insufficient_stock_lines.empty? &&
|
||||
OrderCycles::DistributedVariantsService.new(@order.order_cycle, @order.distributor).
|
||||
distributes_order_variants?(@order)
|
||||
end
|
||||
|
||||
def ensure_order_not_completed
|
||||
redirect_to main_app.cart_path if @order.completed?
|
||||
end
|
||||
|
||||
@@ -51,7 +51,7 @@ module OrderCompletion
|
||||
|
||||
def order_invalid!
|
||||
Bugsnag.notify("Notice: invalid order loaded during checkout") do |payload|
|
||||
payload.add_metadata :order, @order
|
||||
payload.add_metadata :order, :order, @order
|
||||
end
|
||||
|
||||
flash[:error] = t('checkout.order_not_loaded')
|
||||
|
||||
@@ -21,7 +21,7 @@ module OrderStockCheck
|
||||
return unless current_order_cycle&.closed?
|
||||
|
||||
Bugsnag.notify("Notice: order cycle closed during checkout completion") do |payload|
|
||||
payload.add_metadata :order, current_order
|
||||
payload.add_metadata :order, :order, current_order
|
||||
end
|
||||
current_order.empty!
|
||||
current_order.set_order_cycle! nil
|
||||
|
||||
@@ -7,7 +7,7 @@ class ErrorsController < ApplicationController
|
||||
Bugsnag.notify("404") do |event|
|
||||
event.severity = "info"
|
||||
|
||||
event.add_metadata(:request, request.env)
|
||||
event.add_metadata(:request, :env, request.env)
|
||||
end
|
||||
render status: :not_found, formats: :html
|
||||
end
|
||||
|
||||
@@ -39,7 +39,7 @@ module Spree
|
||||
def create
|
||||
delete_stock_params_and_set_after do
|
||||
@object.attributes = permitted_resource_params
|
||||
if @object.save
|
||||
if @object.save(context: :create_and_create_standard_variant)
|
||||
flash[:success] = flash_message_for(@object, :successfully_created)
|
||||
redirect_after_save
|
||||
else
|
||||
@@ -214,10 +214,10 @@ module Spree
|
||||
|
||||
def notify_bugsnag(error, product, variant)
|
||||
Bugsnag.notify(error) do |report|
|
||||
report.add_metadata(:product, product.attributes)
|
||||
report.add_metadata(:product_error, product.errors.first) unless product.valid?
|
||||
report.add_metadata(:variant, variant.attributes)
|
||||
report.add_metadata(:variant_error, variant.errors.first) unless variant.valid?
|
||||
report.add_metadata(:product,
|
||||
{ product: product.attributes, variant: variant.attributes })
|
||||
report.add_metadata(:product, :product_error, product.errors.first) unless product.valid?
|
||||
report.add_metadata(:product, :variant_error, variant.errors.first) unless variant.valid?
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ module Spree
|
||||
|
||||
def permitted_resource_params
|
||||
params.require(:return_authorization).
|
||||
permit(:amount, :reason, :stock_location_id)
|
||||
permit(:amount, :reason)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,6 +5,8 @@ require 'open_food_network/scope_variants_for_search'
|
||||
module Spree
|
||||
module Admin
|
||||
class VariantsController < ::Admin::ResourceController
|
||||
helper ::Admin::ProductsHelper
|
||||
|
||||
belongs_to 'spree/product'
|
||||
|
||||
before_action :load_data, only: [:new, :edit]
|
||||
|
||||
@@ -18,17 +18,15 @@ module Admin
|
||||
end
|
||||
|
||||
def unit_value_with_description(variant)
|
||||
precised_unit_value = nil
|
||||
return variant.unit_description.to_s if variant.unit_value.nil?
|
||||
|
||||
if variant.unit_value
|
||||
scaled_unit_value = variant.unit_value / (variant.product.variant_unit_scale || 1)
|
||||
precised_unit_value = number_with_precision(
|
||||
scaled_unit_value,
|
||||
precision: nil,
|
||||
strip_insignificant_zeros: true,
|
||||
significant: false,
|
||||
)
|
||||
end
|
||||
scaled_unit_value = variant.unit_value / (variant.variant_unit_scale || 1)
|
||||
precised_unit_value = number_with_precision(
|
||||
scaled_unit_value,
|
||||
precision: nil,
|
||||
strip_insignificant_zeros: true,
|
||||
significant: false,
|
||||
)
|
||||
|
||||
[precised_unit_value, variant.unit_description].compact_blank.join(" ")
|
||||
end
|
||||
|
||||
@@ -58,4 +58,9 @@ module ReportsHelper
|
||||
.where(order_id: orders.map(&:id))
|
||||
.pluck(:originator_id)
|
||||
end
|
||||
|
||||
def datepicker_time(datetime)
|
||||
datetime = Time.zone.parse(datetime) if datetime.is_a? String
|
||||
datetime.strftime('%Y-%m-%d %H:%M')
|
||||
end
|
||||
end
|
||||
|
||||
98
app/jobs/amend_backorder_job.rb
Normal file
98
app/jobs/amend_backorder_job.rb
Normal file
@@ -0,0 +1,98 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# When orders are cancelled, we need to amend
|
||||
# an existing backorder as well.
|
||||
# We're not dealing with line item changes just yet.
|
||||
class AmendBackorderJob < ApplicationJob
|
||||
sidekiq_options retry: 0
|
||||
|
||||
def perform(order)
|
||||
OrderLocker.lock_order_and_variants(order) do
|
||||
amend_backorder(order)
|
||||
end
|
||||
end
|
||||
|
||||
# The following is a mix of the BackorderJob and the CompleteBackorderJob.
|
||||
# TODO: Move the common code into a re-usable service class.
|
||||
def amend_backorder(order)
|
||||
order_cycle = order.order_cycle
|
||||
distributor = order.distributor
|
||||
user = distributor.owner
|
||||
items = backorderable_items(order)
|
||||
|
||||
return if items.empty?
|
||||
|
||||
# We are assuming that all variants are linked to the same wholesale
|
||||
# shop and its catalog:
|
||||
reference_link = items[0].variant.semantic_links[0].semantic_id
|
||||
urls = FdcUrlBuilder.new(reference_link)
|
||||
orderer = FdcBackorderer.new(user, urls)
|
||||
|
||||
backorder = orderer.find_open_order(order)
|
||||
|
||||
variants = order_cycle.variants_distributed_by(distributor)
|
||||
adjust_quantities(order_cycle, user, backorder, urls, variants)
|
||||
|
||||
FdcBackorderer.new(user, urls).send_order(backorder)
|
||||
end
|
||||
|
||||
# Check if we have enough stock to reduce the backorder.
|
||||
#
|
||||
# Our local stock can increase when users cancel their orders.
|
||||
# But stock levels could also have been adjusted manually. So we review all
|
||||
# quantities before finalising the order.
|
||||
def adjust_quantities(order_cycle, user, order, urls, variants)
|
||||
broker = FdcOfferBroker.new(user, urls)
|
||||
|
||||
order.lines.each do |line|
|
||||
line.quantity = line.quantity.to_i
|
||||
wholesale_product_id = line.offer.offeredItem.semanticId
|
||||
transformation = broker.wholesale_to_retail(wholesale_product_id)
|
||||
linked_variant = variants.linked_to(transformation.retail_product_id)
|
||||
|
||||
# Assumption: If a transformation is present then we only sell the retail
|
||||
# variant. If that can't be found, it was deleted and we'll ignore that
|
||||
# for now.
|
||||
next if linked_variant.nil?
|
||||
|
||||
# Find all line items for this order cycle
|
||||
# Update quantity accordingly
|
||||
if linked_variant.on_demand
|
||||
release_superfluous_stock(line, linked_variant, transformation)
|
||||
else
|
||||
aggregate_final_quantities(order_cycle, line, linked_variant, transformation)
|
||||
end
|
||||
end
|
||||
|
||||
# Clean up empty lines:
|
||||
order.lines.reject! { |line| line.quantity.zero? }
|
||||
end
|
||||
|
||||
# We look at all linked variants.
|
||||
def backorderable_items(order)
|
||||
order.line_items.select do |item|
|
||||
# TODO: scope variants to hub.
|
||||
# We are only supporting producer stock at the moment.
|
||||
item.variant.semantic_links.present?
|
||||
end
|
||||
end
|
||||
|
||||
def release_superfluous_stock(line, linked_variant, transformation)
|
||||
# Note that a division of integers dismisses the remainder, like `floor`:
|
||||
wholesale_items_contained_in_stock = linked_variant.on_hand / transformation.factor
|
||||
|
||||
# But maybe we didn't actually order that much:
|
||||
deductable_quantity = [line.quantity, wholesale_items_contained_in_stock].min
|
||||
line.quantity -= deductable_quantity
|
||||
|
||||
retail_stock_changes = deductable_quantity * transformation.factor
|
||||
linked_variant.on_hand -= retail_stock_changes
|
||||
end
|
||||
|
||||
def aggregate_final_quantities(order_cycle, line, variant, transformation)
|
||||
orders = order_cycle.orders.invoiceable
|
||||
quantity = Spree::LineItem.where(order: orders, variant:).sum(:quantity)
|
||||
wholesale_quantity = (quantity.to_f / transformation.factor).ceil
|
||||
line.quantity = wholesale_quantity
|
||||
end
|
||||
end
|
||||
139
app/jobs/backorder_job.rb
Normal file
139
app/jobs/backorder_job.rb
Normal file
@@ -0,0 +1,139 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class BackorderJob < ApplicationJob
|
||||
# In the current FDC project, one shop wants to review and adjust orders
|
||||
# before finalising. They also run a market stall and need to adjust stock
|
||||
# levels after the market. This should be done within four hours.
|
||||
SALE_SESSION_DELAYS = {
|
||||
# https://openfoodnetwork.org.uk/handleyfarm/shop
|
||||
"https://openfoodnetwork.org.uk/api/dfc/enterprises/203468" => 4.hours,
|
||||
}.freeze
|
||||
|
||||
queue_as :default
|
||||
sidekiq_options retry: 0
|
||||
|
||||
def self.check_stock(order)
|
||||
links = SemanticLink.where(subject: order.variants)
|
||||
|
||||
perform_later(order) if links.exists?
|
||||
rescue StandardError => e
|
||||
# Errors here shouldn't affect the checkout. So let's report them
|
||||
# separately:
|
||||
Bugsnag.notify(e) do |payload|
|
||||
payload.add_metadata(:order, :order, order)
|
||||
end
|
||||
end
|
||||
|
||||
def perform(order)
|
||||
OrderLocker.lock_order_and_variants(order) do
|
||||
place_backorder(order)
|
||||
end
|
||||
rescue StandardError
|
||||
# If the backordering fails, we need to tell the shop owner because they
|
||||
# need to organgise more stock.
|
||||
BackorderMailer.backorder_failed(order).deliver_later
|
||||
|
||||
raise
|
||||
end
|
||||
|
||||
def place_backorder(order)
|
||||
user = order.distributor.owner
|
||||
items = backorderable_items(order)
|
||||
|
||||
return if items.empty?
|
||||
|
||||
# We are assuming that all variants are linked to the same wholesale
|
||||
# shop and its catalog:
|
||||
reference_link = items[0].variant.semantic_links[0].semantic_id
|
||||
urls = FdcUrlBuilder.new(reference_link)
|
||||
orderer = FdcBackorderer.new(user, urls)
|
||||
|
||||
backorder = orderer.find_or_build_order(order)
|
||||
broker = load_broker(order.distributor.owner, urls)
|
||||
ordered_quantities = {}
|
||||
|
||||
items.each do |item|
|
||||
retail_quantity = add_item_to_backorder(item, broker, backorder, orderer)
|
||||
ordered_quantities[item] = retail_quantity
|
||||
end
|
||||
|
||||
place_order(user, order, orderer, backorder)
|
||||
|
||||
items.each do |item|
|
||||
variant = item.variant
|
||||
variant.on_hand += ordered_quantities[item] if variant.on_demand
|
||||
end
|
||||
end
|
||||
|
||||
# We look at linked variants which are either stock controlled or
|
||||
# are on demand with negative stock.
|
||||
def backorderable_items(order)
|
||||
order.line_items.select do |item|
|
||||
# TODO: scope variants to hub.
|
||||
# We are only supporting producer stock at the moment.
|
||||
variant = item.variant
|
||||
variant.semantic_links.present? &&
|
||||
(variant.on_demand == false || variant.on_hand&.negative?)
|
||||
end
|
||||
end
|
||||
|
||||
def add_item_to_backorder(line_item, broker, backorder, orderer)
|
||||
variant = line_item.variant
|
||||
needed_quantity = needed_quantity(line_item)
|
||||
solution = broker.best_offer(variant.semantic_links[0].semantic_id)
|
||||
|
||||
# The number of wholesale packs we need to order to fulfill the
|
||||
# needed quantity.
|
||||
# For example, we order 2 packs of 12 cans if we need 15 cans.
|
||||
wholesale_quantity = (needed_quantity.to_f / solution.factor).ceil
|
||||
|
||||
# The number of individual retail items we get with the wholesale order.
|
||||
# For example, if we order 2 packs of 12 cans, we will get 24 cans
|
||||
# and we'll account for that in our stock levels.
|
||||
retail_quantity = wholesale_quantity * solution.factor
|
||||
|
||||
line = orderer.find_or_build_order_line(backorder, solution.offer)
|
||||
line.quantity = line.quantity.to_i + wholesale_quantity
|
||||
|
||||
retail_quantity
|
||||
end
|
||||
|
||||
# We have two different types of stock management:
|
||||
#
|
||||
# 1. on demand
|
||||
# We don't restrict sales but account for the quantity sold in our local
|
||||
# stock level. If it goes negative, we need more stock and trigger a
|
||||
# backorder.
|
||||
# 2. limited stock
|
||||
# The local stock level is a copy from another catalog. We limit sales
|
||||
# according to that stock level. Every order reduces the local stock level
|
||||
# and needs to trigger a backorder of the same quantity to stay in sync.
|
||||
def needed_quantity(line_item)
|
||||
variant = line_item.variant
|
||||
|
||||
if variant.on_demand
|
||||
-1 * variant.on_hand # on_hand is negative and we need to replenish it.
|
||||
else
|
||||
line_item.quantity # We need to order exactly what's we sold.
|
||||
end
|
||||
end
|
||||
|
||||
def load_broker(user, urls)
|
||||
FdcOfferBroker.new(user, urls)
|
||||
end
|
||||
|
||||
def place_order(user, order, orderer, backorder)
|
||||
placed_order = orderer.send_order(backorder)
|
||||
|
||||
return unless orderer.new?(backorder)
|
||||
|
||||
delay = SALE_SESSION_DELAYS.fetch(backorder.client, 1.minute)
|
||||
wait_until = order.order_cycle.orders_close_at + delay
|
||||
CompleteBackorderJob.set(wait_until:)
|
||||
.perform_later(
|
||||
user, order.distributor, order.order_cycle, placed_order.semanticId
|
||||
)
|
||||
|
||||
order.exchange.semantic_links.create!(semantic_id: placed_order.semanticId)
|
||||
end
|
||||
end
|
||||
90
app/jobs/complete_backorder_job.rb
Normal file
90
app/jobs/complete_backorder_job.rb
Normal file
@@ -0,0 +1,90 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# After an order cycle closed, we need to finalise open draft orders placed
|
||||
# to replenish stock.
|
||||
class CompleteBackorderJob < ApplicationJob
|
||||
sidekiq_options retry: 0
|
||||
|
||||
# Required parameters:
|
||||
#
|
||||
# * user: to authenticate DFC requests
|
||||
# * distributor: to reconile with its catalog
|
||||
# * order_cycle: to scope the catalog when looking up variants
|
||||
# Multiple variants can be linked to the same remote product.
|
||||
# To reduce ambiguity, we'll reconcile only with products
|
||||
# from the given distributor in a given order cycle for which
|
||||
# the remote backorder was placed.
|
||||
# * order_id: the remote semantic id of a draft order
|
||||
# Having the id makes sure that we don't accidentally finalise
|
||||
# someone else's order.
|
||||
def perform(user, distributor, order_cycle, order_id)
|
||||
order = FdcBackorderer.new(user, nil).find_order(order_id)
|
||||
|
||||
return if order&.lines.blank?
|
||||
|
||||
urls = FdcUrlBuilder.new(order.lines[0].offer.offeredItem.semanticId)
|
||||
|
||||
variants = order_cycle.variants_distributed_by(distributor)
|
||||
adjust_quantities(order_cycle, user, order, urls, variants)
|
||||
|
||||
FdcBackorderer.new(user, urls).complete_order(order)
|
||||
|
||||
exchange = order_cycle.exchanges.outgoing.find_by(receiver: distributor)
|
||||
exchange.semantic_links.find_by(semantic_id: order_id)&.destroy!
|
||||
rescue StandardError
|
||||
BackorderMailer.backorder_incomplete(user, distributor, order_cycle, order_id).deliver_later
|
||||
|
||||
raise
|
||||
end
|
||||
|
||||
# Check if we have enough stock to reduce the backorder.
|
||||
#
|
||||
# Our local stock can increase when users cancel their orders.
|
||||
# But stock levels could also have been adjusted manually. So we review all
|
||||
# quantities before finalising the order.
|
||||
def adjust_quantities(order_cycle, user, order, urls, variants)
|
||||
broker = FdcOfferBroker.new(user, urls)
|
||||
|
||||
order.lines.each do |line|
|
||||
line.quantity = line.quantity.to_i
|
||||
wholesale_product_id = line.offer.offeredItem.semanticId
|
||||
transformation = broker.wholesale_to_retail(wholesale_product_id)
|
||||
linked_variant = variants.linked_to(transformation.retail_product_id)
|
||||
|
||||
# Assumption: If a transformation is present then we only sell the retail
|
||||
# variant. If that can't be found, it was deleted and we'll ignore that
|
||||
# for now.
|
||||
next if linked_variant.nil?
|
||||
|
||||
# Find all line items for this order cycle
|
||||
# Update quantity accordingly
|
||||
if linked_variant.on_demand
|
||||
release_superfluous_stock(line, linked_variant, transformation)
|
||||
else
|
||||
aggregate_final_quantities(order_cycle, line, linked_variant, transformation)
|
||||
end
|
||||
end
|
||||
|
||||
# Clean up empty lines:
|
||||
order.lines.reject! { |line| line.quantity.zero? }
|
||||
end
|
||||
|
||||
def release_superfluous_stock(line, linked_variant, transformation)
|
||||
# Note that a division of integers dismisses the remainder, like `floor`:
|
||||
wholesale_items_contained_in_stock = linked_variant.on_hand / transformation.factor
|
||||
|
||||
# But maybe we didn't actually order that much:
|
||||
deductable_quantity = [line.quantity, wholesale_items_contained_in_stock].min
|
||||
line.quantity -= deductable_quantity
|
||||
|
||||
retail_stock_changes = deductable_quantity * transformation.factor
|
||||
linked_variant.on_hand -= retail_stock_changes
|
||||
end
|
||||
|
||||
def aggregate_final_quantities(order_cycle, line, variant, transformation)
|
||||
orders = order_cycle.orders.invoiceable
|
||||
quantity = Spree::LineItem.where(order: orders, variant:).sum(:quantity)
|
||||
wholesale_quantity = (quantity.to_f / transformation.factor).ceil
|
||||
line.quantity = wholesale_quantity
|
||||
end
|
||||
end
|
||||
79
app/jobs/stock_sync_job.rb
Normal file
79
app/jobs/stock_sync_job.rb
Normal file
@@ -0,0 +1,79 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class StockSyncJob < ApplicationJob
|
||||
# No retry but stay as failed job:
|
||||
sidekiq_options retry: 0
|
||||
|
||||
# We synchronise stock of stock-controlled variants linked to a remote
|
||||
# product. These variants are rare though and we check first before we
|
||||
# enqueue a new job. That should save some time loading the order with
|
||||
# all the stock data to make this decision.
|
||||
def self.sync_linked_catalogs(order)
|
||||
user = order.distributor.owner
|
||||
catalog_ids(order).each do |catalog_id|
|
||||
perform_later(user, catalog_id)
|
||||
end
|
||||
rescue StandardError => e
|
||||
# Errors here shouldn't affect the shopping. So let's report them
|
||||
# separately:
|
||||
Bugsnag.notify(e) do |payload|
|
||||
payload.add_metadata(:order, :order, order)
|
||||
end
|
||||
end
|
||||
|
||||
def self.sync_linked_catalogs_now(order)
|
||||
user = order.distributor.owner
|
||||
catalog_ids(order).each do |catalog_id|
|
||||
perform_now(user, catalog_id)
|
||||
end
|
||||
rescue StandardError => e
|
||||
# Errors here shouldn't affect the shopping. So let's report them
|
||||
# separately:
|
||||
Bugsnag.notify(e) do |payload|
|
||||
payload.add_metadata(:order, :order, order)
|
||||
end
|
||||
end
|
||||
|
||||
def self.catalog_ids(order)
|
||||
stock_controlled_variants = order.variants.reject(&:on_demand)
|
||||
links = SemanticLink.where(subject: stock_controlled_variants)
|
||||
semantic_ids = links.pluck(:semantic_id)
|
||||
semantic_ids.map do |product_id|
|
||||
FdcUrlBuilder.new(product_id).catalog_url
|
||||
end.uniq
|
||||
end
|
||||
|
||||
def perform(user, catalog_id)
|
||||
products = load_products(user, catalog_id)
|
||||
products_by_id = products.index_by(&:semanticId)
|
||||
product_ids = products_by_id.keys
|
||||
variants = linked_variants(user.enterprises, product_ids)
|
||||
|
||||
# Avoid race condition between checkout and stock sync.
|
||||
Spree::Variant.transaction do
|
||||
variants.order(:id).lock.each do |variant|
|
||||
next if variant.on_demand
|
||||
|
||||
product = products_by_id[variant.semantic_links[0].semantic_id]
|
||||
catalog_item = product&.catalogItems&.first
|
||||
CatalogItemBuilder.apply_stock(catalog_item, variant)
|
||||
variant.stock_items[0].save!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def load_products(user, catalog_id)
|
||||
json_catalog = DfcRequest.new(user).call(catalog_id)
|
||||
graph = DfcIo.import(json_catalog)
|
||||
|
||||
graph.select do |subject|
|
||||
subject.is_a? DataFoodConsortium::Connector::SuppliedProduct
|
||||
end
|
||||
end
|
||||
|
||||
def linked_variants(enterprises, product_ids)
|
||||
Spree::Variant.where(supplier: enterprises)
|
||||
.includes(:semantic_links).references(:semantic_links)
|
||||
.where(semantic_links: { semantic_id: product_ids })
|
||||
end
|
||||
end
|
||||
@@ -56,7 +56,7 @@ class SubscriptionConfirmJob < ApplicationJob
|
||||
send_failed_payment_email(order)
|
||||
else
|
||||
Bugsnag.notify(e) do |payload|
|
||||
payload.add_metadata :order, order
|
||||
payload.add_metadata :order, :order, order
|
||||
end
|
||||
send_failed_payment_email(order, e.message)
|
||||
end
|
||||
@@ -109,8 +109,7 @@ class SubscriptionConfirmJob < ApplicationJob
|
||||
SubscriptionMailer.failed_payment_email(order).deliver_now
|
||||
rescue StandardError => e
|
||||
Bugsnag.notify(e) do |payload|
|
||||
payload.add_metadata :order, order
|
||||
payload.add_metadata :error_message, error_message
|
||||
payload.add_metadata :subscription_data, { order:, error_message: }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
24
app/mailers/backorder_mailer.rb
Normal file
24
app/mailers/backorder_mailer.rb
Normal file
@@ -0,0 +1,24 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class BackorderMailer < ApplicationMailer
|
||||
include I18nHelper
|
||||
|
||||
def backorder_failed(order)
|
||||
@order = order
|
||||
@linked_variants = order.variants
|
||||
|
||||
I18n.with_locale valid_locale(order.distributor.owner) do
|
||||
mail(to: order.distributor.owner.email)
|
||||
end
|
||||
end
|
||||
|
||||
def backorder_incomplete(user, distributor, order_cycle, order_id)
|
||||
@distributor = distributor
|
||||
@order_cycle = order_cycle
|
||||
@order_id = order_id
|
||||
|
||||
I18n.with_locale valid_locale(user) do
|
||||
mail(to: user.email)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -43,19 +43,9 @@ module VariantStock
|
||||
def on_demand
|
||||
# A variant that has not been saved yet or has been soft-deleted doesn't have a stock item
|
||||
# This provides a default value for variant.on_demand
|
||||
# using Spree::StockLocation.backorderable_default
|
||||
return Spree::StockLocation.first.backorderable_default if new_record? || deleted?
|
||||
return false if new_record? || deleted?
|
||||
|
||||
# This can be removed unless we have seen this error in Bugsnag recently
|
||||
if stock_item.nil?
|
||||
Bugsnag.notify(
|
||||
RuntimeError.new("Variant #stock_item called, but the stock_item does not exist!"),
|
||||
object: as_json
|
||||
)
|
||||
return Spree::StockLocation.first.backorderable_default
|
||||
end
|
||||
|
||||
stock_item.backorderable?
|
||||
stock_item&.backorderable?
|
||||
end
|
||||
|
||||
# Sets whether the variant can be ordered on demand or not. Note that
|
||||
|
||||
@@ -4,13 +4,14 @@
|
||||
#
|
||||
# Here we store keys and links to access the app.
|
||||
class ConnectedApp < ApplicationRecord
|
||||
TYPES = ['discover_regen', 'affiliate_sales_data'].freeze
|
||||
TYPES = ['discover_regen', 'affiliate_sales_data', 'vine'].freeze
|
||||
|
||||
belongs_to :enterprise
|
||||
after_destroy :disconnect
|
||||
|
||||
scope :discover_regen, -> { where(type: "ConnectedApp") }
|
||||
scope :affiliate_sales_data, -> { where(type: "ConnectedApps::AffiliateSalesData") }
|
||||
scope :vine, -> { where(type: "ConnectedApps::Vine") }
|
||||
|
||||
scope :connecting, -> { where(data: nil) }
|
||||
scope :ready, -> { where.not(data: nil) }
|
||||
|
||||
21
app/models/connected_apps/vine.rb
Normal file
21
app/models/connected_apps/vine.rb
Normal file
@@ -0,0 +1,21 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# An enterprise can opt-in to use VINE API to manage vouchers
|
||||
#
|
||||
module ConnectedApps
|
||||
class Vine < ConnectedApp
|
||||
encrypts :data
|
||||
|
||||
def connect(api_key:, secret:, vine_api:, **_opts)
|
||||
response = vine_api.my_team
|
||||
|
||||
return update data: { api_key:, secret: } if response.success?
|
||||
|
||||
errors.add(:base, I18n.t("activerecord.errors.models.connected_apps.vine.api_request_error"))
|
||||
|
||||
false
|
||||
end
|
||||
|
||||
def disconnect; end
|
||||
end
|
||||
end
|
||||
@@ -5,11 +5,6 @@ class CustomTab < ApplicationRecord
|
||||
|
||||
validates :title, presence: true, length: { maximum: 20 }
|
||||
|
||||
# Remove any unsupported HTML.
|
||||
def content
|
||||
HtmlSanitizer.sanitize(super)
|
||||
end
|
||||
|
||||
# Remove any unsupported HTML.
|
||||
def content=(html)
|
||||
super(HtmlSanitizer.sanitize(html))
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
class Customer < ApplicationRecord
|
||||
include SetUnusedAddressFields
|
||||
|
||||
self.ignored_columns += ['name']
|
||||
|
||||
acts_as_taggable
|
||||
|
||||
searchable_attributes :first_name, :last_name, :email, :code
|
||||
|
||||
@@ -247,9 +247,17 @@ class Enterprise < ApplicationRecord
|
||||
count(distinct: true)
|
||||
end
|
||||
|
||||
# Remove any unsupported HTML.
|
||||
def long_description=(html)
|
||||
super(HtmlSanitizer.sanitize(html))
|
||||
super(HtmlSanitizer.sanitize_and_enforce_link_target_blank(html))
|
||||
end
|
||||
|
||||
def preferred_shopfront_message=(html)
|
||||
self.prefers_shopfront_message = HtmlSanitizer.sanitize_and_enforce_link_target_blank(html)
|
||||
end
|
||||
|
||||
def preferred_shopfront_closed_message=(html)
|
||||
self.prefers_shopfront_closed_message =
|
||||
HtmlSanitizer.sanitize_and_enforce_link_target_blank(html)
|
||||
end
|
||||
|
||||
def contact
|
||||
|
||||
@@ -74,14 +74,9 @@ class EnterpriseGroup < ApplicationRecord
|
||||
permalink
|
||||
end
|
||||
|
||||
# Remove any unsupported HTML.
|
||||
def long_description
|
||||
HtmlSanitizer.sanitize(super)
|
||||
end
|
||||
|
||||
# Remove any unsupported HTML.
|
||||
def long_description=(html)
|
||||
super(HtmlSanitizer.sanitize(html))
|
||||
super(HtmlSanitizer.sanitize_and_enforce_link_target_blank(html))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -22,6 +22,10 @@ class Exchange < ApplicationRecord
|
||||
has_many :exchange_fees, dependent: :destroy
|
||||
has_many :enterprise_fees, through: :exchange_fees
|
||||
|
||||
# Links to open backorders of a distributor (outgoing exchanges only)
|
||||
# Don't allow removal of distributor from OC while we have an open backorder.
|
||||
has_many :semantic_links, as: :subject, dependent: :restrict_with_error
|
||||
|
||||
validates :sender_id, uniqueness: { scope: [:order_cycle_id, :receiver_id, :incoming] }
|
||||
|
||||
before_destroy :delete_related_exchange_variants, prepend: true
|
||||
|
||||
@@ -224,6 +224,9 @@ module ProductImport
|
||||
# Ensure attributes are correctly copied to a new product's variant
|
||||
variant = product.variants.first
|
||||
variant.display_name = entry.display_name if entry.display_name
|
||||
variant.variant_unit = entry.variant_unit if entry.variant_unit
|
||||
variant.variant_unit_name = entry.variant_unit_name if entry.variant_unit_name
|
||||
variant.variant_unit_scale = entry.variant_unit_scale if entry.variant_unit_scale
|
||||
variant.import_date = @import_time
|
||||
variant.supplier_id = entry.producer_id
|
||||
variant.save
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
|
||||
module ProductImport
|
||||
class EntryValidator
|
||||
SKIP_VALIDATE_ON_UPDATE = [:description].freeze
|
||||
|
||||
# rubocop:disable Metrics/ParameterLists
|
||||
def initialize(current_user, import_time, spreadsheet_data, editable_enterprises,
|
||||
inventory_permissions, reset_counts, import_settings, all_entries)
|
||||
@@ -22,9 +20,8 @@ module ProductImport
|
||||
end
|
||||
# rubocop:enable Metrics/ParameterLists
|
||||
|
||||
def self.non_updatable_fields
|
||||
def self.non_updatable_variant_fields
|
||||
{
|
||||
description: :description,
|
||||
unit_type: :variant_unit_scale,
|
||||
variant_unit_name: :variant_unit_name,
|
||||
}
|
||||
@@ -67,8 +64,7 @@ module ProductImport
|
||||
|
||||
def mark_as_new_variant(entry, product_id)
|
||||
variant_attributes = entry.assignable_attributes.except(
|
||||
'id', 'product_id', 'on_hand', 'on_demand', 'variant_unit', 'variant_unit_name',
|
||||
'variant_unit_scale'
|
||||
'id', 'product_id', 'on_hand', 'on_demand'
|
||||
)
|
||||
# Variant needs a product. Product needs to be assigned first in order for
|
||||
# delegate to work. name= will fail otherwise.
|
||||
@@ -297,11 +293,11 @@ module ProductImport
|
||||
end
|
||||
|
||||
products.flat_map(&:variants).each do |existing_variant|
|
||||
unit_scale = existing_variant.product.variant_unit_scale
|
||||
unit_scale = existing_variant.variant_unit_scale
|
||||
unscaled_units = entry.unscaled_units.to_f || 0
|
||||
entry.unit_value = unscaled_units * unit_scale unless unit_scale.nil?
|
||||
|
||||
if entry_matches_existing_variant?(entry, existing_variant)
|
||||
if entry.match_inventory_variant?(existing_variant)
|
||||
variant_override = create_inventory_item(entry, existing_variant)
|
||||
return validate_inventory_item(entry, variant_override)
|
||||
end
|
||||
@@ -311,17 +307,6 @@ module ProductImport
|
||||
error: I18n.t('admin.product_import.model.not_found'))
|
||||
end
|
||||
|
||||
def entry_matches_existing_variant?(entry, existing_variant)
|
||||
display_name_are_the_same?(entry, existing_variant) &&
|
||||
existing_variant.unit_value == entry.unit_value.to_f
|
||||
end
|
||||
|
||||
def display_name_are_the_same?(entry, existing_variant)
|
||||
return true if entry.display_name.blank? && existing_variant.display_name.blank?
|
||||
|
||||
existing_variant.display_name == entry.display_name
|
||||
end
|
||||
|
||||
def category_validation(entry)
|
||||
category_name = entry.category
|
||||
|
||||
@@ -364,13 +349,13 @@ module ProductImport
|
||||
return
|
||||
end
|
||||
|
||||
products.each { |product| product_field_errors(entry, product) }
|
||||
|
||||
products.flat_map(&:variants).each do |existing_variant|
|
||||
if entry_matches_existing_variant?(entry, existing_variant) &&
|
||||
existing_variant.deleted_at.nil?
|
||||
return mark_as_existing_variant(entry, existing_variant)
|
||||
end
|
||||
next unless entry.match_variant?(existing_variant) &&
|
||||
existing_variant.deleted_at.nil?
|
||||
|
||||
variant_field_errors(entry, existing_variant)
|
||||
|
||||
return mark_as_existing_variant(entry, existing_variant)
|
||||
end
|
||||
|
||||
mark_as_new_variant(entry, products.first.id)
|
||||
@@ -392,8 +377,7 @@ module ProductImport
|
||||
|
||||
def mark_as_existing_variant(entry, existing_variant)
|
||||
existing_variant.assign_attributes(
|
||||
entry.assignable_attributes.except('id', 'product_id', 'variant_unit', 'variant_unit_name',
|
||||
'variant_unit_scale')
|
||||
entry.assignable_attributes.except('id', 'product_id')
|
||||
)
|
||||
check_on_hand_nil(entry, existing_variant)
|
||||
|
||||
@@ -406,11 +390,10 @@ module ProductImport
|
||||
end
|
||||
end
|
||||
|
||||
def product_field_errors(entry, existing_product)
|
||||
EntryValidator.non_updatable_fields.each do |display_name, attribute|
|
||||
next if attributes_match?(attribute, existing_product, entry) ||
|
||||
attributes_blank?(attribute, existing_product, entry)
|
||||
next if ignore_when_updating_product?(attribute)
|
||||
def variant_field_errors(entry, existing_variant)
|
||||
EntryValidator.non_updatable_variant_fields.each do |display_name, attribute|
|
||||
next if attributes_match?(attribute, existing_variant, entry) ||
|
||||
attributes_blank?(attribute, existing_variant, entry)
|
||||
|
||||
mark_as_invalid(entry, attribute: display_name,
|
||||
error: I18n.t('admin.product_import.model.not_updatable'))
|
||||
@@ -423,10 +406,6 @@ module ProductImport
|
||||
existing_product_value == convert_to_trusted_type(entry_value, existing_product_value)
|
||||
end
|
||||
|
||||
def ignore_when_updating_product?(attribute)
|
||||
SKIP_VALIDATE_ON_UPDATE.include? attribute
|
||||
end
|
||||
|
||||
def convert_to_trusted_type(untrusted_attribute, trusted_attribute)
|
||||
case trusted_attribute
|
||||
when Integer
|
||||
|
||||
@@ -84,6 +84,14 @@ module ProductImport
|
||||
invalid_attrs.except(* NON_PRODUCT_ATTRIBUTES, *NON_DISPLAY_ATTRIBUTES)
|
||||
end
|
||||
|
||||
def match_variant?(variant)
|
||||
match_display_name?(variant) && variant.unit_value.to_d == unscaled_units.to_d
|
||||
end
|
||||
|
||||
def match_inventory_variant?(variant)
|
||||
match_display_name?(variant) && variant.unit_value.to_d == unit_value.to_d
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def remove_empty_skus(attrs)
|
||||
@@ -99,5 +107,11 @@ module ProductImport
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def match_display_name?(variant)
|
||||
return true if display_name.blank? && variant.display_name.blank?
|
||||
|
||||
variant.display_name == display_name
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
# Link a Spree::Variant to an external DFC SuppliedProduct.
|
||||
class SemanticLink < ApplicationRecord
|
||||
belongs_to :variant, class_name: "Spree::Variant"
|
||||
self.ignored_columns += [:variant_id]
|
||||
|
||||
belongs_to :subject, polymorphic: true
|
||||
|
||||
validates :semantic_id, presence: true
|
||||
end
|
||||
|
||||
@@ -35,7 +35,6 @@ module Spree
|
||||
can [:read, :update, :destroy], Spree::User, id: user.id
|
||||
can [:index, :read], State
|
||||
can [:index, :read], StockItem
|
||||
can [:index, :read], StockLocation
|
||||
can [:index, :read], StockMovement
|
||||
can [:index, :read], Taxon
|
||||
can [:index, :read], Variant
|
||||
@@ -245,7 +244,7 @@ module Spree
|
||||
can [:admin, :index, :show, :create], ::Admin::ReportsController
|
||||
can [:admin, :show, :create, :customers, :orders_and_distributors, :group_buys, :payments,
|
||||
:orders_and_fulfillment, :products_and_inventory, :order_cycle_management,
|
||||
:packing, :enterprise_fee_summary, :bulk_coop], :report
|
||||
:packing, :enterprise_fee_summary, :bulk_coop, :suppliers], :report
|
||||
end
|
||||
|
||||
def add_order_cycle_management_abilities(user)
|
||||
|
||||
@@ -45,7 +45,8 @@ module Spree
|
||||
after_destroy :update_order
|
||||
after_save :update_order
|
||||
|
||||
delegate :product, :variant_unit, :unit_description, :display_name, :display_as, to: :variant
|
||||
delegate :product, :variant_unit, :unit_description, :display_name, :display_as,
|
||||
:variant_unit_scale, :variant_unit_name, to: :variant
|
||||
|
||||
# Allows manual skipping of Stock::AvailabilityValidator
|
||||
attr_accessor :skip_stock_check, :target_shipment
|
||||
|
||||
@@ -67,8 +67,12 @@ module Spree
|
||||
class_name: 'Spree::Adjustment',
|
||||
dependent: :destroy
|
||||
has_many :invoices, dependent: :restrict_with_exception
|
||||
|
||||
belongs_to :order_cycle, optional: true
|
||||
has_one :exchange, ->(order) {
|
||||
outgoing.to_enterprise(order.distributor)
|
||||
}, through: :order_cycle, source: :exchanges
|
||||
has_many :semantic_links, through: :exchange
|
||||
|
||||
belongs_to :distributor, class_name: 'Enterprise', optional: true
|
||||
belongs_to :customer, optional: true
|
||||
has_one :proxy_order, dependent: :destroy
|
||||
@@ -388,6 +392,8 @@ module Spree
|
||||
|
||||
deliver_order_confirmation_email
|
||||
|
||||
BackorderJob.check_stock(self)
|
||||
|
||||
state_changes.create(
|
||||
previous_state: 'cart',
|
||||
next_state: 'complete',
|
||||
|
||||
@@ -142,6 +142,8 @@ module Spree
|
||||
|
||||
OrderMailer.cancel_email(id).deliver_later if send_cancellation_email
|
||||
update(payment_state: updater.update_payment_state)
|
||||
|
||||
AmendBackorderJob.perform_later(self)
|
||||
end
|
||||
|
||||
def after_resume
|
||||
|
||||
@@ -155,7 +155,6 @@ module Spree
|
||||
if adjustment
|
||||
adjustment.originator = payment_method
|
||||
adjustment.label = adjustment_label
|
||||
adjustment.amount = payment_method.compute_amount(self)
|
||||
adjustment.save
|
||||
elsif !processing_refund? && payment_method.present?
|
||||
payment_method.create_adjustment(adjustment_label, self, true)
|
||||
|
||||
@@ -38,6 +38,7 @@ module Spree
|
||||
|
||||
# strips all non-price-like characters from the price, taking into account locale settings
|
||||
def parse_price(price)
|
||||
return nil if price.blank?
|
||||
return price unless price.is_a?(String)
|
||||
|
||||
separator, _delimiter = I18n.t([:'number.currency.format.separator',
|
||||
|
||||
@@ -22,7 +22,12 @@ module Spree
|
||||
include LogDestroyPerformer
|
||||
|
||||
self.belongs_to_required_by_default = false
|
||||
self.ignored_columns += [:supplier_id]
|
||||
# These columns have been moved to variant. Currently this is only for documentation purposes,
|
||||
# because they are declared as attr_accessor below, declaring them as ignored columns has no
|
||||
# effect
|
||||
self.ignored_columns += [
|
||||
:supplier_id, :primary_taxon_id, :variant_unit, :variant_unit_scale, :variant_unit_name
|
||||
]
|
||||
|
||||
acts_as_paranoid
|
||||
|
||||
@@ -45,20 +50,30 @@ module Spree
|
||||
|
||||
validates_lengths_from_database
|
||||
validates :name, presence: true
|
||||
|
||||
validates :variant_unit, presence: true
|
||||
validates :unit_value, numericality: {
|
||||
greater_than: 0,
|
||||
if: ->(p) { p.variant_unit.in?(%w(weight volume)) && new_record? }
|
||||
}
|
||||
validates :variant_unit_scale,
|
||||
presence: { if: ->(p) { %w(weight volume).include? p.variant_unit } }
|
||||
validates :variant_unit_name,
|
||||
presence: { if: ->(p) { p.variant_unit == 'items' } }
|
||||
validate :validate_image
|
||||
validates :price, numericality: { greater_than_or_equal_to: 0, if: ->{ new_record? } }
|
||||
|
||||
accepts_nested_attributes_for :variants, allow_destroy: true
|
||||
# These validators are used to make sure the standard variant created via
|
||||
# `ensure_standard_variant` will be valid. The are only used when creating a new product
|
||||
with_options on: :create_and_create_standard_variant do
|
||||
validates :supplier_id, presence: true
|
||||
validates :primary_taxon_id, presence: true
|
||||
validates :variant_unit, presence: true
|
||||
validates :unit_value, presence: true, if: ->(product) {
|
||||
%w(weight volume).include?(product.variant_unit)
|
||||
}
|
||||
validates :unit_value, numericality: { greater_than: 0 }, allow_blank: true
|
||||
validates :unit_description, presence: true, if: ->(product) {
|
||||
product.variant_unit.present? && product.unit_value.nil?
|
||||
}
|
||||
validates :variant_unit_scale, presence: true, if: ->(product) {
|
||||
%w(weight volume).include?(product.variant_unit)
|
||||
}
|
||||
validates :variant_unit_name, presence: true, if: ->(product) {
|
||||
product.variant_unit == 'items'
|
||||
}
|
||||
end
|
||||
|
||||
accepts_nested_attributes_for :image
|
||||
accepts_nested_attributes_for :product_properties,
|
||||
allow_destroy: true,
|
||||
@@ -66,14 +81,12 @@ module Spree
|
||||
|
||||
# Transient attributes used temporarily when creating a new product,
|
||||
# these values are persisted on the product's variant
|
||||
attr_accessor :price, :display_as, :unit_value, :unit_description, :tax_category_id,
|
||||
:shipping_category_id, :primary_taxon_id, :supplier_id
|
||||
attr_accessor :price, :display_as, :unit_value, :unit_description, :variant_unit,
|
||||
:variant_unit_name, :variant_unit_scale, :tax_category_id, :shipping_category_id,
|
||||
:primary_taxon_id, :supplier_id
|
||||
|
||||
after_validation :validate_variant_attrs, on: :create
|
||||
after_create :ensure_standard_variant
|
||||
after_update :touch_supplier, if: :saved_change_to_primary_taxon_id?
|
||||
around_destroy :destruction
|
||||
after_save :update_units
|
||||
after_touch :touch_supplier
|
||||
|
||||
# -- Scopes
|
||||
@@ -198,10 +211,6 @@ module Spree
|
||||
end
|
||||
end
|
||||
|
||||
def total_on_hand
|
||||
stock_items.sum(&:count_on_hand)
|
||||
end
|
||||
|
||||
def properties_including_inherited
|
||||
# Product properties override producer properties
|
||||
ps = product_properties.all
|
||||
@@ -245,6 +254,7 @@ module Spree
|
||||
end
|
||||
end
|
||||
|
||||
# rubocop:disable Metrics/AbcSize
|
||||
def ensure_standard_variant
|
||||
return unless variants.empty?
|
||||
|
||||
@@ -254,36 +264,16 @@ module Spree
|
||||
variant.display_as = display_as
|
||||
variant.unit_value = unit_value
|
||||
variant.unit_description = unit_description
|
||||
variant.variant_unit = variant_unit
|
||||
variant.variant_unit_name = variant_unit_name
|
||||
variant.variant_unit_scale = variant_unit_scale
|
||||
variant.tax_category_id = tax_category_id
|
||||
variant.shipping_category_id = shipping_category_id
|
||||
variant.primary_taxon_id = primary_taxon_id
|
||||
variant.supplier_id = supplier_id
|
||||
variants << variant
|
||||
end
|
||||
|
||||
# Format as per WeightsAndMeasures (todo: re-orgnaise maybe after product/variant refactor)
|
||||
def variant_unit_with_scale
|
||||
# Our code is based upon English based number formatting with a period `.`
|
||||
scale_clean = ActiveSupport::NumberHelper.number_to_rounded(variant_unit_scale,
|
||||
precision: nil,
|
||||
significant: false,
|
||||
strip_insignificant_zeros: true,
|
||||
locale: :en)
|
||||
[variant_unit, scale_clean].compact_blank.join("_")
|
||||
end
|
||||
|
||||
def variant_unit_with_scale=(variant_unit_with_scale)
|
||||
values = variant_unit_with_scale.split("_")
|
||||
assign_attributes(
|
||||
variant_unit: values[0],
|
||||
variant_unit_scale: values[1] || nil
|
||||
)
|
||||
end
|
||||
|
||||
# Remove any unsupported HTML.
|
||||
def description
|
||||
HtmlSanitizer.sanitize(super)
|
||||
end
|
||||
# rubocop:enable Metrics/AbcSize
|
||||
|
||||
# Remove any unsupported HTML.
|
||||
def description=(html)
|
||||
@@ -292,27 +282,6 @@ module Spree
|
||||
|
||||
private
|
||||
|
||||
def validate_variant_attrs
|
||||
# Avoid running validation when we can't set variant attrs
|
||||
# eg clone product. Will raise error if clonning a product with no variant
|
||||
return if variants.first&.valid?
|
||||
|
||||
errors.add(:primary_taxon_id, :blank) unless Spree::Taxon.find_by(id: primary_taxon_id)
|
||||
errors.add(:supplier_id, :blank) unless Enterprise.find_by(id: supplier_id)
|
||||
end
|
||||
|
||||
def update_units
|
||||
return unless saved_change_to_variant_unit? || saved_change_to_variant_unit_name?
|
||||
|
||||
variants.each do |v|
|
||||
if v.persisted?
|
||||
v.update_units
|
||||
else
|
||||
v.assign_units
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def touch_supplier
|
||||
return if variants.empty?
|
||||
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
module Spree
|
||||
class ReturnAuthorization < ApplicationRecord
|
||||
self.ignored_columns += [:stock_location_id]
|
||||
acts_as_paranoid
|
||||
|
||||
belongs_to :order, class_name: 'Spree::Order', inverse_of: :return_authorizations
|
||||
|
||||
has_many :inventory_units, inverse_of: :return_authorization, dependent: :nullify
|
||||
has_one :stock_location, dependent: nil
|
||||
before_save :force_positive_amount
|
||||
before_create :generate_number
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ module Spree
|
||||
|
||||
def initialize(variant)
|
||||
@variant = variant
|
||||
@stock_items = fetch_stock_items
|
||||
@stock_items = @variant.stock_items
|
||||
end
|
||||
|
||||
def total_on_hand
|
||||
@@ -25,16 +25,6 @@ module Spree
|
||||
def can_supply?(required)
|
||||
total_on_hand >= required || backorderable?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_stock_items
|
||||
# Don't re-fetch associated stock items from the DB if we've already eager-loaded them
|
||||
return @variant.stock_items if @variant.stock_items.loaded?
|
||||
|
||||
Spree::StockItem.joins(:stock_location).
|
||||
where(:variant_id => @variant, Spree::StockLocation.table_name => { active: true })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
module Spree
|
||||
class StockLocation < ApplicationRecord
|
||||
self.belongs_to_required_by_default = false
|
||||
self.ignored_columns += [:backorderable_default, :active]
|
||||
|
||||
has_many :stock_items, dependent: :delete_all, inverse_of: :stock_location
|
||||
has_many :stock_movements, through: :stock_items
|
||||
@@ -12,15 +13,9 @@ module Spree
|
||||
|
||||
validates :name, presence: true
|
||||
|
||||
scope :active, -> { where(active: true) }
|
||||
|
||||
after_create :create_stock_items
|
||||
|
||||
# Wrapper for creating a new stock item respecting the backorderable config
|
||||
def propagate_variant(variant)
|
||||
stock_items.create!(variant:, backorderable: backorderable_default)
|
||||
end
|
||||
|
||||
def stock_item(variant)
|
||||
stock_items.where(variant_id: variant).order(:id).first
|
||||
end
|
||||
@@ -56,7 +51,7 @@ module Spree
|
||||
private
|
||||
|
||||
def create_stock_items
|
||||
Variant.find_each { |variant| propagate_variant(variant) }
|
||||
Variant.find_each { |variant| stock_items.create!(variant:) }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -105,6 +105,15 @@ module Spree
|
||||
if default_zone_or_zone_match?(item.order)
|
||||
calculator.compute(item)
|
||||
else
|
||||
# Tax refund should not be possible with the way our production server are configured
|
||||
Bugsnag.notify(
|
||||
"Notice: Tax refund should not be possible, please check the default zone and " \
|
||||
"the tax rate zone configuration"
|
||||
) do |payload|
|
||||
payload.add_metadata :order_tax_zone, item.order.tax_zone
|
||||
payload.add_metadata :tax_rate_zone, zone
|
||||
payload.add_metadata :default_zone, Zone.default_tax
|
||||
end
|
||||
# In this case, it's a refund.
|
||||
calculator.compute(item) * - 1
|
||||
end
|
||||
|
||||
@@ -60,7 +60,7 @@ module Spree
|
||||
has_many :exchanges, through: :exchange_variants
|
||||
has_many :variant_overrides, dependent: :destroy
|
||||
has_many :inventory_items, dependent: :destroy
|
||||
has_many :semantic_links, dependent: :delete_all
|
||||
has_many :semantic_links, as: :subject, dependent: :delete_all
|
||||
has_many :supplier_properties, through: :supplier, source: :properties
|
||||
|
||||
localize_number :price, :weight
|
||||
@@ -71,21 +71,25 @@ module Spree
|
||||
validates :tax_category, presence: true,
|
||||
if: proc { Spree::Config.products_require_tax_category }
|
||||
|
||||
validates :variant_unit, presence: true
|
||||
validates :unit_value, presence: true, if: ->(variant) {
|
||||
%w(weight volume).include?(variant.product&.variant_unit)
|
||||
%w(weight volume).include?(variant.variant_unit)
|
||||
}
|
||||
|
||||
validates :unit_value, numericality: { greater_than: 0 }, allow_blank: true
|
||||
validates :price, numericality: { greater_than_or_equal_to: 0 }
|
||||
|
||||
validates :unit_description, presence: true, if: ->(variant) {
|
||||
variant.product&.variant_unit.present? && variant.unit_value.nil?
|
||||
variant.variant_unit.present? && variant.unit_value.nil?
|
||||
}
|
||||
validates :variant_unit_scale, presence: true, if: ->(variant) {
|
||||
%w(weight volume).include?(variant.variant_unit)
|
||||
}
|
||||
validates :variant_unit_name, presence: true, if: ->(variant) {
|
||||
variant.variant_unit == 'items'
|
||||
}
|
||||
|
||||
before_validation :set_cost_currency
|
||||
before_validation :ensure_shipping_category
|
||||
before_validation :ensure_unit_value
|
||||
before_validation :update_weight_from_unit_value, if: ->(v) { v.product.present? }
|
||||
before_validation :update_weight_from_unit_value
|
||||
before_validation :convert_variant_weight_to_decimal
|
||||
|
||||
before_save :assign_units, if: ->(variant) {
|
||||
@@ -95,6 +99,9 @@ module Spree
|
||||
after_create :create_stock_items
|
||||
around_destroy :destruction
|
||||
after_save :save_default_price
|
||||
after_save :update_units, if: -> {
|
||||
saved_change_to_variant_unit? || saved_change_to_variant_unit_name?
|
||||
}
|
||||
|
||||
# default variant scope only lists non-deleted variants
|
||||
scope :deleted, -> { where.not(deleted_at: nil) }
|
||||
@@ -170,6 +177,11 @@ module Spree
|
||||
select("spree_variants.id") })
|
||||
end
|
||||
|
||||
def self.linked_to(semantic_id)
|
||||
includes(:semantic_links).references(:semantic_links)
|
||||
.where(semantic_links: { semantic_id: }).first
|
||||
end
|
||||
|
||||
def tax_category
|
||||
super || TaxCategory.find_by(is_default: true)
|
||||
end
|
||||
@@ -214,6 +226,25 @@ module Spree
|
||||
Spree::Stock::Quantifier.new(self).total_on_hand
|
||||
end
|
||||
|
||||
# Format as per WeightsAndMeasures
|
||||
def variant_unit_with_scale
|
||||
# Our code is based upon English based number formatting with a period `.`
|
||||
scale_clean = ActiveSupport::NumberHelper.number_to_rounded(variant_unit_scale,
|
||||
precision: nil,
|
||||
significant: false,
|
||||
strip_insignificant_zeros: true,
|
||||
locale: :en)
|
||||
[variant_unit, scale_clean].compact_blank.join("_")
|
||||
end
|
||||
|
||||
def variant_unit_with_scale=(variant_unit_with_scale)
|
||||
values = variant_unit_with_scale.split("_")
|
||||
assign_attributes(
|
||||
variant_unit: values[0],
|
||||
variant_unit_scale: values[1] || nil
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def check_currency
|
||||
@@ -235,13 +266,15 @@ module Spree
|
||||
end
|
||||
|
||||
def create_stock_items
|
||||
return unless stock_items.empty?
|
||||
|
||||
StockLocation.find_each do |stock_location|
|
||||
stock_location.propagate_variant(self)
|
||||
stock_items.create!(stock_location:)
|
||||
end
|
||||
end
|
||||
|
||||
def update_weight_from_unit_value
|
||||
return unless product.variant_unit == 'weight' && unit_value.present?
|
||||
return unless variant_unit == 'weight' && unit_value.present?
|
||||
|
||||
self.weight = weight_from_unit_value
|
||||
end
|
||||
@@ -261,7 +294,7 @@ module Spree
|
||||
|
||||
def ensure_unit_value
|
||||
Bugsnag.notify("Trying to set unit_value to NaN") if unit_value&.nan?
|
||||
return unless (product&.variant_unit == "items" && unit_value.nil?) || unit_value&.nan?
|
||||
return unless (variant_unit == "items" && unit_value.nil?) || unit_value&.nan?
|
||||
|
||||
self.unit_value = 1.0
|
||||
end
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
module Api
|
||||
module Admin
|
||||
class ProductSerializer < ActiveModel::Serializer
|
||||
attributes :id, :name, :sku, :variant_unit, :variant_unit_scale, :variant_unit_name,
|
||||
:inherits_properties, :on_hand, :price, :import_date, :image_url,
|
||||
attributes :id, :name, :sku, :inherits_properties, :on_hand, :price, :import_date, :image_url,
|
||||
:thumb_url, :variants
|
||||
|
||||
def variants
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
module Api
|
||||
module Admin
|
||||
class UnitsProductSerializer < ActiveModel::Serializer
|
||||
attributes :id, :name, :group_buy_unit_size, :variant_unit, :variant_unit_scale
|
||||
attributes :id, :name, :group_buy_unit_size
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
module Api
|
||||
module Admin
|
||||
class UnitsVariantSerializer < ActiveModel::Serializer
|
||||
attributes :id, :full_name, :unit_value
|
||||
attributes :id, :full_name, :unit_value, :variant_unit, :variant_unit_scale
|
||||
|
||||
def full_name
|
||||
full_name = object.full_name
|
||||
|
||||
@@ -6,7 +6,8 @@ module Api
|
||||
attributes :id, :name, :producer_name, :image, :sku, :import_date, :tax_category_id,
|
||||
:options_text, :unit_value, :unit_description, :unit_to_display,
|
||||
:display_as, :display_name, :name_to_display, :variant_overrides_count,
|
||||
:price, :on_demand, :on_hand, :in_stock, :stock_location_id, :stock_location_name
|
||||
:price, :on_demand, :on_hand, :in_stock, :stock_location_id, :stock_location_name,
|
||||
:variant_unit, :variant_unit_scale, :variant_unit_name, :variant_unit_with_scale
|
||||
|
||||
has_one :primary_taxon, key: :category_id, embed: :id
|
||||
has_one :supplier, key: :producer_id, embed: :id
|
||||
|
||||
@@ -95,9 +95,7 @@ module Api
|
||||
.merge(Exchange.to_enterprise(enterprise))
|
||||
.select('DISTINCT spree_properties.*')
|
||||
|
||||
return properties.merge(OrderCycle.active) if active
|
||||
|
||||
properties
|
||||
properties.merge(OrderCycle.active)
|
||||
end
|
||||
|
||||
def distributed_producer_properties
|
||||
@@ -106,16 +104,14 @@ module Api
|
||||
properties = Spree::Property
|
||||
.joins(
|
||||
producer_properties: {
|
||||
producer: { supplied_products: { variants: { exchanges: :order_cycle } } }
|
||||
producer: { supplied_variants: { exchanges: :order_cycle } }
|
||||
}
|
||||
)
|
||||
.merge(Exchange.outgoing)
|
||||
.merge(Exchange.to_enterprise(enterprise))
|
||||
.select('DISTINCT spree_properties.*')
|
||||
|
||||
return properties.merge(OrderCycle.active) if active
|
||||
|
||||
properties
|
||||
properties.merge(OrderCycle.active)
|
||||
end
|
||||
|
||||
def active
|
||||
|
||||
@@ -10,33 +10,6 @@ class CurrentOrderLocker
|
||||
# https://guides.rubyonrails.org/action_controller_overview.html#filters
|
||||
#
|
||||
def self.around(controller, &)
|
||||
lock_order_and_variants(controller.current_order, &)
|
||||
OrderLocker.lock_order_and_variants(controller.current_order, &)
|
||||
end
|
||||
|
||||
# Locking will not prevent all access to these rows. Other processes are
|
||||
# only waiting if they try to lock one of these rows as well.
|
||||
#
|
||||
# https://api.rubyonrails.org/classes/ActiveRecord/Locking/Pessimistic.html
|
||||
#
|
||||
def self.lock_order_and_variants(order)
|
||||
return yield if order.nil?
|
||||
|
||||
order.with_lock do
|
||||
lock_variants_of(order)
|
||||
yield
|
||||
end
|
||||
end
|
||||
private_class_method :lock_order_and_variants
|
||||
|
||||
# There are many places in which stock is stored in the database. Row locking
|
||||
# on variant level ensures that there are no conflicts even when an item is
|
||||
# sold through multiple shops.
|
||||
def self.lock_variants_of(order)
|
||||
variant_ids = order.line_items.select(:variant_id)
|
||||
|
||||
# Ordering the variants by id prevents deadlocks. Plucking the ids sends
|
||||
# the locking query without building Spree::Variant objects.
|
||||
Spree::Variant.where(id: variant_ids).order(:id).lock.pluck(:id)
|
||||
end
|
||||
private_class_method :lock_variants_of
|
||||
end
|
||||
|
||||
163
app/services/fdc_backorderer.rb
Normal file
163
app/services/fdc_backorderer.rb
Normal file
@@ -0,0 +1,163 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Place and update orders based on missing stock.
|
||||
class FdcBackorderer
|
||||
attr_reader :user, :urls
|
||||
|
||||
def initialize(user, urls)
|
||||
@user = user
|
||||
@urls = urls
|
||||
end
|
||||
|
||||
def find_or_build_order(ofn_order)
|
||||
find_open_order(ofn_order) || build_new_order(ofn_order)
|
||||
end
|
||||
|
||||
def build_new_order(ofn_order)
|
||||
OrderBuilder.new_order(ofn_order, urls.orders_url).tap do |order|
|
||||
order.saleSession = build_sale_session(ofn_order)
|
||||
end
|
||||
end
|
||||
|
||||
# Try the new method and fall back to old method.
|
||||
def find_open_order(ofn_order)
|
||||
lookup_open_order(ofn_order) || find_last_open_order
|
||||
end
|
||||
|
||||
def lookup_open_order(ofn_order)
|
||||
# There should be only one link at the moment but we may support
|
||||
# ordering from multiple suppliers one day.
|
||||
semantic_ids = ofn_order.semantic_links.pluck(:semantic_id)
|
||||
|
||||
semantic_ids.lazy
|
||||
# Make sure we select an order from the right supplier:
|
||||
.select { |id| id.starts_with?(urls.orders_url) }
|
||||
# Fetch the order from the remote DFC server, lazily:
|
||||
.map { |id| find_order(id) }
|
||||
.compact
|
||||
# Just in case someone completed the order without updating our database:
|
||||
.select { |o| o.orderStatus[:path] == "Held" }
|
||||
.first
|
||||
# The DFC Connector doesn't recognise status values properly yet.
|
||||
# So we are overriding the value with something that can be exported.
|
||||
&.tap { |o| o.orderStatus = "dfc-v:Held" }
|
||||
end
|
||||
|
||||
# DEPRECATED
|
||||
#
|
||||
# We now store links to orders we placed. So we don't need to search
|
||||
# through all orders and pick a random open one.
|
||||
# But for compatibility with currently open order cycles that don't have
|
||||
# a stored link yet, we keep this method as well.
|
||||
def find_last_open_order
|
||||
graph = import(urls.orders_url)
|
||||
open_orders = graph&.select do |o|
|
||||
o.semanticType == "dfc-b:Order" && o.orderStatus[:path] == "Held"
|
||||
end
|
||||
|
||||
return if open_orders.blank?
|
||||
|
||||
# If there are multiple open orders, we don't know which one to choose.
|
||||
# We want the order we placed for the same distributor in the same order
|
||||
# cycle before. So here are some assumptions for this to work:
|
||||
#
|
||||
# * We see only orders for our distributor. The endpoint URL contains the
|
||||
# the distributor name and is currently hardcoded.
|
||||
# * There's only one open order cycle at a time. Otherwise we may select
|
||||
# an order of an old order cycle.
|
||||
# * Orders are finalised when the order cycle closes. So _Held_ orders
|
||||
# always belong to an open order cycle.
|
||||
# * We see only our own orders. This assumption is wrong. The Shopify
|
||||
# integration places held orders as well and they are visible to us.
|
||||
#
|
||||
# Unfortunately, the endpoint doesn't tell who placed the order.
|
||||
# TODO: We need to remember the link to the order locally.
|
||||
# Or the API is updated to include the orderer.
|
||||
#
|
||||
# For now, we just guess:
|
||||
open_orders.last.tap do |order|
|
||||
# The DFC Connector doesn't recognise status values properly yet.
|
||||
# So we are overriding the value with something that can be exported.
|
||||
order.orderStatus = "dfc-v:Held"
|
||||
end
|
||||
end
|
||||
|
||||
def find_order(semantic_id)
|
||||
find_subject(import(semantic_id), "dfc-b:Order")
|
||||
end
|
||||
|
||||
def find_or_build_order_line(order, offer)
|
||||
find_order_line(order, offer) || build_order_line(order, offer)
|
||||
end
|
||||
|
||||
def build_order_line(order, offer)
|
||||
# Order lines are enumerated in the FDC API and we must assign a unique
|
||||
# semantic id. We need to look at current ids to avoid collisions.
|
||||
# existing_ids = order.lines.map do |line|
|
||||
# line.semanticId.match(/[0-9]+$/).to_s.to_i
|
||||
# end
|
||||
# next_id = existing_ids.max.to_i + 1
|
||||
|
||||
# Suggested by FDC team:
|
||||
next_id = order.lines.count + 1
|
||||
|
||||
OrderLineBuilder.build(offer, 0).tap do |line|
|
||||
line.semanticId = "#{order.semanticId}/OrderLines/#{next_id}"
|
||||
order.lines << line
|
||||
end
|
||||
end
|
||||
|
||||
def find_order_line(order, offer)
|
||||
order.lines.find do |line|
|
||||
line.offer.offeredItem.semanticId == offer.offeredItem.semanticId
|
||||
end
|
||||
end
|
||||
|
||||
def find_subject(object_or_graph, type)
|
||||
if object_or_graph.is_a?(Array)
|
||||
object_or_graph.find { |i| i.semanticType == type }
|
||||
else
|
||||
object_or_graph
|
||||
end
|
||||
end
|
||||
|
||||
def import(url)
|
||||
api = DfcRequest.new(user)
|
||||
json = api.call(url)
|
||||
DfcIo.import(json)
|
||||
end
|
||||
|
||||
def send_order(backorder)
|
||||
lines = backorder.lines
|
||||
offers = lines.map(&:offer)
|
||||
products = offers.map(&:offeredItem)
|
||||
sessions = [backorder.saleSession].compact
|
||||
json = DfcIo.export(backorder, *lines, *offers, *products, *sessions)
|
||||
|
||||
api = DfcRequest.new(user)
|
||||
|
||||
method = if new?(backorder)
|
||||
:post # -> create
|
||||
else
|
||||
:put # -> update
|
||||
end
|
||||
|
||||
result = api.call(backorder.semanticId, json, method:)
|
||||
find_subject(DfcIo.import(result), "dfc-b:Order")
|
||||
end
|
||||
|
||||
def complete_order(backorder)
|
||||
backorder.orderStatus = "dfc-v:Complete"
|
||||
send_order(backorder)
|
||||
end
|
||||
|
||||
def new?(order)
|
||||
order.semanticId == urls.orders_url
|
||||
end
|
||||
|
||||
def build_sale_session(order)
|
||||
SaleSessionBuilder.build(order.order_cycle).tap do |session|
|
||||
session.semanticId = urls.sale_session_url
|
||||
end
|
||||
end
|
||||
end
|
||||
89
app/services/fdc_offer_broker.rb
Normal file
89
app/services/fdc_offer_broker.rb
Normal file
@@ -0,0 +1,89 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Finds wholesale offers for retail products.
|
||||
class FdcOfferBroker
|
||||
# TODO: Find a better way to provide this data.
|
||||
Solution = Struct.new(:product, :factor, :offer)
|
||||
RetailSolution = Struct.new(:retail_product_id, :factor)
|
||||
|
||||
def self.load_catalog(user, urls)
|
||||
api = DfcRequest.new(user)
|
||||
catalog_json = api.call(urls.catalog_url)
|
||||
DfcIo.import(catalog_json)
|
||||
end
|
||||
|
||||
def initialize(user, urls)
|
||||
@user = user
|
||||
@urls = urls
|
||||
end
|
||||
|
||||
def catalog
|
||||
@catalog ||= self.class.load_catalog(@user, @urls)
|
||||
end
|
||||
|
||||
def best_offer(product_id)
|
||||
Solution.new(
|
||||
wholesale_product(product_id),
|
||||
contained_quantity(product_id),
|
||||
offer_of(wholesale_product(product_id))
|
||||
)
|
||||
end
|
||||
|
||||
def wholesale_product(product_id)
|
||||
production_flow = catalog_item("#{product_id}/AsPlannedProductionFlow")
|
||||
|
||||
if production_flow
|
||||
wholesale_product_id = production_flow.product
|
||||
catalog_item(wholesale_product_id)
|
||||
else
|
||||
# We didn't find a wholesale variant, falling back to the given product.
|
||||
catalog_item(product_id)
|
||||
end
|
||||
end
|
||||
|
||||
def contained_quantity(product_id)
|
||||
consumption_flow = catalog_item("#{product_id}/AsPlannedConsumptionFlow")
|
||||
|
||||
# If we don't find a transformation, we return the original product,
|
||||
# which contains exactly one of itself (identity).
|
||||
consumption_flow&.quantity&.value&.to_i || 1
|
||||
end
|
||||
|
||||
def wholesale_to_retail(wholesale_product_id)
|
||||
production_flow = flow_producing(wholesale_product_id)
|
||||
|
||||
return RetailSolution.new(wholesale_product_id, 1) if production_flow.nil?
|
||||
|
||||
consumption_flow = catalog_item(
|
||||
production_flow.semanticId.sub("AsPlannedProductionFlow", "AsPlannedConsumptionFlow")
|
||||
)
|
||||
retail_product_id = consumption_flow.product
|
||||
|
||||
contained_quantity = consumption_flow.quantity.value.to_i
|
||||
|
||||
RetailSolution.new(retail_product_id, contained_quantity)
|
||||
end
|
||||
|
||||
def offer_of(product)
|
||||
product&.catalogItems&.first&.offers&.first&.tap do |offer|
|
||||
# Unfortunately, the imported catalog doesn't provide the reverse link:
|
||||
offer.offeredItem = product
|
||||
end
|
||||
end
|
||||
|
||||
def catalog_item(id)
|
||||
@catalog_by_id ||= catalog.index_by(&:semanticId)
|
||||
@catalog_by_id[id]
|
||||
end
|
||||
|
||||
def flow_producing(wholesale_product_id)
|
||||
@production_flows_by_product_id ||= production_flows.index_by(&:product)
|
||||
@production_flows_by_product_id[wholesale_product_id]
|
||||
end
|
||||
|
||||
def production_flows
|
||||
@production_flows ||= catalog.select do |i|
|
||||
i.semanticType == "dfc-b:AsPlannedProductionFlow"
|
||||
end
|
||||
end
|
||||
end
|
||||
16
app/services/fdc_url_builder.rb
Normal file
16
app/services/fdc_url_builder.rb
Normal file
@@ -0,0 +1,16 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# The DFC standard doesn't include endpoint discovery yet.
|
||||
# So for now we are guessing URLs based on our FDC pilot project.
|
||||
class FdcUrlBuilder
|
||||
attr_reader :catalog_url, :orders_url, :sale_session_url
|
||||
|
||||
# At the moment, we start with a product link like this:
|
||||
#
|
||||
# https://env-0105831.jcloud-ver-jpe.ik-server.com/api/dfc/Enterprises/test-hodmedod/SuppliedProducts/44519466467635
|
||||
def initialize(semantic_id)
|
||||
@catalog_url, _slash, _id = semantic_id.rpartition("/")
|
||||
@orders_url = @catalog_url.sub("/SuppliedProducts", "/Orders")
|
||||
@sale_session_url = @catalog_url.sub("/SuppliedProducts", "/SalesSession/#")
|
||||
end
|
||||
end
|
||||
@@ -18,4 +18,16 @@ class HtmlSanitizer
|
||||
html, tags: ALLOWED_TAGS, attributes: (ALLOWED_ATTRIBUTES + ALLOWED_TRIX_DATA_ATTRIBUTES)
|
||||
)
|
||||
end
|
||||
|
||||
def self.sanitize_and_enforce_link_target_blank(html)
|
||||
sanitize(enforce_link_target_blank(html))
|
||||
end
|
||||
|
||||
def self.enforce_link_target_blank(html)
|
||||
return if html.nil?
|
||||
|
||||
Nokogiri::HTML::DocumentFragment.parse(html).tap do |document|
|
||||
document.css("a").each { |link| link["target"] = "_blank" }
|
||||
end.to_s
|
||||
end
|
||||
end
|
||||
|
||||
33
app/services/order_locker.rb
Normal file
33
app/services/order_locker.rb
Normal file
@@ -0,0 +1,33 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Locks an order including its variants.
|
||||
#
|
||||
# It should be used when making major changes like checking out the order.
|
||||
# It can keep stock checking in sync and prevent overselling of an item.
|
||||
class OrderLocker
|
||||
# Locking will not prevent all access to these rows. Other processes are
|
||||
# only waiting if they try to lock one of these rows as well.
|
||||
#
|
||||
# https://api.rubyonrails.org/classes/ActiveRecord/Locking/Pessimistic.html
|
||||
#
|
||||
def self.lock_order_and_variants(order)
|
||||
return yield if order.nil?
|
||||
|
||||
order.with_lock do
|
||||
lock_variants_of(order)
|
||||
yield
|
||||
end
|
||||
end
|
||||
|
||||
# There are many places in which stock is stored in the database. Row locking
|
||||
# on variant level ensures that there are no conflicts even when an item is
|
||||
# sold through multiple shops.
|
||||
def self.lock_variants_of(order)
|
||||
variant_ids = order.line_items.select(:variant_id)
|
||||
|
||||
# Ordering the variants by id prevents deadlocks. Plucking the ids sends
|
||||
# the locking query without building Spree::Variant objects.
|
||||
Spree::Variant.where(id: variant_ids).order(:id).lock.pluck(:id)
|
||||
end
|
||||
private_class_method :lock_variants_of
|
||||
end
|
||||
@@ -4,11 +4,10 @@ module PermittedAttributes
|
||||
class Variant
|
||||
def self.attributes
|
||||
[
|
||||
:id, :sku, :on_hand, :on_demand, :shipping_category_id,
|
||||
:price, :unit_value, :unit_description,
|
||||
:display_name, :display_as, :tax_category_id,
|
||||
:weight, :height, :width, :depth, :taxon_ids, :primary_taxon_id,
|
||||
:supplier_id
|
||||
:id, :sku, :on_hand, :on_demand, :shipping_category_id, :price, :unit_value,
|
||||
:unit_description, :variant_unit, :variant_unit_name, :variant_unit_scale, :display_name,
|
||||
:display_as, :tax_category_id, :weight, :height, :width, :depth, :taxon_ids,
|
||||
:primary_taxon_id, :supplier_id
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -25,7 +25,7 @@ class PlaceProxyOrder
|
||||
rescue StandardError => e
|
||||
summarizer.record_and_log_error(:processing, order, e.message)
|
||||
Bugsnag.notify(e) do |payload|
|
||||
payload.add_metadata :order, order
|
||||
payload.add_metadata :order, :order, order
|
||||
end
|
||||
end
|
||||
|
||||
@@ -57,8 +57,7 @@ class PlaceProxyOrder
|
||||
true
|
||||
rescue StandardError => e
|
||||
Bugsnag.notify(e) do |payload|
|
||||
payload.add_metadata :subscription, subscription
|
||||
payload.add_metadata :proxy_order, proxy_order
|
||||
payload.add_metadata(:proxy_order, { subscription:, proxy_order: })
|
||||
end
|
||||
false
|
||||
end
|
||||
|
||||
@@ -146,11 +146,11 @@ module Sets
|
||||
|
||||
def notify_bugsnag(error, product, variant, variant_attributes)
|
||||
Bugsnag.notify(error) do |report|
|
||||
report.add_metadata(:product, product.attributes)
|
||||
report.add_metadata(:product_error, product.errors.first) unless product.valid?
|
||||
report.add_metadata(:variant_attributes, variant_attributes)
|
||||
report.add_metadata(:variant, variant.attributes)
|
||||
report.add_metadata(:variant_error, variant.errors.first) unless variant.valid?
|
||||
report.add_metadata( :product_set,
|
||||
{ product: product.attributes, variant_attributes:,
|
||||
variant: variant.attributes } )
|
||||
report.add_metadata(:product_set, :product_error, product.errors.first) if !product.valid?
|
||||
report.add_metadata(:product_set, :variant_error, variant.errors.first) if !variant.valid?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,12 +3,11 @@
|
||||
class UnitPrice
|
||||
def initialize(variant)
|
||||
@variant = variant
|
||||
@product = variant.product
|
||||
end
|
||||
|
||||
def denominator
|
||||
# catches any case where unit is not kg, lb, or L.
|
||||
return @variant.unit_value if @product&.variant_unit == "items"
|
||||
return @variant.unit_value if @variant.variant_unit == "items"
|
||||
|
||||
case unit
|
||||
when "lb"
|
||||
@@ -23,13 +22,13 @@ class UnitPrice
|
||||
def unit
|
||||
return "lb" if WeightsAndMeasures.new(@variant).system == "imperial"
|
||||
|
||||
case @product&.variant_unit
|
||||
case @variant.variant_unit
|
||||
when "weight"
|
||||
"kg"
|
||||
when "volume"
|
||||
"L"
|
||||
else
|
||||
@product.variant_unit_name.presence || I18n.t("item")
|
||||
@variant.variant_unit_name.presence || I18n.t("item")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -32,16 +32,18 @@ module VariantUnits
|
||||
private
|
||||
|
||||
def value_scaled?
|
||||
@nameable.product.variant_unit_scale.present?
|
||||
@nameable.variant_unit_scale.present?
|
||||
end
|
||||
|
||||
def option_value_value_unit
|
||||
if @nameable.unit_value.present? && @nameable.product&.persisted?
|
||||
if %w(weight volume).include? @nameable.product.variant_unit
|
||||
if @nameable.unit_value.present?
|
||||
if %w(weight volume).include? @nameable.variant_unit
|
||||
value, unit_name = option_value_value_unit_scaled
|
||||
else
|
||||
value = @nameable.unit_value
|
||||
unit_name = pluralize(@nameable.product.variant_unit_name, value)
|
||||
|
||||
unit_name = @nameable.variant_unit_name
|
||||
unit_name = pluralize(unit_name, value) if unit_name.present?
|
||||
end
|
||||
|
||||
value = value.to_i if value == value.to_i
|
||||
|
||||
@@ -64,12 +64,12 @@ module VariantUnits
|
||||
|
||||
def unit_value_attributes
|
||||
units = { unit_presentation: option_value_name }
|
||||
units.merge!(variant_unit: product.variant_unit) if has_attribute?(:variant_unit)
|
||||
units.merge!(variant_unit:) if has_attribute?(:variant_unit)
|
||||
units
|
||||
end
|
||||
|
||||
def weight_from_unit_value
|
||||
(unit_value || 0) / 1000 if product.variant_unit == 'weight'
|
||||
(unit_value || 0) / 1000 if variant_unit == 'weight'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
39
app/services/vine_api_service.rb
Normal file
39
app/services/vine_api_service.rb
Normal file
@@ -0,0 +1,39 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "faraday"
|
||||
|
||||
class VineApiService
|
||||
attr_reader :api_key, :jwt_generator
|
||||
|
||||
def initialize(api_key:, jwt_generator:)
|
||||
@vine_api_url = ENV.fetch("VINE_API_URL")
|
||||
@api_key = api_key
|
||||
@jwt_generator = jwt_generator
|
||||
end
|
||||
|
||||
def my_team
|
||||
my_team_url = "#{@vine_api_url}/my-team"
|
||||
|
||||
jwt = jwt_generator.generate_token
|
||||
connection = Faraday.new(
|
||||
request: { timeout: 30 },
|
||||
headers: {
|
||||
'X-Authorization': "JWT #{jwt}",
|
||||
Accept: "application/json"
|
||||
}
|
||||
) do |f|
|
||||
f.request :json
|
||||
f.response :json
|
||||
f.request :authorization, 'Bearer', api_key
|
||||
end
|
||||
|
||||
response = connection.get(my_team_url)
|
||||
|
||||
if !response.success?
|
||||
Rails.logger.error "VineApiService#my_team -- response_status: #{response.status}"
|
||||
Rails.logger.error "VineApiService#my_team -- response: #{response.body}"
|
||||
end
|
||||
|
||||
response
|
||||
end
|
||||
end
|
||||
21
app/services/vine_jwt_service.rb
Normal file
21
app/services/vine_jwt_service.rb
Normal file
@@ -0,0 +1,21 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class VineJwtService
|
||||
ALGORITHM = "HS256"
|
||||
ISSUER = "openfoodnetwork"
|
||||
|
||||
def initialize(secret: )
|
||||
@secret = secret
|
||||
end
|
||||
|
||||
def generate_token
|
||||
generation_time = Time.zone.now
|
||||
payload = {
|
||||
iss: ISSUER,
|
||||
iat: generation_time.to_i,
|
||||
exp: (generation_time + 1.minute).to_i,
|
||||
}
|
||||
|
||||
JWT.encode(payload, @secret, ALGORITHM)
|
||||
end
|
||||
end
|
||||
@@ -16,10 +16,10 @@ class WeightsAndMeasures
|
||||
def system
|
||||
return "custom" unless scales = scales_for_variant_unit(ignore_available_units: true)
|
||||
|
||||
product_scale = @variant.product.variant_unit_scale&.to_f
|
||||
return "custom" unless product_scale.present? && product_scale.positive?
|
||||
variant_scale = @variant.variant_unit_scale&.to_f
|
||||
return "custom" unless variant_scale.present? && variant_scale.positive?
|
||||
|
||||
scales[product_scale]['system']
|
||||
scales[variant_scale]['system']
|
||||
end
|
||||
|
||||
# @returns enumerable with label and value for select
|
||||
@@ -92,9 +92,9 @@ class WeightsAndMeasures
|
||||
}.freeze
|
||||
|
||||
def scales_for_variant_unit(ignore_available_units: false)
|
||||
return @units[@variant.product.variant_unit] if ignore_available_units
|
||||
return @units[@variant.variant_unit] if ignore_available_units
|
||||
|
||||
@units[@variant.product.variant_unit]&.reject { |_scale, unit_info|
|
||||
@units[@variant.variant_unit]&.reject { |_scale, unit_info|
|
||||
self.class.available_units.exclude?(unit_info['name'])
|
||||
}
|
||||
end
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
%fieldset.alpha.no-border-bottom#about_panel{ data: { "tabs-and-panels-target": "panel" } }
|
||||
%legend= t('.about')
|
||||
= f.field_container :long_description do
|
||||
%text-angular{'id' => 'enterprise_group_long_description', 'name' => 'enterprise_group[long_description]', 'class' => 'text-angular', "textangular-links-target-blank" => true,
|
||||
'ta-toolbar' => "[['h1','h2','h3','h4','p'],['bold','italics','underline','clear'],['insertLink']]"}
|
||||
!= @enterprise_group[:long_description]
|
||||
= f.hidden_field :long_description, id: "enterprise_group_long_description"
|
||||
%trix-editor{ input: "enterprise_group_long_description", "data-controller": "trixeditor" }
|
||||
|
||||
@@ -4,14 +4,7 @@
|
||||
.omega.eight.columns
|
||||
= f.text_field :description, maxlength: 255, placeholder: t('.desc_short_placeholder')
|
||||
.row
|
||||
.alpha.three.columns
|
||||
.alpha.eleven.columns
|
||||
= f.label :long_description, t('.desc_long')
|
||||
.omega.eight.columns
|
||||
-# textAngular toolbar options, add to the ta-toolbar array below and separate into groups with extra ],[ if needed:
|
||||
-# ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'pre', 'quote'],
|
||||
-# ['bold', 'italics', 'underline', 'strikeThrough', 'ul', 'ol', 'redo', 'undo', 'clear'],
|
||||
-# ['justifyLeft','justifyCenter','justifyRight','indent','outdent'],
|
||||
-# ['html', 'insertImage', 'insertLink', 'insertVideo']
|
||||
%text-angular{'ng-model' => 'Enterprise.long_description', 'id' => 'enterprise_long_description', 'name' => 'enterprise[long_description]', 'class' => 'text-angular', "textangular-links-target-blank" => true,
|
||||
'ta-toolbar' => "[['h1','h2','h3','h4','p'],['bold','italics','underline','clear'],['insertLink']]",
|
||||
'placeholder' => t('.desc_long_placeholder')}
|
||||
= f.hidden_field :long_description, id: "enterprise_long_description"
|
||||
%trix-editor{ input: "enterprise_long_description", "data-controller": "trixeditor" }
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
.row
|
||||
.three.columns.alpha
|
||||
.eleven.columns.alpha
|
||||
= f.label "enterprise_preferred_shopfront_message", t('.shopfront_message')
|
||||
.eight.columns.omega
|
||||
%text-angular{'ng-model' => 'Enterprise.preferred_shopfront_message', 'id' => 'enterprise_preferred_shopfront_message', 'name' => 'enterprise[preferred_shopfront_message]', 'class' => 'text-angular textangular-strip', 'ta-paste' => "stripFormatting($html)", "textangular-links-target-blank" => true,
|
||||
'ta-toolbar' => "[['h1','h2','h3','h4','p'],['bold','italics','underline','clear'],['insertLink']]",
|
||||
'placeholder' => t('.shopfront_message_placeholder')}
|
||||
= f.hidden_field :preferred_shopfront_message, id: "enterprise_preferred_shopfront_message"
|
||||
%trix-editor{ input: "enterprise_preferred_shopfront_message", "data-controller": "trixeditor", placeholder: t('.shopfront_message_placeholder') }
|
||||
.row
|
||||
.three.columns.alpha
|
||||
.eleven.columns.alpha
|
||||
= f.label "enterprise_preferred_shopfront_closed_message", t('.shopfront_closed_message')
|
||||
.eight.columns.omega
|
||||
%text-angular{'ng-model' => 'Enterprise.preferred_shopfront_closed_message', 'id' => 'enterprise_preferred_shopfront_closed_message', 'name' => 'enterprise[preferred_shopfront_closed_message]', 'class' => 'text-angular textangular-strip', 'ta-paste' => "stripFormatting($html)", "textangular-links-target-blank" => true,
|
||||
'ta-toolbar' => "[['h1','h2','h3','h4','p'],['bold','italics','underline','clear'],['insertLink']]",
|
||||
'placeholder' => t('.shopfront_closed_message_placeholder')}
|
||||
= f.hidden_field :preferred_shopfront_closed_message, id: "enterprise_preferred_shopfront_closed_message"
|
||||
%trix-editor{ input: "enterprise_preferred_shopfront_closed_message", "data-controller": "trixeditor", placeholder: t('.shopfront_closed_message_placeholder') }
|
||||
|
||||
.row
|
||||
.text-normal
|
||||
@@ -77,7 +73,7 @@
|
||||
= f.radio_button :preferred_product_low_stock_display, true, 'ng-model' => 'Enterprise.preferred_product_low_stock_display', 'ng-value' => 'true'
|
||||
= f.label :preferred_product_low_stock_display, t('.enabled'), value: :true
|
||||
.five.columns.omega
|
||||
= f.radio_button :preferred_product_low_stock_display, false, 'ng-model' => 'Enterprise.preferred_product_low_stock_display', 'ng-value' => 'false'
|
||||
= f.radio_button :preferred_product_low_stock_display, false, 'ng-model' => 'Enterprise.preferred_product_low_stock_display', 'ng-value' => 'false'
|
||||
= f.label :preferred_product_low_stock_display, t('.disabled'), value: :false
|
||||
|
||||
.row
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
%section.connected_app
|
||||
.connected-app__head
|
||||
%div
|
||||
%h3= t ".title"
|
||||
%p= t ".tagline"
|
||||
.connected-app__vine
|
||||
- if connected_app.nil?
|
||||
= form_with url: admin_enterprise_connected_apps_path(enterprise.id) do |f|
|
||||
.connected-app__vine-content
|
||||
.vine-api-key
|
||||
= f.hidden_field :type, value: "ConnectedApps::Vine"
|
||||
= f.label :vine_api_key, t(".vine_api_key")
|
||||
%span.required *
|
||||
= f.text_field :vine_api_key, { disabled: !managed_by_user?(enterprise) }
|
||||
= f.label :vine_secret, t(".vine_secret")
|
||||
%span.required *
|
||||
= f.text_field :vine_secret, { disabled: !managed_by_user?(enterprise) }
|
||||
%div
|
||||
- disabled = managed_by_user?(enterprise) ? {} : { disabled: true, "data-disable-with": false }
|
||||
= f.submit t(".enable"), disabled
|
||||
|
||||
-# This is only seen by super-admins:
|
||||
%em= t(".need_to_be_manager") unless managed_by_user?(enterprise)
|
||||
- else
|
||||
.connected-app__vine-content
|
||||
.vine-disable
|
||||
= button_to t(".disable"), admin_enterprise_connected_app_path(connected_app.id, enterprise_id: enterprise.id), method: :delete
|
||||
%hr
|
||||
.connected-app__description
|
||||
= t ".description_html"
|
||||
@@ -11,9 +11,7 @@
|
||||
= t('.proceed')
|
||||
%button.button.secondary{ "ng-click": "submit($event, '#{main_app.admin_order_cycles_path}')", type: "button", style: "display: none;", data: { action: 'click->modal#close', 'trigger-action': 'saveAndBack' } }
|
||||
= t('.proceed')
|
||||
%button.button.primary{ type: "button", 'data-action': 'click->modal#close' }
|
||||
= t('.cancel')
|
||||
|
||||
= link_to t('.cancel'), admin_order_cycles_path, id: 'cancel', class: 'button primary', data: { 'order-cycle-form-target': 'cancel' }
|
||||
- if action == 'bulk_update'
|
||||
%button.button.secondary{ "ng-click": "saveAll($event)", type: "button", style: "display: none;", data: { action: 'click->modal#close', trigger_action: 'bulk_save' } }
|
||||
= t('.proceed')
|
||||
|
||||
@@ -7,18 +7,8 @@
|
||||
%td.col-sku.field.naked_inputs
|
||||
= f.text_field :sku, 'aria-label': t('admin.products_page.columns.sku')
|
||||
= error_message_on product, :sku
|
||||
%td.col-unit_scale.field.naked_inputs{ 'data-controller': 'toggle-control', 'data-toggle-control-match-value': 'items' }
|
||||
= f.hidden_field :variant_unit
|
||||
= f.hidden_field :variant_unit_scale
|
||||
= f.select :variant_unit_with_scale,
|
||||
options_for_select(WeightsAndMeasures.variant_unit_options, product.variant_unit_with_scale),
|
||||
{},
|
||||
class: "fullwidth no-input",
|
||||
'aria-label': t('admin.products_page.columns.unit_scale'),
|
||||
data: { "controller": "tom-select", "tom-select-options-value": '{ "plugins": [] }', action: "change->toggle-control#displayIfMatch"}
|
||||
.field
|
||||
= f.text_field :variant_unit_name, 'aria-label': t('items'), 'data-toggle-control-target': 'control', style: (product.variant_unit == "items" ? "" : "display: none")
|
||||
= error_message_on product, :variant_unit_name, 'data-toggle-control-target': 'control'
|
||||
%td.col-unit_scale.align-right
|
||||
-# empty
|
||||
%td.col-unit.align-right
|
||||
-# empty
|
||||
%td.col-price.align-right
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
.form-buttons
|
||||
%a.button.reset.medium{ href: admin_products_path(page: @page, per_page: @per_page, search_term: @search_term, producer_id: @producer_id, category_id: @category_id), 'data-turbo': "false" }
|
||||
= t('.reset')
|
||||
= form.submit t('.save'), class: "medium"
|
||||
= form.submit t('.save'), { class: "medium", data: { action: "click->bulk-form#popoutEmptyVariantUnit" }}
|
||||
%tr
|
||||
%th.col-image.align-left= # image
|
||||
= render partial: 'spree/admin/shared/stimulus_sortable_header',
|
||||
|
||||
@@ -7,8 +7,17 @@
|
||||
%td.col-sku.field.naked_inputs
|
||||
= f.text_field :sku, 'aria-label': t('admin.products_page.columns.sku')
|
||||
= error_message_on variant, :sku
|
||||
%td.col-unit_scale
|
||||
-# empty
|
||||
%td.col-unir_scale.field.naked_inputs{ 'data-controller': 'toggle-control', 'data-toggle-control-match-value': 'items' }
|
||||
= f.hidden_field :variant_unit
|
||||
= f.hidden_field :variant_unit_scale
|
||||
= f.select :variant_unit_with_scale,
|
||||
options_for_select(WeightsAndMeasures.variant_unit_options, variant.variant_unit_with_scale),
|
||||
{ include_blank: true },
|
||||
{ class: "fullwidth no-input", 'aria-label': t('admin.products_page.columns.unit_scale'), data: { "controller": "tom-select", "tom-select-options-value": '{ "plugins": [] }', action: "change->toggle-control#displayIfMatch" }, required: true }
|
||||
= error_message_on variant, :variant_unit, 'data-toggle-control-target': 'control'
|
||||
.field
|
||||
= f.text_field :variant_unit_name, 'aria-label': t('items'), 'data-toggle-control-target': 'control', style: (variant.variant_unit == "items" ? "" : "display: none")
|
||||
= error_message_on variant, :variant_unit_name, 'data-toggle-control-target': 'control'
|
||||
%td.col-unit.field.popout{'data-controller': "popout", 'data-popout-update-display-value': "false"}
|
||||
= f.button :unit_to_display, class: "popout__button", 'aria-label': t('admin.products_page.columns.unit'), 'data-popout-target': "button" do
|
||||
= variant.unit_to_display # Show the generated summary of unit values
|
||||
@@ -18,7 +27,7 @@
|
||||
= f.hidden_field :unit_value
|
||||
= f.hidden_field :unit_description
|
||||
= f.text_field :unit_value_with_description,
|
||||
value: unit_value_with_description(variant), 'aria-label': t('admin.products_page.columns.unit_value')
|
||||
value: unit_value_with_description(variant), 'aria-label': t('admin.products_page.columns.unit_value'), required: true
|
||||
.field
|
||||
= f.label :display_as, t('admin.products_page.columns.display_as')
|
||||
= f.text_field :display_as, placeholder: VariantUnits::OptionValueNamer.new(variant).name
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
= render partial: 'spree/admin/shared/product_sub_menu'
|
||||
|
||||
#products_v3_page{ "data-controller": "products", 'data-turbo': true }
|
||||
#products_v3_page{ 'data-turbo': true }
|
||||
= render partial: "content", locals: { products: @products, pagy: @pagy, search_term: @search_term,
|
||||
producer_options: producers, producer_id: @producer_id,
|
||||
category_options: categories, category_id: @category_id,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user