diff --git a/db/changes/10270-wisemen/00-ACL.sql b/db/changes/10270-wisemen/00-ACL.sql new file mode 100644 index 0000000000..40e47b1a35 --- /dev/null +++ b/db/changes/10270-wisemen/00-ACL.sql @@ -0,0 +1 @@ +INSERT INTO `salix`.`ACL` (`model`, `property`, `accessType`, `permission`, `principalType`, `principalId`) VALUES ('FixedPrice', '*', '*', 'ALLOW', 'ROLE', 'buyer'); \ No newline at end of file diff --git a/modules/item/back/methods/fixed-price/filter.js b/modules/item/back/methods/fixed-price/filter.js new file mode 100644 index 0000000000..22cf2bf446 --- /dev/null +++ b/modules/item/back/methods/fixed-price/filter.js @@ -0,0 +1,192 @@ + +const ParameterizedSQL = require('loopback-connector').ParameterizedSQL; +const buildFilter = require('vn-loopback/util/filter').buildFilter; +const mergeFilters = require('vn-loopback/util/filter').mergeFilters; + +module.exports = Self => { + Self.remoteMethodCtx('filter', { + description: 'Find all instances of the model matched by filter from the data source.', + accessType: 'READ', + accepts: [ + { + arg: 'filter', + type: 'object', + description: 'Filter defining where, order, offset, and limit - must be a JSON-encoded string', + }, + { + arg: 'search', + type: 'string', + description: `If it's and integer searchs by itemFk, otherwise it searchs by the itemType code`, + }, + { + arg: 'itemFk', + type: 'integer', + description: 'The item id', + }, + { + arg: 'typeFk', + type: 'integer', + description: 'The item type id', + }, + { + arg: 'categoryFk', + type: 'integer', + description: 'The item category id', + }, + { + arg: 'warehouseFk', + type: 'integer', + description: 'The warehouse id', + }, + { + arg: 'buyerFk', + type: 'integer', + description: 'The buyer id', + }, + { + arg: 'rate2', + type: 'integer', + description: 'The price per unit', + }, + { + arg: 'rate3', + type: 'integer', + description: 'The price per package', + }, + { + arg: 'minPrice', + type: 'integer', + description: 'The minimum price of the item', + }, + { + arg: 'hasMinPrice', + type: 'boolean', + description: 'whether a minimum price has been defined for the item', + }, + { + arg: 'started', + type: 'date', + description: 'Price validity start date', + }, + { + arg: 'ended', + type: 'date', + description: 'Price validity end date', + }, + { + arg: 'tags', + type: ['object'], + description: 'List of tags to filter with', + }, + { + arg: 'mine', + type: 'Boolean', + description: `Search requests attended by the current user` + } + ], + returns: { + type: ['Object'], + root: true + }, + http: { + path: `/filter`, + verb: 'GET' + } + }); + + Self.filter = async(ctx, filter) => { + const conn = Self.dataSource.connector; + let userId = ctx.req.accessToken.userId; + + if (ctx.args.mine) + ctx.args.buyerFk = userId; + + const where = buildFilter(ctx.args, (param, value) => { + switch (param) { + case 'search': + return /^\d+$/.test(value) + ? {'fp.itemFk': {inq: value}} + : {'it.code': {like: `%${value}%`}}; + case 'categoryFk': + return {'it.categoryFk': value}; + case 'buyerFk': + return {'it.workerFk': value}; + case 'warehouseFk': + case 'rate2': + case 'rate3': + case 'started': + case 'ended': + param = `fp.${param}`; + return {[param]: value}; + case 'minPrice': + case 'hasMinPrice': + case 'typeFk': + param = `i.${param}`; + return {[param]: value}; + } + }); + filter = mergeFilters(filter, {where}); + + const stmts = []; + let stmt; + + stmt = new ParameterizedSQL( + `SELECT fp.id, + fp.itemFk, + fp.warehouseFk, + fp.rate2, + fp.rate3, + fp.started, + fp.ended, + i.minPrice, + i.hasMinPrice, + i.name, + i.subName, + i.tag5, + i.value5, + i.tag6, + i.value6, + i.tag7, + i.value7, + i.tag8, + i.value8, + i.tag9, + i.value9, + i.tag10, + i.value10 + FROM priceFixed fp + JOIN item i ON i.id = fp.itemFk + JOIN itemType it ON it.id = i.typeFk` + ); + + if (ctx.args.tags) { + let i = 1; + for (const tag of ctx.args.tags) { + const tAlias = `it${i++}`; + + if (tag.tagFk) { + stmt.merge({ + sql: `JOIN vn.itemTag ${tAlias} ON ${tAlias}.itemFk = i.id + AND ${tAlias}.tagFk = ? + AND ${tAlias}.value LIKE ?`, + params: [tag.tagFk, `%${tag.value}%`], + }); + } else { + stmt.merge({ + sql: `JOIN vn.itemTag ${tAlias} ON ${tAlias}.itemFk = i.id + AND ${tAlias}.value LIKE ?`, + params: [`%${tag.value}%`], + }); + } + } + } + + stmt.merge(conn.makeWhere(filter.where)); + stmt.merge(conn.makePagination(filter)); + + const fixedPriceIndex = stmts.push(stmt) - 1; + const sql = ParameterizedSQL.join(stmts, ';'); + const result = await conn.executeStmt(sql); + return fixedPriceIndex === 0 ? result : result[fixedPriceIndex]; + }; +}; diff --git a/modules/item/back/methods/fixed-price/specs/upsertFixedPrice.spec.js b/modules/item/back/methods/fixed-price/specs/upsertFixedPrice.spec.js new file mode 100644 index 0000000000..67910d4175 --- /dev/null +++ b/modules/item/back/methods/fixed-price/specs/upsertFixedPrice.spec.js @@ -0,0 +1,62 @@ +const app = require('vn-loopback/server/server'); + +describe('upsertFixedPrice()', () => { + const now = new Date(); + const fixedPriceId = 1; + let originalFixedPrice; + let originalItem; + + beforeAll(async() => { + originalFixedPrice = await app.models.FixedPrice.findById(fixedPriceId); + originalItem = await app.models.Item.findById(originalFixedPrice.itemFk); + }); + + beforeAll(async() => { + await originalFixedPrice.save(); + await originalItem.save(); + }); + + it(`should toggle the hasMinPrice boolean if there's a minPrice and update the rest of the data`, async() => { + const ctx = {args: { + id: fixedPriceId, + itemFk: 1, + warehouseFk: 1, + rate2: 100, + rate3: 300, + started: now, + ended: now, + minPrice: 100, + hasMinPrice: false + }}; + + const result = await app.models.FixedPrice.upsertFixedPrice(ctx, ctx.args.id); + + delete ctx.args.started; + delete ctx.args.ended; + ctx.args.hasMinPrice = true; + + expect(result).toEqual(jasmine.objectContaining(ctx.args)); + }); + + it(`should toggle the hasMinPrice boolean if there's no minPrice and update the rest of the data`, async() => { + const ctx = {args: { + id: fixedPriceId, + itemFk: 1, + warehouseFk: 1, + rate2: 2.5, + rate3: 2, + started: now, + ended: now, + minPrice: 0, + hasMinPrice: true + }}; + + const result = await app.models.FixedPrice.upsertFixedPrice(ctx, ctx.args.id); + + delete ctx.args.started; + delete ctx.args.ended; + ctx.args.hasMinPrice = false; + + expect(result).toEqual(jasmine.objectContaining(ctx.args)); + }); +}); diff --git a/modules/item/back/methods/fixed-price/upsertFixedPrice.js b/modules/item/back/methods/fixed-price/upsertFixedPrice.js new file mode 100644 index 0000000000..dbdeebdab1 --- /dev/null +++ b/modules/item/back/methods/fixed-price/upsertFixedPrice.js @@ -0,0 +1,116 @@ +module.exports = Self => { + Self.remoteMethod('upsertFixedPrice', { + description: 'Inserts or updates a fixed price for an item', + accessType: 'WRITE', + accepts: [{ + arg: 'ctx', + type: 'object', + http: {source: 'context'} + }, + { + arg: 'id', + type: 'number', + description: 'The fixed price id' + }, + { + arg: 'itemFk', + type: 'number' + }, + { + arg: 'warehouseFk', + type: 'number' + }, + { + arg: 'started', + type: 'date' + }, + { + arg: 'ended', + type: 'date' + }, + { + arg: 'rate2', + type: 'number' + }, + { + arg: 'rate3', + type: 'number' + }, + { + arg: 'minPrice', + type: 'number' + }, + { + arg: 'hasMinPrice', + type: 'any' + }], + returns: { + type: 'object', + root: true + }, + http: { + path: `/upsertFixedPrice`, + verb: 'PATCH' + } + }); + + Self.upsertFixedPrice = async ctx => { + const models = Self.app.models; + + const args = ctx.args; + const tx = await models.Address.beginTransaction({}); + try { + const options = {transaction: tx}; + delete args.ctx; // removed unwanted data + + const fixedPrice = await models.FixedPrice.upsert(args, options); + const targetItem = await models.Item.findById(args.itemFk, null, options); + await targetItem.updateAttributes({ + minPrice: args.minPrice, + hasMinPrice: args.minPrice ? true : false + }, options); + + const itemFields = [ + 'minPrice', + 'hasMinPrice', + 'name', + 'subName', + 'tag5', + 'value5', + 'tag6', + 'value6', + 'tag7', + 'value7', + 'tag8', + 'value8', + 'tag9', + 'value9', + 'tag10', + 'value10' + ]; + + const fieldsCopy = [].concat(itemFields); + const filter = { + include: { + relation: 'item', + scope: { + fields: fieldsCopy + } + } + }; + + const result = await models.FixedPrice.findById(fixedPrice.id, filter, options); + const item = result.item(); + + for (let key of itemFields) + result[key] = item[key]; + + await tx.commit(); + return result; + } catch (e) { + await tx.rollback(); + throw e; + } + }; +}; + diff --git a/modules/item/back/model-config.json b/modules/item/back/model-config.json index 13e30dc156..9f101f9c7a 100644 --- a/modules/item/back/model-config.json +++ b/modules/item/back/model-config.json @@ -76,5 +76,8 @@ }, "TaxType": { "dataSource": "vn" + }, + "FixedPrice": { + "dataSource": "vn" } } diff --git a/modules/item/back/models/fixed-price.js b/modules/item/back/models/fixed-price.js new file mode 100644 index 0000000000..9c78c586f4 --- /dev/null +++ b/modules/item/back/models/fixed-price.js @@ -0,0 +1,4 @@ +module.exports = Self => { + require('../methods/fixed-price/filter')(Self); + require('../methods/fixed-price/upsertFixedPrice')(Self); +}; diff --git a/modules/item/back/models/fixed-price.json b/modules/item/back/models/fixed-price.json new file mode 100644 index 0000000000..85e9194a3b --- /dev/null +++ b/modules/item/back/models/fixed-price.json @@ -0,0 +1,59 @@ +{ + "name": "FixedPrice", + "base": "VnModel", + "options": { + "mysql": { + "table": "priceFixed" + } + }, + "properties": { + "id": { + "type": "number", + "id": true, + "description": "Identifier" + }, + "itemFk": { + "type": "number", + "required": true + }, + "warehouseFk": { + "type": "number" + }, + "rate2": { + "type": "number", + "required": true + }, + "rate3": { + "type": "number", + "required": true + }, + "started": { + "type": "date", + "required": true + }, + "ended": { + "type": "date", + "required": true + } + }, + "relations": { + "item": { + "type": "belongsTo", + "model": "Item", + "foreignKey": "itemFk" + }, + "warehouse": { + "type": "belongsTo", + "model": "Warehouse", + "foreignKey": "warehouseFk" + } + }, + "acls": [ + { + "accessType": "READ", + "principalType": "ROLE", + "principalId": "buyer", + "permission": "ALLOW" + } + ] +} \ No newline at end of file diff --git a/modules/item/front/fixed-price-search-panel/index.html b/modules/item/front/fixed-price-search-panel/index.html new file mode 100644 index 0000000000..5a1e7781e3 --- /dev/null +++ b/modules/item/front/fixed-price-search-panel/index.html @@ -0,0 +1,136 @@ + + +
+
+ + + + + + + + + +
{{name}}
+
+ {{category.name}} +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + Tags + + + + + + + + + + + + + + + + + +
+
diff --git a/modules/item/front/fixed-price-search-panel/index.js b/modules/item/front/fixed-price-search-panel/index.js new file mode 100644 index 0000000000..ec13765fde --- /dev/null +++ b/modules/item/front/fixed-price-search-panel/index.js @@ -0,0 +1,19 @@ +import ngModule from '../module'; +import SearchPanel from 'core/components/searchbar/search-panel'; + +class Controller extends SearchPanel { + get filter() { + return this.$.filter; + } + + set filter(value = {}) { + if (!value.tags) value.tags = [{}]; + + this.$.filter = value; + } +} + +ngModule.vnComponent('vnFixedPriceSearchPanel', { + template: require('./index.html'), + controller: Controller +}); diff --git a/modules/item/front/fixed-price-search-panel/locale/es.yml b/modules/item/front/fixed-price-search-panel/locale/es.yml new file mode 100644 index 0000000000..06e5e6b26c --- /dev/null +++ b/modules/item/front/fixed-price-search-panel/locale/es.yml @@ -0,0 +1,4 @@ +Started: Inicio +Ended: Fin +Minimum price: Precio mínimo +Item ID: ID Artículo \ No newline at end of file diff --git a/modules/item/front/fixed-price/index.html b/modules/item/front/fixed-price/index.html new file mode 100644 index 0000000000..0fe71554f5 --- /dev/null +++ b/modules/item/front/fixed-price/index.html @@ -0,0 +1,162 @@ + + + + + + + + + + +
+ + + + + Item ID + Item + Warehouse + P.P.U. + P.P.P. + Min price + Started + Ended + + + + + + + + {{price.itemFk}} + + + + {{::id}} - {{::name}} + + + + + + + + + + + + + + + {{price.rate2 | currency: 'EUR':2}} + + + + + + + {{price.rate3 | currency: 'EUR':2}} + + + + + + + {{(price.hasMinPrice ? (price.minPrice | currency: 'EUR':2) : "-")}} + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+
+ + \ No newline at end of file diff --git a/modules/item/front/fixed-price/index.js b/modules/item/front/fixed-price/index.js new file mode 100644 index 0000000000..8e47ea5f21 --- /dev/null +++ b/modules/item/front/fixed-price/index.js @@ -0,0 +1,48 @@ +import ngModule from '../module'; +import Section from 'salix/components/section'; +import './style.scss'; + +export default class Controller extends Section { + constructor($element, $) { + super($element, $); + } + + /** + * Inserts a new instance + */ + add() { + this.$.model.insert({}); + } + + upsertPrice(price) { + price.hasMinPrice = price.minPrice ? true : false; + let requiredFields = ['itemFk', 'started', 'ended', 'rate2', 'rate3']; + + for (let field of requiredFields) + if (price[field] == undefined) return; + + const query = 'FixedPrices/upsertFixedPrice'; + this.$http.patch(query, price) + .then(res => { + this.vnApp.showSuccess(this.$t('Data saved!')); + Object.assign(price, res.data); + }); + } + + removePrice($index) { + const price = this.$.model.data[$index]; + if (price.id) { + this.$http.delete(`FixedPrices/${price.id}`) + .then(() => { + this.$.model.remove($index); + this.vnApp.showSuccess(this.$t('Data saved!')); + }); + } else + this.$.model.remove($index); + } +} + +ngModule.vnComponent('vnFixedPrice', { + template: require('./index.html'), + controller: Controller +}); diff --git a/modules/item/front/fixed-price/locale/es.yml b/modules/item/front/fixed-price/locale/es.yml new file mode 100644 index 0000000000..1f39fcc7f1 --- /dev/null +++ b/modules/item/front/fixed-price/locale/es.yml @@ -0,0 +1 @@ +Fixed prices: Precios fijados \ No newline at end of file diff --git a/modules/item/front/fixed-price/style.scss b/modules/item/front/fixed-price/style.scss new file mode 100644 index 0000000000..e5da01da1d --- /dev/null +++ b/modules/item/front/fixed-price/style.scss @@ -0,0 +1,5 @@ +@import "variables"; + +vn-date-picker { + max-width: 90px; +} \ No newline at end of file diff --git a/modules/item/front/index.js b/modules/item/front/index.js index 0f11c05630..e6a37abfc9 100644 --- a/modules/item/front/index.js +++ b/modules/item/front/index.js @@ -21,4 +21,6 @@ import './botanical'; import './barcode'; import './summary'; import './waste'; +import './fixed-price'; +import './fixed-price-search-panel'; diff --git a/modules/item/front/routes.json b/modules/item/front/routes.json index d3bde02056..560d82ce6a 100644 --- a/modules/item/front/routes.json +++ b/modules/item/front/routes.json @@ -8,7 +8,8 @@ "main": [ {"state": "item.index", "icon": "icon-item"}, {"state": "item.request", "icon": "pan_tool"}, - {"state": "item.waste", "icon": "icon-claims"} + {"state": "item.waste", "icon": "icon-claims"}, + {"state": "item.fixedPrice", "icon": "icon-invoices"} ], "card": [ {"state": "item.card.basicData", "icon": "settings"}, @@ -32,22 +33,26 @@ "abstract": true, "description": "Items", "component": "vn-items" - }, { + }, + { "url": "/index?q", "state": "item.index", "component": "vn-item-index", "description": "Items" - }, { + }, + { "url": "/create", "state": "item.create", "component": "vn-item-create", "description": "New item" - }, { + }, + { "url": "/:id", "state": "item.card", "abstract": true, "component": "vn-item-card" - }, { + }, + { "url" : "/basic-data", "state": "item.card.basicData", "component": "vn-item-basic-data", @@ -56,7 +61,8 @@ "item": "$ctrl.item" }, "acl": ["buyer"] - }, { + }, + { "url" : "/tags", "state": "item.card.tags", "component": "vn-item-tags", @@ -65,13 +71,15 @@ "item-tags": "$ctrl.itemTags" }, "acl": ["buyer", "replenisher"] - }, { + }, + { "url" : "/tax", "state": "item.card.tax", "component": "vn-item-tax", "description": "Tax", "acl": ["administrative","buyer"] - }, { + }, + { "url" : "/niche", "state": "item.card.niche", "component": "vn-item-niche", @@ -80,7 +88,8 @@ "item": "$ctrl.item" }, "acl": ["buyer","replenisher"] - }, { + }, + { "url" : "/botanical", "state": "item.card.botanical", "component": "vn-item-botanical", @@ -89,7 +98,8 @@ "item": "$ctrl.item" }, "acl": ["buyer"] - }, { + }, + { "url" : "/barcode", "state": "item.card.itemBarcode", "component": "vn-item-barcode", @@ -98,7 +108,8 @@ "item": "$ctrl.item" }, "acl": ["buyer","replenisher"] - }, { + }, + { "url" : "/summary", "state": "item.card.summary", "component": "vn-item-summary", @@ -106,7 +117,8 @@ "params": { "item": "$ctrl.item" } - }, { + }, + { "url" : "/diary?warehouseFk&lineFk", "state": "item.card.diary", "component": "vn-item-diary", @@ -115,7 +127,8 @@ "item": "$ctrl.item" }, "acl": ["employee"] - }, { + }, + { "url" : "/last-entries", "state": "item.card.last-entries", "component": "vn-item-last-entries", @@ -124,12 +137,14 @@ "item": "$ctrl.item" }, "acl": ["employee"] - }, { + }, + { "url" : "/log", "state": "item.card.log", "component": "vn-item-log", "description": "Log" - }, { + }, + { "url" : "/request?q", "state": "item.request", "component": "vn-item-request", @@ -138,12 +153,20 @@ "item": "$ctrl.item" }, "acl": ["employee"] - }, { + }, + { "url" : "/waste", "state": "item.waste", "component": "vn-item-waste", "description": "Waste breakdown", "acl": ["buyer"] + }, + { + "url" : "/fixed-price", + "state": "item.fixedPrice", + "component": "vn-fixed-price", + "description": "Fixed prices", + "acl": ["buyer"] } ] } \ No newline at end of file