Files
PetitTetonMeteor/imports/api/Sale.js

534 lines
19 KiB
JavaScript
Raw Normal View History

import { Meteor } from 'meteor/meteor';
import { Mongo } from 'meteor/mongo';
import { check } from 'meteor/check';
import 'meteor/aldeed:collection2/static'
import SimpleSchema from 'meteor/aldeed:simple-schema';
2020-01-16 09:31:12 -08:00
import Batches from "./Batch";
import Measures from "./Measure";
/**
* 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.
*/
let Sales = new Mongo.Collection('Sales');
if(Meteor.isServer) {
//Set MongoDB indexes (or remove them) here.
try {
Sales.rawCollection().createIndex({date: -1}, {unique: false})
Sales.rawCollection().createIndex({productId: -1}, {unique: false})
Sales.rawCollection().createIndex({measureId: -1}, {unique: false})
Sales.rawCollection().createIndex({venueId: -1}, {unique: false})
} catch(e) {console.log("Caught exception while setting indexes in MongoDB"); console.error(e)}
}
let SalesSchema = new SimpleSchema({
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
},
timestamp: {
type: Date,
label: "Timestamp",
optional: true
},
weekOfYear: {
type: Number,
label: "Week Of Year",
optional: true
},
amount: {
type: Number,
label: "Amount",
optional: false,
//decimal: true
},
price: {
type: Number,
label: "Price",
optional: false,
min: 0,
exclusiveMin: true,
//decimal: true
},
measureId: {
type: String,
label: "Measure Id",
trim: false,
regEx: SimpleSchema.RegEx.Id,
//index: 1
},
productId: {
type: String,
label: "Product Id",
trim: false,
regEx: SimpleSchema.RegEx.Id,
//index: 1
},
venueId: {
type: String,
label: "Vendor Id",
trim: false,
regEx: SimpleSchema.RegEx.Id,
//index: 1
// autoform: {
// type: 'relation',
// settings: {
// collection: 'Venues',
// fields: ['name']
// }
// }
},
comment: {
type: String,
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",
optional: false
}
});
Sales.attachSchema(SalesSchema);
if(Meteor.isServer) {
Meteor.publish('sales', function(query, sort, limit = 100, skipCount) {
let dbQuery = [];
if(query) {
_.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'}});
}
}
});
}
if(!_.isNumber(limit)) limit = 100;
if(!_.isNumber(skipCount) || skipCount < 0) skipCount = 0;
dbQuery = dbQuery.length > 0 ? {$and: dbQuery} : {};
2020-01-16 09:31:12 -08:00
//console.log("dbQuery=" + JSON.stringify(dbQuery));
//console.log("Result Count: " + Batches.find(query).count());
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) {
let pipeline = [];
let group = {
$group: {
_id: {
//year: {$dateToString: {format: '%Y', date: '$date'}}//{$year: '$date'}
year: {$substr: ['$date', 0, 4]}
},
'total': {
$sum: {
$multiply: ['$price', '$amount']
}
}
}
};
let project = {
$project: {
year: '$_id.year',
date: '$_id.year',
total: true,
_id: {$concat: ['$_id.year']}
}
};
pipeline.push(group);
pipeline.push(project);
//Annual is assumed if not week or month.
if(time === 'weekly') {
group.$group._id.week = '$weekOfYear'; //{$dateToString: {format: '%U', date: new Date({$concat: [{$substr: ['$date', 0, 4]}, '-', {$substr: ['$date', 4, 2]}, '-', {$substr: ['$date', 6, 2]}]}) }}; //{$week: '$date'};
project.$project.week = '$_id.week';
project.$project.date = {$concat: [{$substr: ['$_id.week', 0, 2]}, '-', '$_id.year']};
project.$project._id.$concat.push({$substr: ['$_id.week', 0, 2]});
}
else if(time === 'monthly') {
group.$group._id.month = {$substr: ['$date', 4, 6]}; //{$dateToString: {format: '%m', date: new Date({$concat: [{$substr: ['$date', 0, 4]}, '-', {$substr: ['$date', 4, 2]}, '-', {$substr: ['$date', 6, 2]}]}) }}; //{$month: '$date'};
project.$project.month = '$_id.month';
project.$project.date = {$concat: ['$_id.month', '-', '$_id.year']};
project.$project._id.$concat.push('$_id.month');
}
if(options === 'markets') {
group.$group._id.venueId = '$venueId';
project.$project.venueId = '$_id.venueId';
project.$project._id.$concat.push('$_id.venueId');
pipeline.push({$lookup: {from: 'Venues', localField: 'venueId', foreignField: '_id', as: 'venue'}});
pipeline.push({$project: {year: 1, week: 1, month: 1, total: 1, venueId: 1, venue: {$arrayElemAt: ['$venue', 0]}}});
pipeline.push({$project: {year: 1, week: 1, month: 1, total: 1, venueId: 1, venue: '$venue.name'}});
}
else if(options === 'types') {
//query[0].$group.month = {$month: '$date'};
//TODO: Need to divide the sales up by:
// Sweets
// Savories
// Meats
// VAP
// Egg
// Other Produce
// Total Produce
// Jars
}
ReactiveAggregate(this, Sales, pipeline, {clientCollection: 'salesTotals'});
/*
{$sales: {
_id: Random.id(),
year: $_id.$year,
total: $total
}}
*/
//ReactiveAggregate(this, Sales, [query], {clientCollection: 'salesTotals', transform: function(doc) {
// console.log("Running transform function");
// Object.assign(doc._id, doc);
// doc._id = Random.id();
// return doc;
//}});
});
Meteor.methods({
getSalesCount: async function(query) {
//TODO: Validate the query?
return await Sales.countDocuments(query);
},
insertSale: async function(sale) {
check(sale, {
date: Number, // TODO: Check that the format is YYYYMMDD
amount: Match.Where(function(x) {
check(x, Number);
return x > 0;
}),
price: Match.Where(function(x) {
check(x, Number);
return x > 0;
}),
measureId: String,
productId: String,
venueId: String,
comment: Match.Optional(String)
});
let dateString = sale.date.toString();
sale.createdAt = new Date();
sale.timestamp = new Date(dateString.substring(0, 4) + "-" + dateString.substring(4, 6) + "-" + dateString.substring(6, 8) + "T00:00:00Z");
sale.weekOfYear = sale.timestamp.getWeek().toString();
if(Roles.userIsInRole(this.userId, [Meteor.UserRoles.ROLE_UPDATE])) {
await Sales.insertAsync(sale, function(err, id) {
if(err) console.log(err);
}, {bypassCollection2: true});
}
else throw new Meteor.Error(403, "Not authorized.");
},
deleteSale: async function(id) {
check(id, String);
if(Roles.userIsInRole(this.userId, [Meteor.UserRoles.ROLE_UPDATE])) {
await Sales.removeAsync(id);
}
else throw new Meteor.Error(403, "Not authorized.");
},
editSaleComment: async function(id, comment) {
check(id, String);
check(comment, String);
//Trim and convert empty comment to undefined.
comment = comment ? comment.trim() : undefined;
comment = comment && comment.length > 0 ? comment : undefined;
if(Roles.userIsInRole(this.userId, [Meteor.UserRoles.ROLE_UPDATE])) {
console.log("Changed comment of " + id + " to: " + comment);
if(comment) {
await Sales.updateAsync(id, {$set: {comment}}, function(error, count) {
if(error) throw new Meteor.Error(400, "Unexpected database error: " + error);
});
}
else {
await Sales.updateAsync(id, {$unset: {comment: ""}}, function(error, count) {
if(error) throw new Meteor.Error(400, "Unexpected database error: " + error);
});
}
}
else throw new Meteor.Error(403, "Not authorized.");
},
updateSale: async function(id, date, venueId, price, amount) {
check(id, String);
check(date, Number); // TODO: Check that the format is YYYYMMDD
check(venueId, String);
check(price, Number);
check(amount, Number);
let dateString = date.toString();
let timestamp = new Date(dateString.substring(0, 4) + "-" + dateString.substring(4, 6) + "-" + dateString.substring(6, 8) + "T00:00:00Z");
let weekOfYear = timestamp.getWeek().toString();
if(Roles.userIsInRole(this.userId, [Meteor.UserRoles.ROLE_UPDATE])) {
await Sales.updateAsync(id, {$set: {date, venueId, price, amount, timestamp, weekOfYear}}, function(err, id) {
if(err) console.log(err);
}, {bypassCollection2: true});
}
else throw new Meteor.Error(403, "Not authorized.");
},
countSales: async function() {
if(Roles.userIsInRole(this.userId, [Meteor.UserRoles.ROLE_UPDATE])) {
return await Sales.countDocuments({});
}
else throw new Meteor.Error(403, "Not authorized.");
},
removeDuplicateSales: async 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 = await Sales.findOneAsync({isDuplicateOf: id});
if(sale) {
await Sales.removeAsync({_id: sale._id});
}
}
else {
await Sales.removeAsync({isDuplicateOf: id});
}
}
},
ignoreDuplicateSales: async 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.
await Sales.updateAsync({$or: [{_id: id}, {isDuplicateOf: id}]}, {$set: {ignoreDuplicates: true}});
}
},
markDuplicateSales: async function() {
if(Roles.userIsInRole(this.userId, [Meteor.UserRoles.ROLE_UPDATE])) {
let sales = await Sales.find({}, {sort: {date: 1, venueId: 1, productId: 1, price: 1, amount: 1, measureId: 1, createdAt: 1}}).fetchAsync();
// 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;
await Sales.updateAsync(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;
await Sales.updateAsync(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;
await Sales.updateAsync(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;
await Sales.updateAsync(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.");
}
*/
});
}
//Allows the client to do DB interaction without calling server side methods, while still retaining control over whether the user can make changes.
Sales.allow({
insert: function() {return false;},
update: function() {return false;},
remove: function() {return false;}
});
export default Sales;