Files
PetitTetonMeteor/imports/api/Sale.js

520 lines
18 KiB
JavaScript
Raw Normal View History

import { Meteor } from 'meteor/meteor';
import { Mongo } from 'meteor/mongo';
import { check } from 'meteor/check';
import {SimpleSchema} from 'meteor/aldeed:simple-schema';
2020-01-16 09:31:12 -08:00
import Batches from "./Batch";
/**
* 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: 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: function(query) {
//TODO: Validate the query?
return Sales.find(query).count();
},
insertSale: 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])) {
Sales.insert(sale, function(err, id) {
if(err) console.log(err);
}, {bypassCollection2: true});
}
else throw new Meteor.Error(403, "Not authorized.");
},
deleteSale: function(id) {
check(id, String);
if(Roles.userIsInRole(this.userId, [Meteor.UserRoles.ROLE_UPDATE])) {
Sales.remove(id);
}
else throw new Meteor.Error(403, "Not authorized.");
},
editSaleComment: 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) {
Sales.update(id, {$set: {comment}}, function(error, count) {
if(error) throw new Meteor.Error(400, "Unexpected database error: " + error);
});
}
else {
Sales.update(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: 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])) {
Sales.update(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: 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.");
}
*/
});
}
//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;