Redesigned the querying for the sale duplicates screen to use aggregation; Finished the styling of the sale duplicate screen; Tested the functionality of sale duplicates; Added a way to show hidden (ignored) duplicates.
This commit is contained in:
44
imports/api/Logs.js
Normal file
44
imports/api/Logs.js
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { Mongo } from 'meteor/mongo';
|
||||
|
||||
// The logging tool is primarily for managing administrative functions such that administrators can view the app logs and issue commands that might generate administrative logging.
|
||||
|
||||
Meteor.log = new Logger();
|
||||
Logs = new Mongo.Collection('Logs');
|
||||
|
||||
let logMongo = new LoggerMongo(Meteor.log, {
|
||||
collection: Logs
|
||||
});
|
||||
logMongo.enable({
|
||||
enable: true,
|
||||
client: false, /* Client calls are not executed on the client. */
|
||||
server: true /* Calls from the client will be executed on the server. */
|
||||
});
|
||||
|
||||
if(Meteor.isServer) {
|
||||
Logs._ensureIndex({'date': 1}, {expireAfterSeconds: 86400});
|
||||
Meteor.publish('logs', function() {
|
||||
return Logs.find({}, {limit: 10000});
|
||||
});
|
||||
Meteor.methods({
|
||||
clearLogs: function() {
|
||||
return Logs.remove({}, function(err) {
|
||||
if(err) Meteor.log.error(err);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Logs.allow({
|
||||
insert: () => false,
|
||||
update: () => false,
|
||||
remove: () => false
|
||||
});
|
||||
|
||||
Logs.deny({
|
||||
insert: () => true,
|
||||
update: () => true,
|
||||
remove: () => true
|
||||
});
|
||||
|
||||
export default Logs;
|
||||
@@ -3,6 +3,13 @@ import { Mongo } from 'meteor/mongo';
|
||||
import { check } from 'meteor/check';
|
||||
import {SimpleSchema} from 'meteor/aldeed:simple-schema';
|
||||
|
||||
/**
|
||||
* Notes:
|
||||
* The Product object has a prices field which is an object whose fields names are Measure ID's. Each field value (for each Measure ID) is an object that has a 'price', 'effectiveDate', and 'previousPrice'.
|
||||
* The effectiveDate field stores the date as a number in the format YYYYMMDD. Converting this number into a local date is done with moment(sale.date.toString(), "YYYYMMDD").toDate(), and converting it to a number from a date can be accomplished with ~~(moment(date).format("YYYYMMDD")), where the ~~ is a bitwise not and converts a string to a number quickly and reliably.
|
||||
* Because the structure of the Product object is so complicated, the normal checking that is done by the framework cannot be used.
|
||||
*/
|
||||
|
||||
Products = new Mongo.Collection('Products');
|
||||
|
||||
const ProductsSchema = new SimpleSchema({
|
||||
@@ -237,7 +244,7 @@ if(Meteor.isServer) {
|
||||
check(measureId, String);
|
||||
check(price, Number);
|
||||
if(setPrevious) check(setPrevious, Boolean);
|
||||
if(effectiveDate) check(effectiveDate, Date);
|
||||
if(effectiveDate) check(effectiveDate, Number); // TODO: Check that the format is YYYYMMDD
|
||||
|
||||
if(Roles.userIsInRole(this.userId, [Meteor.UserRoles.ROLE_UPDATE])) {
|
||||
let products = Products.find({_id: {$in: productIds}}, {fields: {prices: 1}}).fetch();
|
||||
|
||||
@@ -3,10 +3,15 @@ import { Mongo } from 'meteor/mongo';
|
||||
import { check } from 'meteor/check';
|
||||
import {SimpleSchema} from 'meteor/aldeed:simple-schema';
|
||||
|
||||
/**
|
||||
* Notes:
|
||||
* The Sale object has a date field which stores the date as a number in the format YYYYMMDD. Converting this number into a local date is done with moment(sale.date.toString(), "YYYYMMDD").toDate(), and converting it to a number from a date can be accomplished with ~~(moment(date).format("YYYYMMDD")), where the ~~ is a bitwise not and converts a string to a number quickly and reliably.
|
||||
*/
|
||||
|
||||
Sales = new Mongo.Collection('Sales');
|
||||
let SalesSchema = new SimpleSchema({
|
||||
date: {
|
||||
type: Date,
|
||||
type: Number, // A number in the format of YYYYMMDD to allow for searching using greater and less than, and to prevent timezones from messing everything up.
|
||||
label: "Date",
|
||||
optional: false,
|
||||
index: 1
|
||||
@@ -58,6 +63,19 @@ let SalesSchema = new SimpleSchema({
|
||||
trim: false,
|
||||
optional: true
|
||||
},
|
||||
ignoreDuplicates: {
|
||||
type: Boolean,
|
||||
optional: true
|
||||
},
|
||||
isDuplicateOf: {
|
||||
type: String,
|
||||
trim: false,
|
||||
optional: true
|
||||
},
|
||||
duplicateCount: {
|
||||
type: Number,
|
||||
optional: true
|
||||
},
|
||||
createdAt: {
|
||||
type: Date,
|
||||
label: "Created On",
|
||||
@@ -71,14 +89,22 @@ if(Meteor.isServer) {
|
||||
let dbQuery = [];
|
||||
|
||||
if(query) {
|
||||
// _.each(_.keys(query), function(key) {
|
||||
// if(_.isObject(query[key])) dbQuery[key] = query[key];
|
||||
// else if(_.isNumber(query[key])) dbQuery[key] = query[key];
|
||||
// else dbQuery[key] = {$regex: RegExp.escape(query[key]), $options: 'i'};
|
||||
// });
|
||||
|
||||
_.each(_.keys(query), function(key) {
|
||||
if(_.isObject(query[key])) dbQuery.push({[key]: query[key]});
|
||||
//if(_.isObject(query[key])) dbQuery.push({[key]: query[key]});
|
||||
if(_.isObject(query[key])) {
|
||||
if(query[key].type === 'dateRange') {
|
||||
if(query[key].start && query[key].end)
|
||||
dbQuery.push({[key]: {$gte: query[key].start, $lte: query[key].end}});
|
||||
else if(query[key].start)
|
||||
dbQuery.push({[key]: {$gte: query[key].start}});
|
||||
else if(query[key].end)
|
||||
dbQuery.push({[key]: {$lte: query[key].end}});
|
||||
// Do nothing if a start and/or end are not provided.
|
||||
}
|
||||
else {
|
||||
dbQuery.push({[key]: query[key]});
|
||||
}
|
||||
}
|
||||
else if(_.isNumber(query[key])) dbQuery.push({[key]: query[key]});
|
||||
else {
|
||||
let searchValue = query[key];
|
||||
@@ -97,6 +123,65 @@ if(Meteor.isServer) {
|
||||
dbQuery = dbQuery.length > 0 ? {$and: dbQuery} : {};
|
||||
return Meteor.collections.Sales.find(dbQuery, {limit: limit, sort, skip: skipCount});
|
||||
});
|
||||
Meteor.publish('duplicateSales', function(query, includeIgnoredDuplicates) {
|
||||
// Start with the duplicate count needing to be greater than zero, and with duplicates marked as ignored not included.
|
||||
let dbQuery = [{duplicateCount: {$gt: 0}}];
|
||||
|
||||
// If we should include ignored duplicates than add it to the query as a requirement.
|
||||
if(!includeIgnoredDuplicates) {
|
||||
dbQuery.push({$or: [{ignoreDuplicates: {$exists: false}}, {ignoreDuplicates: false}]});
|
||||
}
|
||||
|
||||
//if(query) {
|
||||
// // Add each query requirement sent by the client.
|
||||
// _.each(_.keys(query), function(key) {
|
||||
// //if(_.isObject(query[key])) dbQuery.push({[key]: query[key]});
|
||||
// if(_.isObject(query[key])) {
|
||||
// if(query[key].type === 'dateRange') {
|
||||
// if(query[key].start && query[key].end)
|
||||
// dbQuery.push({[key]: {$gte: query[key].start, $lte: query[key].end}});
|
||||
// else if(query[key].start)
|
||||
// dbQuery.push({[key]: {$gte: query[key].start}});
|
||||
// else if(query[key].end)
|
||||
// dbQuery.push({[key]: {$lte: query[key].end}});
|
||||
// // Do nothing if a start and/or end are not provided.
|
||||
// }
|
||||
// else {
|
||||
// dbQuery.push({[key]: query[key]});
|
||||
// }
|
||||
// }
|
||||
// else if(_.isNumber(query[key])) dbQuery.push({[key]: query[key]});
|
||||
// else {
|
||||
// let searchValue = query[key];
|
||||
// let searches = searchValue && searchValue.length > 0 ? searchValue.split(/\s+/) : undefined;
|
||||
//
|
||||
// for(let search of searches) {
|
||||
// dbQuery.push({[key]: {$regex: '\\b' + search, $options: 'i'}});
|
||||
// }
|
||||
// }
|
||||
// });
|
||||
//}
|
||||
|
||||
// Wrap the array of requirements with an $and, or remove the single requirement from the array (if there is only a single requirement).
|
||||
if(dbQuery.length === 1) dbQuery = dbQuery[0];
|
||||
else dbQuery = {$and: dbQuery};
|
||||
|
||||
// Find all Sale objects marked as having at least one duplicate.
|
||||
//return Meteor.collections.Sales.find(dbQuery);
|
||||
|
||||
let pipeline = [
|
||||
{$match: dbQuery},
|
||||
{$lookup: {from: "Products", localField: "productId", foreignField: "_id", as: "product"}},
|
||||
{$lookup: {from: "Measures", localField: "measureId", foreignField: "_id", as: "measure"}},
|
||||
{$lookup: {from: "Venues", localField: "venueId", foreignField: "_id", as: "venue"}},
|
||||
{$unwind: "$product"},
|
||||
{$unwind: "$measure"},
|
||||
{$unwind: "$venue"},
|
||||
{$project: {_id: 1, date: 1, amount: 1, price: 1, venueId: 1, productId: 1, measureId: 1, duplicateCount: 1, ignoreDuplicates: 1, 'productName': '$product.name', 'measureName': '$measure.name', 'venueName': '$venue.name'}}
|
||||
];
|
||||
|
||||
ReactiveAggregate(this, Sales, pipeline, {clientCollection: 'duplicateSales'});
|
||||
});
|
||||
// time: expects either undefined, 'weekly', or 'monthly'
|
||||
// options: expects either undefined, 'markets', or 'types'
|
||||
Meteor.publish('salesTotals', function(time, options) {
|
||||
@@ -185,7 +270,7 @@ if(Meteor.isServer) {
|
||||
},
|
||||
insertSale: function(sale) {
|
||||
check(sale, {
|
||||
date: Date,
|
||||
date: Number, // TODO: Check that the format is YYYYMMDD
|
||||
amount: Match.Where(function(x) {
|
||||
check(x, Number);
|
||||
return x > 0;
|
||||
@@ -199,7 +284,7 @@ if(Meteor.isServer) {
|
||||
venueId: String,
|
||||
comment: Match.Optional(String)
|
||||
});
|
||||
//TODO: Check the structure of sale. Use: check(sale, {name: String, ...});
|
||||
|
||||
sale.createdAt = new Date();
|
||||
|
||||
if(Roles.userIsInRole(this.userId, [Meteor.UserRoles.ROLE_UPDATE])) {
|
||||
@@ -242,7 +327,7 @@ if(Meteor.isServer) {
|
||||
},
|
||||
updateSale: function(id, date, venueId, price, amount) {
|
||||
check(id, String);
|
||||
check(date, Date);
|
||||
check(date, Number); // TODO: Check that the format is YYYYMMDD
|
||||
check(venueId, String);
|
||||
check(price, Number);
|
||||
check(amount, Number);
|
||||
@@ -252,7 +337,152 @@ if(Meteor.isServer) {
|
||||
if(err) console.log(err);
|
||||
}, {bypassCollection2: true});
|
||||
}
|
||||
else throw new Meteor.Error(403, "Not authorized.");
|
||||
},
|
||||
countSales: function() {
|
||||
if(Roles.userIsInRole(this.userId, [Meteor.UserRoles.ROLE_UPDATE])) {
|
||||
return Sales.find({}).count();
|
||||
}
|
||||
else throw new Meteor.Error(403, "Not authorized.");
|
||||
},
|
||||
removeDuplicateSales: function(id, justOne) { // Expects the id of the sale that has duplicates and an optional boolean flag (justOne) indicating whether just one duplicate should be removed, or all of them (default).
|
||||
if(Roles.userIsInRole(this.userId, [Meteor.UserRoles.ROLE_UPDATE])) {
|
||||
// Remove either one or all of the duplicates of the Sale with the given ID.
|
||||
if(justOne) {
|
||||
let sale = Sales.findOne({isDuplicateOf: id});
|
||||
|
||||
if(sale) {
|
||||
Sales.remove({_id: sale._id});
|
||||
}
|
||||
}
|
||||
else {
|
||||
Sales.remove({isDuplicateOf: id});
|
||||
}
|
||||
}
|
||||
},
|
||||
ignoreDuplicateSales: function(id) { // Expects the id of the sale that has duplicates. Will mark this sale and all duplicates to be ignored in future duplicate checks.
|
||||
if(Roles.userIsInRole(this.userId, [Meteor.UserRoles.ROLE_UPDATE])) {
|
||||
// Mark to ignore duplicates for this Sale (id) and all its duplicates, and clear any duplicate counts and references.
|
||||
//Sales.update({$or: [{_id: id}, {isDuplicateOf: id}]}, {$set: {ignoreDuplicates: true}, $unset: {isDuplicateOf: "", duplicateCount: ""}});
|
||||
|
||||
// Mark to ignore duplicates for this Sale (id). We will leave the duplicate count and references so that the duplicates will show in a query if we want to revisit those marked as ignored.
|
||||
Sales.update({$or: [{_id: id}, {isDuplicateOf: id}]}, {$set: {ignoreDuplicates: true}});
|
||||
}
|
||||
},
|
||||
markDuplicateSales: function() {
|
||||
if(Roles.userIsInRole(this.userId, [Meteor.UserRoles.ROLE_UPDATE])) {
|
||||
let sales = Sales.find({}, {sort: {date: 1, venueId: 1, productId: 1, price: 1, amount: 1, measureId: 1, createdAt: 1}}).fetch();
|
||||
|
||||
// Iterate over all the sales looking for sales that have duplicates.
|
||||
// Since the sales are sorted by sale date, venueId, productId, price, amount, and measureId which all must be identical to be considered a possible duplicate sale, we only have to check subsequent sales until a non-duplicate is found.
|
||||
for(let i = 0; i < sales.length;) {
|
||||
let sale = sales[i];
|
||||
|
||||
// If this is marked as a duplicate of another sale, but we got to this point in the loop then the sale it is a duplicate of must have been removed or marked to ignore duplicates.
|
||||
if(sale.isDuplicateOf) {
|
||||
delete sale.isDuplicateOf;
|
||||
Sales.update(sale._id, {$unset: {isDuplicateOf: ""}}, function(err, id) {
|
||||
if(err) console.log(err);
|
||||
}, {bypassCollection2: true});
|
||||
}
|
||||
|
||||
//Skip this one if it is marked to ignore duplicates.
|
||||
//if(sale.ignoreDuplicates) {
|
||||
// i++;
|
||||
//}
|
||||
//else {
|
||||
let keepChecking = true;
|
||||
let duplicateCount = 0;
|
||||
|
||||
// Keep checking subsequent sales until a non-duplicate is found. Ignore anything marked to ignore duplicates. Count the number of duplicates and also mark duplicates to reference the Sale we are currently checking.
|
||||
while(keepChecking) {
|
||||
let checkSale = sales[++i]; // Increment the index to the next Sale object.
|
||||
|
||||
// Since it is possible to exceed the length of the array, we will check for an undefined next sale and set the flag to stop checking if one is found.
|
||||
if(checkSale && sale.productId === checkSale.productId && sale.venueId === checkSale.venueId && sale.price === checkSale.price && sale.amount === checkSale.amount && sale.measureId === checkSale.measureId) {
|
||||
// Mark the next sale as a duplicate of the currently examined sale.
|
||||
checkSale.isDuplicateOf = sale._id;
|
||||
Sales.update(checkSale._id, {$set: {isDuplicateOf: checkSale.isDuplicateOf}}, function(err, id) {
|
||||
if(err) console.log(err);
|
||||
}, {bypassCollection2: true});
|
||||
duplicateCount++;
|
||||
}
|
||||
else {
|
||||
// Stop checking.
|
||||
keepChecking = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Make sure the currently checked sale has a proper duplicate count before moving on in the search.
|
||||
if(duplicateCount > 0) {
|
||||
if(sale.duplicateCount !== duplicateCount) {
|
||||
// Update the sale's duplicate count.
|
||||
sale.duplicateCount = duplicateCount;
|
||||
Sales.update(sale._id, {$set: {duplicateCount: sale.duplicateCount}}, function(err, id) {
|
||||
if(err) console.log(err);
|
||||
}, {bypassCollection2: true});
|
||||
}
|
||||
}
|
||||
else if(sale.duplicateCount) {
|
||||
// Remove the duplicate count if it is set.
|
||||
delete sale.duplicateCount;
|
||||
Sales.update(sale._id, {$unset: {duplicateCount: ""}}, function(err, id) {
|
||||
if(err) console.log(err);
|
||||
}, {bypassCollection2: true});
|
||||
}
|
||||
//}
|
||||
}
|
||||
}
|
||||
else throw new Meteor.Error(403, "Not authorized.");
|
||||
}
|
||||
/*
|
||||
countDuplicateSales: function() {
|
||||
if(Roles.userIsInRole(this.userId, [Meteor.UserRoles.ROLE_MANAGE])) {
|
||||
let sales = Sales.find({}, {sort: {date: 1, venueId: 1, productId: 1, price: 1, amount: 1, measureId: 1}}).fetch();
|
||||
let salesByDate = {};
|
||||
let lastDate = undefined;
|
||||
let lastDateCollection;
|
||||
let duplicates = [];
|
||||
|
||||
//Create a map of Sale arrays by sale date.
|
||||
for(let i = 0; i < sales.length; i++) {
|
||||
let date = sales[i].date;
|
||||
|
||||
if(date) {
|
||||
if(date === lastDate) {
|
||||
lastDateCollection.push(sales[i]);
|
||||
}
|
||||
else {
|
||||
lastDate = date;
|
||||
salesByDate[date] = lastDateCollection = [sales[i]];
|
||||
}
|
||||
}
|
||||
else {
|
||||
Meteor.log.error("Found a sale without a date!!!");
|
||||
}
|
||||
}
|
||||
|
||||
for(let date in salesByDate) {
|
||||
let sales = salesByDate[date];
|
||||
|
||||
for(let i = 0; i < sales.length - 1; i++) {
|
||||
if(sales[i].productId === sales[i+1].productId && sales[i].venueId === sales[i+1].venueId && sales[i].price === sales[i+1].price && sales[i].amount === sales[i+1].amount && sales[i].measureId === sales[i+1].amount) {
|
||||
duplicates.push([sales[i], sales[i+1]]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return duplicates;
|
||||
}
|
||||
else throw new Meteor.Error(403, "Not authorized.");
|
||||
},
|
||||
deleteDuplicateSales: function() {
|
||||
if(Roles.userIsInRole(this.userId, [Meteor.UserRoles.ROLE_MANAGE])) {
|
||||
|
||||
}
|
||||
else throw new Meteor.Error(403, "Not authorized.");
|
||||
}
|
||||
*/
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -4,11 +4,12 @@ import Products from "./Product.js";
|
||||
import ProductTags from "./ProductTag.js";
|
||||
import Sales from "./Sale.js";
|
||||
import SalesSheets from "./SalesSheet.js";
|
||||
import Logs from "./Logs.js";
|
||||
import Users from "./User.js";
|
||||
import UserRoles from "./Roles.js";
|
||||
|
||||
//Save the collections in the Meteor.collections property for easy access without name conflicts.
|
||||
Meteor.collections = {Measures, Venues, Products, ProductTags, Sales, SalesSheets, Users, UserRoles};
|
||||
Meteor.collections = {Measures, Venues, Products, ProductTags, Sales, SalesSheets, Logs, Users, UserRoles};
|
||||
|
||||
//If this is the server then setup the default admin user if none exist.
|
||||
if(Meteor.isServer) {
|
||||
|
||||
Reference in New Issue
Block a user