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:
Wynne Crisman
2017-05-26 11:17:32 -07:00
parent e1b0b19589
commit 210517a5c2
42 changed files with 15153 additions and 8505 deletions

44
imports/api/Logs.js Normal file
View 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;

View File

@@ -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();

View File

@@ -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.");
}
*/
});
}

View File

@@ -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) {